mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-06-30 11:23:33 +00:00
Location Settings (#302)
* Delete locations + Show online status * style tweaks * tweaks * location rescan button * fix location delete db locking bug * opting to remove self referencial relation on file_path * correct query * consolodate migration * consolodate migrations
This commit is contained in:
parent
7cb6e4f574
commit
2cc3f3d95d
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
@ -17,6 +17,7 @@
|
|||
"proptype",
|
||||
"quicktime",
|
||||
"repr",
|
||||
"rescan",
|
||||
"Roadmap",
|
||||
"subpackage",
|
||||
"svgr",
|
||||
|
|
|
@ -2,5 +2,8 @@
|
|||
"name": "@sd/server",
|
||||
"version": "0.0.0",
|
||||
"main": "index.js",
|
||||
"license": "GPL-3.0-only"
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"dev": "cargo watch -x 'run -p server'"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
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: "SysVolumeUnmount", params: { id: number, } } | { key: "GenerateThumbsForLocation", params: { id: number, path: string, } } | { key: "IdentifyUniqueFiles", params: { id: number, path: string, } };
|
||||
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, } };
|
|
@ -1,4 +1,4 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { JobStatus } from "./JobStatus";
|
||||
|
||||
export interface JobReport { id: string, name: string, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: string, }
|
||||
export interface JobReport { id: string, name: string, data: string | null, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: string, }
|
|
@ -1,4 +1,4 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Platform } from "./Platform";
|
||||
|
||||
export interface LibraryNode { uuid: string, name: string, platform: Platform, tcp_address: string, last_seen: string, last_synchronized: string, }
|
||||
export interface LibraryNode { uuid: string, name: string, platform: Platform, last_seen: string, }
|
|
@ -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 { LibraryNode } from "./LibraryNode";
|
||||
|
||||
export interface LocationResource { id: number, name: string | null, path: string | null, total_capacity: number | null, available_capacity: number | null, is_removable: boolean | null, is_online: boolean, date_created: string, }
|
||||
export interface LocationResource { id: number, name: string | null, path: string | null, total_capacity: number | null, available_capacity: number | null, is_removable: boolean | null, node: LibraryNode | null, is_online: boolean, date_created: string, }
|
|
@ -0,0 +1,48 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "jobs" ADD COLUMN "data" TEXT;
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_locations" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"pub_id" TEXT NOT NULL,
|
||||
"node_id" INTEGER,
|
||||
"name" TEXT,
|
||||
"local_path" TEXT,
|
||||
"total_capacity" INTEGER,
|
||||
"available_capacity" INTEGER,
|
||||
"filesystem" TEXT,
|
||||
"disk_type" INTEGER,
|
||||
"is_removable" BOOLEAN,
|
||||
"is_online" BOOLEAN NOT NULL DEFAULT true,
|
||||
"date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "locations_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "nodes" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_locations" ("available_capacity", "date_created", "disk_type", "filesystem", "id", "is_online", "is_removable", "local_path", "name", "node_id", "pub_id", "total_capacity") SELECT "available_capacity", "date_created", "disk_type", "filesystem", "id", "is_online", "is_removable", "local_path", "name", "node_id", "pub_id", "total_capacity" FROM "locations";
|
||||
DROP TABLE "locations";
|
||||
ALTER TABLE "new_locations" RENAME TO "locations";
|
||||
CREATE UNIQUE INDEX "locations_pub_id_key" ON "locations"("pub_id");
|
||||
CREATE TABLE "new_file_paths" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"is_dir" BOOLEAN NOT NULL DEFAULT false,
|
||||
"location_id" INTEGER,
|
||||
"materialized_path" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"extension" TEXT,
|
||||
"file_id" INTEGER,
|
||||
"parent_id" INTEGER,
|
||||
"key_id" INTEGER,
|
||||
"date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"date_modified" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"date_indexed" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "file_paths_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "locations" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "file_paths_file_id_fkey" FOREIGN KEY ("file_id") REFERENCES "files" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "file_paths_key_id_fkey" FOREIGN KEY ("key_id") REFERENCES "keys" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_file_paths" ("date_created", "date_indexed", "date_modified", "extension", "file_id", "id", "is_dir", "key_id", "location_id", "materialized_path", "name", "parent_id") SELECT "date_created", "date_indexed", "date_modified", "extension", "file_id", "id", "is_dir", "key_id", "location_id", "materialized_path", "name", "parent_id" FROM "file_paths";
|
||||
DROP TABLE "file_paths";
|
||||
ALTER TABLE "new_file_paths" RENAME TO "file_paths";
|
||||
CREATE INDEX "file_paths_location_id_idx" ON "file_paths"("location_id");
|
||||
CREATE UNIQUE INDEX "file_paths_location_id_materialized_path_name_extension_key" ON "file_paths"("location_id", "materialized_path", "name", "extension");
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
|
@ -74,6 +74,7 @@ model Node {
|
|||
sync_events SyncEvent[]
|
||||
jobs Job[]
|
||||
|
||||
Location Location[]
|
||||
@@map("nodes")
|
||||
}
|
||||
|
||||
|
@ -107,14 +108,14 @@ model Location {
|
|||
is_online Boolean @default(true)
|
||||
date_created DateTime @default(now())
|
||||
|
||||
node Node? @relation(fields: [node_id], references: [id])
|
||||
file_paths FilePath[]
|
||||
@@map("locations")
|
||||
}
|
||||
|
||||
model File {
|
||||
id Int @id @default(autoincrement())
|
||||
// content addressable storage id - sha256
|
||||
// this does not need to be unique, as incoming replicas will always ignore if at least one exists
|
||||
// content addressable storage id - sha256 sampled checksum
|
||||
cas_id String @unique
|
||||
// full byte contents digested into sha256 checksum
|
||||
integrity_checksum String? @unique
|
||||
|
@ -157,7 +158,7 @@ model FilePath {
|
|||
id Int @id @default(autoincrement())
|
||||
is_dir Boolean @default(false)
|
||||
// location that owns this path
|
||||
location_id Int
|
||||
location_id Int?
|
||||
// a path generated from local file_path ids eg: "34/45/67/890"
|
||||
materialized_path String
|
||||
// the name and extension
|
||||
|
@ -175,14 +176,17 @@ model FilePath {
|
|||
date_modified DateTime @default(now())
|
||||
date_indexed DateTime @default(now())
|
||||
|
||||
file File? @relation(fields: [file_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
location Location? @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
parent FilePath? @relation("directory_file_paths", fields: [parent_id], references: [id])
|
||||
children FilePath[] @relation("directory_file_paths")
|
||||
file File? @relation(fields: [file_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
location Location? @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
|
||||
// NOTE: this self relation for the file tree was causing SQLite to go to forever bed, disabling until workaround
|
||||
// parent FilePath? @relation("directory_file_paths", fields: [parent_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
// children FilePath[] @relation("directory_file_paths")
|
||||
|
||||
key Key? @relation(fields: [key_id], references: [id])
|
||||
|
||||
@@unique([location_id, materialized_path, name, extension])
|
||||
@@index([location_id])
|
||||
@@map("file_paths")
|
||||
}
|
||||
|
||||
|
@ -282,11 +286,12 @@ model LabelOnFile {
|
|||
}
|
||||
|
||||
model Job {
|
||||
id String @id
|
||||
id String @id
|
||||
name String
|
||||
node_id Int
|
||||
action Int
|
||||
status Int @default(0)
|
||||
status Int @default(0)
|
||||
data String?
|
||||
|
||||
task_count Int @default(1)
|
||||
completed_task_count Int @default(0)
|
||||
|
|
|
@ -151,7 +151,7 @@ pub async fn get_images(
|
|||
path: &str,
|
||||
) -> Result<Vec<file_path::Data>, std::io::Error> {
|
||||
let mut params = vec![
|
||||
file_path::location_id::equals(location_id),
|
||||
file_path::location_id::equals(Some(location_id)),
|
||||
file_path::extension::in_vec(vec![
|
||||
"png".to_string(),
|
||||
"jpeg".to_string(),
|
||||
|
|
|
@ -159,7 +159,12 @@ impl Job for FileIdentifierJob {
|
|||
}
|
||||
|
||||
// handle loop end
|
||||
let last_row = file_paths.last().unwrap();
|
||||
let last_row = match file_paths.last() {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
break;
|
||||
}
|
||||
};
|
||||
cursor = last_row.id;
|
||||
completed += 1;
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ pub async fn open_dir(
|
|||
let directory = db
|
||||
.file_path()
|
||||
.find_first(vec![
|
||||
file_path::location_id::equals(location.id),
|
||||
file_path::location_id::equals(Some(location.id)),
|
||||
file_path::materialized_path::equals(path.into()),
|
||||
file_path::is_dir::equals(true),
|
||||
])
|
||||
|
@ -35,7 +35,7 @@ pub async fn open_dir(
|
|||
let mut file_paths: Vec<FilePath> = db
|
||||
.file_path()
|
||||
.find_many(vec![
|
||||
file_path::location_id::equals(location.id),
|
||||
file_path::location_id::equals(Some(location.id)),
|
||||
file_path::parent_id::equals(Some(directory.id)),
|
||||
])
|
||||
.with(file_path::file::fetch())
|
||||
|
|
|
@ -110,7 +110,7 @@ impl Into<FilePath> for file_path::Data {
|
|||
materialized_path: self.materialized_path,
|
||||
file_id: self.file_id,
|
||||
parent_id: self.parent_id,
|
||||
location_id: self.location_id,
|
||||
location_id: self.location_id.unwrap_or(0),
|
||||
date_indexed: self.date_indexed.into(),
|
||||
name: self.name,
|
||||
extension: self.extension,
|
||||
|
|
|
@ -18,7 +18,8 @@ use std::{
|
|||
use tokio::sync::Mutex;
|
||||
use ts_rs::TS;
|
||||
|
||||
const MAX_WORKERS: usize = 4;
|
||||
// db is single threaded, nerd
|
||||
const MAX_WORKERS: usize = 1;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait Job: Send + Sync + Debug {
|
||||
|
@ -40,6 +41,7 @@ impl Jobs {
|
|||
running_workers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ingest(&mut self, ctx: &CoreContext, job: Box<dyn Job>) {
|
||||
// create worker to process job
|
||||
if self.running_workers.len() < MAX_WORKERS {
|
||||
|
@ -57,6 +59,7 @@ impl Jobs {
|
|||
self.job_queue.push_back(job);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ingest_queue(&mut self, _ctx: &CoreContext, job: Box<dyn Job>) {
|
||||
self.job_queue.push_back(job);
|
||||
}
|
||||
|
@ -69,6 +72,7 @@ impl Jobs {
|
|||
self.ingest(ctx, job).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_running(&self) -> Vec<JobReport> {
|
||||
let mut ret = vec![];
|
||||
|
||||
|
@ -78,6 +82,19 @@ impl Jobs {
|
|||
}
|
||||
ret
|
||||
}
|
||||
|
||||
pub async fn queue_pending_job(ctx: &CoreContext) -> Result<(), JobError> {
|
||||
let db = &ctx.database;
|
||||
|
||||
let next_job = db
|
||||
.job()
|
||||
.find_first(vec![job::status::equals(JobStatus::Queued.int_value())])
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_history(ctx: &CoreContext) -> Result<Vec<JobReport>, JobError> {
|
||||
let db = &ctx.database;
|
||||
let jobs = db
|
||||
|
@ -103,6 +120,7 @@ pub enum JobReportUpdate {
|
|||
pub struct JobReport {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub data: Option<String>,
|
||||
// client_id: i32,
|
||||
#[ts(type = "string")]
|
||||
pub date_created: chrono::DateTime<chrono::Utc>,
|
||||
|
@ -131,6 +149,7 @@ impl Into<JobReport> for job::Data {
|
|||
completed_task_count: self.completed_task_count,
|
||||
date_created: self.date_created.into(),
|
||||
date_modified: self.date_modified.into(),
|
||||
data: self.data,
|
||||
message: String::new(),
|
||||
seconds_elapsed: self.seconds_elapsed,
|
||||
}
|
||||
|
@ -147,6 +166,7 @@ impl JobReport {
|
|||
date_modified: chrono::Utc::now(),
|
||||
status: JobStatus::Queued,
|
||||
task_count: 0,
|
||||
data: None,
|
||||
completed_task_count: 0,
|
||||
message: String::new(),
|
||||
seconds_elapsed: 0,
|
||||
|
@ -154,6 +174,13 @@ impl JobReport {
|
|||
}
|
||||
pub async fn create(&self, ctx: &CoreContext) -> Result<(), JobError> {
|
||||
let config = get_nodestate();
|
||||
|
||||
let mut params = Vec::new();
|
||||
|
||||
if let Some(_) = &self.data {
|
||||
params.push(job::data::set(self.data.clone()))
|
||||
}
|
||||
|
||||
ctx.database
|
||||
.job()
|
||||
.create(
|
||||
|
@ -161,7 +188,7 @@ impl JobReport {
|
|||
job::name::set(self.name.clone()),
|
||||
job::action::set(1),
|
||||
job::nodes::link(node::id::equals(config.node_id)),
|
||||
vec![],
|
||||
params,
|
||||
)
|
||||
.exec()
|
||||
.await?;
|
||||
|
|
|
@ -36,6 +36,10 @@ impl WorkerContext {
|
|||
.send(WorkerEvent::Progressed(updates))
|
||||
.unwrap_or(());
|
||||
}
|
||||
// save the job data to
|
||||
// pub fn save_data () {
|
||||
|
||||
// }
|
||||
}
|
||||
|
||||
// a worker is a dedicated thread that runs a single job
|
||||
|
|
|
@ -258,13 +258,11 @@ impl Node {
|
|||
CoreResponse::Success(())
|
||||
}
|
||||
ClientCommand::LocDelete { id } => {
|
||||
ctx.database
|
||||
.location()
|
||||
.find_unique(location::id::equals(id))
|
||||
.delete()
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
sys::delete_location(&ctx, id).await?;
|
||||
CoreResponse::Success(())
|
||||
}
|
||||
ClientCommand::LocRescan { id } => {
|
||||
sys::scan_location(&ctx, id, String::new());
|
||||
CoreResponse::Success(())
|
||||
}
|
||||
// CRUD for files
|
||||
|
@ -374,6 +372,7 @@ pub enum ClientCommand {
|
|||
LocCreate { path: String },
|
||||
LocUpdate { id: i32, name: Option<String> },
|
||||
LocDelete { id: i32 },
|
||||
LocRescan { id: i32 },
|
||||
// System
|
||||
SysVolumeUnmount { id: i32 },
|
||||
GenerateThumbsForLocation { id: i32, path: String },
|
||||
|
|
|
@ -19,11 +19,18 @@ pub struct LibraryNode {
|
|||
pub uuid: String,
|
||||
pub name: String,
|
||||
pub platform: Platform,
|
||||
pub tcp_address: String,
|
||||
#[ts(type = "string")]
|
||||
pub last_seen: DateTime<Utc>,
|
||||
#[ts(type = "string")]
|
||||
pub last_synchronized: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Into<LibraryNode> for node::Data {
|
||||
fn into(self) -> LibraryNode {
|
||||
LibraryNode {
|
||||
uuid: self.pub_id,
|
||||
name: self.name,
|
||||
platform: IntEnum::from_int(self.platform).unwrap(),
|
||||
last_seen: self.last_seen.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(i32)]
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
use crate::{
|
||||
encode::ThumbnailJob,
|
||||
file::{cas::FileIdentifierJob, indexer::IndexerJob},
|
||||
node::get_nodestate,
|
||||
prisma::location,
|
||||
node::{get_nodestate, LibraryNode},
|
||||
prisma::{file_path, location},
|
||||
ClientQuery, CoreContext, CoreEvent,
|
||||
};
|
||||
use prisma_client_rust::{raw, PrismaValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fs, io, io::Write, path::Path};
|
||||
use thiserror::Error;
|
||||
|
@ -21,13 +22,14 @@ pub struct LocationResource {
|
|||
pub total_capacity: Option<i32>,
|
||||
pub available_capacity: Option<i32>,
|
||||
pub is_removable: Option<bool>,
|
||||
pub node: Option<LibraryNode>,
|
||||
pub is_online: bool,
|
||||
#[ts(type = "string")]
|
||||
pub date_created: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl Into<LocationResource> for location::Data {
|
||||
fn into(self) -> LocationResource {
|
||||
fn into(mut self) -> LocationResource {
|
||||
LocationResource {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
|
@ -35,6 +37,7 @@ impl Into<LocationResource> for location::Data {
|
|||
total_capacity: self.total_capacity,
|
||||
available_capacity: self.available_capacity,
|
||||
is_removable: self.is_removable,
|
||||
node: self.node.take().unwrap_or(None).map(|node| (*node).into()),
|
||||
is_online: self.is_online,
|
||||
date_created: self.date_created.into(),
|
||||
}
|
||||
|
@ -81,26 +84,24 @@ 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 }));
|
||||
// TODO: make a way to stop jobs so this can be canceled without rebooting app
|
||||
// ctx.queue_job(Box::new(ThumbnailJob {
|
||||
// location_id,
|
||||
// path: "".to_string(),
|
||||
// background: false,
|
||||
// }));
|
||||
}
|
||||
|
||||
pub async fn new_location_and_scan(
|
||||
ctx: &CoreContext,
|
||||
path: &str,
|
||||
) -> Result<LocationResource, SysError> {
|
||||
let location = create_location(&ctx, path).await?;
|
||||
|
||||
ctx.spawn_job(Box::new(IndexerJob {
|
||||
path: path.to_string(),
|
||||
}));
|
||||
|
||||
ctx.queue_job(Box::new(FileIdentifierJob {
|
||||
location_id: location.id,
|
||||
path: path.to_string(),
|
||||
}));
|
||||
|
||||
ctx.queue_job(Box::new(ThumbnailJob {
|
||||
location_id: location.id,
|
||||
path: "".to_string(),
|
||||
background: false,
|
||||
}));
|
||||
scan_location(&ctx, location.id, path.to_string());
|
||||
|
||||
Ok(location)
|
||||
}
|
||||
|
@ -108,7 +109,12 @@ pub async fn new_location_and_scan(
|
|||
pub async fn get_locations(ctx: &CoreContext) -> Result<Vec<LocationResource>, SysError> {
|
||||
let db = &ctx.database;
|
||||
|
||||
let locations = db.location().find_many(vec![]).exec().await?;
|
||||
let locations = db
|
||||
.location()
|
||||
.find_many(vec![])
|
||||
.with(location::node::fetch())
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
// turn locations into LocationResource
|
||||
let locations: Vec<LocationResource> = locations
|
||||
|
@ -175,6 +181,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)),
|
||||
],
|
||||
)
|
||||
.exec()
|
||||
|
@ -213,6 +220,29 @@ 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;
|
||||
|
||||
db.file_path()
|
||||
.find_many(vec![file_path::location_id::equals(Some(location_id))])
|
||||
.delete()
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
db.location()
|
||||
.find_unique(location::id::equals(location_id))
|
||||
.delete()
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::SysGetLocations))
|
||||
.await;
|
||||
|
||||
println!("Location {} deleted", location_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum LocationError {
|
||||
#[error("Failed to create location (uuid {uuid:?})")]
|
||||
|
|
|
@ -19,7 +19,7 @@ export function AppLayout() {
|
|||
return false;
|
||||
}}
|
||||
className={clsx(
|
||||
'flex flex-row h-screen overflow-hidden text-gray-900 select-none dark:text-white',
|
||||
'flex flex-row h-screen overflow-hidden text-gray-900 select-none dark:text-white cursor-default',
|
||||
isWindowRounded && 'rounded-xl',
|
||||
hasWindowBorder && 'border border-gray-200 dark:border-gray-500'
|
||||
)}
|
||||
|
|
|
@ -6,6 +6,7 @@ import React, { useState } from 'react';
|
|||
import { Rings } from 'react-loading-icons';
|
||||
|
||||
import FileItem from '../file/FileItem';
|
||||
import Loader from '../primitive/Loader';
|
||||
import ProgressBar from '../primitive/ProgressBar';
|
||||
|
||||
export interface DeviceProps {
|
||||
|
@ -45,13 +46,7 @@ export function Device(props: DeviceProps) {
|
|||
<div className="flex flex-grow" />
|
||||
{props.runningJob && (
|
||||
<div className="flex flex-row ml-5 bg-gray-300 bg-opacity-50 rounded-md dark:bg-gray-550">
|
||||
<Rings
|
||||
stroke="#2599FF"
|
||||
strokeOpacity={4}
|
||||
strokeWidth={10}
|
||||
speed={0.5}
|
||||
className="ml-0.5 mt-[2px] -mr-1 w-7 h-7"
|
||||
/>
|
||||
<Loader />
|
||||
<div className="flex flex-col p-2">
|
||||
<span className="mb-[2px] -mt-1 truncate text-gray-450 text-tiny">
|
||||
{props.runningJob.task}...
|
||||
|
|
|
@ -35,20 +35,20 @@ export const Inspector = (props: {
|
|||
location?: LocationResource;
|
||||
selectedFile?: FilePath;
|
||||
}) => {
|
||||
const file_path = props.selectedFile;
|
||||
let full_path = `${props.location?.path}/${file_path?.materialized_path}`;
|
||||
const file_path = props.selectedFile,
|
||||
full_path = `${props.location?.path}/${file_path?.materialized_path}`,
|
||||
file_id = props.selectedFile?.file?.id || -1;
|
||||
|
||||
// notes are stored in global state by their file id
|
||||
// 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 file_id = props.selectedFile?.file?.id || -1;
|
||||
// show cached note over server note
|
||||
// show cached note over server note, important to check for undefined not falsey
|
||||
const note =
|
||||
notes[file_id] === undefined ? props.selectedFile?.file?.note || null : notes[file_id];
|
||||
|
||||
// when input is updated
|
||||
// when input is updated, cache note
|
||||
function handleNoteUpdate(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
if (e.target.value !== note) {
|
||||
setNote(file_id, e.target.value);
|
||||
|
@ -63,95 +63,95 @@ export const Inspector = (props: {
|
|||
}, [note]);
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
show={true}
|
||||
enter="transition-translate ease-in-out duration-200"
|
||||
enterFrom="translate-x-64"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-translate ease-in-out duration-200"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-64"
|
||||
>
|
||||
<div className="flex p-2 pr-1 mr-1 pb-[51px] w-72 flex-wrap overflow-x-hidden custom-scroll inspector-scroll">
|
||||
{!!file_path && (
|
||||
<div className="flex flex-col pb-2 overflow-hidden bg-white rounded-lg select-text dark:bg-gray-600 bg-opacity-70">
|
||||
<div className="flex items-center justify-center w-full h-64 overflow-hidden rounded-t-lg bg-gray-50 dark:bg-gray-900">
|
||||
<FileThumb
|
||||
hasThumbnailOverride={false}
|
||||
className="!m-0 flex flex-shrink flex-grow-0"
|
||||
file={file_path}
|
||||
locationId={props.locationId}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="pt-3 pl-3 text-base font-bold">{file_path?.name}</h3>
|
||||
<div className="flex flex-row m-3 space-x-2">
|
||||
<Button size="sm" noPadding>
|
||||
<Heart className="w-[18px] h-[18px]" />
|
||||
</Button>
|
||||
<Button size="sm" noPadding>
|
||||
<ShareIcon className="w-[18px] h-[18px]" />
|
||||
</Button>
|
||||
<Button size="sm" noPadding>
|
||||
<Link className="w-[18px] h-[18px]" />
|
||||
</Button>
|
||||
</div>
|
||||
{file_path?.file?.cas_id && (
|
||||
<MetaItem title="Unique Content ID" value={file_path.file.cas_id as string} />
|
||||
)}
|
||||
<Divider />
|
||||
<MetaItem title="URI" value={full_path} />
|
||||
<Divider />
|
||||
<MetaItem
|
||||
title="Date Created"
|
||||
value={moment(file_path?.date_created).format('MMMM Do YYYY, h:mm:ss a')}
|
||||
// <Transition
|
||||
// as={React.Fragment}
|
||||
// show={true}
|
||||
// enter="transition-translate ease-in-out duration-200"
|
||||
// enterFrom="translate-x-64"
|
||||
// enterTo="translate-x-0"
|
||||
// leave="transition-translate ease-in-out duration-200"
|
||||
// leaveFrom="translate-x-0"
|
||||
// leaveTo="translate-x-64"
|
||||
// >
|
||||
<div className="flex p-2 pr-1 mr-1 pb-[51px] w-[330px] flex-wrap overflow-x-hidden custom-scroll inspector-scroll">
|
||||
{!!file_path && (
|
||||
<div className="flex flex-col w-full pb-2 overflow-hidden bg-white rounded-lg select-text dark:bg-gray-600 bg-opacity-70">
|
||||
<div className="flex items-center justify-center w-full h-64 overflow-hidden rounded-t-lg bg-gray-50 dark:bg-gray-900">
|
||||
<FileThumb
|
||||
hasThumbnailOverride={false}
|
||||
className="!m-0 flex flex-shrink flex-grow-0"
|
||||
file={file_path}
|
||||
locationId={props.locationId}
|
||||
/>
|
||||
<Divider />
|
||||
<MetaItem
|
||||
title="Date Indexed"
|
||||
value={moment(file_path?.date_indexed).format('MMMM Do YYYY, h:mm:ss a')}
|
||||
/>
|
||||
{!file_path?.is_dir && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className="flex flex-row items-center px-3 py-2 meta-item">
|
||||
{file_path?.extension && (
|
||||
<span className="inline px-1 mr-1 text-xs font-bold uppercase bg-gray-500 rounded-md text-gray-150">
|
||||
{file_path?.extension}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-xs text-gray-600 break-all truncate dark:text-gray-300">
|
||||
{file_path?.extension
|
||||
? //@ts-ignore
|
||||
types[file_path.extension.toUpperCase()]?.descriptions.join(' / ')
|
||||
: 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
{file_path.file && (
|
||||
<>
|
||||
<Divider />
|
||||
<MetaItem
|
||||
title="Note"
|
||||
value={
|
||||
<TextArea
|
||||
className="mt-2 text-xs leading-snug !py-2"
|
||||
value={note || ''}
|
||||
onChange={handleNoteUpdate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
<h3 className="pt-3 pl-3 text-base font-bold">{file_path?.name}</h3>
|
||||
<div className="flex flex-row m-3 space-x-2">
|
||||
<Button size="sm" noPadding>
|
||||
<Heart className="w-[18px] h-[18px]" />
|
||||
</Button>
|
||||
<Button size="sm" noPadding>
|
||||
<ShareIcon className="w-[18px] h-[18px]" />
|
||||
</Button>
|
||||
<Button size="sm" noPadding>
|
||||
<Link className="w-[18px] h-[18px]" />
|
||||
</Button>
|
||||
</div>
|
||||
{file_path?.file?.cas_id && (
|
||||
<MetaItem title="Unique Content ID" value={file_path.file.cas_id as string} />
|
||||
)}
|
||||
<Divider />
|
||||
<MetaItem title="URI" value={full_path} />
|
||||
<Divider />
|
||||
<MetaItem
|
||||
title="Date Created"
|
||||
value={moment(file_path?.date_created).format('MMMM Do YYYY, h:mm:ss a')}
|
||||
/>
|
||||
<Divider />
|
||||
<MetaItem
|
||||
title="Date Indexed"
|
||||
value={moment(file_path?.date_indexed).format('MMMM Do YYYY, h:mm:ss a')}
|
||||
/>
|
||||
{!file_path?.is_dir && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className="flex flex-row items-center px-3 py-2 meta-item">
|
||||
{file_path?.extension && (
|
||||
<span className="inline px-1 mr-1 text-xs font-bold uppercase bg-gray-500 rounded-md text-gray-150">
|
||||
{file_path?.extension}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* <div className="flex flex-row m-3">
|
||||
<p className="text-xs text-gray-600 break-all truncate dark:text-gray-300">
|
||||
{file_path?.extension
|
||||
? //@ts-ignore
|
||||
types[file_path.extension.toUpperCase()]?.descriptions.join(' / ')
|
||||
: 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
{file_path.file && (
|
||||
<>
|
||||
<Divider />
|
||||
<MetaItem
|
||||
title="Note"
|
||||
value={
|
||||
<TextArea
|
||||
className="mt-2 text-xs leading-snug !py-2"
|
||||
value={note || ''}
|
||||
onChange={handleNoteUpdate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* <div className="flex flex-row m-3">
|
||||
<Button size="sm">Mint</Button>
|
||||
</div> */}
|
||||
{/* <MetaItem title="Date Last Modified" value={file?.date_modified} />
|
||||
{/* <MetaItem title="Date Last Modified" value={file?.date_modified} />
|
||||
<MetaItem title="Date Indexed" value={file?.date_indexed} /> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
// </Transition>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -93,7 +93,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col flex-grow-0 flex-shrink-0 w-48 min-h-full px-2.5 overflow-x-hidden overflow-y-scroll border-r border-gray-100 no-scrollbar bg-gray-50 dark:bg-gray-850 dark:border-gray-600',
|
||||
'flex flex-col flex-grow-0 flex-shrink-0 w-48 min-h-full px-2.5 overflow-x-hidden overflow-y-scroll border-r border-gray-100 no-scrollbar bg-gray-50 dark:bg-gray-850 dark:border-gray-750',
|
||||
{
|
||||
'dark:!bg-opacity-40': appProps?.platform === 'macOS'
|
||||
}
|
||||
|
|
|
@ -1,14 +1,19 @@
|
|||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Button } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import Loader from '../primitive/Loader';
|
||||
|
||||
export interface DialogProps {
|
||||
trigger: ReactNode;
|
||||
ctaLabel?: string;
|
||||
ctaDanger?: boolean;
|
||||
ctaAction?: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
children?: ReactNode;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function Dialog(props: DialogProps) {
|
||||
|
@ -26,12 +31,21 @@ export default function Dialog(props: DialogProps) {
|
|||
{props.children}
|
||||
</div>
|
||||
<div className="flex flex-row justify-end px-3 py-3 space-x-2 bg-gray-600 border-t border-gray-550">
|
||||
{props.loading && <Loader />}
|
||||
<div className="flex-grow" />
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button size="sm" variant="gray">
|
||||
<Button loading={props.loading} disabled={props.loading} size="sm" variant="gray">
|
||||
Close
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
<Button onClick={props.ctaAction} size="sm" variant="primary">
|
||||
<Button
|
||||
onClick={props.ctaAction}
|
||||
size="sm"
|
||||
loading={props.loading}
|
||||
disabled={props.loading}
|
||||
variant={props.ctaDanger ? 'colored' : 'primary'}
|
||||
className={clsx(props.ctaDanger && 'bg-red-500 border-red-500')}
|
||||
>
|
||||
{props.ctaLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -104,7 +104,7 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
<TopBarButton
|
||||
icon={ArrowsClockwise}
|
||||
onClick={() => {
|
||||
generateThumbsForLocation({ id: locationId, path: '' });
|
||||
// generateThumbsForLocation({ id: locationId, path: '' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
import { DotsVerticalIcon, RefreshIcon } from '@heroicons/react/outline';
|
||||
import { CogIcon, TrashIcon } from '@heroicons/react/solid';
|
||||
import { command, useBridgeCommand } from '@sd/client';
|
||||
import { LocationResource } from '@sd/core';
|
||||
import { Button } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Folder } from '../icons/Folder';
|
||||
import Dialog from '../layout/Dialog';
|
||||
|
||||
interface LocationListItemProps {
|
||||
location: LocationResource;
|
||||
}
|
||||
|
||||
export default function LocationListItem({ location }: LocationListItemProps) {
|
||||
const [hide, setHide] = useState(false);
|
||||
|
||||
const { mutate: locRescan } = useBridgeCommand('LocRescan');
|
||||
|
||||
const { mutate: deleteLoc, isLoading: locDeletePending } = useBridgeCommand('LocDelete', {
|
||||
onSuccess: () => {
|
||||
setHide(true);
|
||||
}
|
||||
});
|
||||
|
||||
if (hide) return <></>;
|
||||
|
||||
return (
|
||||
<div className="flex w-full px-4 py-2 border border-gray-500 rounded-lg bg-gray-550">
|
||||
<DotsVerticalIcon className="w-5 h-5 mt-3 mr-1 -ml-3 cursor-move drag-handle opacity-10" />
|
||||
<Folder size={30} className="mr-3" />
|
||||
<div className="flex flex-col">
|
||||
<h1 className="pt-0.5 text-sm font-semibold">{location.name}</h1>
|
||||
<p className="mt-0.5 text-sm select-text text-gray-250">
|
||||
<span className="py-[1px] px-1 bg-gray-500 rounded mr-1">{location.node?.name}</span>
|
||||
{location.path}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-grow" />
|
||||
<div className="flex h-[45px] p-2 space-x-2">
|
||||
<Button disabled variant="gray" className="!py-1.5 !px-2 pointer-events-none flex">
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'w-2 h-2 rounded-full',
|
||||
location.is_online ? 'bg-green-500' : 'bg-red-500'
|
||||
)}
|
||||
/>
|
||||
<span className="ml-1.5 text-xs text-gray-350">
|
||||
{location.is_online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</>
|
||||
</Button>
|
||||
<Dialog
|
||||
title="Delete Location"
|
||||
description="Deleting a location will also remove all files associated with it from the Spacedrive database, the files themselves will not be deleted."
|
||||
ctaAction={() => {
|
||||
deleteLoc({ id: location.id });
|
||||
}}
|
||||
loading={locDeletePending}
|
||||
ctaDanger
|
||||
ctaLabel="Delete"
|
||||
trigger={
|
||||
<Button variant="gray" className="!p-1.5">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="gray"
|
||||
className="!p-1.5"
|
||||
onClick={() => {
|
||||
// this should cause a lite directory rescan, but this will do for now, so the button does something useful
|
||||
locRescan({ id: location.id });
|
||||
}}
|
||||
>
|
||||
<RefreshIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
{/* <Button variant="gray" className="!p-1.5">
|
||||
<CogIcon className="w-4 h-4" />
|
||||
</Button> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
15
packages/interface/src/components/primitive/Loader.tsx
Normal file
15
packages/interface/src/components/primitive/Loader.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { Puff } from 'react-loading-icons';
|
||||
|
||||
export default function Loader(props: { className?: string }) {
|
||||
return (
|
||||
<Puff
|
||||
stroke="#2599FF"
|
||||
strokeOpacity={4}
|
||||
strokeWidth={5}
|
||||
speed={1}
|
||||
className={clsx('ml-0.5 mt-[2px] -mr-1 w-7 h-7', props.className)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -17,8 +17,9 @@ export const DebugScreen: React.FC<{}> = (props) => {
|
|||
// });
|
||||
const { mutate: identifyFiles } = useBridgeCommand('IdentifyUniqueFiles');
|
||||
return (
|
||||
<div className="flex flex-col w-full h-screen p-5 custom-scroll page-scroll">
|
||||
<div className="flex flex-col space-y-5 pb-7">
|
||||
<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" />
|
||||
<div className="flex flex-col p-5 pt-2 space-y-5 pb-7">
|
||||
<h1 className="text-lg font-bold ">Developer Debugger</h1>
|
||||
<div className="flex flex-row pb-4 space-x-2">
|
||||
<Button
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { useBridgeQuery } from '@sd/client';
|
||||
import React 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';
|
||||
|
@ -11,18 +13,20 @@ import { SettingsHeader } from '../../components/settings/SettingsHeader';
|
|||
// ];
|
||||
|
||||
export default function LocationSettings() {
|
||||
// const locations = useBridgeQuery("SysGetLocation")
|
||||
const { data: locations } = useBridgeQuery('SysGetLocations');
|
||||
|
||||
console.log({ locations });
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
{/*<Button size="sm">Add Location</Button>*/}
|
||||
<SettingsHeader title="Locations" description="Manage your settings related to locations." />
|
||||
<InputContainer
|
||||
title="Something about a vault"
|
||||
description="Local cache storage for media previews and thumbnails."
|
||||
>
|
||||
<div className="flex flex-row space-x-2"></div>
|
||||
</InputContainer>
|
||||
<SettingsHeader title="Locations" description="Manage your storage locations." />
|
||||
|
||||
<div className="grid space-y-2">
|
||||
{locations?.map((location) => (
|
||||
<LocationListItem key={location.id} location={location} />
|
||||
))}
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -40,14 +40,12 @@ const variants = {
|
|||
dark:hover:bg-gray-500
|
||||
dark:bg-opacity-80
|
||||
dark:hover:bg-opacity-100
|
||||
dark:active:bg-gray-550
|
||||
dark:active:opacity-80
|
||||
|
||||
border-gray-200
|
||||
hover:border-gray-300
|
||||
active:border-gray-200
|
||||
dark:border-gray-500
|
||||
dark:active:border-gray-600
|
||||
dark:hover:border-gray-500
|
||||
|
||||
text-gray-700
|
||||
|
@ -68,6 +66,12 @@ const variants = {
|
|||
hover:border-primary-500
|
||||
active:border-primary-700
|
||||
`,
|
||||
colored: `
|
||||
text-white
|
||||
shadow-sm
|
||||
hover:bg-opacity-90
|
||||
active:bg-opacity-100
|
||||
`,
|
||||
selected: `bg-gray-100 dark:bg-gray-500
|
||||
text-black hover:text-black active:text-black dark:hover:text-white dark:text-white
|
||||
`
|
||||
|
@ -114,7 +118,7 @@ export const Button = forwardRef<
|
|||
) => {
|
||||
className = clsx(
|
||||
'border rounded-md items-center transition-colors duration-100 cursor-default',
|
||||
{ 'opacity-5': loading, '!p-1': noPadding },
|
||||
{ 'opacity-70': loading, '!p-1': noPadding },
|
||||
{ 'justify-center': !justifyLeft },
|
||||
sizes[size || 'default'],
|
||||
variants[variant || 'default'],
|
||||
|
|
Loading…
Reference in a new issue