mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-18 13:26:00 -04:00
Library manager (#258)
This commit is contained in:
BIN
Cargo.lock
generated
BIN
Cargo.lock
generated
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, } };
|
||||
@@ -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, } };
|
||||
3
core/bindings/ConfigMetadata.ts
Normal file
3
core/bindings/ConfigMetadata.ts
Normal 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, }
|
||||
@@ -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 };
|
||||
3
core/bindings/LibraryCommand.ts
Normal file
3
core/bindings/LibraryCommand.ts
Normal 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, } };
|
||||
3
core/bindings/LibraryConfig.ts
Normal file
3
core/bindings/LibraryConfig.ts
Normal 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, }
|
||||
4
core/bindings/LibraryConfigWrapped.ts
Normal file
4
core/bindings/LibraryConfigWrapped.ts
Normal 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, }
|
||||
3
core/bindings/LibraryQuery.ts
Normal file
3
core/bindings/LibraryQuery.ts
Normal 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" };
|
||||
3
core/bindings/NodeConfig.ts
Normal file
3
core/bindings/NodeConfig.ts
Normal 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, }
|
||||
@@ -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, }
|
||||
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
464
core/src/lib.rs
464
core/src/lib.rs
@@ -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>),
|
||||
|
||||
72
core/src/library/library_config.rs
Normal file
72
core/src/library/library_config.rs
Normal 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,
|
||||
}
|
||||
46
core/src/library/library_ctx.rs
Normal file
46
core/src/library/library_ctx.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
271
core/src/library/library_manager.rs
Normal file
271
core/src/library/library_manager.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
149
core/src/node/config.rs
Normal 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(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
1
packages/client/src/context/index.ts
Normal file
1
packages/client/src/context/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './AppPropsContext';
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './query';
|
||||
export * from './state';
|
||||
@@ -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'));
|
||||
// }
|
||||
@@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}));
|
||||
1
packages/client/src/hooks/index.ts
Normal file
1
packages/client/src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './useCoreEvents';
|
||||
59
packages/client/src/hooks/useCoreEvents.tsx
Normal file
59
packages/client/src/hooks/useCoreEvents.tsx
Normal 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]);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './bridge';
|
||||
export * from './files';
|
||||
export * from './ClientProvider';
|
||||
export * from './stores';
|
||||
export * from './hooks';
|
||||
export * from './context';
|
||||
|
||||
4
packages/client/src/stores/index.ts
Normal file
4
packages/client/src/stores/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './useLibraryStore';
|
||||
export * from './useExplorerStore';
|
||||
export * from './useInspectorStore';
|
||||
export * from './useInspectorStore';
|
||||
@@ -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(() => ({}))
|
||||
}));
|
||||
@@ -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
|
||||
});
|
||||
67
packages/client/src/stores/useLibraryStore.ts
Normal file
67
packages/client/src/stores/useLibraryStore.ts
Normal 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 };
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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 ?? ''];
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
packages/interface/src/components/layout/Card.tsx
Normal file
15
packages/interface/src/components/layout/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppProps, Platform } from '@sd/client';
|
||||
|
||||
import App from './App';
|
||||
import { AppProps, Platform } from './AppPropsContext';
|
||||
|
||||
export type { AppProps, Platform };
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
83
packages/interface/src/screens/settings/Settings.tsx
Normal file
83
packages/interface/src/screens/settings/Settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -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 (
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -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 (
|
||||
@@ -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 (
|
||||
@@ -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 (
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
40
packages/interface/src/screens/settings/node/P2PSettings.tsx
Normal file
40
packages/interface/src/screens/settings/node/P2PSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user