diff --git a/Cargo.lock b/Cargo.lock index 10d750559..445fd550c 100644 Binary files a/Cargo.lock and b/Cargo.lock differ diff --git a/apps/landing/src/app/api/[...auth]/authjs-adapter-drizzle-mysql/index.ts b/apps/landing/src/app/api/[...auth]/authjs-adapter-drizzle-mysql/index.ts index 2673edadf..0d1543bcb 100644 --- a/apps/landing/src/app/api/[...auth]/authjs-adapter-drizzle-mysql/index.ts +++ b/apps/landing/src/app/api/[...auth]/authjs-adapter-drizzle-mysql/index.ts @@ -17,7 +17,6 @@ */ import type { Adapter } from '@auth/core/adapters'; import { and, eq } from 'drizzle-orm'; -// @ts-expect-error import { v4 as uuid } from 'uuid'; import type { DbClient, Schema } from './schema'; diff --git a/core/Cargo.toml b/core/Cargo.toml index 5c7804177..e39d8618a 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -58,6 +58,7 @@ serde_json = "1.0" futures = "0.3" rmp = "^0.8.11" rmp-serde = "^1.1.1" +rmpv = "^1.0.0" blake3 = "1.3.3" hostname = "0.3.1" uuid = { version = "1.3.3", features = ["v4", "serde"] } diff --git a/core/prisma/migrations/20230711114013_preferences/migration.sql b/core/prisma/migrations/20230711114013_preferences/migration.sql new file mode 100644 index 000000000..2552b1fe3 --- /dev/null +++ b/core/prisma/migrations/20230711114013_preferences/migration.sql @@ -0,0 +1,5 @@ +-- CreateTable +CREATE TABLE "preference" ( + "key" TEXT NOT NULL PRIMARY KEY, + "value" BLOB +); diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index 01273b7fb..1745b19ed 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -458,3 +458,11 @@ model IndexerRulesInLocation { @@id([location_id, indexer_rule_id]) @@map("indexer_rule_in_location") } + +/// @shared(id: key) +model Preference { + key String @id + value Bytes? + + @@map("preference") +} diff --git a/core/src/api/mod.rs b/core/src/api/mod.rs index 6b2b3fec3..3bc083bd8 100644 --- a/core/src/api/mod.rs +++ b/core/src/api/mod.rs @@ -28,6 +28,7 @@ mod libraries; mod locations; mod nodes; mod p2p; +mod preferences; mod search; mod sync; mod tags; @@ -82,6 +83,7 @@ pub(crate) fn mount() -> Arc { .merge("p2p.", p2p::mount()) .merge("nodes.", nodes::mount()) .merge("sync.", sync::mount()) + .merge("preferences.", preferences::mount()) .merge("invalidation.", utils::mount_invalidate()) .build( #[allow(clippy::let_and_return)] @@ -98,6 +100,7 @@ pub(crate) fn mount() -> Arc { }, ) .arced(); + InvalidRequests::validate(r.clone()); // This validates all invalidation calls. r diff --git a/core/src/api/preferences.rs b/core/src/api/preferences.rs new file mode 100644 index 000000000..ac455604c --- /dev/null +++ b/core/src/api/preferences.rs @@ -0,0 +1,21 @@ +use rspc::alpha::AlphaRouter; + +use super::{utils::library, Ctx, R}; +use crate::preferences::LibraryPreferences; + +pub(crate) fn mount() -> AlphaRouter { + R.router() + .procedure("update", { + R.with2(library()) + .mutation(|(_, library), args: LibraryPreferences| async move { + args.write(&library.db).await?; + + Ok(()) + }) + }) + .procedure("get", { + R.with2(library()).query(|(_, library), _: ()| async move { + Ok(LibraryPreferences::read(&library.db).await?) + }) + }) +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 4885040b2..7aaf7e5f3 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -33,6 +33,7 @@ pub(crate) mod location; pub(crate) mod node; pub(crate) mod object; pub(crate) mod p2p; +pub(crate) mod preferences; pub(crate) mod sync; pub(crate) mod util; pub(crate) mod volume; diff --git a/core/src/preferences/kv.rs b/core/src/preferences/kv.rs new file mode 100644 index 000000000..f7927a239 --- /dev/null +++ b/core/src/preferences/kv.rs @@ -0,0 +1,159 @@ +use std::collections::BTreeMap; + +use crate::prisma::{preference, PrismaClient}; +use itertools::Itertools; +use rmpv::Value; +use serde::{de::DeserializeOwned, Serialize}; + +use super::Preferences; + +#[derive(Debug)] +pub struct PreferenceKey(Vec); + +impl PreferenceKey { + pub fn new(value: impl Into) -> Self { + Self( + value + .into() + .split('.') + .map(ToString::to_string) + .collect_vec(), + ) + } + + pub fn prepend_path(&mut self, prefix: &str) { + self.0 = [prefix.to_string()] + .into_iter() + .chain(self.0.drain(..)) + .collect_vec(); + } +} + +impl std::fmt::Display for PreferenceKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.join(".")) + } +} + +#[derive(Debug)] +pub struct PreferenceValue(Vec); + +impl PreferenceValue { + pub fn new(value: impl Serialize) -> Self { + let mut bytes = vec![]; + + rmp_serde::encode::write_named(&mut bytes, &value).unwrap(); + + // let value = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap(); + + Self(bytes) + } + + pub fn from_value(value: Value) -> Self { + let mut bytes = vec![]; + + rmpv::encode::write_value(&mut bytes, &value).unwrap(); + + Self(bytes) + } +} + +#[derive(Debug)] +pub struct PreferenceKVs(Vec<(PreferenceKey, PreferenceValue)>); + +impl IntoIterator for PreferenceKVs { + type Item = (PreferenceKey, PreferenceValue); + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +#[derive(Debug)] +pub enum Entry { + Value(Vec), + Nested(Entries), +} + +impl Entry { + pub fn expect_value(self) -> T { + match self { + Self::Value(value) => rmp_serde::decode::from_read(value.as_slice()).unwrap(), + _ => panic!("Expected value"), + } + } + + pub fn expect_nested(self) -> Entries { + match self { + Self::Nested(entries) => entries, + _ => panic!("Expected nested entry"), + } + } +} + +pub type Entries = BTreeMap; + +impl PreferenceKVs { + pub fn new(values: Vec<(PreferenceKey, PreferenceValue)>) -> Self { + Self(values) + } + + pub fn with_prefix(mut self, prefix: &str) -> Self { + for (key, _) in &mut self.0 { + key.prepend_path(prefix); + } + + self + } + + pub fn to_upserts(self, db: &PrismaClient) -> Vec { + self.0 + .into_iter() + .map(|(key, value)| { + let value = vec![preference::value::set(Some(value.0))]; + + db.preference().upsert( + preference::key::equals(key.to_string()), + preference::create(key.to_string(), value.clone()), + value, + ) + }) + .collect() + } + + pub fn parse(self) -> T { + let entries = self + .0 + .into_iter() + .fold(BTreeMap::new(), |mut acc, (key, value)| { + let key_parts = key.0; + let key_parts_len = key_parts.len(); + + { + let mut curr_map: &mut BTreeMap = &mut acc; + + for (i, part) in key_parts.into_iter().enumerate() { + if i >= key_parts_len - 1 { + curr_map.insert(part, Entry::Value(value.0)); + break; + } else { + curr_map = match curr_map + .entry(part) + .or_insert(Entry::Nested(BTreeMap::new())) + { + Entry::Nested(map) => map, + _ => unreachable!(), + }; + } + } + } + + acc + }); + + dbg!(&entries); + + T::from_entries(entries) + } +} diff --git a/core/src/preferences/mod.rs b/core/src/preferences/mod.rs new file mode 100644 index 000000000..65521a4f1 --- /dev/null +++ b/core/src/preferences/mod.rs @@ -0,0 +1,151 @@ +mod kv; + +pub use kv::*; +use specta::Type; + +use crate::prisma::PrismaClient; +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// Preferences are a set of types that are serialized as a list of key-value pairs, +// where nested type keys are serialized as a dot-separated path. +// They are serailized as a list because this allows preferences to be a synchronisation boundary, +// whereas their values (referred to as settings) will be overwritten. + +#[derive(Clone, Serialize, Deserialize, Type, Debug)] +pub struct LibraryPreferences { + #[serde(default)] + #[specta(optional)] + location: HashMap, +} + +impl LibraryPreferences { + pub async fn write(self, db: &PrismaClient) -> prisma_client_rust::Result<()> { + let kvs = self.to_kvs(); + + db._batch(kvs.to_upserts(&db)).await?; + + Ok(()) + } + + pub async fn read(db: &PrismaClient) -> prisma_client_rust::Result { + let kvs = db.preference().find_many(vec![]).exec().await?; + + let prefs = PreferenceKVs::new( + kvs.into_iter() + .filter_map(|data| { + let a = rmpv::decode::read_value(&mut data.value?.as_slice()).unwrap(); + + Some((PreferenceKey::new(data.key), PreferenceValue::from_value(a))) + }) + .collect(), + ); + + Ok(prefs.parse()) + } +} + +#[derive(Clone, Serialize, Deserialize, Type, Debug)] +pub struct LocationPreferences { + /// View settings for the location - all writes are overwrites! + #[specta(optional)] + view: Option, +} + +#[derive(Clone, Serialize, Deserialize, Type, Debug)] +pub struct LocationViewSettings { + layout: ExplorerLayout, + list: ListViewSettings, +} + +#[derive(Clone, Serialize, Deserialize, Type, Default, Debug)] +pub struct ListViewSettings { + columns: HashMap, + sort_col: Option, +} + +#[derive(Clone, Serialize, Deserialize, Type, Default, Debug)] +pub struct ListViewColumnSettings { + hide: bool, + size: Option, +} + +#[derive(Clone, Serialize, Deserialize, Type, Debug)] +pub enum ExplorerLayout { + Grid, + List, + Media, +} + +impl Preferences for HashMap +where + V: Preferences, +{ + fn to_kvs(self) -> PreferenceKVs { + PreferenceKVs::new( + self.into_iter() + .flat_map(|(id, value)| { + let mut buf = Uuid::encode_buffer(); + + let id = id.as_simple().encode_lower(&mut buf); + + value.to_kvs().with_prefix(id) + }) + .collect(), + ) + } + + fn from_entries(entries: Entries) -> Self { + entries + .into_iter() + .map(|(key, value)| { + let id = Uuid::parse_str(&key).unwrap(); + + (id, V::from_entries(value.expect_nested())) + }) + .collect() + } +} + +impl Preferences for LibraryPreferences { + fn to_kvs(self) -> PreferenceKVs { + let Self { location } = self; + + location.to_kvs().with_prefix("location") + } + + fn from_entries(mut entries: Entries) -> Self { + Self { + location: entries + .remove("location") + .map(|value| HashMap::from_entries(value.expect_nested())) + .unwrap_or_default(), + } + } +} + +impl Preferences for LocationPreferences { + fn to_kvs(self) -> PreferenceKVs { + let Self { view } = self; + + PreferenceKVs::new( + [view.map(|view| (PreferenceKey::new("view"), PreferenceValue::new(view)))] + .into_iter() + .flatten() + .collect(), + ) + } + + fn from_entries(mut entries: Entries) -> Self { + Self { + view: entries.remove("view").map(|view| view.expect_value()), + } + } +} + +pub trait Preferences { + fn to_kvs(self) -> PreferenceKVs; + fn from_entries(entries: Entries) -> Self; +} diff --git a/core/src/sync/manager.rs b/core/src/sync/manager.rs index 0331fab5e..db9d49ba6 100644 --- a/core/src/sync/manager.rs +++ b/core/src/sync/manager.rs @@ -303,6 +303,7 @@ impl SyncManager { .await?; } }, + ModelSyncData::Preference(_, _) => todo!(), } if let CRDTOperationType::Shared(shared_op) = op.typ { diff --git a/interface/package.json b/interface/package.json index 6ed9841c4..effea049f 100644 --- a/interface/package.json +++ b/interface/package.json @@ -37,6 +37,7 @@ "@tanstack/react-table": "^8.8.5", "@tanstack/react-virtual": "3.0.0-beta.54", "@types/react-scroll-sync": "^0.8.4", + "@types/uuid": "^9.0.2", "@vitejs/plugin-react": "^2.1.0", "autoprefixer": "^10.4.12", "class-variance-authority": "^0.5.3", @@ -67,6 +68,7 @@ "use-count-up": "^3.0.1", "use-debounce": "^8.0.4", "use-resize-observer": "^9.1.0", + "uuid": "^9.0.0", "valtio": "^1.7.4" }, "devDependencies": { diff --git a/interface/util/uuid.ts b/interface/util/uuid.ts new file mode 100644 index 000000000..a56a94278 --- /dev/null +++ b/interface/util/uuid.ts @@ -0,0 +1 @@ +export { stringify } from 'uuid'; diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 4fe053d78..cb2893fe0 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -19,6 +19,7 @@ export type Procedures = { { key: "locations.list", input: LibraryArgs, result: { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; node_id: number | null; node: Node | null }[] } | { key: "nodeState", input: never, result: NodeState } | { key: "nodes.listLocations", input: LibraryArgs, result: ExplorerItem[] } | + { key: "preferences.get", input: LibraryArgs, result: LibraryPreferences } | { key: "search.objects", input: LibraryArgs, result: SearchData } | { key: "search.paths", input: LibraryArgs, result: SearchData } | { key: "sync.messages", input: LibraryArgs, result: CRDTOperation[] } | @@ -61,6 +62,7 @@ export type Procedures = { { key: "p2p.acceptSpacedrop", input: [string, string | null], result: null } | { key: "p2p.pair", input: LibraryArgs, result: number } | { key: "p2p.spacedrop", input: SpacedropArgs, result: string | null } | + { key: "preferences.update", input: LibraryArgs, result: null } | { key: "tags.assign", input: LibraryArgs, result: null } | { key: "tags.create", input: LibraryArgs, result: Tag } | { key: "tags.delete", input: LibraryArgs, result: null } | @@ -97,6 +99,8 @@ export type EditLibraryArgs = { id: string; name: LibraryName | null; descriptio export type ExplorerItem = { type: "Path"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: FilePathWithObject } | { type: "Object"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: ObjectWithFilePaths } | { type: "Location"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: Location } +export type ExplorerLayout = "Grid" | "List" | "Media" + export type FileCopierJobInit = { source_location_id: number; target_location_id: number; sources_file_path_ids: number[]; target_location_relative_directory_path: string; target_file_name_suffix: string | null } export type FileCutterJobInit = { source_location_id: number; target_location_id: number; sources_file_path_ids: number[]; target_location_relative_directory_path: string } @@ -158,8 +162,14 @@ export type LibraryConfigWrapped = { uuid: string; config: SanitisedLibraryConfi export type LibraryName = string +export type LibraryPreferences = { location?: { [key: string]: LocationPreferences } } + export type LightScanArgs = { location_id: number; sub_path: string } +export type ListViewColumnSettings = { hide: boolean; size: number | null } + +export type ListViewSettings = { columns: { [key: string]: ListViewColumnSettings }; sort_col: string | null } + export type Location = { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; node_id: number | null } /** @@ -169,6 +179,8 @@ export type Location = { id: number; pub_id: number[]; name: string | null; path */ export type LocationCreateArgs = { path: string; dry_run: boolean; indexer_rules_ids: number[] } +export type LocationPreferences = { view?: LocationViewSettings | null } + /** * `LocationUpdateArgs` is the argument received from the client using `rspc` to update a location. * It contains the id of the location to be updated, possible a name to change the current location's name @@ -179,6 +191,8 @@ export type LocationCreateArgs = { path: string; dry_run: boolean; indexer_rules */ export type LocationUpdateArgs = { id: number; name: string | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; indexer_rules_ids: number[] } +export type LocationViewSettings = { layout: ExplorerLayout; list: ListViewSettings } + export type LocationWithIndexerRules = { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; node_id: number | null; indexer_rules: { indexer_rule: IndexerRule }[] } export type MaybeNot = T | { not: T } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1935a700..65a0758a9 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ