Library manager (#258)

This commit is contained in:
Oscar Beaumont
2022-07-11 10:05:24 +08:00
committed by GitHub
parent d2de698764
commit 017ef8b005
99 changed files with 2181 additions and 1536 deletions

BIN
Cargo.lock generated
View File

Binary file not shown.

View File

@@ -1,16 +1,15 @@
use std::time::{Duration, Instant};
use dotenvy::dotenv;
use sdcore::{ClientCommand, ClientQuery, CoreController, CoreEvent, CoreResponse, Node};
use tauri::api::path;
use tauri::Manager;
use sdcore::{ClientCommand, ClientQuery, CoreEvent, CoreResponse, Node, NodeController};
use tauri::{api::path, Manager};
#[cfg(target_os = "macos")]
mod macos;
mod menu;
#[tauri::command(async)]
async fn client_query_transport(
core: tauri::State<'_, CoreController>,
core: tauri::State<'_, NodeController>,
data: ClientQuery,
) -> Result<CoreResponse, String> {
match core.query(data).await {
@@ -24,7 +23,7 @@ async fn client_query_transport(
#[tauri::command(async)]
async fn client_command_transport(
core: tauri::State<'_, CoreController>,
core: tauri::State<'_, NodeController>,
data: ClientCommand,
) -> Result<CoreResponse, String> {
match core.command(data).await {
@@ -48,17 +47,11 @@ async fn main() {
dotenv().ok();
env_logger::init();
let data_dir = path::data_dir().unwrap_or(std::path::PathBuf::from("./"));
let mut data_dir = path::data_dir().unwrap_or(std::path::PathBuf::from("./"));
data_dir = data_dir.join("spacedrive");
// create an instance of the core
let (mut node, mut event_receiver) = Node::new(data_dir).await;
// run startup tasks
node.initializer().await;
// extract the node controller
let controller = node.get_controller();
// throw the node into a dedicated thread
tokio::spawn(async move {
node.start().await;
});
let (controller, mut event_receiver, node) = Node::new(data_dir).await;
tokio::spawn(node.start());
// create tauri app
tauri::Builder::default()
// pass controller to the tauri state manager

View File

@@ -1,42 +0,0 @@
# Infrastructure setups up the Kubernetes cluster for Spacedrive!
#
# To get the service account token use the following:
# ```bash
# TOKENNAME=`kubectl -n spacedrive get sa/spacedrive-ci -o jsonpath='{.secrets[0].name}'`
# kubectl -n spacedrive get secret $TOKENNAME -o jsonpath='{.data.token}' | base64 -d
# ```
apiVersion: v1
kind: Namespace
metadata:
name: spacedrive
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: spacedrive-ci
namespace: spacedrive
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: spacedrive-ns-full
namespace: spacedrive
rules:
- apiGroups: ['apps']
resources: ['deployments']
verbs: ['get', 'patch']
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: spacedrive-ci-rb
namespace: spacedrive
subjects:
- kind: ServiceAccount
name: spacedrive-ci
namespace: spacedrive
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: spacedrive-ns-full

View File

@@ -1,118 +0,0 @@
# This will deploy the Spacedrive Server container to the `spacedrive`` namespace on Kubernetes.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sdserver-ingress
namespace: spacedrive
labels:
app.kubernetes.io/name: sdserver
app.kubernetes.io/component: webserver
annotations:
traefik.ingress.kubernetes.io/router.tls.certresolver: le
traefik.ingress.kubernetes.io/router.middlewares: kube-system-antiseo@kubernetescrd
spec:
rules:
- host: spacedrive.otbeaumont.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sdserver-service
port:
number: 8080
---
apiVersion: v1
kind: Service
metadata:
name: sdserver-service
namespace: spacedrive
labels:
app.kubernetes.io/name: sdserver
app.kubernetes.io/component: webserver
spec:
ports:
- port: 8080
targetPort: 8080
protocol: TCP
selector:
app.kubernetes.io/name: sdserver
app.kubernetes.io/component: webserver
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: sdserver-pvc
namespace: spacedrive
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 512M
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sdserver-deployment
namespace: spacedrive
labels:
app.kubernetes.io/name: sdserver
app.kubernetes.io/component: webserver
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: sdserver
app.kubernetes.io/component: webserver
template:
metadata:
labels:
app.kubernetes.io/name: sdserver
app.kubernetes.io/component: webserver
spec:
restartPolicy: Always
# refer to Dockerfile to find securityContext values
securityContext:
runAsUser: 101
runAsGroup: 101
fsGroup: 101
containers:
- name: sdserver
image: ghcr.io/oscartbeaumont/spacedrive/server:staging
imagePullPolicy: Always
ports:
- containerPort: 8080
volumeMounts:
- name: data-volume
mountPath: /data
securityContext:
allowPrivilegeEscalation: false
resources:
limits:
memory: 100Mi
cpu: 100m
requests:
memory: 5Mi
cpu: 10m
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
failureThreshold: 4
periodSeconds: 5
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 20
failureThreshold: 3
periodSeconds: 10
volumes:
- name: data-volume
persistentVolumeClaim:
claimName: sdserver-pvc

View File

@@ -1,4 +1,4 @@
use sdcore::{ClientCommand, ClientQuery, CoreController, CoreEvent, CoreResponse, Node};
use sdcore::{ClientCommand, ClientQuery, CoreEvent, CoreResponse, Node, NodeController};
use std::{env, path::Path};
use actix::{
@@ -19,7 +19,7 @@ const DATA_DIR_ENV_VAR: &'static str = "DATA_DIR";
/// Define HTTP actor
struct Socket {
_event_receiver: web::Data<mpsc::Receiver<CoreEvent>>,
core: web::Data<CoreController>,
core: web::Data<NodeController>,
}
impl Actor for Socket {
@@ -52,7 +52,15 @@ impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for Socket {
match msg {
Ok(ws::Message::Ping(msg)) => ctx.pong(&msg),
Ok(ws::Message::Text(text)) => {
let msg: SocketMessage = serde_json::from_str(&text).unwrap();
let msg = serde_json::from_str::<SocketMessage>(&text);
let msg = match msg {
Ok(msg) => msg,
Err(err) => {
println!("Error parsing message: {}", err);
return;
},
};
let core = self.core.clone();
@@ -133,7 +141,7 @@ async fn ws_handler(
req: HttpRequest,
stream: web::Payload,
event_receiver: web::Data<mpsc::Receiver<CoreEvent>>,
controller: web::Data<CoreController>,
controller: web::Data<NodeController>,
) -> Result<HttpResponse, Error> {
let resp = ws::start(
Socket {
@@ -178,7 +186,7 @@ async fn main() -> std::io::Result<()> {
async fn setup() -> (
web::Data<mpsc::Receiver<CoreEvent>>,
web::Data<CoreController>,
web::Data<NodeController>,
) {
let data_dir_path = match env::var(DATA_DIR_ENV_VAR) {
Ok(path) => Path::new(&path).to_path_buf(),
@@ -196,15 +204,8 @@ async fn setup() -> (
},
};
let (mut node, event_receiver) = Node::new(data_dir_path).await;
node.initializer().await;
let controller = node.get_controller();
tokio::spawn(async move {
node.start().await;
});
let (controller, event_receiver, node) = Node::new(data_dir_path).await;
tokio::spawn(node.start());
(web::Data::new(event_receiver), web::Data::new(controller))
}

View File

@@ -31,6 +31,16 @@ class Transport extends BaseTransport {
});
}
async query(query: ClientQuery) {
if (websocket.readyState == 0) {
let resolve: () => void;
const promise = new Promise((res) => {
resolve = () => res(undefined);
});
// @ts-ignore
websocket.addEventListener('open', resolve);
await promise;
}
const id = randomId();
let resolve: (data: any) => void;

View File

@@ -24,10 +24,9 @@ ring = "0.17.0-alpha.10"
int-enum = "0.4.0"
# Project dependencies
ts-rs = { version = "6.1", features = ["chrono-impl"] }
ts-rs = { version = "6.1", features = ["chrono-impl", "uuid-impl"] }
prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust.git", tag = "0.5.0" }
walkdir = "^2.3.2"
lazy_static = "1.4.0"
uuid = "0.8"
sysinfo = "0.23.9"
thiserror = "1.0.30"

View File

@@ -1,3 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LibraryCommand } from "./LibraryCommand";
export type ClientCommand = { key: "FileReadMetaData", params: { id: number, } } | { key: "FileSetNote", params: { id: number, note: string | null, } } | { key: "FileDelete", params: { id: number, } } | { key: "LibDelete", params: { id: number, } } | { key: "TagCreate", params: { name: string, color: string, } } | { key: "TagUpdate", params: { name: string, color: string, } } | { key: "TagAssign", params: { file_id: number, tag_id: number, } } | { key: "TagDelete", params: { id: number, } } | { key: "LocCreate", params: { path: string, } } | { key: "LocUpdate", params: { id: number, name: string | null, } } | { key: "LocDelete", params: { id: number, } } | { key: "LocRescan", params: { id: number, } } | { key: "SysVolumeUnmount", params: { id: number, } } | { key: "GenerateThumbsForLocation", params: { id: number, path: string, } } | { key: "IdentifyUniqueFiles", params: { id: number, path: string, } };
export type ClientCommand = { key: "CreateLibrary", params: { name: string, } } | { key: "EditLibrary", params: { id: string, name: string | null, description: string | null, } } | { key: "DeleteLibrary", params: { id: string, } } | { key: "LibraryCommand", params: { library_id: string, command: LibraryCommand, } };

View File

@@ -1,3 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LibraryQuery } from "./LibraryQuery";
export type ClientQuery = { key: "NodeGetState" } | { key: "SysGetVolumes" } | { key: "LibGetTags" } | { key: "JobGetRunning" } | { key: "JobGetHistory" } | { key: "SysGetLocations" } | { key: "SysGetLocation", params: { id: number, } } | { key: "LibGetExplorerDir", params: { location_id: number, path: string, limit: number, } } | { key: "GetLibraryStatistics" } | { key: "GetNodes" };
export type ClientQuery = { key: "NodeGetLibraries" } | { key: "NodeGetState" } | { key: "SysGetVolumes" } | { key: "JobGetRunning" } | { key: "GetNodes" } | { key: "LibraryQuery", params: { library_id: string, query: LibraryQuery, } };

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface ConfigMetadata { version: string | null, }

View File

@@ -1,9 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectoryWithContents } from "./DirectoryWithContents";
import type { JobReport } from "./JobReport";
import type { LibraryConfigWrapped } from "./LibraryConfigWrapped";
import type { LocationResource } from "./LocationResource";
import type { NodeState } from "./NodeState";
import type { Statistics } from "./Statistics";
import type { Volume } from "./Volume";
export type CoreResponse = { key: "Success", data: null } | { key: "SysGetVolumes", data: Array<Volume> } | { key: "SysGetLocation", data: LocationResource } | { key: "SysGetLocations", data: Array<LocationResource> } | { key: "LibGetExplorerDir", data: DirectoryWithContents } | { key: "NodeGetState", data: NodeState } | { key: "LocCreate", data: LocationResource } | { key: "JobGetRunning", data: Array<JobReport> } | { key: "JobGetHistory", data: Array<JobReport> } | { key: "GetLibraryStatistics", data: Statistics };
export type CoreResponse = { key: "Success", data: null } | { key: "Error", data: string } | { key: "NodeGetLibraries", data: Array<LibraryConfigWrapped> } | { key: "SysGetVolumes", data: Array<Volume> } | { key: "SysGetLocation", data: LocationResource } | { key: "SysGetLocations", data: Array<LocationResource> } | { key: "LibGetExplorerDir", data: DirectoryWithContents } | { key: "NodeGetState", data: NodeState } | { key: "LocCreate", data: LocationResource } | { key: "JobGetRunning", data: Array<JobReport> } | { key: "JobGetHistory", data: Array<JobReport> } | { key: "GetLibraryStatistics", data: Statistics };

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type LibraryCommand = { key: "FileReadMetaData", params: { id: number, } } | { key: "FileSetNote", params: { id: number, note: string | null, } } | { key: "FileDelete", params: { id: number, } } | { key: "TagCreate", params: { name: string, color: string, } } | { key: "TagUpdate", params: { name: string, color: string, } } | { key: "TagAssign", params: { file_id: number, tag_id: number, } } | { key: "TagDelete", params: { id: number, } } | { key: "LocCreate", params: { path: string, } } | { key: "LocUpdate", params: { id: number, name: string | null, } } | { key: "LocDelete", params: { id: number, } } | { key: "LocRescan", params: { id: number, } } | { key: "SysVolumeUnmount", params: { id: number, } } | { key: "GenerateThumbsForLocation", params: { id: number, path: string, } } | { key: "IdentifyUniqueFiles", params: { id: number, path: string, } };

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface LibraryConfig { version: string | null, name: string, description: string, }

View File

@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LibraryConfig } from "./LibraryConfig";
export interface LibraryConfigWrapped { uuid: string, config: LibraryConfig, }

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type LibraryQuery = { key: "LibGetTags" } | { key: "JobGetHistory" } | { key: "SysGetLocations" } | { key: "SysGetLocation", params: { id: number, } } | { key: "LibGetExplorerDir", params: { location_id: number, path: string, limit: number, } } | { key: "GetLibraryStatistics" };

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface NodeConfig { version: string | null, id: string, name: string, p2p_port: number | null, }

View File

@@ -1,4 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LibraryState } from "./LibraryState";
export interface NodeState { node_pub_id: string, node_id: number, node_name: string, data_path: string, tcp_port: number, libraries: Array<LibraryState>, current_library_uuid: string, }
export interface NodeState { version: string | null, id: string, name: string, p2p_port: number | null, data_path: string, }

View File

@@ -2,6 +2,7 @@ export * from './bindings/Client';
export * from './bindings/ClientCommand';
export * from './bindings/ClientQuery';
export * from './bindings/ClientState';
export * from './bindings/ConfigMetadata';
export * from './bindings/CoreEvent';
export * from './bindings/CoreResource';
export * from './bindings/CoreResponse';
@@ -12,9 +13,14 @@ export * from './bindings/FileKind';
export * from './bindings/FilePath';
export * from './bindings/JobReport';
export * from './bindings/JobStatus';
export * from './bindings/LibraryCommand';
export * from './bindings/LibraryConfig';
export * from './bindings/LibraryConfigWrapped';
export * from './bindings/LibraryNode';
export * from './bindings/LibraryQuery';
export * from './bindings/LibraryState';
export * from './bindings/LocationResource';
export * from './bindings/NodeConfig';
export * from './bindings/NodeState';
export * from './bindings/Platform';
export * from './bindings/Statistics';

View File

@@ -0,0 +1,29 @@
/*
Warnings:
- You are about to drop the `libraries` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `library_statistics` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "libraries";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "library_statistics";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "statistics" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"date_captured" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"total_file_count" INTEGER NOT NULL DEFAULT 0,
"library_db_size" TEXT NOT NULL DEFAULT '0',
"total_bytes_used" TEXT NOT NULL DEFAULT '0',
"total_bytes_capacity" TEXT NOT NULL DEFAULT '0',
"total_unique_bytes" TEXT NOT NULL DEFAULT '0',
"total_bytes_free" TEXT NOT NULL DEFAULT '0',
"preview_media_bytes" TEXT NOT NULL DEFAULT '0'
);

View File

@@ -35,21 +35,9 @@ model SyncEvent {
@@map("sync_events")
}
model Library {
id Int @id @default(autoincrement())
pub_id String @unique
name String
is_primary Boolean @default(true)
date_created DateTime @default(now())
timezone String?
@@map("libraries")
}
model LibraryStatistics {
model Statistics {
id Int @id @default(autoincrement())
date_captured DateTime @default(now())
library_id Int @unique
total_file_count Int @default(0)
library_db_size String @default("0")
total_bytes_used String @default("0")
@@ -58,7 +46,7 @@ model LibraryStatistics {
total_bytes_free String @default("0")
preview_media_bytes String @default("0")
@@map("library_statistics")
@@map("statistics")
}
model Node {

View File

@@ -1,9 +1,8 @@
use crate::job::JobReportUpdate;
use crate::node::get_nodestate;
use crate::job::{JobReportUpdate, JobResult};
use crate::library::LibraryContext;
use crate::{
job::{Job, WorkerContext},
prisma::file_path,
CoreContext,
};
use crate::{sys, CoreEvent};
use futures::executor::block_on;
@@ -29,11 +28,18 @@ impl Job for ThumbnailJob {
fn name(&self) -> &'static str {
"thumbnailer"
}
async fn run(&self, ctx: WorkerContext) -> Result<(), Box<dyn std::error::Error>> {
let config = get_nodestate();
let core_ctx = ctx.core_ctx.clone();
async fn run(&self, ctx: WorkerContext) -> JobResult {
let library_ctx = ctx.library_ctx();
let thumbnail_dir = Path::new(&library_ctx.config().data_directory())
.join(THUMBNAIL_CACHE_DIR_NAME)
.join(format!("{}", self.location_id));
let location = sys::get_location(&core_ctx, self.location_id).await?;
let location = sys::get_location(&library_ctx, self.location_id).await?;
info!(
"Searching for images in location {} at path {}",
location.id, self.path
);
info!(
"Searching for images in location {} at path {}",
@@ -41,17 +47,12 @@ impl Job for ThumbnailJob {
);
// create all necessary directories if they don't exist
fs::create_dir_all(
Path::new(&config.data_path)
.join(THUMBNAIL_CACHE_DIR_NAME)
.join(format!("{}", self.location_id)),
)?;
fs::create_dir_all(&thumbnail_dir)?;
let root_path = location.path.unwrap();
// query database for all files in this location that need thumbnails
let image_files = get_images(&core_ctx, self.location_id, &self.path).await?;
let image_files = get_images(&library_ctx, self.location_id, &self.path).await?;
info!("Found {:?} files", image_files.len());
let is_background = self.background.clone();
tokio::task::spawn_blocking(move || {
@@ -89,9 +90,7 @@ impl Job for ThumbnailJob {
};
// Define and write the WebP-encoded file to a given path
let output_path = Path::new(&config.data_path)
.join(THUMBNAIL_CACHE_DIR_NAME)
.join(format!("{}", location.id))
let output_path = Path::new(&thumbnail_dir)
.join(&cas_id)
.with_extension("webp");
@@ -107,7 +106,7 @@ impl Job for ThumbnailJob {
ctx.progress(vec![JobReportUpdate::CompletedTaskCount(i + 1)]);
if !is_background {
block_on(ctx.core_ctx.emit(CoreEvent::NewThumbnail { cas_id }));
block_on(ctx.library_ctx().emit(CoreEvent::NewThumbnail { cas_id }));
};
} else {
info!("Thumb exists, skipping... {}", output_path.display());
@@ -146,7 +145,7 @@ pub fn generate_thumbnail(
}
pub async fn get_images(
ctx: &CoreContext,
ctx: &LibraryContext,
location_id: i32,
path: &str,
) -> Result<Vec<file_path::Data>, std::io::Error> {
@@ -166,7 +165,7 @@ pub async fn get_images(
}
let image_files = ctx
.database
.db
.file_path()
.find_many(params)
.with(file_path::file::fetch())

View File

@@ -2,10 +2,10 @@ use super::checksum::generate_cas_id;
use crate::{
file::FileError,
job::JobReportUpdate,
job::{Job, WorkerContext},
job::{Job, JobResult, WorkerContext},
library::LibraryContext,
prisma::{file, file_path},
sys::get_location,
CoreContext,
};
use chrono::{DateTime, FixedOffset};
use futures::executor::block_on;
@@ -33,13 +33,14 @@ impl Job for FileIdentifierJob {
fn name(&self) -> &'static str {
"file_identifier"
}
async fn run(&self, ctx: WorkerContext) -> Result<(), Box<dyn std::error::Error>> {
async fn run(&self, ctx: WorkerContext) -> JobResult {
info!("Identifying orphan file paths...");
let location = get_location(&ctx.core_ctx, self.location_id).await?;
let location = get_location(&ctx.library_ctx(), self.location_id).await?;
let location_path = location.path.unwrap_or("".to_string());
let total_count = count_orphan_file_paths(&ctx.core_ctx, location.id.into()).await?;
let total_count = count_orphan_file_paths(&ctx.library_ctx(), location.id.into()).await?;
info!("Found {} orphan file paths", total_count);
let task_count = (total_count as f64 / CHUNK_SIZE as f64).ceil() as usize;
@@ -48,9 +49,9 @@ impl Job for FileIdentifierJob {
// update job with total task count based on orphan file_paths count
ctx.progress(vec![JobReportUpdate::TaskCount(task_count)]);
let db = ctx.core_ctx.database.clone();
// dedicated tokio thread for task
let _ctx = tokio::task::spawn_blocking(move || {
let db = ctx.library_ctx().db;
let mut completed: usize = 0;
let mut cursor: i32 = 1;
// loop until task count is complete
@@ -60,7 +61,7 @@ impl Job for FileIdentifierJob {
let mut cas_lookup: HashMap<String, i32> = HashMap::new();
// get chunk of orphans to process
let file_paths = match block_on(get_orphan_file_paths(&ctx.core_ctx, cursor)) {
let file_paths = match block_on(get_orphan_file_paths(&ctx.library_ctx(), cursor)) {
Ok(file_paths) => file_paths,
Err(e) => {
info!("Error getting orphan file paths: {}", e);
@@ -192,11 +193,10 @@ struct CountRes {
}
pub async fn count_orphan_file_paths(
ctx: &CoreContext,
ctx: &LibraryContext,
location_id: i64,
) -> Result<usize, FileError> {
let db = &ctx.database;
let files_count = db
let files_count = ctx.db
._query_raw::<CountRes>(raw!(
"SELECT COUNT(*) AS count FROM file_paths WHERE file_id IS NULL AND is_dir IS FALSE AND location_id = {}",
PrismaValue::Int(location_id)
@@ -206,10 +206,10 @@ pub async fn count_orphan_file_paths(
}
pub async fn get_orphan_file_paths(
ctx: &CoreContext,
ctx: &LibraryContext,
cursor: i32,
) -> Result<Vec<file_path::Data>, FileError> {
let db = &ctx.database;
let db = &ctx.db;
info!(
"discovering {} orphan file paths at cursor: {:?}",
CHUNK_SIZE, cursor
@@ -225,6 +225,7 @@ pub async fn get_orphan_file_paths(
.take(CHUNK_SIZE as i64)
.exec()
.await?;
Ok(files)
}

View File

@@ -1,25 +1,22 @@
use crate::{
encode::THUMBNAIL_CACHE_DIR_NAME,
file::{DirectoryWithContents, FileError, FilePath},
node::get_nodestate,
library::LibraryContext,
prisma::file_path,
sys::get_location,
CoreContext,
};
use std::path::Path;
pub async fn open_dir(
ctx: &CoreContext,
ctx: &LibraryContext,
location_id: &i32,
path: &str,
) -> Result<DirectoryWithContents, FileError> {
let db = &ctx.database;
let config = get_nodestate();
// get location
let location = get_location(ctx, location_id.clone()).await?;
let directory = db
let directory = ctx
.db
.file_path()
.find_first(vec![
file_path::location_id::equals(Some(location.id)),
@@ -32,7 +29,8 @@ pub async fn open_dir(
println!("DIRECTORY: {:?}", directory);
let mut file_paths: Vec<FilePath> = db
let mut file_paths: Vec<FilePath> = ctx
.db
.file_path()
.find_many(vec![
file_path::location_id::equals(Some(location.id)),
@@ -47,7 +45,7 @@ pub async fn open_dir(
for file_path in &mut file_paths {
if let Some(file) = &mut file_path.file {
let thumb_path = Path::new(&config.data_path)
let thumb_path = Path::new(&ctx.config().data_directory())
.join(THUMBNAIL_CACHE_DIR_NAME)
.join(format!("{}", location.id))
.join(file.cas_id.clone())

View File

@@ -1,4 +1,4 @@
use crate::job::{Job, JobReportUpdate, WorkerContext};
use crate::job::{Job, JobReportUpdate, JobResult, WorkerContext};
use self::scan::ScanProgress;
mod scan;
@@ -17,9 +17,8 @@ impl Job for IndexerJob {
fn name(&self) -> &'static str {
"indexer"
}
async fn run(&self, ctx: WorkerContext) -> Result<(), Box<dyn std::error::Error>> {
let core_ctx = ctx.core_ctx.clone();
scan_path(&core_ctx, self.path.as_str(), move |p| {
async fn run(&self, ctx: WorkerContext) -> JobResult {
scan_path(&ctx.library_ctx(), self.path.as_str(), move |p| {
ctx.progress(
p.iter()
.map(|p| match p.clone() {

View File

@@ -1,6 +1,7 @@
use crate::job::JobResult;
use crate::library::LibraryContext;
use crate::sys::{create_location, LocationResource};
use crate::CoreContext;
use chrono::{DateTime, FixedOffset, Utc};
use chrono::{DateTime, Utc};
use log::{error, info};
use prisma_client_rust::prisma_models::PrismaValue;
use prisma_client_rust::raw;
@@ -21,11 +22,10 @@ static BATCH_SIZE: usize = 100;
// creates a vector of valid path buffers from a directory
pub async fn scan_path(
ctx: &CoreContext,
ctx: &LibraryContext,
path: &str,
on_progress: impl Fn(Vec<ScanProgress>) + Send + Sync + 'static,
) -> Result<(), Box<dyn std::error::Error>> {
let db = &ctx.database;
) -> JobResult {
let path = path.to_string();
let location = create_location(&ctx, &path).await?;
@@ -36,7 +36,8 @@ pub async fn scan_path(
id: Option<i32>,
}
// grab the next id so we can increment in memory for batch inserting
let first_file_id = match db
let first_file_id = match ctx
.db
._query_raw::<QueryRes>(raw!("SELECT MAX(id) id FROM file_paths"))
.await
{
@@ -162,7 +163,7 @@ pub async fn scan_path(
files
);
let count = db._execute_raw(raw).await;
let count = ctx.db._execute_raw(raw).await;
info!("Inserted {:?} records", count);
}

View File

@@ -1,12 +1,14 @@
use chrono::{DateTime, Utc};
use int_enum::IntEnum;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ts_rs::TS;
use crate::{
library::LibraryContext,
prisma::{self, file, file_path},
sys::SysError,
ClientQuery, CoreContext, CoreError, CoreEvent, CoreResponse,
ClientQuery, CoreError, CoreEvent, CoreResponse, LibraryQuery,
};
pub mod cas;
pub mod explorer;
@@ -32,9 +34,9 @@ pub struct File {
pub ipfs_id: Option<String>,
pub note: Option<String>,
pub date_created: chrono::DateTime<chrono::Utc>,
pub date_modified: chrono::DateTime<chrono::Utc>,
pub date_indexed: chrono::DateTime<chrono::Utc>,
pub date_created: DateTime<Utc>,
pub date_modified: DateTime<Utc>,
pub date_indexed: DateTime<Utc>,
pub paths: Vec<FilePath>,
// pub media_data: Option<MediaData>,
@@ -55,9 +57,9 @@ pub struct FilePath {
pub file_id: Option<i32>,
pub parent_id: Option<i32>,
pub date_created: chrono::DateTime<chrono::Utc>,
pub date_modified: chrono::DateTime<chrono::Utc>,
pub date_indexed: chrono::DateTime<chrono::Utc>,
pub date_created: DateTime<chrono::Utc>,
pub date_modified: DateTime<chrono::Utc>,
pub date_indexed: DateTime<chrono::Utc>,
pub file: Option<File>,
}
@@ -141,12 +143,12 @@ pub enum FileError {
}
pub async fn set_note(
ctx: CoreContext,
ctx: LibraryContext,
id: i32,
note: Option<String>,
) -> Result<CoreResponse, CoreError> {
let response = ctx
.database
let _response = ctx
.db
.file()
.find_unique(file::id::equals(id))
.update(vec![file::note::set(note.clone())])
@@ -154,10 +156,13 @@ pub async fn set_note(
.await
.unwrap();
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibGetExplorerDir {
limit: 0,
path: "".to_string(),
location_id: 0,
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::LibGetExplorerDir {
limit: 0,
path: "".to_string(),
location_id: 0,
},
}))
.await;

View File

@@ -3,48 +3,69 @@ use super::{
JobError,
};
use crate::{
node::get_nodestate,
library::LibraryContext,
prisma::{job, node},
CoreContext,
};
use int_enum::IntEnum;
use log::info;
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, VecDeque},
error::Error,
fmt::Debug,
sync::Arc,
};
use tokio::sync::Mutex;
use tokio::sync::{mpsc, Mutex, RwLock};
use ts_rs::TS;
// db is single threaded, nerd
const MAX_WORKERS: usize = 1;
pub type JobResult = Result<(), Box<dyn Error + Send + Sync>>;
#[async_trait::async_trait]
pub trait Job: Send + Sync + Debug {
async fn run(&self, ctx: WorkerContext) -> Result<(), Box<dyn std::error::Error>>;
async fn run(&self, ctx: WorkerContext) -> JobResult;
fn name(&self) -> &'static str;
}
// jobs struct is maintained by the core
pub struct Jobs {
job_queue: VecDeque<Box<dyn Job>>,
// workers are spawned when jobs are picked off the queue
running_workers: HashMap<String, Arc<Mutex<Worker>>>,
pub enum JobManagerEvent {
IngestJob(LibraryContext, Box<dyn Job>),
}
impl Jobs {
pub fn new() -> Self {
Self {
job_queue: VecDeque::new(),
running_workers: HashMap::new(),
}
// jobs struct is maintained by the core
pub struct JobManager {
job_queue: RwLock<VecDeque<Box<dyn Job>>>,
// workers are spawned when jobs are picked off the queue
running_workers: RwLock<HashMap<String, Arc<Mutex<Worker>>>>,
internal_sender: mpsc::UnboundedSender<JobManagerEvent>,
}
impl JobManager {
pub fn new() -> Arc<Self> {
let (internal_sender, mut internal_reciever) = mpsc::unbounded_channel();
let this = Arc::new(Self {
job_queue: RwLock::new(VecDeque::new()),
running_workers: RwLock::new(HashMap::new()),
internal_sender,
});
let this2 = this.clone();
tokio::spawn(async move {
while let Some(event) = internal_reciever.recv().await {
match event {
JobManagerEvent::IngestJob(ctx, job) => this2.clone().ingest(&ctx, job).await,
}
}
});
this
}
pub async fn ingest(&mut self, ctx: &CoreContext, job: Box<dyn Job>) {
pub async fn ingest(self: Arc<Self>, ctx: &LibraryContext, job: Box<dyn Job>) {
// create worker to process job
if self.running_workers.len() < MAX_WORKERS {
let mut running_workers = self.running_workers.write().await;
if running_workers.len() < MAX_WORKERS {
info!("Running job: {:?}", job.name());
let worker = Worker::new(job);
@@ -52,51 +73,57 @@ impl Jobs {
let wrapped_worker = Arc::new(Mutex::new(worker));
Worker::spawn(wrapped_worker.clone(), ctx).await;
Worker::spawn(self.clone(), wrapped_worker.clone(), ctx).await;
self.running_workers.insert(id, wrapped_worker);
running_workers.insert(id, wrapped_worker);
} else {
self.job_queue.push_back(job);
self.job_queue.write().await.push_back(job);
}
}
pub fn ingest_queue(&mut self, _ctx: &CoreContext, job: Box<dyn Job>) {
self.job_queue.push_back(job);
pub async fn ingest_queue(&self, _ctx: &LibraryContext, job: Box<dyn Job>) {
self.job_queue.write().await.push_back(job);
}
pub async fn complete(&mut self, ctx: &CoreContext, job_id: String) {
pub async fn complete(self: Arc<Self>, ctx: &LibraryContext, job_id: String) {
// remove worker from running workers
self.running_workers.remove(&job_id);
self.running_workers.write().await.remove(&job_id);
// continue queue
let job = self.job_queue.pop_front();
let job = self.job_queue.write().await.pop_front();
if let Some(job) = job {
self.ingest(ctx, job).await;
// We can't directly execute `self.ingest` here because it would cause an async cycle.
self.internal_sender
.send(JobManagerEvent::IngestJob(ctx.clone(), job))
.unwrap_or_else(|_| {
println!("Failed to ingest job!");
});
}
}
pub async fn get_running(&self) -> Vec<JobReport> {
let mut ret = vec![];
for worker in self.running_workers.values() {
for worker in self.running_workers.read().await.values() {
let worker = worker.lock().await;
ret.push(worker.job_report.clone());
}
ret
}
pub async fn queue_pending_job(ctx: &CoreContext) -> Result<(), JobError> {
let db = &ctx.database;
// pub async fn queue_pending_job(ctx: &LibraryContext) -> Result<(), JobError> {
// let db = &ctx.db;
let next_job = db
.job()
.find_first(vec![job::status::equals(JobStatus::Queued.int_value())])
.exec()
.await?;
// let _next_job = db
// .job()
// .find_first(vec![job::status::equals(JobStatus::Queued.int_value())])
// .exec()
// .await?;
Ok(())
}
// Ok(())
// }
pub async fn get_history(ctx: &CoreContext) -> Result<Vec<JobReport>, JobError> {
let db = &ctx.database;
pub async fn get_history(ctx: &LibraryContext) -> Result<Vec<JobReport>, JobError> {
let db = &ctx.db;
let jobs = db
.job()
.find_many(vec![job::status::not(JobStatus::Running.int_value())])
@@ -172,30 +199,29 @@ impl JobReport {
seconds_elapsed: 0,
}
}
pub async fn create(&self, ctx: &CoreContext) -> Result<(), JobError> {
let config = get_nodestate();
pub async fn create(&self, ctx: &LibraryContext) -> Result<(), JobError> {
let mut params = Vec::new();
if let Some(_) = &self.data {
params.push(job::data::set(self.data.clone()))
}
ctx.database
ctx.db
.job()
.create(
job::id::set(self.id.clone()),
job::name::set(self.name.clone()),
job::action::set(1),
job::nodes::link(node::id::equals(config.node_id)),
job::nodes::link(node::id::equals(ctx.node_local_id)),
params,
)
.exec()
.await?;
Ok(())
}
pub async fn update(&self, ctx: &CoreContext) -> Result<(), JobError> {
ctx.database
pub async fn update(&self, ctx: &LibraryContext) -> Result<(), JobError> {
ctx.db
.job()
.find_unique(job::id::equals(self.id.clone()))
.update(vec![

View File

@@ -1,8 +1,8 @@
use super::{
jobs::{JobReport, JobReportUpdate, JobStatus},
Job,
Job, JobManager,
};
use crate::{ClientQuery, CoreContext, CoreEvent, InternalEvent};
use crate::{library::LibraryContext, ClientQuery, CoreEvent, LibraryQuery};
use std::{sync::Arc, time::Duration};
use tokio::{
sync::{
@@ -26,8 +26,8 @@ enum WorkerState {
#[derive(Clone)]
pub struct WorkerContext {
pub uuid: String,
pub core_ctx: CoreContext,
pub sender: UnboundedSender<WorkerEvent>,
library_ctx: LibraryContext,
sender: UnboundedSender<WorkerEvent>,
}
impl WorkerContext {
@@ -36,9 +36,13 @@ impl WorkerContext {
.send(WorkerEvent::Progressed(updates))
.unwrap_or(());
}
pub fn library_ctx(&self) -> LibraryContext {
self.library_ctx.clone()
}
// save the job data to
// pub fn save_data () {
// }
}
@@ -63,7 +67,11 @@ impl Worker {
}
}
// spawns a thread and extracts channel sender to communicate with it
pub async fn spawn(worker: Arc<Mutex<Self>>, ctx: &CoreContext) {
pub async fn spawn(
job_manager: Arc<JobManager>,
worker: Arc<Mutex<Self>>,
ctx: &LibraryContext,
) {
// we capture the worker receiver channel so state can be updated from inside the worker
let mut worker_mut = worker.lock().await;
// extract owned job and receiver from Self
@@ -76,10 +84,11 @@ impl Worker {
WorkerState::Running => unreachable!(),
};
let worker_sender = worker_mut.worker_sender.clone();
let core_ctx = ctx.clone();
worker_mut.job_report.status = JobStatus::Running;
let ctx = ctx.clone();
worker_mut.job_report.create(&ctx).await.unwrap_or(());
// spawn task to handle receiving events from the worker
@@ -94,7 +103,7 @@ impl Worker {
tokio::spawn(async move {
let worker_ctx = WorkerContext {
uuid,
core_ctx,
library_ctx: ctx.clone(),
sender: worker_sender,
};
let job_start = Instant::now();
@@ -113,20 +122,17 @@ impl Worker {
}
});
let result = job.run(worker_ctx.clone()).await;
if let Err(e) = result {
println!("job failed {:?}", e);
worker_ctx.sender.send(WorkerEvent::Failed).unwrap_or(());
} else {
// handle completion
worker_ctx.sender.send(WorkerEvent::Completed).unwrap_or(());
match job.run(worker_ctx.clone()).await {
Ok(_) => {
worker_ctx.sender.send(WorkerEvent::Completed).unwrap_or(());
}
Err(err) => {
println!("job '{}' failed with error: {}", worker_ctx.uuid, err);
worker_ctx.sender.send(WorkerEvent::Failed).unwrap_or(());
}
}
worker_ctx
.core_ctx
.internal_sender
.send(InternalEvent::JobComplete(worker_ctx.uuid.clone()))
.unwrap_or(());
job_manager.complete(&ctx, worker_ctx.uuid).await;
});
}
@@ -137,7 +143,7 @@ impl Worker {
async fn track_progress(
worker: Arc<Mutex<Self>>,
mut channel: UnboundedReceiver<WorkerEvent>,
ctx: CoreContext,
ctx: LibraryContext,
) {
while let Some(command) = channel.recv().await {
let mut worker = worker.lock().await;
@@ -176,16 +182,23 @@ impl Worker {
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::JobGetRunning))
.await;
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::JobGetHistory))
.await;
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::JobGetHistory,
}))
.await;
break;
}
WorkerEvent::Failed => {
worker.job_report.status = JobStatus::Failed;
worker.job_report.update(&ctx).await.unwrap_or(());
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::JobGetHistory))
.await;
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::JobGetHistory,
}))
.await;
break;
}
}

View File

@@ -1,11 +1,13 @@
use crate::{
file::cas::FileIdentifierJob, library::get_library_path, node::NodeState,
prisma::file as prisma_file, prisma::location, util::db::create_connection,
};
use job::{Job, JobReport, Jobs};
use prisma::PrismaClient;
use crate::{file::cas::FileIdentifierJob, prisma::file as prisma_file, prisma::location};
use job::{JobManager, JobReport};
use library::{LibraryConfig, LibraryConfigWrapped, LibraryManager};
use node::{NodeConfig, NodeConfigManager};
use serde::{Deserialize, Serialize};
use std::{fs, sync::Arc};
use std::{
fs,
path::{Path, PathBuf},
sync::Arc,
};
use thiserror::Error;
use tokio::sync::{
mpsc::{self, unbounded_channel, UnboundedReceiver, UnboundedSender},
@@ -32,12 +34,12 @@ pub struct ReturnableMessage<D, R = Result<CoreResponse, CoreError>> {
}
// core controller is passed to the client to communicate with the core which runs in a dedicated thread
pub struct CoreController {
pub struct NodeController {
query_sender: UnboundedSender<ReturnableMessage<ClientQuery>>,
command_sender: UnboundedSender<ReturnableMessage<ClientCommand>>,
}
impl CoreController {
impl NodeController {
pub async fn query(&self, query: ClientQuery) -> Result<CoreResponse, CoreError> {
// a one time use channel to send and await a response
let (sender, recv) = oneshot::channel();
@@ -64,35 +66,14 @@ impl CoreController {
}
}
#[derive(Debug)]
pub enum InternalEvent {
JobIngest(Box<dyn Job>),
JobQueue(Box<dyn Job>),
JobComplete(String),
}
#[derive(Clone)]
pub struct CoreContext {
pub database: Arc<PrismaClient>,
pub struct NodeContext {
pub event_sender: mpsc::Sender<CoreEvent>,
pub internal_sender: UnboundedSender<InternalEvent>,
pub config: Arc<NodeConfigManager>,
pub jobs: Arc<JobManager>,
}
impl CoreContext {
pub fn spawn_job(&self, job: Box<dyn Job>) {
self.internal_sender
.send(InternalEvent::JobIngest(job))
.unwrap_or_else(|e| {
println!("Failed to spawn job. {:?}", e);
});
}
pub fn queue_job(&self, job: Box<dyn Job>) {
self.internal_sender
.send(InternalEvent::JobQueue(job))
.unwrap_or_else(|e| {
println!("Failed to queue job. {:?}", e);
});
}
impl NodeContext {
pub async fn emit(&self, event: CoreEvent) {
self.event_sender.send(event).await.unwrap_or_else(|e| {
println!("Failed to emit event. {:?}", e);
@@ -101,11 +82,9 @@ impl CoreContext {
}
pub struct Node {
state: NodeState,
jobs: job::Jobs,
database: Arc<PrismaClient>,
// filetype_registry: library::TypeRegistry,
// extension_registry: library::ExtensionRegistry,
config: Arc<NodeConfigManager>,
library_manager: Arc<LibraryManager>,
jobs: Arc<JobManager>,
// global messaging channels
query_channel: (
@@ -117,73 +96,52 @@ pub struct Node {
UnboundedReceiver<ReturnableMessage<ClientCommand>>,
),
event_sender: mpsc::Sender<CoreEvent>,
// a channel for child threads to send events back to the core
internal_channel: (
UnboundedSender<InternalEvent>,
UnboundedReceiver<InternalEvent>,
),
}
impl Node {
// create new instance of node, run startup tasks
pub async fn new(mut data_dir: std::path::PathBuf) -> (Node, mpsc::Receiver<CoreEvent>) {
let (event_sender, event_recv) = mpsc::channel(100);
data_dir = data_dir.join("spacedrive");
let data_dir = data_dir.to_str().unwrap();
// create data directory if it doesn't exist
pub async fn new(data_dir: PathBuf) -> (NodeController, mpsc::Receiver<CoreEvent>, Node) {
fs::create_dir_all(&data_dir).unwrap();
// prepare basic client state
let mut state = NodeState::new(data_dir, "diamond-mastering-space-dragon").unwrap();
// load from disk
state
.read_disk()
.unwrap_or(println!("Error: No node state found, creating new one..."));
state.save();
println!("Node State: {:?}", state);
// connect to default library
let database = Arc::new(
create_connection(&get_library_path(&data_dir))
.await
.unwrap(),
);
let internal_channel = unbounded_channel::<InternalEvent>();
let node = Node {
state,
query_channel: unbounded_channel(),
command_channel: unbounded_channel(),
jobs: Jobs::new(),
event_sender,
database,
internal_channel,
let (event_sender, event_recv) = mpsc::channel(100);
let config = NodeConfigManager::new(data_dir.clone()).await.unwrap();
let jobs = JobManager::new();
let node_ctx = NodeContext {
event_sender: event_sender.clone(),
config: config.clone(),
jobs: jobs.clone(),
};
(node, event_recv)
let node = Node {
config,
library_manager: LibraryManager::new(Path::new(&data_dir).join("libraries"), node_ctx)
.await
.unwrap(),
query_channel: unbounded_channel(),
command_channel: unbounded_channel(),
jobs,
event_sender,
};
(
NodeController {
query_sender: node.query_channel.0.clone(),
command_sender: node.command_channel.0.clone(),
},
event_recv,
node,
)
}
pub fn get_context(&self) -> CoreContext {
CoreContext {
database: self.database.clone(),
pub fn get_context(&self) -> NodeContext {
NodeContext {
event_sender: self.event_sender.clone(),
internal_sender: self.internal_channel.0.clone(),
config: self.config.clone(),
jobs: self.jobs.clone(),
}
}
pub fn get_controller(&self) -> CoreController {
CoreController {
query_sender: self.query_channel.0.clone(),
command_sender: self.command_channel.0.clone(),
}
}
pub async fn start(&mut self) {
let ctx = self.get_context();
pub async fn start(mut self) {
loop {
// listen on global messaging channels for incoming messages
tokio::select! {
@@ -195,174 +153,200 @@ impl Node {
let res = self.exec_command(msg.data).await;
msg.return_sender.send(res).unwrap_or(());
}
Some(event) = self.internal_channel.1.recv() => {
match event {
InternalEvent::JobIngest(job) => {
self.jobs.ingest(&ctx, job).await;
},
InternalEvent::JobQueue(job) => {
self.jobs.ingest_queue(&ctx, job);
},
InternalEvent::JobComplete(id) => {
self.jobs.complete(&ctx, id).await;
},
}
}
}
}
}
// load library database + initialize client with db
pub async fn initializer(&self) {
println!("Initializing...");
let ctx = self.get_context();
if self.state.libraries.len() == 0 {
match library::create(&ctx, None).await {
Ok(library) => println!("Created new library: {:?}", library),
Err(e) => println!("Error creating library: {:?}", e),
}
} else {
for library in self.state.libraries.iter() {
// init database for library
match library::load(&ctx, &library.library_path, &library.library_uuid).await {
Ok(library) => println!("Loaded library: {:?}", library),
Err(e) => println!("Error loading library: {:?}", e),
}
}
}
// init node data within library
match node::LibraryNode::create(&self).await {
Ok(_) => println!("Spacedrive online"),
Err(e) => println!("Error initializing node: {:?}", e),
};
}
async fn exec_command(&mut self, cmd: ClientCommand) -> Result<CoreResponse, CoreError> {
println!("Core command: {:?}", cmd);
let ctx = self.get_context();
Ok(match cmd {
// CRUD for locations
ClientCommand::LocCreate { path } => {
let loc = sys::new_location_and_scan(&ctx, &path).await?;
// ctx.queue_job(Box::new(FileIdentifierJob));
CoreResponse::LocCreate(loc)
ClientCommand::CreateLibrary { name } => {
self.library_manager
.create(LibraryConfig {
name: name.to_string(),
..Default::default()
})
.await
.unwrap();
CoreResponse::Success(())
}
ClientCommand::LocUpdate { id, name } => {
ctx.database
.location()
.find_unique(location::id::equals(id))
.update(vec![location::name::set(name)])
.exec()
.await?;
ClientCommand::EditLibrary {
id,
name,
description,
} => {
self.library_manager
.edit_library(id, name, description)
.await
.unwrap();
CoreResponse::Success(())
}
ClientCommand::DeleteLibrary { id } => {
self.library_manager.delete_library(id).await.unwrap();
CoreResponse::Success(())
}
ClientCommand::LibraryCommand {
library_id,
command,
} => {
let ctx = self.library_manager.get_ctx(library_id).await.unwrap();
match command {
// CRUD for locations
LibraryCommand::LocCreate { path } => {
let loc = sys::new_location_and_scan(&ctx, &path).await?;
// ctx.queue_job(Box::new(FileIdentifierJob));
CoreResponse::LocCreate(loc)
}
LibraryCommand::LocUpdate { id, name } => {
ctx.db
.location()
.find_unique(location::id::equals(id))
.update(vec![location::name::set(name)])
.exec()
.await?;
CoreResponse::Success(())
}
ClientCommand::LocDelete { id } => {
sys::delete_location(&ctx, id).await?;
CoreResponse::Success(())
}
ClientCommand::LocRescan { id } => {
sys::scan_location(&ctx, id, String::new());
CoreResponse::Success(())
}
// CRUD for files
ClientCommand::FileReadMetaData { id: _ } => todo!(),
ClientCommand::FileSetNote { id, note } => file::set_note(ctx, id, note).await?,
// ClientCommand::FileEncrypt { id: _, algorithm: _ } => todo!(),
ClientCommand::FileDelete { id } => {
ctx.database
.file()
.find_unique(prisma_file::id::equals(id))
.delete()
.exec()
.await?;
CoreResponse::Success(())
}
LibraryCommand::LocDelete { id } => {
sys::delete_location(&ctx, id).await?;
CoreResponse::Success(())
}
LibraryCommand::LocRescan { id } => {
sys::scan_location(&ctx, id, String::new()).await;
CoreResponse::Success(())
}
// CRUD for files
LibraryCommand::FileReadMetaData { id: _ } => todo!(),
LibraryCommand::FileSetNote { id, note } => {
file::set_note(ctx, id, note).await?
}
// ClientCommand::FileEncrypt { id: _, algorithm: _ } => todo!(),
LibraryCommand::FileDelete { id } => {
ctx.db
.file()
.find_unique(prisma_file::id::equals(id))
.delete()
.exec()
.await?;
CoreResponse::Success(())
}
// CRUD for tags
ClientCommand::TagCreate { name: _, color: _ } => todo!(),
ClientCommand::TagAssign {
file_id: _,
tag_id: _,
} => todo!(),
ClientCommand::TagDelete { id: _ } => todo!(),
// CRUD for libraries
ClientCommand::SysVolumeUnmount { id: _ } => todo!(),
ClientCommand::LibDelete { id: _ } => todo!(),
ClientCommand::TagUpdate { name: _, color: _ } => todo!(),
ClientCommand::GenerateThumbsForLocation { id, path } => {
ctx.spawn_job(Box::new(ThumbnailJob {
location_id: id,
path,
background: false, // fix
}));
CoreResponse::Success(())
}
// ClientCommand::PurgeDatabase => {
// println!("Purging database...");
// fs::remove_file(Path::new(&self.state.data_path).join("library.db")).unwrap();
// CoreResponse::Success(())
// }
ClientCommand::IdentifyUniqueFiles { id, path } => {
ctx.spawn_job(Box::new(FileIdentifierJob {
location_id: id,
path,
}));
CoreResponse::Success(())
CoreResponse::Success(())
}
// CRUD for tags
LibraryCommand::TagCreate { name: _, color: _ } => todo!(),
LibraryCommand::TagAssign {
file_id: _,
tag_id: _,
} => todo!(),
LibraryCommand::TagUpdate { name: _, color: _ } => todo!(),
LibraryCommand::TagDelete { id: _ } => todo!(),
// CRUD for libraries
LibraryCommand::SysVolumeUnmount { id: _ } => todo!(),
LibraryCommand::GenerateThumbsForLocation { id, path } => {
ctx.spawn_job(Box::new(ThumbnailJob {
location_id: id,
path,
background: false, // fix
}))
.await;
CoreResponse::Success(())
}
LibraryCommand::IdentifyUniqueFiles { id, path } => {
ctx.spawn_job(Box::new(FileIdentifierJob {
location_id: id,
path,
}))
.await;
CoreResponse::Success(())
}
}
}
})
}
// query sources of data
async fn exec_query(&self, query: ClientQuery) -> Result<CoreResponse, CoreError> {
let ctx = self.get_context();
Ok(match query {
// return the client state from memory
ClientQuery::NodeGetState => CoreResponse::NodeGetState(self.state.clone()),
// get system volumes without saving to library
ClientQuery::SysGetVolumes => CoreResponse::SysGetVolumes(sys::Volume::get_volumes()?),
ClientQuery::SysGetLocations => {
CoreResponse::SysGetLocations(sys::get_locations(&ctx).await?)
}
// get location from library
ClientQuery::SysGetLocation { id } => {
CoreResponse::SysGetLocation(sys::get_location(&ctx, id).await?)
}
// return contents of a directory for the explorer
ClientQuery::LibGetExplorerDir {
path,
location_id,
limit: _,
} => CoreResponse::LibGetExplorerDir(
file::explorer::open_dir(&ctx, &location_id, &path).await?,
ClientQuery::NodeGetLibraries => CoreResponse::NodeGetLibraries(
self.library_manager.get_all_libraries_config().await,
),
ClientQuery::LibGetTags => todo!(),
ClientQuery::NodeGetState => CoreResponse::NodeGetState(NodeState {
config: self.config.get().await,
data_path: self.config.data_directory().to_str().unwrap().to_string(),
}),
ClientQuery::SysGetVolumes => CoreResponse::SysGetVolumes(sys::Volume::get_volumes()?),
ClientQuery::JobGetRunning => {
CoreResponse::JobGetRunning(self.jobs.get_running().await)
}
ClientQuery::JobGetHistory => {
CoreResponse::JobGetHistory(Jobs::get_history(&ctx).await?)
}
ClientQuery::GetLibraryStatistics => {
CoreResponse::GetLibraryStatistics(library::Statistics::calculate(&ctx).await?)
}
ClientQuery::GetNodes => todo!(),
ClientQuery::LibraryQuery { library_id, query } => {
let ctx = match self.library_manager.get_ctx(library_id.clone()).await {
Some(ctx) => ctx,
None => {
println!("Library '{}' not found!", library_id);
return Ok(CoreResponse::Error("Library not found".into()));
}
};
match query {
LibraryQuery::SysGetLocations => {
CoreResponse::SysGetLocations(sys::get_locations(&ctx).await?)
}
// get location from library
LibraryQuery::SysGetLocation { id } => {
CoreResponse::SysGetLocation(sys::get_location(&ctx, id).await?)
}
// return contents of a directory for the explorer
LibraryQuery::LibGetExplorerDir {
path,
location_id,
limit: _,
} => CoreResponse::LibGetExplorerDir(
file::explorer::open_dir(&ctx, &location_id, &path).await?,
),
LibraryQuery::LibGetTags => todo!(),
LibraryQuery::JobGetHistory => {
CoreResponse::JobGetHistory(JobManager::get_history(&ctx).await?)
}
LibraryQuery::GetLibraryStatistics => CoreResponse::GetLibraryStatistics(
library::Statistics::calculate(&ctx).await?,
),
}
}
})
}
}
// represents an event this library can emit
/// is a command destined for the core
#[derive(Serialize, Deserialize, Debug, TS)]
#[serde(tag = "key", content = "params")]
#[ts(export)]
pub enum ClientCommand {
// Libraries
CreateLibrary {
name: String,
},
EditLibrary {
id: String,
name: Option<String>,
description: Option<String>,
},
DeleteLibrary {
id: String,
},
LibraryCommand {
library_id: String,
command: LibraryCommand,
},
}
/// is a command destined for a specific library which is loaded into the core.
#[derive(Serialize, Deserialize, Debug, TS)]
#[serde(tag = "key", content = "params")]
#[ts(export)]
pub enum LibraryCommand {
// Files
FileReadMetaData { id: i32 },
FileSetNote { id: i32, note: Option<String> },
// FileEncrypt { id: i32, algorithm: EncryptionAlgorithm },
FileDelete { id: i32 },
// Library
LibDelete { id: i32 },
// Tags
TagCreate { name: String, color: String },
TagUpdate { name: String, color: String },
@@ -380,15 +364,28 @@ pub enum ClientCommand {
IdentifyUniqueFiles { id: i32, path: String },
}
// represents an event this library can emit
/// is a query destined for the core
#[derive(Serialize, Deserialize, Debug, TS)]
#[serde(tag = "key", content = "params")]
#[ts(export)]
pub enum ClientQuery {
NodeGetLibraries,
NodeGetState,
SysGetVolumes,
LibGetTags,
JobGetRunning,
GetNodes,
LibraryQuery {
library_id: String,
query: LibraryQuery,
},
}
/// is a query destined for a specific library which is loaded into the core.
#[derive(Serialize, Deserialize, Debug, TS)]
#[serde(tag = "key", content = "params")]
#[ts(export)]
pub enum LibraryQuery {
LibGetTags,
JobGetHistory,
SysGetLocations,
SysGetLocation {
@@ -400,7 +397,6 @@ pub enum ClientQuery {
limit: i32,
},
GetLibraryStatistics,
GetNodes,
}
// represents an event this library can emit
@@ -417,11 +413,21 @@ pub enum CoreEvent {
DatabaseDisconnected { reason: Option<String> },
}
#[derive(Serialize, Deserialize, Debug, TS)]
#[ts(export)]
pub struct NodeState {
#[serde(flatten)]
pub config: NodeConfig,
pub data_path: String,
}
#[derive(Serialize, Deserialize, Debug, TS)]
#[serde(tag = "key", content = "data")]
#[ts(export)]
pub enum CoreResponse {
Success(()),
Error(String),
NodeGetLibraries(Vec<LibraryConfigWrapped>),
SysGetVolumes(Vec<sys::Volume>),
SysGetLocation(sys::LocationResource),
SysGetLocations(Vec<sys::LocationResource>),

View File

@@ -0,0 +1,72 @@
use std::{
fs::File,
io::{BufReader, Seek, SeekFrom},
path::PathBuf,
};
use serde::{Deserialize, Serialize};
use std::io::Write;
use ts_rs::TS;
use crate::node::ConfigMetadata;
use super::LibraryManagerError;
/// LibraryConfig holds the configuration for a specific library. This is stored as a '{uuid}.sdlibrary' file.
#[derive(Debug, Serialize, Deserialize, Clone, TS, Default)]
#[ts(export)]
pub struct LibraryConfig {
#[serde(flatten)]
pub metadata: ConfigMetadata,
/// name is the display name of the library. This is used in the UI and is set by the user.
pub name: String,
/// description is a user set description of the library. This is used in the UI and is set by the user.
pub description: String,
}
impl LibraryConfig {
/// read will read the configuration from disk and return it.
pub(super) async fn read(file_dir: PathBuf) -> Result<LibraryConfig, LibraryManagerError> {
let mut file = File::open(&file_dir)?;
let base_config: ConfigMetadata = serde_json::from_reader(BufReader::new(&mut file))?;
Self::migrate_config(base_config.version, file_dir)?;
file.seek(SeekFrom::Start(0))?;
Ok(serde_json::from_reader(BufReader::new(&mut file))?)
}
/// save will write the configuration back to disk
pub(super) async fn save(
file_dir: PathBuf,
config: &LibraryConfig,
) -> Result<(), LibraryManagerError> {
File::create(file_dir)
.map_err(LibraryManagerError::IOError)?
.write_all(serde_json::to_string(config)?.as_bytes())
.map_err(LibraryManagerError::IOError)?;
Ok(())
}
/// migrate_config is a function used to apply breaking changes to the library config file.
fn migrate_config(
current_version: Option<String>,
config_path: PathBuf,
) -> Result<(), LibraryManagerError> {
match current_version {
None => Err(LibraryManagerError::MigrationError(format!(
"Your Spacedrive library at '{}' is missing the `version` field",
config_path.display()
))),
_ => Ok(()),
}
}
}
// used to return to the frontend with uuid context
#[derive(Serialize, Deserialize, Debug, TS)]
#[ts(export)]
pub struct LibraryConfigWrapped {
pub uuid: String,
pub config: LibraryConfig,
}

View File

@@ -0,0 +1,46 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::{job::Job, node::NodeConfigManager, prisma::PrismaClient, CoreEvent, NodeContext};
use super::LibraryConfig;
/// LibraryContext holds context for a library which can be passed around the application.
#[derive(Clone)]
pub struct LibraryContext {
/// id holds the ID of the current library.
pub id: Uuid,
/// config holds the configuration of the current library.
pub config: LibraryConfig,
/// db holds the database client for the current library.
pub db: Arc<PrismaClient>,
/// node_local_id holds the local ID of the node which is running the library.
pub node_local_id: i32,
/// node_context holds the node context for the node which this library is running on.
pub(super) node_context: NodeContext,
}
impl LibraryContext {
pub(crate) async fn spawn_job(&self, job: Box<dyn Job>) {
self.node_context.jobs.clone().ingest(self, job).await;
}
pub(crate) async fn queue_job(&self, job: Box<dyn Job>) {
self.node_context.jobs.ingest_queue(self, job).await;
}
pub(crate) async fn emit(&self, event: CoreEvent) {
self.node_context
.event_sender
.send(event)
.await
.unwrap_or_else(|e| {
println!("Failed to emit event. {:?}", e);
});
}
pub(crate) fn config(&self) -> Arc<NodeConfigManager> {
self.node_context.config.clone()
}
}

View File

@@ -0,0 +1,271 @@
use std::{
env, fs, io,
path::{Path, PathBuf},
str::FromStr,
sync::Arc,
};
use thiserror::Error;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::{
node::Platform,
prisma::{self, node},
util::db::load_and_migrate,
ClientQuery, CoreEvent, NodeContext,
};
use super::{LibraryConfig, LibraryConfigWrapped, LibraryContext};
/// LibraryManager is a singleton that manages all libraries for a node.
pub struct LibraryManager {
/// libraries_dir holds the path to the directory where libraries are stored.
libraries_dir: PathBuf,
/// libraries holds the list of libraries which are currently loaded into the node.
libraries: RwLock<Vec<LibraryContext>>,
/// node_context holds the context for the node which this library manager is running on.
node_context: NodeContext,
}
#[derive(Error, Debug)]
pub enum LibraryManagerError {
#[error("error saving or loading the config from the filesystem")]
IOError(#[from] io::Error),
#[error("error serializing or deserializing the JSON in the config file")]
JsonError(#[from] serde_json::Error),
#[error("Database error")]
DatabaseError(#[from] prisma::QueryError),
#[error("Library not found error")]
LibraryNotFoundError,
#[error("error migrating the config file")]
MigrationError(String),
#[error("failed to parse uuid")]
UuidError(#[from] uuid::Error),
}
impl LibraryManager {
pub(crate) async fn new(
libraries_dir: PathBuf,
node_context: NodeContext,
) -> Result<Arc<Self>, LibraryManagerError> {
fs::create_dir_all(&libraries_dir)?;
let mut libraries = Vec::new();
for entry in fs::read_dir(&libraries_dir)?
.into_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry.path().is_file()
&& entry
.path()
.extension()
.map(|v| &*v == "sdlibrary")
.unwrap_or(false)
}) {
let config_path = entry.path();
let library_id = match Path::new(&config_path)
.file_stem()
.map(|v| v.to_str().map(|v| Uuid::from_str(v)))
{
Some(Some(Ok(id))) => id,
_ => {
println!("Attempted to load library from path '{}' but it has an invalid filename. Skipping...", config_path.display());
continue;
}
};
let db_path = config_path.clone().with_extension("db");
if !db_path.exists() {
println!(
"Found library '{}' but no matching database file was found. Skipping...",
config_path.display()
);
continue;
}
let config = LibraryConfig::read(config_path).await?;
libraries.push(
Self::load(
library_id,
db_path.to_str().unwrap(),
config,
node_context.clone(),
)
.await?,
);
}
let this = Arc::new(Self {
libraries: RwLock::new(libraries),
libraries_dir,
node_context,
});
// TODO: Remove this before merging PR -> Currently it exists to make the app usable
if this.libraries.read().await.len() == 0 {
this.create(LibraryConfig {
name: "My Default Library".into(),
..Default::default()
})
.await
.unwrap();
}
Ok(this)
}
/// create creates a new library with the given config and mounts it into the running [LibraryManager].
pub(crate) async fn create(&self, config: LibraryConfig) -> Result<(), LibraryManagerError> {
let id = Uuid::new_v4();
LibraryConfig::save(
Path::new(&self.libraries_dir).join(format!("{}.sdlibrary", id.to_string())),
&config,
)
.await?;
let library = Self::load(
id,
&Path::new(&self.libraries_dir)
.join(format!("{}.db", id.to_string()))
.to_str()
.unwrap(),
config,
self.node_context.clone(),
)
.await?;
self.libraries.write().await.push(library);
self.node_context
.emit(CoreEvent::InvalidateQuery(ClientQuery::NodeGetLibraries))
.await;
Ok(())
}
pub(crate) async fn get_all_libraries_config(&self) -> Vec<LibraryConfigWrapped> {
self.libraries
.read()
.await
.iter()
.map(|lib| LibraryConfigWrapped {
config: lib.config.clone(),
uuid: lib.id.to_string(),
})
.collect()
}
pub(crate) async fn edit_library(
&self,
id: String,
name: Option<String>,
description: Option<String>,
) -> Result<(), LibraryManagerError> {
// check library is valid
let mut libraries = self.libraries.write().await;
let library = libraries
.iter_mut()
.find(|lib| lib.id == Uuid::from_str(&id).unwrap())
.ok_or(LibraryManagerError::LibraryNotFoundError)?;
// update the library
if let Some(name) = name {
library.config.name = name;
}
if let Some(description) = description {
library.config.description = description;
}
LibraryConfig::save(
Path::new(&self.libraries_dir).join(format!("{}.sdlibrary", id.to_string())),
&library.config,
)
.await?;
self.node_context
.emit(CoreEvent::InvalidateQuery(ClientQuery::NodeGetLibraries))
.await;
Ok(())
}
pub async fn delete_library(&self, id: String) -> Result<(), LibraryManagerError> {
let mut libraries = self.libraries.write().await;
let id = Uuid::parse_str(&id)?;
let library = libraries
.iter()
.find(|l| l.id == id)
.ok_or(LibraryManagerError::LibraryNotFoundError)?;
fs::remove_file(
Path::new(&self.libraries_dir).join(format!("{}.db", library.id.to_string())),
)?;
fs::remove_file(
Path::new(&self.libraries_dir).join(format!("{}.sdlibrary", library.id.to_string())),
)?;
libraries.retain(|l| l.id != id);
self.node_context
.emit(CoreEvent::InvalidateQuery(ClientQuery::NodeGetLibraries))
.await;
Ok(())
}
// get_ctx will return the library context for the given library id.
pub(crate) async fn get_ctx(&self, library_id: String) -> Option<LibraryContext> {
self.libraries
.read()
.await
.iter()
.find(|lib| lib.id.to_string() == library_id)
.map(|v| v.clone())
}
/// load the library from a given path
pub(crate) async fn load(
id: Uuid,
db_path: &str,
config: LibraryConfig,
node_context: NodeContext,
) -> Result<LibraryContext, LibraryManagerError> {
let db = Arc::new(
load_and_migrate(&format!("file:{}", db_path))
.await
.unwrap(),
);
let node_config = node_context.config.get().await;
let platform = match env::consts::OS {
"windows" => Platform::Windows,
"macos" => Platform::MacOS,
"linux" => Platform::Linux,
_ => Platform::Unknown,
};
let node_data = db
.node()
.upsert(
node::pub_id::equals(id.to_string()),
(
node::pub_id::set(id.to_string()),
node::name::set(node_config.name.clone()),
vec![node::platform::set(platform as i32)],
),
vec![node::name::set(node_config.name.clone())],
)
.exec()
.await?;
Ok(LibraryContext {
id,
config,
db,
node_local_id: node_data.id,
node_context,
})
}
}

View File

@@ -1,99 +0,0 @@
use uuid::Uuid;
use crate::node::{get_nodestate, LibraryState};
use crate::prisma::library;
use crate::util::db::{run_migrations, DatabaseError};
use crate::CoreContext;
pub static LIBRARY_DB_NAME: &str = "library.db";
pub static DEFAULT_NAME: &str = "My Library";
pub fn get_library_path(data_path: &str) -> String {
let path = data_path.to_owned();
format!("{}/{}", path, LIBRARY_DB_NAME)
}
// pub async fn get(core: &Node) -> Result<library::Data, LibraryError> {
// let config = get_nodestate();
// let db = &core.database;
// let library_state = config.get_current_library();
// println!("{:?}", library_state);
// // get library from db
// let library = match db
// .library()
// .find_unique(library::pub_id::equals(library_state.library_uuid.clone()))
// .exec()
// .await?
// {
// Some(library) => Ok(library),
// None => {
// // update config library state to offline
// // config.libraries
// Err(anyhow::anyhow!("library_not_found"))
// }
// };
// Ok(library.unwrap())
// }
pub async fn load(
ctx: &CoreContext,
library_path: &str,
library_id: &str,
) -> Result<(), DatabaseError> {
let mut config = get_nodestate();
println!("Initializing library: {} {}", &library_id, library_path);
if config.current_library_uuid != library_id {
config.current_library_uuid = library_id.to_string();
config.save();
}
// create connection with library database & run migrations
run_migrations(&ctx).await?;
// if doesn't exist, mark as offline
Ok(())
}
pub async fn create(ctx: &CoreContext, name: Option<String>) -> Result<(), ()> {
let mut config = get_nodestate();
let uuid = Uuid::new_v4().to_string();
println!("Creating library {:?}, UUID: {:?}", name, uuid);
let library_state = LibraryState {
library_uuid: uuid.clone(),
library_path: get_library_path(&config.data_path),
..LibraryState::default()
};
run_migrations(&ctx).await.unwrap();
config.libraries.push(library_state);
config.current_library_uuid = uuid;
config.save();
let db = &ctx.database;
let library = db
.library()
.create(
library::pub_id::set(config.current_library_uuid),
library::name::set(name.unwrap_or(DEFAULT_NAME.into())),
vec![],
)
.exec()
.await
.unwrap();
println!("library created in database: {:?}", library);
Ok(())
}

View File

@@ -1,7 +1,11 @@
mod loader;
mod library_config;
mod library_ctx;
mod library_manager;
mod statistics;
pub use loader::*;
pub use library_config::*;
pub use library_ctx::*;
pub use library_manager::*;
pub use statistics::*;
use thiserror::Error;

View File

@@ -1,15 +1,10 @@
use crate::{
node::get_nodestate,
prisma::{library, library_statistics::*},
sys::Volume,
CoreContext,
};
use crate::{prisma::statistics::*, sys::Volume};
use fs_extra::dir::get_size;
use serde::{Deserialize, Serialize};
use std::fs;
use ts_rs::TS;
use super::LibraryError;
use super::{LibraryContext, LibraryError};
#[derive(Debug, Serialize, Deserialize, TS, Clone)]
#[ts(export)]
@@ -52,14 +47,11 @@ impl Default for Statistics {
}
impl Statistics {
pub async fn retrieve(ctx: &CoreContext) -> Result<Statistics, LibraryError> {
let config = get_nodestate();
let db = &ctx.database;
let library_data = config.get_current_library();
let library_statistics_db = match db
.library_statistics()
.find_unique(id::equals(library_data.library_id))
pub async fn retrieve(ctx: &LibraryContext) -> Result<Statistics, LibraryError> {
let library_statistics_db = match ctx
.db
.statistics()
.find_unique(id::equals(ctx.node_local_id))
.exec()
.await?
{
@@ -70,31 +62,11 @@ impl Statistics {
Ok(library_statistics_db.into())
}
pub async fn calculate(ctx: &CoreContext) -> Result<Statistics, LibraryError> {
let config = get_nodestate();
let db = &ctx.database;
// get library from client state
let library_data = config.get_current_library();
println!(
"Calculating library statistics {:?}",
library_data.library_uuid
);
// get library from db
let library = db
.library()
.find_unique(library::pub_id::equals(
library_data.library_uuid.to_string(),
))
.exec()
.await?;
if library.is_none() {
return Err(LibraryError::LibraryNotFound);
}
let library_statistics = db
.library_statistics()
.find_unique(id::equals(library_data.library_id))
pub async fn calculate(ctx: &LibraryContext) -> Result<Statistics, LibraryError> {
let _statistics = ctx
.db
.statistics()
.find_unique(id::equals(ctx.node_local_id))
.exec()
.await?;
@@ -113,14 +85,15 @@ impl Statistics {
}
}
let library_db_size = match fs::metadata(library_data.library_path.as_str()) {
let library_db_size = match fs::metadata(ctx.config().data_directory()) {
Ok(metadata) => metadata.len(),
Err(_) => 0,
};
println!("{:?}", library_statistics);
let mut thumbsnails_dir = ctx.config().data_directory();
thumbsnails_dir.push("thumbnails");
let thumbnail_folder_size = get_size(&format!("{}/{}", config.data_path, "thumbnails"));
let thumbnail_folder_size = get_size(&thumbsnails_dir);
let statistics = Statistics {
library_db_size: library_db_size.to_string(),
@@ -130,18 +103,11 @@ impl Statistics {
..Statistics::default()
};
let library_local_id = match library {
Some(library) => library.id,
None => library_data.library_id,
};
db.library_statistics()
ctx.db
.statistics()
.upsert(
library_id::equals(library_local_id),
(
library_id::set(library_local_id),
vec![library_db_size::set(statistics.library_db_size.clone())],
),
id::equals(1),
vec![library_db_size::set(statistics.library_db_size.clone())],
vec![
total_file_count::set(statistics.total_file_count.clone()),
total_bytes_used::set(statistics.total_bytes_used.clone()),

149
core/src/node/config.rs Normal file
View File

@@ -0,0 +1,149 @@
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{self, BufReader, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::{RwLock, RwLockWriteGuard};
use ts_rs::TS;
use uuid::Uuid;
/// NODE_STATE_CONFIG_NAME is the name of the file which stores the NodeState
pub const NODE_STATE_CONFIG_NAME: &str = "node_state.sdconfig";
/// ConfigMetadata is a part of node configuration that is loaded before the main configuration and contains information about the schema of the config.
/// This allows us to migrate breaking changes to the config format between Spacedrive releases.
#[derive(Debug, Serialize, Deserialize, Clone, TS)]
#[ts(export)]
pub struct ConfigMetadata {
/// version of Spacedrive. Determined from `CARGO_PKG_VERSION` environment variable.
pub version: Option<String>,
}
impl Default for ConfigMetadata {
fn default() -> Self {
Self {
version: Some(env!("CARGO_PKG_VERSION").into()),
}
}
}
/// NodeConfig is the configuration for a node. This is shared between all libraries and is stored in a JSON file on disk.
#[derive(Debug, Serialize, Deserialize, Clone, TS)]
#[ts(export)]
pub struct NodeConfig {
#[serde(flatten)]
pub metadata: ConfigMetadata,
/// id is a unique identifier for the current node. Each node has a public identifier (this one) and is given a local id for each library (done within the library code).
pub id: Uuid,
/// name is the display name of the current node. This is set by the user and is shown in the UI. // TODO: Length validation so it can fit in DNS record
pub name: String,
// the port this node uses for peer to peer communication. By default a random free port will be chosen each time the application is started.
pub p2p_port: Option<u32>,
}
#[derive(Error, Debug)]
pub enum NodeConfigError {
#[error("error saving or loading the config from the filesystem")]
IOError(#[from] io::Error),
#[error("error serializing or deserializing the JSON in the config file")]
JsonError(#[from] serde_json::Error),
#[error("error migrating the config file")]
MigrationError(String),
}
impl NodeConfig {
fn default() -> Self {
NodeConfig {
id: Uuid::new_v4(),
name: match hostname::get() {
Ok(hostname) => hostname.to_string_lossy().into_owned(),
Err(err) => {
eprintln!("Falling back to default node name as an error occurred getting your systems hostname: '{}'", err);
"my-spacedrive".into()
}
},
p2p_port: None,
metadata: ConfigMetadata {
version: Some(env!("CARGO_PKG_VERSION").into()),
},
}
}
}
pub struct NodeConfigManager(RwLock<NodeConfig>, PathBuf);
impl NodeConfigManager {
/// new will create a new NodeConfigManager with the given path to the config file.
pub(crate) async fn new(data_path: PathBuf) -> Result<Arc<Self>, NodeConfigError> {
Ok(Arc::new(Self(
RwLock::new(Self::read(&data_path).await?),
data_path,
)))
}
/// get will return the current NodeConfig in a read only state.
pub(crate) async fn get(&self) -> NodeConfig {
self.0.read().await.clone()
}
/// data_directory returns the path to the directory storing the configuration data.
pub(crate) fn data_directory(&self) -> PathBuf {
self.1.clone()
}
/// write allows the user to update the configuration. This is done in a closure while a Mutex lock is held so that the user can't cause a race condition if the config were to be updated in multiple parts of the app at the same time.
#[allow(unused)]
pub(crate) async fn write<F: FnOnce(RwLockWriteGuard<NodeConfig>)>(
&self,
mutation_fn: F,
) -> Result<NodeConfig, NodeConfigError> {
mutation_fn(self.0.write().await);
let config = self.0.read().await;
Self::save(&self.1, &config).await?;
Ok(config.clone())
}
/// read will read the configuration from disk and return it.
async fn read(base_path: &PathBuf) -> Result<NodeConfig, NodeConfigError> {
let path = Path::new(base_path).join(NODE_STATE_CONFIG_NAME);
match path.exists() {
true => {
let mut file = File::open(&path)?;
let base_config: ConfigMetadata =
serde_json::from_reader(BufReader::new(&mut file))?;
Self::migrate_config(base_config.version, path)?;
file.seek(SeekFrom::Start(0))?;
Ok(serde_json::from_reader(BufReader::new(&mut file))?)
}
false => {
let config = NodeConfig::default();
Self::save(base_path, &config).await?;
Ok(config)
}
}
}
/// save will write the configuration back to disk
async fn save(base_path: &PathBuf, config: &NodeConfig) -> Result<(), NodeConfigError> {
let path = Path::new(base_path).join(NODE_STATE_CONFIG_NAME);
File::create(path)?.write_all(serde_json::to_string(config)?.as_bytes())?;
Ok(())
}
/// migrate_config is a function used to apply breaking changes to the config file.
fn migrate_config(
current_version: Option<String>,
config_path: PathBuf,
) -> Result<(), NodeConfigError> {
match current_version {
None => {
Err(NodeConfigError::MigrationError(format!("Your Spacedrive config file stored at '{}' is missing the `version` field. If you just upgraded please delete the file and restart Spacedrive! Please note this upgrade will stop using your old 'library.db' as the folder structure has changed.", config_path.display())))
}
_ => Ok(()),
}
}
}

View File

@@ -1,17 +1,10 @@
use crate::{
prisma::{self, node},
Node,
};
use chrono::{DateTime, Utc};
use int_enum::IntEnum;
use serde::{Deserialize, Serialize};
use std::env;
use thiserror::Error;
use ts_rs::TS;
mod state;
pub use state::*;
mod config;
use crate::prisma::node;
pub use config::*;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
@@ -44,65 +37,3 @@ pub enum Platform {
IOS = 4,
Android = 5,
}
impl LibraryNode {
pub async fn create(node: &Node) -> Result<(), NodeError> {
println!("Creating node...");
let mut config = state::get_nodestate();
let db = &node.database;
let hostname = match hostname::get() {
Ok(hostname) => hostname.to_str().unwrap_or_default().to_owned(),
Err(_) => "unknown".to_owned(),
};
let platform = match env::consts::OS {
"windows" => Platform::Windows,
"macos" => Platform::MacOS,
"linux" => Platform::Linux,
_ => Platform::Unknown,
};
let _node = match db
.node()
.find_unique(node::pub_id::equals(config.node_pub_id.clone()))
.exec()
.await?
{
Some(node) => node,
None => {
db.node()
.create(
node::pub_id::set(config.node_pub_id.clone()),
node::name::set(hostname.clone()),
vec![node::platform::set(platform as i32)],
)
.exec()
.await?
}
};
config.node_name = hostname;
config.node_id = _node.id;
config.save();
println!("node: {:?}", &_node);
Ok(())
}
// pub async fn get_nodes(ctx: &CoreContext) -> Result<Vec<node::Data>, NodeError> {
// let db = &ctx.database;
// let _node = db.node().find_many(vec![]).exec().await?;
// Ok(_node)
// }
}
#[derive(Error, Debug)]
pub enum NodeError {
#[error("Database error")]
DatabaseError(#[from] prisma::QueryError),
}

View File

@@ -1,107 +0,0 @@
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{BufReader, Write};
use std::sync::RwLock;
use ts_rs::TS;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, Clone, Default, TS)]
#[ts(export)]
pub struct NodeState {
pub node_pub_id: String,
pub node_id: i32,
pub node_name: String,
// config path is stored as struct can exist only in memory during startup and be written to disk later without supplying path
pub data_path: String,
// the port this node uses to listen for incoming connections
pub tcp_port: u32,
// all the libraries loaded by this node
pub libraries: Vec<LibraryState>,
// used to quickly find the default library
pub current_library_uuid: String,
}
pub static NODE_STATE_CONFIG_NAME: &str = "node_state.json";
#[derive(Debug, Serialize, Deserialize, Clone, Default, TS)]
#[ts(export)]
pub struct LibraryState {
pub library_uuid: String,
pub library_id: i32,
pub library_path: String,
pub offline: bool,
}
// global, thread-safe storage for node state
lazy_static! {
static ref CONFIG: RwLock<Option<NodeState>> = RwLock::new(None);
}
pub fn get_nodestate() -> NodeState {
match CONFIG.read() {
Ok(guard) => guard.clone().unwrap_or(NodeState::default()),
Err(_) => return NodeState::default(),
}
}
impl NodeState {
pub fn new(data_path: &str, node_name: &str) -> Result<Self, ()> {
let uuid = Uuid::new_v4().to_string();
// create struct and assign defaults
let config = Self {
node_pub_id: uuid,
data_path: data_path.to_string(),
node_name: node_name.to_string(),
..Default::default()
};
Ok(config)
}
pub fn save(&self) {
self.write_memory();
// only write to disk if config path is set
if !&self.data_path.is_empty() {
let config_path = format!("{}/{}", &self.data_path, NODE_STATE_CONFIG_NAME);
let mut file = fs::File::create(config_path).unwrap();
let json = serde_json::to_string(&self).unwrap();
file.write_all(json.as_bytes()).unwrap();
}
}
pub fn read_disk(&mut self) -> Result<(), ()> {
let config_path = format!("{}/{}", &self.data_path, NODE_STATE_CONFIG_NAME);
// open the file and parse json
match fs::File::open(config_path) {
Ok(file) => {
let reader = BufReader::new(file);
let data = serde_json::from_reader(reader).unwrap();
// assign to self
*self = data;
}
_ => {}
}
Ok(())
}
fn write_memory(&self) {
let mut writeable = CONFIG.write().unwrap();
*writeable = Some(self.clone());
}
pub fn get_current_library(&self) -> LibraryState {
match self
.libraries
.iter()
.find(|lib| lib.library_uuid == self.current_library_uuid)
{
Some(lib) => lib.clone(),
None => LibraryState::default(),
}
}
pub fn get_current_library_db_path(&self) -> String {
format!("{}/library.db", &self.get_current_library().library_path)
}
}

View File

@@ -1,11 +1,10 @@
use crate::{
encode::ThumbnailJob,
file::{cas::FileIdentifierJob, indexer::IndexerJob},
node::{get_nodestate, LibraryNode},
library::LibraryContext,
node::LibraryNode,
prisma::{file_path, location},
ClientQuery, CoreContext, CoreEvent,
ClientQuery, CoreEvent, LibraryQuery,
};
use prisma_client_rust::{raw, PrismaValue};
use serde::{Deserialize, Serialize};
use std::{fs, io, io::Write, path::Path};
use thiserror::Error;
@@ -66,13 +65,12 @@ static DOTFILE_NAME: &str = ".spacedrive";
// }
pub async fn get_location(
ctx: &CoreContext,
ctx: &LibraryContext,
location_id: i32,
) -> Result<LocationResource, SysError> {
let db = &ctx.database;
// get location by location_id from db and include location_paths
let location = match db
let location = match ctx
.db
.location()
.find_unique(location::id::equals(location_id))
.exec()
@@ -84,9 +82,11 @@ pub async fn get_location(
Ok(location.into())
}
pub fn scan_location(ctx: &CoreContext, location_id: i32, path: String) {
ctx.spawn_job(Box::new(IndexerJob { path: path.clone() }));
ctx.queue_job(Box::new(FileIdentifierJob { location_id, path }));
pub async fn scan_location(ctx: &LibraryContext, location_id: i32, path: String) {
ctx.spawn_job(Box::new(IndexerJob { path: path.clone() }))
.await;
ctx.queue_job(Box::new(FileIdentifierJob { location_id, path }))
.await;
// TODO: make a way to stop jobs so this can be canceled without rebooting app
// ctx.queue_job(Box::new(ThumbnailJob {
// location_id,
@@ -96,18 +96,18 @@ pub fn scan_location(ctx: &CoreContext, location_id: i32, path: String) {
}
pub async fn new_location_and_scan(
ctx: &CoreContext,
ctx: &LibraryContext,
path: &str,
) -> Result<LocationResource, SysError> {
let location = create_location(&ctx, path).await?;
scan_location(&ctx, location.id, path.to_string());
scan_location(&ctx, location.id, path.to_string()).await;
Ok(location)
}
pub async fn get_locations(ctx: &CoreContext) -> Result<Vec<LocationResource>, SysError> {
let db = &ctx.database;
pub async fn get_locations(ctx: &LibraryContext) -> Result<Vec<LocationResource>, SysError> {
let db = &ctx.db;
let locations = db
.location()
@@ -125,10 +125,10 @@ pub async fn get_locations(ctx: &CoreContext) -> Result<Vec<LocationResource>, S
Ok(locations)
}
pub async fn create_location(ctx: &CoreContext, path: &str) -> Result<LocationResource, SysError> {
let db = &ctx.database;
let config = get_nodestate();
pub async fn create_location(
ctx: &LibraryContext,
path: &str,
) -> Result<LocationResource, SysError> {
// check if we have access to this location
if !Path::new(path).exists() {
Err(LocationError::NotFound(path.to_string()))?;
@@ -155,7 +155,8 @@ pub async fn create_location(ctx: &CoreContext, path: &str) -> Result<LocationRe
}
// check if location already exists
let location = match db
let location = match ctx
.db
.location()
.find_first(vec![location::local_path::equals(Some(path.to_string()))])
.exec()
@@ -171,7 +172,8 @@ pub async fn create_location(ctx: &CoreContext, path: &str) -> Result<LocationRe
let p = Path::new(&path);
let location = db
let location = ctx
.db
.location()
.create(
location::pub_id::set(uuid.to_string()),
@@ -181,7 +183,7 @@ pub async fn create_location(ctx: &CoreContext, path: &str) -> Result<LocationRe
)),
location::is_online::set(true),
location::local_path::set(Some(path.to_string())),
location::node_id::set(Some(config.node_id)),
location::node_id::set(Some(ctx.node_local_id)),
],
)
.exec()
@@ -197,7 +199,7 @@ pub async fn create_location(ctx: &CoreContext, path: &str) -> Result<LocationRe
let data = DotSpacedrive {
location_uuid: uuid.to_string(),
library_uuid: config.current_library_uuid,
library_uuid: ctx.id.to_string(),
};
let json = match serde_json::to_string(&data) {
@@ -210,8 +212,8 @@ pub async fn create_location(ctx: &CoreContext, path: &str) -> Result<LocationRe
Err(e) => Err(LocationError::DotfileWriteFailure(e, path.to_string()))?,
}
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::SysGetLocations))
.await;
// ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::SysGetLocations))
// .await;
location
}
@@ -220,8 +222,8 @@ pub async fn create_location(ctx: &CoreContext, path: &str) -> Result<LocationRe
Ok(location.into())
}
pub async fn delete_location(ctx: &CoreContext, location_id: i32) -> Result<(), SysError> {
let db = &ctx.database;
pub async fn delete_location(ctx: &LibraryContext, location_id: i32) -> Result<(), SysError> {
let db = &ctx.db;
db.file_path()
.find_many(vec![file_path::location_id::equals(Some(location_id))])
@@ -235,8 +237,11 @@ pub async fn delete_location(ctx: &CoreContext, location_id: i32) -> Result<(),
.exec()
.await?;
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::SysGetLocations))
.await;
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::SysGetLocations,
}))
.await;
println!("Location {} deleted", location_id);

View File

@@ -1,5 +1,5 @@
// use crate::native;
use crate::{node::get_nodestate, prisma::volume::*};
use crate::{library::LibraryContext, prisma::volume::*};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
// #[cfg(not(target_os = "macos"))]
@@ -7,8 +7,6 @@ use std::process::Command;
// #[cfg(not(target_os = "macos"))]
use sysinfo::{DiskExt, System, SystemExt};
use crate::CoreContext;
use super::SysError;
#[derive(Serialize, Deserialize, Debug, Default, Clone, TS)]
@@ -26,23 +24,21 @@ pub struct Volume {
}
impl Volume {
pub async fn save(ctx: &CoreContext) -> Result<(), SysError> {
let db = &ctx.database;
let config = get_nodestate();
pub async fn save(ctx: &LibraryContext) -> Result<(), SysError> {
let volumes = Self::get_volumes()?;
// enter all volumes associate with this client add to db
for volume in volumes {
db.volume()
ctx.db
.volume()
.upsert(
node_id_mount_point_name(
config.node_id.clone(),
ctx.node_local_id.clone(),
volume.mount_point.to_string(),
volume.name.to_string(),
),
(
node_id::set(config.node_id),
node_id::set(ctx.node_local_id),
name::set(volume.name),
mount_point::set(volume.mount_point),
vec![

View File

@@ -1,159 +1,123 @@
use crate::prisma::{self, migration, PrismaClient};
use crate::CoreContext;
use data_encoding::HEXLOWER;
use include_dir::{include_dir, Dir};
use prisma_client_rust::raw;
use ring::digest::{Context, Digest, SHA256};
use std::ffi::OsStr;
use std::io::{self, BufReader, Read};
use prisma_client_rust::{raw, NewClientError};
use ring::digest::{Context, SHA256};
use thiserror::Error;
const INIT_MIGRATION: &str = include_str!("../../prisma/migrations/migration_table/migration.sql");
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/prisma/migrations");
/// MigrationError represents an error that occurring while opening a initialising and running migrations on the database.
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("Unable to initialize the Prisma client")]
ClientError(#[from] prisma::NewClientError),
pub enum MigrationError {
#[error("An error occurred while initialising a new database connection")]
DatabaseIntialisation(#[from] NewClientError),
#[error("An error occurred with the database while applying migrations")]
DatabaseError(#[from] prisma_client_rust::queries::Error),
#[error("An error occured reading the embedded migration files. {0}. Please report to Spacedrive developers!")]
InvalidEmbeddedMigration(&'static str),
}
pub async fn create_connection(path: &str) -> Result<PrismaClient, DatabaseError> {
println!("Creating database connection: {:?}", path);
let client = prisma::new_client_with_url(&format!("file:{}", &path)).await?;
/// load_and_migrate will load the database from the given path and migrate it to the latest version of the schema.
pub async fn load_and_migrate(db_url: &str) -> Result<PrismaClient, MigrationError> {
let client = prisma::new_client_with_url(db_url).await?;
Ok(client)
}
pub fn sha256_digest<R: Read>(mut reader: R) -> Result<Digest, io::Error> {
let mut context = Context::new(&SHA256);
let mut buffer = [0; 1024];
loop {
let count = reader.read(&mut buffer)?;
if count == 0 {
break;
}
context.update(&buffer[..count]);
}
Ok(context.finish())
}
pub async fn run_migrations(ctx: &CoreContext) -> Result<(), DatabaseError> {
let client = &ctx.database;
match client
let migrations_table_missing = client
._query_raw::<serde_json::Value>(raw!(
"SELECT name FROM sqlite_master WHERE type='table' AND name='_migrations'"
))
.await
{
Ok(data) => {
if data.len() == 0 {
// execute migration
match client._execute_raw(raw!(INIT_MIGRATION)).await {
Ok(_) => {}
Err(e) => {
println!("Failed to create migration table: {}", e);
}
};
.await?
.len() == 0;
let value: Vec<serde_json::Value> = client
._query_raw(raw!(
"SELECT name FROM sqlite_master WHERE type='table' AND name='_migrations'"
))
.await
.unwrap();
if migrations_table_missing {
client._execute_raw(raw!(INIT_MIGRATION)).await?;
}
#[cfg(debug_assertions)]
println!("Migration table created: {:?}", value);
}
let mut migration_subdirs = MIGRATIONS_DIR
.dirs()
.filter(|subdir| {
subdir
.path()
.file_name()
.map(|name| name != OsStr::new("migration_table"))
.unwrap_or(false)
let mut migration_directories = MIGRATIONS_DIR
.dirs()
.map(|dir| {
dir.path()
.file_name()
.ok_or(MigrationError::InvalidEmbeddedMigration(
"File has malformed name",
))
.and_then(|name| {
name.to_str()
.ok_or_else(|| {
MigrationError::InvalidEmbeddedMigration(
"File name contains malformed characters",
)
})
.map(|name| (name, dir))
})
.collect::<Vec<_>>();
})
.filter_map(|v| match v {
Ok((name, _)) if name == "migration_table" => None,
Ok((name, dir)) => match name[..14].parse::<i64>() {
Ok(timestamp) => Some(Ok((name, timestamp, dir))),
Err(_) => Some(Err(MigrationError::InvalidEmbeddedMigration(
"File name is incorrectly formatted",
))),
},
Err(v) => Some(Err(v)),
})
.collect::<Result<Vec<_>, _>>()?;
migration_subdirs.sort_by(|a, b| {
let a_name = a.path().file_name().unwrap().to_str().unwrap();
let b_name = b.path().file_name().unwrap().to_str().unwrap();
// We sort the migrations so they are always applied in the correct order
migration_directories.sort_by(|(_, a_time, _), (_, b_time, _)| a_time.cmp(&b_time));
let a_time = a_name[..14].parse::<i64>().unwrap();
let b_time = b_name[..14].parse::<i64>().unwrap();
for (name, _, dir) in migration_directories {
let migration_file_raw = dir
.get_file(dir.path().join("./migration.sql"))
.ok_or(MigrationError::InvalidEmbeddedMigration(
"Failed to find 'migration.sql' file in '{}' migration subdirectory",
))?
.contents_utf8()
.ok_or(
MigrationError::InvalidEmbeddedMigration(
"Failed to open the contents of 'migration.sql' file in '{}' migration subdirectory",
)
)?;
a_time.cmp(&b_time)
});
// Generate SHA256 checksum of migration
let mut checksum = Context::new(&SHA256);
checksum.update(migration_file_raw.as_bytes());
let checksum = HEXLOWER.encode(checksum.finish().as_ref());
for subdir in migration_subdirs {
println!("{:?}", subdir.path());
let migration_file = subdir
.get_file(subdir.path().join("./migration.sql"))
.unwrap();
let migration_sql = migration_file.contents_utf8().unwrap();
// get existing migration by checksum, if it doesn't exist run the migration
if client
.migration()
.find_unique(migration::checksum::equals(checksum.clone()))
.exec()
.await?
.is_none()
{
// Create migration record
client
.migration()
.create(
migration::name::set(name.to_string()),
migration::checksum::set(checksum.clone()),
vec![],
)
.exec()
.await?;
let digest = sha256_digest(BufReader::new(migration_file.contents())).unwrap();
// create a lowercase hash from
let checksum = HEXLOWER.encode(digest.as_ref());
let name = subdir.path().file_name().unwrap().to_str().unwrap();
// get existing migration by checksum, if it doesn't exist run the migration
let existing_migration = client
// Split the migrations file up into each individual step and apply them all
let steps = migration_file_raw.split(";").collect::<Vec<&str>>();
let steps = &steps[0..steps.len() - 1];
for (i, step) in steps.iter().enumerate() {
client._execute_raw(raw!(*step)).await?;
client
.migration()
.find_unique(migration::checksum::equals(checksum.clone()))
.update(vec![migration::steps_applied::set(i as i32 + 1)])
.exec()
.await
.unwrap();
if existing_migration.is_none() {
#[cfg(debug_assertions)]
println!("Running migration: {}", name);
let steps = migration_sql.split(";").collect::<Vec<&str>>();
let steps = &steps[0..steps.len() - 1];
client
.migration()
.create(
migration::name::set(name.to_string()),
migration::checksum::set(checksum.clone()),
vec![],
)
.exec()
.await
.unwrap();
for (i, step) in steps.iter().enumerate() {
match client._execute_raw(raw!(*step)).await {
Ok(_) => {
client
.migration()
.find_unique(migration::checksum::equals(checksum.clone()))
.update(vec![migration::steps_applied::set(i as i32 + 1)])
.exec()
.await
.unwrap();
}
Err(e) => {
println!("Error running migration: {}", name);
println!("{}", e);
break;
}
}
}
#[cfg(debug_assertions)]
println!("Migration {} recorded successfully", name);
}
.await?;
}
}
Err(err) => {
panic!("Failed to check migration table existence: {:?}", err);
}
}
Ok(())
Ok(client)
}

View File

@@ -13,25 +13,27 @@
"lint": "TIMING=1 eslint src --fix",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"devDependencies": {
"@types/react": "^18.0.9",
"scripts": "*",
"tsconfig": "*",
"typescript": "^4.7.2"
},
"jest": {
"preset": "scripts/jest/node"
},
"dependencies": {
"@sd/config": "workspace:*",
"@sd/core": "workspace:*",
"@sd/interface": "workspace:*",
"eventemitter3": "^4.0.7",
"immer": "^9.0.14",
"react-query": "^3.39.1",
"lodash": "^4.17.21",
"react-query": "^3.34.19",
"zustand": "4.0.0-rc.1"
},
"devDependencies": {
"@types/react": "^18.0.9",
"scripts": "*",
"tsconfig": "*",
"typescript": "^4.7.2",
"@types/lodash": "^4.14.182"
},
"peerDependencies": {
"react": "^18.0.0",
"react-query": "^3.34.19"
"react": "^18.0.0"
}
}

View File

@@ -1,12 +1,8 @@
import { ClientCommand, ClientQuery, CoreResponse } from '@sd/core';
import { ClientCommand, ClientQuery, CoreResponse, LibraryCommand, LibraryQuery } from '@sd/core';
import { EventEmitter } from 'eventemitter3';
import {
UseMutationOptions,
UseQueryOptions,
UseQueryResult,
useMutation,
useQuery
} from 'react-query';
import { UseMutationOptions, UseQueryOptions, useMutation, useQuery } from 'react-query';
import { useLibraryStore } from './stores';
// global var to store the transport TODO: not global :D
export let transport: BaseTransport | null = null;
@@ -23,11 +19,15 @@ export function setTransport(_transport: BaseTransport) {
// extract keys from generated Rust query/command types
type QueryKeyType = ClientQuery['key'];
type LibraryQueryKeyType = LibraryQuery['key'];
type CommandKeyType = ClientCommand['key'];
type LibraryCommandKeyType = LibraryCommand['key'];
// extract the type from the union
type CQType<K> = Extract<ClientQuery, { key: K }>;
type LQType<K> = Extract<LibraryQuery, { key: K }>;
type CCType<K> = Extract<ClientCommand, { key: K }>;
type LCType<K> = Extract<LibraryCommand, { key: K }>;
type CRType<K> = Extract<CoreResponse, { key: K }>;
// extract payload type
@@ -35,20 +35,18 @@ type ExtractParams<P> = P extends { params: any } ? P['params'] : never;
type ExtractData<D> = D extends { data: any } ? D['data'] : never;
// vanilla method to call the transport
export async function queryBridge<
K extends QueryKeyType,
CQ extends CQType<K>,
CR extends CRType<K>
>(key: K, params?: ExtractParams<CQ>): Promise<ExtractData<CR>> {
async function queryBridge<K extends QueryKeyType, CQ extends CQType<K>, CR extends CRType<K>>(
key: K,
params?: ExtractParams<CQ>
): Promise<ExtractData<CR>> {
const result = (await transport?.query({ key, params } as any)) as any;
return result?.data;
}
export async function commandBridge<
K extends CommandKeyType,
CC extends CCType<K>,
CR extends CRType<K>
>(key: K, params?: ExtractParams<CC>): Promise<ExtractData<CR>> {
async function commandBridge<K extends CommandKeyType, CC extends CCType<K>, CR extends CRType<K>>(
key: K,
params?: ExtractParams<CC>
): Promise<ExtractData<CR>> {
const result = (await transport?.command({ key, params } as any)) as any;
return result?.data;
}
@@ -66,6 +64,21 @@ export function useBridgeQuery<K extends QueryKeyType, CQ extends CQType<K>, CR
);
}
export function useLibraryQuery<
K extends LibraryQueryKeyType,
CQ extends LQType<K>,
CR extends CRType<K>
>(key: K, params?: ExtractParams<CQ>, options: UseQueryOptions<ExtractData<CR>> = {}) {
const library_id = useLibraryStore((state) => state.currentLibraryUuid);
if (!library_id) throw new Error(`Attempted to do library query '${key}' with no library set!`);
return useQuery<ExtractData<CR>>(
[library_id, key, params],
async () => await queryBridge('LibraryQuery', { library_id, query: { key, params } as any }),
options
);
}
export function useBridgeCommand<
K extends CommandKeyType,
CC extends CCType<K>,
@@ -78,9 +91,35 @@ export function useBridgeCommand<
);
}
export function useLibraryCommand<
K extends LibraryCommandKeyType,
LC extends LCType<K>,
CR extends CRType<K>
>(key: K, options: UseMutationOptions<ExtractData<LC>> = {}) {
const library_id = useLibraryStore((state) => state.currentLibraryUuid);
if (!library_id) throw new Error(`Attempted to do library command '${key}' with no library set!`);
return useMutation<ExtractData<CR>, unknown, ExtractParams<LC>>(
[library_id, key],
async (vars?: ExtractParams<LC>) =>
await commandBridge('LibraryCommand', { library_id, command: { key, params: vars } as any }),
options
);
}
export function command<K extends CommandKeyType, CC extends CCType<K>, CR extends CRType<K>>(
key: K,
vars: ExtractParams<CC>
): Promise<ExtractData<CR>> {
return commandBridge(key, vars);
}
export function libraryCommand<
K extends LibraryCommandKeyType,
LC extends LCType<K>,
CR extends CRType<K>
>(key: K, vars: ExtractParams<LC>): Promise<ExtractData<CR>> {
const library_id = useLibraryStore((state) => state.currentLibraryUuid);
if (!library_id) throw new Error(`Attempted to do library command '${key}' with no library set!`);
return commandBridge('LibraryCommand', { library_id, command: { key, params: vars } as any });
}

View File

@@ -0,0 +1 @@
export * from './AppPropsContext';

View File

@@ -1,2 +0,0 @@
export * from './query';
export * from './state';

View File

@@ -1,21 +0,0 @@
import { useState } from 'react';
import { useQuery } from 'react-query';
import { useBridgeCommand, useBridgeQuery } from '../bridge';
import { useFileExplorerState } from './state';
// this hook initializes the explorer state and queries the core
export function useFileExplorer(initialPath = '/', initialLocation: number | null = null) {
const fileState = useFileExplorerState();
// file explorer hooks maintain their own local state relative to exploration
const [path, setPath] = useState(initialPath);
const [locationId, setLocationId] = useState(initialPath);
// const { data: volumes } = useQuery(['sys_get_volumes'], () => bridge('sys_get_volumes'));
return { setPath, setLocationId };
}
// export function useVolumes() {
// return useQuery(['SysGetVolumes'], () => bridge('SysGetVolumes'));
// }

View File

@@ -1,23 +0,0 @@
import produce from 'immer';
import create from 'zustand';
export interface FileExplorerState {
current_location_id: number | null;
row_limit: number;
}
interface FileExplorerStore extends FileExplorerState {
update_row_limit: (new_limit: number) => void;
}
export const useFileExplorerState = create<FileExplorerStore>((set, get) => ({
current_location_id: null,
row_limit: 10,
update_row_limit: (new_limit: number) => {
set((store) =>
produce(store, (draft) => {
draft.row_limit = new_limit;
})
);
}
}));

View File

@@ -0,0 +1 @@
export * from './useCoreEvents';

View File

@@ -0,0 +1,59 @@
import { CoreEvent } from '@sd/core';
import { useContext, useEffect } from 'react';
import { useQueryClient } from 'react-query';
import { transport, useExplorerStore } from '..';
export function useCoreEvents() {
const client = useQueryClient();
const { addNewThumbnail } = useExplorerStore();
useEffect(() => {
function handleCoreEvent(e: CoreEvent) {
switch (e?.key) {
case 'NewThumbnail':
addNewThumbnail(e.data.cas_id);
break;
case 'InvalidateQuery':
case 'InvalidateQueryDebounced':
let query = [];
if (e.data.key === 'LibraryQuery') {
query = [e.data.params.library_id, e.data.params.query.key];
// TODO: find a way to make params accessible in TS
// also this method will only work for queries that use the whole params obj as the second key
// @ts-expect-error
if (e.data.params.query.params) {
// @ts-expect-error
query.push(e.data.params.query.params);
}
} else {
query = [e.data.key];
// TODO: find a way to make params accessible in TS
// also this method will only work for queries that use the whole params obj as the second key
// @ts-expect-error
if (e.data.params) {
// @ts-expect-error
query.push(e.data.params);
}
}
client.invalidateQueries(query);
break;
default:
break;
}
}
// check Tauri Event type
transport?.on('core_event', handleCoreEvent);
return () => {
transport?.off('core_event', handleCoreEvent);
};
// listen('core_event', (e: { payload: CoreEvent }) => {
// });
}, [transport]);
}

View File

@@ -1,3 +1,5 @@
export * from './bridge';
export * from './files';
export * from './ClientProvider';
export * from './stores';
export * from './hooks';
export * from './context';

View File

@@ -0,0 +1,4 @@
export * from './useLibraryStore';
export * from './useExplorerStore';
export * from './useInspectorStore';
export * from './useInspectorStore';

View File

@@ -1,15 +1,16 @@
import create from 'zustand';
type ExplorerState = {
type ExplorerStore = {
selectedRowIndex: number;
setSelectedRowIndex: (index: number) => void;
locationId: number;
setLocationId: (index: number) => void;
newThumbnails: Record<string, boolean>;
addNewThumbnail: (cas_id: string) => void;
reset: () => void;
};
export const useExplorerState = create<ExplorerState>((set) => ({
export const useExplorerStore = create<ExplorerStore>((set) => ({
selectedRowIndex: 1,
setSelectedRowIndex: (index) => set((state) => ({ ...state, selectedRowIndex: index })),
locationId: -1,
@@ -19,5 +20,6 @@ export const useExplorerState = create<ExplorerState>((set) => ({
set((state) => ({
...state,
newThumbnails: { ...state.newThumbnails, [cas_id]: true }
}))
})),
reset: () => set(() => ({}))
}));

View File

@@ -1,17 +1,18 @@
import { command } from '@sd/client';
import produce from 'immer';
import { debounce } from 'lodash';
import create from 'zustand';
import { libraryCommand } from '../bridge';
export type UpdateNoteFN = (vars: { id: number; note: string }) => void;
interface UseInspectorState {
interface InspectorStore {
notes: Record<number, string>;
setNote: (file_id: number, note: string) => void;
unCacheNote: (file_id: number) => void;
}
export const useInspectorState = create<UseInspectorState>((set) => ({
export const useInspectorStore = create<InspectorStore>((set) => ({
notes: {},
// set the note locally
setNote: (file_id, note) => {
@@ -35,7 +36,7 @@ export const useInspectorState = create<UseInspectorState>((set) => ({
// direct command call to update note
export const updateNote = debounce(async (file_id: number, note: string) => {
return await command('FileSetNote', {
return await libraryCommand('FileSetNote', {
id: file_id,
note
});

View File

@@ -0,0 +1,67 @@
import { LibraryConfigWrapped } from '@sd/core';
import produce from 'immer';
import { useMemo } from 'react';
import { useQueryClient } from 'react-query';
import create from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { useBridgeQuery } from '../bridge';
import { useExplorerStore } from './useExplorerStore';
type LibraryStore = {
// the uuid of the currently active library
currentLibraryUuid: string | null;
// for full functionality this should be triggered along-side query invalidation
switchLibrary: (uuid: string) => void;
// a function
init: (libraries: LibraryConfigWrapped[]) => Promise<void>;
};
export const useLibraryStore = create<LibraryStore>()(
devtools(
persist(
(set) => ({
currentLibraryUuid: null,
switchLibrary: (uuid) => {
set((state) =>
produce(state, (draft) => {
draft.currentLibraryUuid = uuid;
})
);
// reset other stores
useExplorerStore().reset();
},
init: async (libraries) => {
set((state) =>
produce(state, (draft) => {
// use first library default if none set
if (!state.currentLibraryUuid) {
draft.currentLibraryUuid = libraries[0].uuid;
}
})
);
}
}),
{ name: 'sd-library-store' }
)
)
);
// this must be used at least once in the app to correct the initial state
// is memorized and can be used safely in any component
export const useCurrentLibrary = () => {
const { currentLibraryUuid, switchLibrary } = useLibraryStore();
const { data: libraries } = useBridgeQuery('NodeGetLibraries', undefined, {});
// memorize library to avoid re-running find function
const currentLibrary = useMemo(() => {
const current = libraries?.find((l) => l.uuid === currentLibraryUuid);
// switch to first library if none set
if (Array.isArray(libraries) && !current && libraries[0]?.uuid) {
switchLibrary(libraries[0]?.uuid);
}
return current;
}, [libraries, currentLibraryUuid]);
return { currentLibrary, libraries, currentLibraryUuid };
};

View File

@@ -46,7 +46,7 @@
"react-loading-icons": "^1.1.0",
"react-loading-skeleton": "^3.1.0",
"react-portal": "^4.2.2",
"react-query": "^3.39.1",
"react-query": "^3.34.19",
"react-router": "6.3.0",
"react-router-dom": "6.3.0",
"react-scrollbars-custom": "^4.0.27",
@@ -55,6 +55,7 @@
"react-virtuoso": "^2.12.1",
"rooks": "^5.11.2",
"tailwindcss": "^3.0.24",
"use-debounce": "^8.0.1",
"zustand": "4.0.0-rc.1"
},
"devDependencies": {

View File

@@ -1,14 +1,14 @@
import '@fontsource/inter/variable.css';
import { BaseTransport, ClientProvider, setTransport } from '@sd/client';
import { useCoreEvents } from '@sd/client';
import { AppProps, AppPropsContext } from '@sd/client';
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { AppProps, AppPropsContext } from './AppPropsContext';
import { AppRouter } from './AppRouter';
import { ErrorFallback } from './ErrorFallback';
import { useCoreEvents } from './hooks/useCoreEvents';
import './style.scss';
const queryClient = new QueryClient();

View File

@@ -1,8 +1,8 @@
import { AppPropsContext } from '@sd/client';
import clsx from 'clsx';
import React, { useContext } from 'react';
import { Outlet } from 'react-router-dom';
import { AppPropsContext } from './AppPropsContext';
import { Sidebar } from './components/file/Sidebar';
export function AppLayout() {

View File

@@ -1,3 +1,5 @@
import { useBridgeQuery } from '@sd/client';
import { useLibraryStore } from '@sd/client';
import React, { useEffect } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom';
@@ -9,56 +11,81 @@ import { ExplorerScreen } from './screens/Explorer';
import { OverviewScreen } from './screens/Overview';
import { PhotosScreen } from './screens/Photos';
import { RedirectPage } from './screens/Redirect';
import { SettingsScreen } from './screens/Settings';
import { TagScreen } from './screens/Tag';
import AppearanceSettings from './screens/settings/AppearanceSettings';
import ContactsSettings from './screens/settings/ContactsSettings';
import ExperimentalSettings from './screens/settings/ExperimentalSettings';
import GeneralSettings from './screens/settings/GeneralSettings';
import KeysSettings from './screens/settings/KeysSetting';
import LibrarySettings from './screens/settings/LibrarySettings';
import LocationSettings from './screens/settings/LocationSettings';
import SecuritySettings from './screens/settings/SecuritySettings';
import SharingSettings from './screens/settings/SharingSettings';
import SyncSettings from './screens/settings/SyncSettings';
import TagsSettings from './screens/settings/TagsSettings';
import { CurrentLibrarySettings } from './screens/settings/CurrentLibrarySettings';
import { SettingsScreen } from './screens/settings/Settings';
import AppearanceSettings from './screens/settings/client/AppearanceSettings';
import GeneralSettings from './screens/settings/client/GeneralSettings';
import ContactsSettings from './screens/settings/library/ContactsSettings';
import KeysSettings from './screens/settings/library/KeysSetting';
import LibraryGeneralSettings from './screens/settings/library/LibraryGeneralSettings';
import LocationSettings from './screens/settings/library/LocationSettings';
import SecuritySettings from './screens/settings/library/SecuritySettings';
import SharingSettings from './screens/settings/library/SharingSettings';
import SyncSettings from './screens/settings/library/SyncSettings';
import TagsSettings from './screens/settings/library/TagsSettings';
import ExperimentalSettings from './screens/settings/node/ExperimentalSettings';
import LibrarySettings from './screens/settings/node/LibrariesSettings';
import NodesSettings from './screens/settings/node/NodesSettings';
import P2PSettings from './screens/settings/node/P2PSettings';
export function AppRouter() {
let location = useLocation();
let state = location.state as { backgroundLocation?: Location };
const libraryState = useLibraryStore();
const { data: libraries } = useBridgeQuery('NodeGetLibraries');
// TODO: This can be removed once we add a setup flow to the app
useEffect(() => {
console.log({ url: location.pathname });
}, [state]);
if (libraryState.currentLibraryUuid === null && libraries && libraries.length > 0) {
libraryState.switchLibrary(libraries[0].uuid);
}
}, [libraryState.currentLibraryUuid, libraries]);
return (
<>
<Routes location={state?.backgroundLocation || location}>
<Route path="/" element={<AppLayout />}>
<Route index element={<RedirectPage to="/overview" />} />
<Route path="overview" element={<OverviewScreen />} />
<Route path="content" element={<ContentScreen />} />
<Route path="photos" element={<PhotosScreen />} />
<Route path="debug" element={<DebugScreen />} />
<Route path={'settings'} element={<SettingsScreen />}>
<Route index element={<GeneralSettings />} />
<Route path="appearance" element={<AppearanceSettings />} />
<Route path="contacts" element={<ContactsSettings />} />
<Route path="experimental" element={<ExperimentalSettings />} />
<Route path="general" element={<GeneralSettings />} />
<Route path="keys" element={<KeysSettings />} />
<Route path="library" element={<LibrarySettings />} />
<Route path="security" element={<SecuritySettings />} />
<Route path="locations" element={<LocationSettings />} />
<Route path="sharing" element={<SharingSettings />} />
<Route path="sync" element={<SyncSettings />} />
<Route path="tags" element={<TagsSettings />} />
{libraryState.currentLibraryUuid === null ? (
<>
{/* TODO: Remove this when adding app setup flow */}
<h1>No Library Loaded...</h1>
</>
) : (
<Routes location={state?.backgroundLocation || location}>
<Route path="/" element={<AppLayout />}>
<Route index element={<RedirectPage to="/overview" />} />
<Route path="overview" element={<OverviewScreen />} />
<Route path="content" element={<ContentScreen />} />
<Route path="photos" element={<PhotosScreen />} />
<Route path="debug" element={<DebugScreen />} />
<Route path={'library-settings'} element={<CurrentLibrarySettings />}>
<Route index element={<LocationSettings />} />
<Route path="general" element={<LibraryGeneralSettings />} />
<Route path="locations" element={<LocationSettings />} />
<Route path="tags" element={<TagsSettings />} />
<Route path="keys" element={<KeysSettings />} />
</Route>
<Route path={'settings'} element={<SettingsScreen />}>
<Route index element={<GeneralSettings />} />
<Route path="general" element={<GeneralSettings />} />
<Route path="appearance" element={<AppearanceSettings />} />
<Route path="nodes" element={<NodesSettings />} />
<Route path="p2p" element={<P2PSettings />} />
<Route path="contacts" element={<ContactsSettings />} />
<Route path="experimental" element={<ExperimentalSettings />} />
<Route path="keys" element={<KeysSettings />} />
<Route path="library" element={<LibrarySettings />} />
<Route path="security" element={<SecuritySettings />} />
<Route path="locations" element={<LocationSettings />} />
<Route path="sharing" element={<SharingSettings />} />
<Route path="sync" element={<SyncSettings />} />
<Route path="tags" element={<TagsSettings />} />
</Route>
<Route path="explorer/:id" element={<ExplorerScreen />} />
<Route path="tag/:id" element={<TagScreen />} />
<Route path="*" element={<NotFound />} />
</Route>
<Route path="explorer/:id" element={<ExplorerScreen />} />
<Route path="tag/:id" element={<TagScreen />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Routes>
)}
</>
);
}

View File

@@ -10,7 +10,7 @@ export function NotFound() {
role="alert"
className="flex flex-col items-center justify-center w-full h-full p-4 rounded-lg dark:text-white"
>
<p className="m-3 mt-20 text-sm font-semibold text-gray-500 uppercase">Error: 404</p>
<p className="m-3 text-sm font-semibold text-gray-500 uppercase">Error: 404</p>
<h1 className="text-4xl font-bold">You chose nothingness.</h1>
<div className="flex flex-row space-x-2">
<Button variant="primary" className="mt-4" onClick={() => navigate(-1)}>

View File

@@ -1,5 +1,7 @@
import { DotsVerticalIcon } from '@heroicons/react/solid';
import { useBridgeQuery } from '@sd/client';
import { useBridgeQuery, useLibraryQuery } from '@sd/client';
import { useExplorerStore } from '@sd/client';
import { AppPropsContext } from '@sd/client';
import { FilePath } from '@sd/core';
import clsx from 'clsx';
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
@@ -7,8 +9,6 @@ import { useSearchParams } from 'react-router-dom';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { useKey, useWindowSize } from 'rooks';
import { AppPropsContext } from '../../AppPropsContext';
import { useExplorerState } from '../../hooks/useExplorerState';
import FileThumb from './FileThumb';
interface IColumn {
@@ -51,10 +51,10 @@ export const FileList: React.FC<{ location_id: number; path: string; limit: numb
const path = props.path;
const { selectedRowIndex, setSelectedRowIndex, setLocationId } = useExplorerState();
const { selectedRowIndex, setSelectedRowIndex, setLocationId } = useExplorerStore();
const [goingUp, setGoingUp] = useState(false);
const { data: currentDir } = useBridgeQuery('LibGetExplorerDir', {
const { data: currentDir } = useLibraryQuery('LibGetExplorerDir', {
location_id: props.location_id,
path,
limit: props.limit
@@ -148,7 +148,7 @@ const RenderRow: React.FC<{
rowIndex: number;
dirId: number;
}> = ({ row, rowIndex, dirId }) => {
const { selectedRowIndex, setSelectedRowIndex } = useExplorerState();
const { selectedRowIndex, setSelectedRowIndex } = useExplorerStore();
const isActive = selectedRowIndex === rowIndex;
let [_, setSearchParams] = useSearchParams();
@@ -202,7 +202,7 @@ const RenderCell: React.FC<{
if (!value) return <></>;
const location = useContext(LocationContext);
const { newThumbnails } = useExplorerState();
const { newThumbnails } = useExplorerStore();
const hasNewThumbnail = !!newThumbnails[row?.file?.cas_id ?? ''];

View File

@@ -1,9 +1,9 @@
import { useBridgeQuery } from '@sd/client';
import { AppPropsContext } from '@sd/client';
import { FilePath } from '@sd/core';
import clsx from 'clsx';
import React, { useContext } from 'react';
import { AppPropsContext } from '../../AppPropsContext';
import icons from '../../assets/icons';
import { Folder } from '../icons/Folder';

View File

@@ -1,5 +1,6 @@
import { Transition } from '@headlessui/react';
import { ShareIcon } from '@heroicons/react/solid';
import { useInspectorStore } from '@sd/client';
import { FilePath, LocationResource } from '@sd/core';
import { Button, TextArea } from '@sd/ui';
import moment from 'moment';
@@ -7,7 +8,6 @@ import { Heart, Link } from 'phosphor-react';
import React, { useEffect } from 'react';
import { default as types } from '../../constants/file-types.json';
import { useInspectorState } from '../../hooks/useInspectorState';
import FileThumb from './FileThumb';
interface MetaItemProps {
@@ -42,7 +42,7 @@ export const Inspector = (props: {
// notes are cached in a store by their file id
// this is so we can ensure every note has been sent to Rust even
// when quickly navigating files, which cancels update function
const { notes, setNote, unCacheNote } = useInspectorState();
const { notes, setNote, unCacheNote } = useInspectorStore();
// show cached note over server note, important to check for undefined not falsey
const note =

View File

@@ -1,13 +1,14 @@
import { LockClosedIcon, PhotographIcon } from '@heroicons/react/outline';
import { CogIcon, EyeOffIcon, PlusIcon } from '@heroicons/react/solid';
import { useBridgeCommand, useBridgeQuery } from '@sd/client';
import { useLibraryCommand, useLibraryQuery } from '@sd/client';
import { useCurrentLibrary, useLibraryStore } from '@sd/client';
import { AppPropsContext } from '@sd/client';
import { Button, Dropdown } from '@sd/ui';
import clsx from 'clsx';
import { CirclesFour, Code, Planet } from 'phosphor-react';
import React, { useContext } from 'react';
import { NavLink, NavLinkProps } from 'react-router-dom';
import React, { useContext, useEffect, useMemo } from 'react';
import { NavLink, NavLinkProps, useNavigate } from 'react-router-dom';
import { AppPropsContext } from '../../AppPropsContext';
import { useNodeStore } from '../device/Stores';
import { Folder } from '../icons/Folder';
import RunningJobsWidget from '../jobs/RunningJobsWidget';
@@ -76,11 +77,30 @@ const macOnly = (platform: string | undefined, classnames: string) =>
export const Sidebar: React.FC<SidebarProps> = (props) => {
const { isExperimental } = useNodeStore();
const appProps = useContext(AppPropsContext);
const { data: locations } = useBridgeQuery('SysGetLocations');
const { data: clientState } = useBridgeQuery('NodeGetState');
const navigate = useNavigate();
const { mutate: createLocation } = useBridgeCommand('LocCreate');
const appProps = useContext(AppPropsContext);
const { data: locationsResponse, isError: isLocationsError } = useLibraryQuery('SysGetLocations');
let locations = Array.isArray(locationsResponse) ? locationsResponse : [];
// initialize libraries
const { init: initLibraries, switchLibrary: _switchLibrary } = useLibraryStore();
const switchLibrary = (uuid: string) => {
navigate('overview');
_switchLibrary(uuid);
};
const { currentLibrary, libraries, currentLibraryUuid } = useCurrentLibrary();
useEffect(() => {
if (libraries && !currentLibraryUuid) initLibraries(libraries);
}, [libraries, currentLibraryUuid]);
const { mutate: createLocation } = useLibraryCommand('LocCreate');
const tags = [
{ id: 1, name: 'Keepsafe', color: '#FF6788' },
@@ -122,7 +142,6 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
appProps?.platform === 'macOS' &&
'dark:!bg-opacity-40 dark:hover:!bg-opacity-70 dark:!border-[#333949] dark:hover:!border-[#394052]'
),
variant: 'gray'
}}
// to support the transparent sidebar on macOS we use slightly adjusted styles
@@ -133,17 +152,22 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
)}
// this shouldn't default to "My Library", it is only this way for landing demo
// TODO: implement demo mode for the sidebar and show loading indicator instead of "My Library"
buttonText={clientState?.node_name || 'My Library'}
buttonText={currentLibrary?.config.name || ' '}
items={[
libraries?.map((library) => ({
name: library.config.name,
selected: library.uuid === currentLibraryUuid,
onPress: () => switchLibrary(library.uuid)
})) || [],
[
{ name: clientState?.node_name || 'My Library', selected: true },
{ name: 'Private Library' }
],
[
{ name: 'Library Settings', icon: CogIcon },
{
name: 'Library Settings',
icon: CogIcon,
onPress: () => navigate('library-settings/general')
},
{ name: 'Add Library', icon: PlusIcon },
{ name: 'Lock', icon: LockClosedIcon },
{ name: 'Hide', icon: EyeOffIcon }
{ name: 'Lock', icon: LockClosedIcon }
// { name: 'Hide', icon: EyeOffIcon }
]
]}
/>
@@ -204,21 +228,23 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
);
})}
<button
onClick={() => {
appProps?.openDialog({ directory: true }).then((result) => {
if (result) createLocation({ path: result as string });
});
}}
className={clsx(
'w-full px-2 py-1.5 mt-1 text-xs font-bold text-center text-gray-400 border border-dashed rounded border-transparent cursor-normal border-gray-350 transition',
appProps?.platform === 'macOS'
? 'dark:text-gray-450 dark:border-gray-450 hover:dark:border-gray-400 dark:border-opacity-60'
: 'dark:text-gray-450 dark:border-gray-550 hover:dark:border-gray-500'
)}
>
Add Location
</button>
{(locations?.length || 0) < 1 && (
<button
onClick={() => {
appProps?.openDialog({ directory: true }).then((result) => {
if (result) createLocation({ path: result as string });
});
}}
className={clsx(
'w-full px-2 py-1.5 mt-1 text-xs font-bold text-center text-gray-400 border border-dashed rounded border-transparent cursor-normal border-gray-350 transition',
appProps?.platform === 'macOS'
? 'dark:text-gray-450 dark:border-gray-450 hover:dark:border-gray-400 dark:border-opacity-60'
: 'dark:text-gray-450 dark:border-gray-550 hover:dark:border-gray-500'
)}
>
Add Location
</button>
)}
</div>
<div>
<Heading>Tags</Heading>

View File

@@ -0,0 +1,15 @@
import clsx from 'clsx';
import React, { ReactNode } from 'react';
export default function Card(props: { children: ReactNode; className?: string }) {
return (
<div
className={clsx(
'flex w-full px-4 py-2 border border-gray-500 rounded-lg bg-gray-550',
props.className
)}
>
{props.children}
</div>
);
}

View File

@@ -5,7 +5,7 @@ import React, { ReactNode } from 'react';
import Loader from '../primitive/Loader';
export interface DialogProps {
export interface DialogProps extends DialogPrimitive.DialogProps {
trigger: ReactNode;
ctaLabel?: string;
ctaDanger?: boolean;
@@ -18,13 +18,15 @@ export interface DialogProps {
export default function Dialog(props: DialogProps) {
return (
<DialogPrimitive.Root>
<DialogPrimitive.Root open={props.open} onOpenChange={props.onOpenChange}>
<DialogPrimitive.Trigger asChild>{props.trigger}</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed top-0 dialog-overlay bottom-0 left-0 right-0 z-50 grid overflow-y-auto bg-black bg-opacity-50 rounded-xl place-items-center m-[1px]">
<DialogPrimitive.Content className="min-w-[300px] max-w-[400px] dialog-content rounded-md bg-gray-650 text-white border border-gray-550 shadow-deep">
<div className="p-5">
<DialogPrimitive.Title className="font-bold ">{props.title}</DialogPrimitive.Title>
<DialogPrimitive.Title className="mb-2 font-bold">
{props.title}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-sm text-gray-300">
{props.description}
</DialogPrimitive.Description>

View File

@@ -1,5 +1,6 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/outline';
import { useBridgeCommand } from '@sd/client';
import { useLibraryCommand } from '@sd/client';
import { useExplorerStore } from '@sd/client';
import { Dropdown } from '@sd/ui';
import clsx from 'clsx';
import {
@@ -15,7 +16,6 @@ import {
import React, { DetailedHTMLProps, HTMLAttributes } from 'react';
import { useNavigate } from 'react-router-dom';
import { useExplorerState } from '../../hooks/useExplorerState';
import { Shortcut } from '../primitive/Shortcut';
import { DefaultProps } from '../primitive/types';
@@ -50,14 +50,14 @@ const TopBarButton: React.FC<TopBarButtonProps> = ({ icon: Icon, ...props }) =>
};
export const TopBar: React.FC<TopBarProps> = (props) => {
const { locationId } = useExplorerState();
const { mutate: generateThumbsForLocation } = useBridgeCommand('GenerateThumbsForLocation', {
const { locationId } = useExplorerStore();
const { mutate: generateThumbsForLocation } = useLibraryCommand('GenerateThumbsForLocation', {
onMutate: (data) => {
console.log('GenerateThumbsForLocation', data);
}
});
const { mutate: identifyUniqueFiles } = useBridgeCommand('IdentifyUniqueFiles', {
const { mutate: identifyUniqueFiles } = useLibraryCommand('IdentifyUniqueFiles', {
onMutate: (data) => {
console.log('IdentifyUniqueFiles', data);
},

View File

@@ -1,6 +1,6 @@
import { DotsVerticalIcon, RefreshIcon } from '@heroicons/react/outline';
import { CogIcon, TrashIcon } from '@heroicons/react/solid';
import { command, useBridgeCommand } from '@sd/client';
import { TrashIcon } from '@heroicons/react/solid';
import { useLibraryCommand } from '@sd/client';
import { LocationResource } from '@sd/core';
import { Button } from '@sd/ui';
import clsx from 'clsx';
@@ -16,9 +16,9 @@ interface LocationListItemProps {
export default function LocationListItem({ location }: LocationListItemProps) {
const [hide, setHide] = useState(false);
const { mutate: locRescan } = useBridgeCommand('LocRescan');
const { mutate: locRescan } = useLibraryCommand('LocRescan');
const { mutate: deleteLoc, isLoading: locDeletePending } = useBridgeCommand('LocDelete', {
const { mutate: deleteLoc, isLoading: locDeletePending } = useLibraryCommand('LocDelete', {
onSuccess: () => {
setHide(true);
}

View File

@@ -5,5 +5,5 @@ interface SettingsContainerProps {
}
export const SettingsContainer: React.FC<SettingsContainerProps> = (props) => {
return <div className="flex flex-col flex-grow max-w-4xl space-y-4 w-ful">{props.children}</div>;
return <div className="flex flex-col flex-grow max-w-4xl space-y-6 w-ful">{props.children}</div>;
};

View File

@@ -1,15 +1,19 @@
import React from 'react';
import React, { ReactNode } from 'react';
interface SettingsHeaderProps {
title: string;
description: string;
rightArea?: ReactNode;
}
export const SettingsHeader: React.FC<SettingsHeaderProps> = (props) => {
return (
<div className="mt-3 mb-3">
<h1 className="text-2xl font-bold">{props.title}</h1>
<p className="mt-1 text-sm text-gray-400">{props.description}</p>
<div className="flex mt-3 mb-3">
<div className="flex-grow">
<h1 className="text-2xl font-bold">{props.title}</h1>
<p className="mt-1 text-sm text-gray-400">{props.description}</p>
</div>
{props.rightArea}
<hr className="mt-4 border-gray-550" />
</div>
);

View File

@@ -0,0 +1,40 @@
import clsx from 'clsx';
import React from 'react';
import { Outlet } from 'react-router';
interface SettingsScreenContainerProps {
children: React.ReactNode;
}
export const SettingsIcon = ({ component: Icon, ...props }: any) => (
<Icon weight="bold" {...props} className={clsx('w-4 h-4 mr-2', props.className)} />
);
export const SettingsHeading: React.FC<{ className?: string; children: string }> = ({
children,
className
}) => (
<div className={clsx('mt-5 mb-1 ml-1 text-xs font-semibold text-gray-300', className)}>
{children}
</div>
);
export const SettingsScreenContainer: React.FC<SettingsScreenContainerProps> = (props) => {
return (
<div className="flex flex-row w-full">
<div className="h-full border-r max-w-[200px] flex-shrink-0 border-gray-100 w-60 dark:border-gray-550">
<div data-tauri-drag-region className="w-full h-7" />
<div className="p-5 pt-0">{props.children}</div>
</div>
<div className="w-full">
<div data-tauri-drag-region className="w-full h-7" />
<div className="flex flex-grow-0 w-full h-full max-h-screen custom-scroll page-scroll">
<div className="flex flex-grow px-12 pb-5">
<Outlet />
<div className="block h-20" />
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,46 +0,0 @@
import { transport } from '@sd/client';
import { CoreEvent } from '@sd/core';
import { useContext, useEffect } from 'react';
import { useQueryClient } from 'react-query';
import { AppPropsContext } from '../AppPropsContext';
import { useExplorerState } from './useExplorerState';
export function useCoreEvents() {
const client = useQueryClient();
const { addNewThumbnail } = useExplorerState();
useEffect(() => {
function handleCoreEvent(e: CoreEvent) {
switch (e?.key) {
case 'NewThumbnail':
addNewThumbnail(e.data.cas_id);
break;
case 'InvalidateQuery':
case 'InvalidateQueryDebounced':
let query = [e.data.key];
// TODO: find a way to make params accessible in TS
// also this method will only work for queries that use the whole params obj as the second key
// @ts-expect-error
if (e.data.params) {
// @ts-expect-error
query.push(e.data.params);
}
client.invalidateQueries(e.data.key);
break;
default:
break;
}
}
// check Tauri Event type
transport?.on('core_event', handleCoreEvent);
return () => {
transport?.off('core_event', handleCoreEvent);
};
// listen('core_event', (e: { payload: CoreEvent }) => {
// });
}, [transport]);
}

View File

@@ -1,5 +1,6 @@
import { AppProps, Platform } from '@sd/client';
import App from './App';
import { AppProps, Platform } from './AppPropsContext';
export type { AppProps, Platform };

View File

@@ -1,21 +1,22 @@
import { useBridgeCommand, useBridgeQuery } from '@sd/client';
import { useBridgeQuery, useLibraryCommand, useLibraryQuery } from '@sd/client';
import { AppPropsContext } from '@sd/client';
import { Button } from '@sd/ui';
import React, { useContext } from 'react';
import { AppPropsContext } from '../AppPropsContext';
import CodeBlock from '../components/primitive/Codeblock';
export const DebugScreen: React.FC<{}> = (props) => {
const appPropsContext = useContext(AppPropsContext);
const { data: client } = useBridgeQuery('NodeGetState');
const { data: nodeState } = useBridgeQuery('NodeGetState');
const { data: libraryState } = useBridgeQuery('NodeGetLibraries');
const { data: jobs } = useBridgeQuery('JobGetRunning');
const { data: jobHistory } = useBridgeQuery('JobGetHistory');
const { data: jobHistory } = useLibraryQuery('JobGetHistory');
// const { mutate: purgeDB } = useBridgeCommand('PurgeDatabase', {
// onMutate: () => {
// alert('Database purged');
// }
// });
const { mutate: identifyFiles } = useBridgeCommand('IdentifyUniqueFiles');
const { mutate: identifyFiles } = useLibraryCommand('IdentifyUniqueFiles');
return (
<div className="flex flex-col w-full h-screen custom-scroll page-scroll">
<div data-tauri-drag-region className="flex flex-shrink-0 w-full h-5" />
@@ -27,8 +28,8 @@ export const DebugScreen: React.FC<{}> = (props) => {
variant="gray"
size="sm"
onClick={() => {
if (client && appPropsContext?.onOpen) {
appPropsContext.onOpen(client.data_path);
if (nodeState && appPropsContext?.onOpen) {
appPropsContext.onOpen(nodeState.data_path);
}
}}
>
@@ -39,8 +40,10 @@ export const DebugScreen: React.FC<{}> = (props) => {
<CodeBlock src={{ ...jobs }} />
<h1 className="text-sm font-bold ">Job History</h1>
<CodeBlock src={{ ...jobHistory }} />
<h1 className="text-sm font-bold ">Client State</h1>
<CodeBlock src={{ ...client }} />
<h1 className="text-sm font-bold ">Node State</h1>
<CodeBlock src={{ ...nodeState }} />
<h1 className="text-sm font-bold ">Libraries</h1>
<CodeBlock src={{ ...libraryState }} />
</div>
</div>
);

View File

@@ -1,11 +1,11 @@
import { useBridgeQuery } from '@sd/client';
import { useLibraryQuery } from '@sd/client';
import { useExplorerStore } from '@sd/client';
import React from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { FileList } from '../components/file/FileList';
import { Inspector } from '../components/file/Inspector';
import { TopBar } from '../components/layout/TopBar';
import { useExplorerState } from '../hooks/useExplorerState';
export const ExplorerScreen: React.FC<{}> = () => {
let [searchParams] = useSearchParams();
@@ -16,13 +16,13 @@ export const ExplorerScreen: React.FC<{}> = () => {
const [limit, setLimit] = React.useState(100);
const { selectedRowIndex } = useExplorerState();
const { selectedRowIndex } = useExplorerStore();
// Current Location
const { data: currentLocation } = useBridgeQuery('SysGetLocation', { id: location_id });
const { data: currentLocation } = useLibraryQuery('SysGetLocation', { id: location_id });
// Current Directory
const { data: currentDir } = useBridgeQuery(
const { data: currentDir } = useLibraryQuery(
'LibGetExplorerDir',
{ location_id: location_id!, path, limit },
{ enabled: !!location_id }

View File

@@ -1,5 +1,6 @@
import { PlusIcon } from '@heroicons/react/solid';
import { useBridgeQuery } from '@sd/client';
import { DatabaseIcon, ExclamationCircleIcon, PlusIcon } from '@heroicons/react/solid';
import { useBridgeQuery, useLibraryQuery } from '@sd/client';
import { AppPropsContext } from '@sd/client';
import { Statistics } from '@sd/core';
import { Button, Input } from '@sd/ui';
import byteSize from 'byte-size';
@@ -10,7 +11,6 @@ import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
import create from 'zustand';
import { AppPropsContext } from '../AppPropsContext';
import { Device } from '../components/device/Device';
import Dialog from '../components/layout/Dialog';
@@ -102,7 +102,7 @@ const StatItem: React.FC<StatItemProps> = (props) => {
export const OverviewScreen = () => {
const { data: libraryStatistics, isLoading: isStatisticsLoading } =
useBridgeQuery('GetLibraryStatistics');
useLibraryQuery('GetLibraryStatistics');
const { data: nodeState } = useBridgeQuery('NodeGetState');
const { overviewStats, setOverviewStats } = useOverviewState();
@@ -157,7 +157,17 @@ export const OverviewScreen = () => {
{/* STAT HEADER */}
<div className="flex w-full">
{/* STAT CONTAINER */}
<div className="flex pb-4 overflow-hidden">
<div className="flex -mb-1 overflow-hidden">
{!libraryStatistics && (
<div className="mb-2 ml-2">
<div className="font-semibold text-gray-200">
<ExclamationCircleIcon className="inline w-4 h-4 mr-1 -mt-1 " /> Missing library
</div>
<span className="text-xs text-gray-400 ">
Ensure the library you have loaded still exists on disk
</span>
</div>
)}
{Object.entries(overviewStats).map(([key, value]) => {
if (!displayableStatItems.includes(key)) return null;
@@ -171,8 +181,9 @@ export const OverviewScreen = () => {
);
})}
</div>
<div className="flex-grow" />
<div className="space-x-2">
<div className="space-x-2 ">
<Dialog
title="Add Device"
description="Connect a new device to your library. Either enter another device's code or copy this one."
@@ -205,7 +216,7 @@ export const OverviewScreen = () => {
</Dialog>
</div>
</div>
<div className="flex flex-col pb-4 space-y-4">
<div className="flex flex-col pb-4 mt-4 space-y-4">
<Device
name={`James' MacBook Pro`}
size="1TB"

View File

@@ -1,92 +0,0 @@
import {
CloudIcon,
CogIcon,
KeyIcon,
LockClosedIcon,
TagIcon,
TerminalIcon,
UsersIcon
} from '@heroicons/react/outline';
import clsx from 'clsx';
import { Database, HardDrive, PaintBrush } from 'phosphor-react';
import React from 'react';
import { Outlet } from 'react-router-dom';
import { SidebarLink } from '../components/file/Sidebar';
const Icon = ({ component: Icon, ...props }: any) => (
<Icon weight="bold" {...props} className={clsx('w-4 h-4 mr-2', props.className)} />
);
const Heading: React.FC<{ className?: string; children: string }> = ({ children, className }) => (
<div className={clsx('mt-5 mb-1 ml-1 text-xs font-semibold text-gray-300', className)}>
{children}
</div>
);
export const SettingsScreen: React.FC<{}> = () => {
return (
<div className="flex flex-row w-full">
<div className="h-full border-r max-w-[200px] flex-shrink-0 border-gray-100 w-60 dark:border-gray-550">
<div data-tauri-drag-region className="w-full h-7" />
<div className="p-5 pt-0">
<Heading className="mt-0">Client</Heading>
<SidebarLink to="/settings/general">
<Icon component={CogIcon} />
General
</SidebarLink>
<SidebarLink to="/settings/security">
<Icon component={LockClosedIcon} />
Security
</SidebarLink>
<SidebarLink to="/settings/appearance">
<Icon component={PaintBrush} />
Appearance
</SidebarLink>
<SidebarLink to="/settings/experimental">
<Icon component={TerminalIcon} />
Experimental
</SidebarLink>
<Heading>Library</Heading>
<SidebarLink to="/settings/library">
<Icon component={Database} />
Database
</SidebarLink>
<SidebarLink to="/settings/locations">
<Icon component={HardDrive} />
Locations
</SidebarLink>
<SidebarLink to="/settings/keys">
<Icon component={KeyIcon} />
Keys
</SidebarLink>
<SidebarLink to="/settings/tags">
<Icon component={TagIcon} />
Tags
</SidebarLink>
<Heading>Cloud</Heading>
<SidebarLink to="/settings/sync">
<Icon component={CloudIcon} />
Sync
</SidebarLink>
<SidebarLink to="/settings/contacts">
<Icon component={UsersIcon} />
Contacts
</SidebarLink>
</div>
</div>
<div className="w-full">
<div data-tauri-drag-region className="w-full h-7" />
<div className="flex flex-grow-0 w-full h-full max-h-screen custom-scroll page-scroll">
<div className="flex flex-grow px-12 pb-5">
<Outlet />
<div className="block h-20" />
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,42 @@
import { CogIcon, DatabaseIcon, KeyIcon, TagIcon } from '@heroicons/react/outline';
import { HardDrive, ShareNetwork } from 'phosphor-react';
import React from 'react';
import { SidebarLink } from '../../components/file/Sidebar';
import {
SettingsHeading,
SettingsIcon,
SettingsScreenContainer
} from '../../components/settings/SettingsScreenContainer';
export const CurrentLibrarySettings: React.FC = () => {
return (
<SettingsScreenContainer>
<SettingsHeading className="!mt-0">Library Settings</SettingsHeading>
<SidebarLink to="/library-settings/general">
<SettingsIcon component={CogIcon} />
General
</SidebarLink>
<SidebarLink to="/library-settings/locations">
<SettingsIcon component={HardDrive} />
Locations
</SidebarLink>
<SidebarLink to="/library-settings/tags">
<SettingsIcon component={TagIcon} />
Tags
</SidebarLink>
<SidebarLink to="/library-settings/keys">
<SettingsIcon component={KeyIcon} />
Keys
</SidebarLink>
<SidebarLink to="/library-settings/backups">
<SettingsIcon component={DatabaseIcon} />
Backups
</SidebarLink>
<SidebarLink to="/library-settings/backups">
<SettingsIcon component={ShareNetwork} />
Sync
</SidebarLink>
</SettingsScreenContainer>
);
};

View File

@@ -1,40 +0,0 @@
import { useBridgeQuery } from '@sd/client';
import React from 'react';
import { InputContainer } from '../../components/primitive/InputContainer';
import Listbox from '../../components/primitive/Listbox';
import { SettingsContainer } from '../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../components/settings/SettingsHeader';
export default function GeneralSettings() {
const { data: volumes } = useBridgeQuery('SysGetVolumes');
return (
<SettingsContainer>
<SettingsHeader
title="General Settings"
description="Basic settings related to this client."
/>
<InputContainer title="Volumes" description="A list of volumes running on this device.">
<div className="flex flex-row space-x-2">
<div className="flex flex-grow">
<Listbox
options={
volumes?.map((volume) => {
const name = volume.name && volume.name.length ? volume.name : volume.mount_point;
return {
key: name,
option: name,
description: volume.mount_point
};
}) ?? []
}
/>
</div>
</div>
</InputContainer>
{/* <div className="">{JSON.stringify({ config })}</div> */}
</SettingsContainer>
);
}

View File

@@ -1,32 +0,0 @@
import React from 'react';
import { Toggle } from '../../components/primitive';
import { InputContainer } from '../../components/primitive/InputContainer';
import { SettingsContainer } from '../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../components/settings/SettingsHeader';
// type LibrarySecurity = 'public' | 'password' | 'vault';
export default function LibrarySettings() {
// const locations = useBridgeQuery("SysGetLocation")
const [encryptOnCloud, setEncryptOnCloud] = React.useState<boolean>(false);
return (
<SettingsContainer>
{/* <Button size="sm">Add Location</Button> */}
<SettingsHeader
title="Library database"
description="The database contains all library data and file metadata."
/>
<InputContainer
mini
title="Encrypt on cloud"
description="Enable if library contains sensitive data and should not be synced to the cloud without full encryption."
>
<div className="flex items-center h-full pl-10">
<Toggle value={encryptOnCloud} onChange={setEncryptOnCloud} size={'sm'} />
</div>
</InputContainer>
</SettingsContainer>
);
}

View File

@@ -1,23 +0,0 @@
import { Button } from '@sd/ui';
import React from 'react';
import { InputContainer } from '../../components/primitive/InputContainer';
import { SettingsContainer } from '../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../components/settings/SettingsHeader';
export default function SecuritySettings() {
return (
<SettingsContainer>
<SettingsHeader title="Security" description="Keep your client safe." />
<InputContainer
title="Vault"
description="You'll need to set a passphrase to enable the vault."
>
<div className="flex flex-row">
<Button variant="primary">Enable Vault</Button>
{/*<Input className="flex-grow" value="jeff" placeholder="/users/jamie/Desktop" />*/}
</div>
</InputContainer>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,83 @@
import {
CogIcon,
CollectionIcon,
GlobeAltIcon,
KeyIcon,
TerminalIcon
} from '@heroicons/react/outline';
import { HardDrive, PaintBrush, ShareNetwork } from 'phosphor-react';
import React from 'react';
import { SidebarLink } from '../../components/file/Sidebar';
import {
SettingsHeading,
SettingsIcon,
SettingsScreenContainer
} from '../../components/settings/SettingsScreenContainer';
export const SettingsScreen: React.FC = () => {
return (
<SettingsScreenContainer>
<SettingsHeading className="!mt-0">Client</SettingsHeading>
<SidebarLink to="/settings/general">
<SettingsIcon component={CogIcon} />
General
</SidebarLink>
<SidebarLink to="/settings/appearance">
<SettingsIcon component={PaintBrush} />
Appearance
</SidebarLink>
<SettingsHeading>Node</SettingsHeading>
<SidebarLink to="/settings/nodes">
<SettingsIcon component={GlobeAltIcon} />
Nodes
</SidebarLink>
<SidebarLink to="/settings/p2p">
<SettingsIcon component={ShareNetwork} />
P2P
</SidebarLink>
<SidebarLink to="/settings/library">
<SettingsIcon component={CollectionIcon} />
Libraries
</SidebarLink>
<SidebarLink to="/settings/security">
<SettingsIcon component={KeyIcon} />
Security
</SidebarLink>
<SettingsHeading>Developer</SettingsHeading>
<SidebarLink to="/settings/experimental">
<SettingsIcon component={TerminalIcon} />
Experimental
</SidebarLink>
{/* <SettingsHeading>Library</SettingsHeading>
<SidebarLink to="/settings/library">
<SettingsIcon component={CollectionIcon} />
My Libraries
</SidebarLink>
<SidebarLink to="/settings/locations">
<SettingsIcon component={HardDrive} />
Locations
</SidebarLink>
<SidebarLink to="/settings/keys">
<SettingsIcon component={KeyIcon} />
Keys
</SidebarLink>
<SidebarLink to="/settings/tags">
<SettingsIcon component={TagIcon} />
Tags
</SidebarLink> */}
{/* <SettingsHeading>Cloud</SettingsHeading>
<SidebarLink to="/settings/sync">
<SettingsIcon component={CloudIcon} />
Sync
</SidebarLink>
<SidebarLink to="/settings/contacts">
<SettingsIcon component={UsersIcon} />
Contacts
</SidebarLink> */}
</SettingsScreenContainer>
);
};

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { SettingsContainer } from '../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../components/settings/SettingsHeader';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function AppearanceSettings() {
return (

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function GeneralSettings() {
// const { data: volumes } = useBridgeQuery('SysGetVolumes');
return (
<SettingsContainer>
<SettingsHeader
title="General Settings"
description="General settings related to this client."
/>
{/* <InputContainer title="Volumes" description="A list of volumes running on this device.">
<div className="flex flex-row space-x-2">
<div className="flex flex-grow">
<Listbox
options={
volumes?.map((volume) => {
const name = volume.name && volume.name.length ? volume.name : volume.mount_point;
return {
key: name,
option: name,
description: volume.mount_point
};
}) ?? []
}
/>
</div>
</div>
</InputContainer> */}
</SettingsContainer>
);
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { SettingsContainer } from '../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../components/settings/SettingsHeader';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function ContactsSettings() {
return (

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { SettingsContainer } from '../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../components/settings/SettingsHeader';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function KeysSettings() {
return (

View File

@@ -0,0 +1,91 @@
import { useBridgeCommand, useBridgeQuery } from '@sd/client';
import { useCurrentLibrary } from '@sd/client';
import { Button, Input } from '@sd/ui';
import React, { useCallback, useEffect, useState } from 'react';
import { useDebounce } from 'use-debounce';
import { Toggle } from '../../../components/primitive';
import { InputContainer } from '../../../components/primitive/InputContainer';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function LibraryGeneralSettings() {
const { currentLibrary, libraries, currentLibraryUuid } = useCurrentLibrary();
const { mutate: editLibrary } = useBridgeCommand('EditLibrary');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [encryptLibrary, setEncryptLibrary] = useState(false);
const [nameDebounced] = useDebounce(name, 500);
const [descriptionDebounced] = useDebounce(description, 500);
useEffect(() => {
if (currentLibrary) {
const { name, description } = currentLibrary.config;
// currentLibrary must be loaded, name must not be empty, and must be different from the current
if (nameDebounced && (nameDebounced !== name || descriptionDebounced !== description)) {
editLibrary({
id: currentLibraryUuid!,
name: nameDebounced,
description: descriptionDebounced
});
}
}
}, [nameDebounced, descriptionDebounced]);
useEffect(() => {
if (currentLibrary) {
setName(currentLibrary.config.name);
setDescription(currentLibrary.config.description);
}
}, [libraries]);
return (
<SettingsContainer>
<SettingsHeader
title="Library Settings"
description="General settings related to the currently active library."
/>
<div className="flex flex-row pb-3 space-x-5">
<div className="flex flex-col flex-grow ">
<span className="mt-2 mb-1 text-xs font-semibold text-gray-300">Name</span>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
defaultValue="My Default Library"
/>
</div>
<div className="flex flex-col flex-grow">
<span className="mt-2 mb-1 text-xs font-semibold text-gray-300">Description</span>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder=""
/>
</div>
</div>
<InputContainer
mini
title="Encrypt Library"
description="Enable encryption for this library, this will only encrypt the Spacedrive database, not the files themselves."
>
<div className="flex items-center ml-3">
<Toggle value={encryptLibrary} onChange={setEncryptLibrary} />
</div>
</InputContainer>
<InputContainer
title="Delete Library"
description="This is permanent, your files will not be deleted, only the Spacedrive library."
>
<div className="mt-2">
<Button size="sm" variant="colored" className="bg-red-500 border-red-500">
Delete Library
</Button>
</div>
</InputContainer>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,55 @@
import { PlusIcon } from '@heroicons/react/solid';
import { useBridgeQuery, useLibraryCommand, useLibraryQuery } from '@sd/client';
import { AppPropsContext } from '@sd/client';
import { Button } from '@sd/ui';
import React, { useContext } from 'react';
import LocationListItem from '../../../components/location/LocationListItem';
import { InputContainer } from '../../../components/primitive/InputContainer';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
// const exampleLocations = [
// { option: 'Macintosh HD', key: 'macintosh_hd' },
// { option: 'LaCie External', key: 'lacie_external' },
// { option: 'Seagate 8TB', key: 'seagate_8tb' }
// ];
export default function LocationSettings() {
const { data: locations } = useLibraryQuery('SysGetLocations');
const appProps = useContext(AppPropsContext);
const { mutate: createLocation } = useLibraryCommand('LocCreate');
return (
<SettingsContainer>
{/*<Button size="sm">Add Location</Button>*/}
<SettingsHeader
title="Locations"
description="Manage your storage locations."
rightArea={
<div className="flex-row space-x-2">
<Button
variant="primary"
size="sm"
onClick={() => {
appProps?.openDialog({ directory: true }).then((result) => {
if (result) createLocation({ path: result as string });
});
}}
>
Add Location
</Button>
</div>
}
/>
<div className="grid space-y-2">
{locations?.map((location) => (
<LocationListItem key={location.id} location={location} />
))}
</div>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,14 @@
import { Button } from '@sd/ui';
import React from 'react';
import { InputContainer } from '../../../components/primitive/InputContainer';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function SecuritySettings() {
return (
<SettingsContainer>
<SettingsHeader title="Security" description="Keep your client safe." />
</SettingsContainer>
);
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { SettingsContainer } from '../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../components/settings/SettingsHeader';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function SharingSettings() {
return (

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { SettingsContainer } from '../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../components/settings/SettingsHeader';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function SyncSettings() {
return (

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { SettingsContainer } from '../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../components/settings/SettingsHeader';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function TagsSettings() {
return (

View File

@@ -1,14 +1,12 @@
import React from 'react';
import { useNodeStore } from '../../components/device/Stores';
import { Toggle } from '../../components/primitive';
import { InputContainer } from '../../components/primitive/InputContainer';
import { SettingsContainer } from '../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../components/settings/SettingsHeader';
import { useNodeStore } from '../../../components/device/Stores';
import { Toggle } from '../../../components/primitive';
import { InputContainer } from '../../../components/primitive/InputContainer';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function ExperimentalSettings() {
// const locations = useBridgeQuery("SysGetLocation")
const { isExperimental, setIsExperimental } = useNodeStore();
return (

View File

@@ -0,0 +1,113 @@
import { CollectionIcon, TrashIcon } from '@heroicons/react/outline';
import { PlusIcon } from '@heroicons/react/solid';
import { useBridgeCommand, useBridgeQuery } from '@sd/client';
import { AppPropsContext } from '@sd/client';
import { LibraryConfig, LibraryConfigWrapped } from '@sd/core';
import { Button, Input } from '@sd/ui';
import React, { useContext, useState } from 'react';
import Card from '../../../components/layout/Card';
import Dialog from '../../../components/layout/Dialog';
import { Toggle } from '../../../components/primitive';
import { InputContainer } from '../../../components/primitive/InputContainer';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
// type LibrarySecurity = 'public' | 'password' | 'vault';
function LibraryListItem(props: { library: LibraryConfigWrapped }) {
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const { mutate: deleteLib, isLoading: libDeletePending } = useBridgeCommand('DeleteLibrary', {
onSuccess: () => {
setOpenDeleteModal(false);
}
});
return (
<Card>
<div className="flex-grow my-0.5">
<h3 className="font-semibold">{props.library.config.name}</h3>
<p className="mt-0.5 text-xs text-gray-200">{props.library.uuid}</p>
</div>
<div>
<Dialog
open={openDeleteModal}
onOpenChange={setOpenDeleteModal}
title="Delete Library"
description="Deleting a library will permanently the database, the files themselves will not be deleted."
ctaAction={() => {
deleteLib({ id: props.library.uuid });
}}
loading={libDeletePending}
ctaDanger
ctaLabel="Delete"
trigger={
<Button variant="gray" className="!p-1.5" onClick={() => {}}>
<TrashIcon className="w-4 h-4" />
</Button>
}
/>
</div>
</Card>
);
}
export default function LibrarySettings() {
const [openCreateModal, setOpenCreateModal] = useState(false);
const [newLibName, setNewLibName] = useState('');
const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeCommand('CreateLibrary', {
onSuccess: () => {
setOpenCreateModal(false);
}
});
const { data: libraries } = useBridgeQuery('NodeGetLibraries');
function createNewLib() {
if (newLibName) {
createLibrary({ name: newLibName });
}
}
return (
<SettingsContainer>
<SettingsHeader
title="Libraries"
description="The database contains all library data and file metadata."
rightArea={
<div className="flex-row space-x-2">
<Dialog
open={openCreateModal}
onOpenChange={setOpenCreateModal}
title="Create New Library"
description="Choose a name for your new library, you can configure this and more settings from the library settings later on."
ctaAction={createNewLib}
loading={createLibLoading}
ctaLabel="Create"
trigger={
<Button variant="primary" size="sm">
Add Library
</Button>
}
>
<Input
className="flex-grow w-full mt-3"
value={newLibName}
placeholder="My Cool Library"
onChange={(e) => setNewLibName(e.target.value)}
/>
</Dialog>
</div>
}
/>
<div className="space-y-2">
{libraries?.map((library) => (
<LibraryListItem key={library.uuid} library={library} />
))}
</div>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function NodesSettings() {
return (
<SettingsContainer>
<SettingsHeader title="Nodes" description="Manage the nodes in your Spacedrive network." />
</SettingsContainer>
);
}

View File

@@ -0,0 +1,40 @@
import { useBridgeQuery } from '@sd/client';
import { Button, Input } from '@sd/ui';
import React from 'react';
import { Toggle } from '../../../components/primitive';
import { InputContainer } from '../../../components/primitive/InputContainer';
import Listbox from '../../../components/primitive/Listbox';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function P2PSettings() {
return (
<SettingsContainer>
<SettingsHeader
title="P2P Settings"
description="Manage how this node communicates with other nodes."
/>
<InputContainer
mini
title="Enable Node Discovery"
description="Allow or block this node from calling an external server to assist in forming a peer-to-peer connection. "
>
<Toggle value />
</InputContainer>
<InputContainer
title="Discovery Server"
description="Configuration server to aid with establishing peer-to-peer to connections between nodes over the internet. Disabling will result in nodes only being accessible over LAN and direct IP connections."
>
<div className="flex flex-col mt-1">
<Input className="flex-grow" disabled defaultValue="https://p2p.spacedrive.com" />
<div className="flex justify-end mt-1">
<a className="p-1 text-sm font-bold text-primary-500 hover:text-primary-400">Change</a>
</div>
</div>
</InputContainer>
</SettingsContainer>
);
}

View File

@@ -17,7 +17,7 @@
"storybook:build": "build-storybook"
},
"dependencies": {
"@headlessui/react": "^1.6.4",
"@headlessui/react": "^1.6.6",
"@heroicons/react": "^1.0.6",
"@radix-ui/react-context-menu": "^0.1.6",
"clsx": "^1.1.1",

View File

@@ -39,7 +39,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ ...props
ref={ref}
{...props}
className={clsx(
`px-3 py-1 rounded-md border leading-7 outline-none shadow-xs focus:ring-2 transition-all`,
`px-3 py-1 text-sm rounded-md border leading-7 outline-none shadow-xs focus:ring-2 transition-all`,
variants[props.variant || 'default'],
props.className
)}

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.