mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-04 11:03:27 +00:00
[ENG-846] Preferences (#1047)
* preferences table + trait * cleaner implementation * router + ts type * preference test * per-column list view preferences * preference read + write * add some docs * migration + docs * remove ts expect error
This commit is contained in:
parent
456a642a1f
commit
07c00a8b7f
11
Cargo.lock
generated
11
Cargo.lock
generated
|
@ -6742,6 +6742,16 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmpv"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de8813b3a2f95c5138fe5925bfb8784175d88d6bff059ba8ce090aa891319754"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rspc"
|
||||
version = "0.1.4"
|
||||
|
@ -7070,6 +7080,7 @@ dependencies = [
|
|||
"regex",
|
||||
"rmp",
|
||||
"rmp-serde",
|
||||
"rmpv",
|
||||
"rspc",
|
||||
"sd-crypto",
|
||||
"sd-ffmpeg",
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "preference" (
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"value" BLOB
|
||||
);
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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<Router> {
|
|||
.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<Router> {
|
|||
},
|
||||
)
|
||||
.arced();
|
||||
|
||||
InvalidRequests::validate(r.clone()); // This validates all invalidation calls.
|
||||
|
||||
r
|
||||
|
|
21
core/src/api/preferences.rs
Normal file
21
core/src/api/preferences.rs
Normal file
|
@ -0,0 +1,21 @@
|
|||
use rspc::alpha::AlphaRouter;
|
||||
|
||||
use super::{utils::library, Ctx, R};
|
||||
use crate::preferences::LibraryPreferences;
|
||||
|
||||
pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
||||
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?)
|
||||
})
|
||||
})
|
||||
}
|
|
@ -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;
|
||||
|
|
159
core/src/preferences/kv.rs
Normal file
159
core/src/preferences/kv.rs
Normal file
|
@ -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<String>);
|
||||
|
||||
impl PreferenceKey {
|
||||
pub fn new(value: impl Into<String>) -> 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<u8>);
|
||||
|
||||
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<Self::Item>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Entry {
|
||||
Value(Vec<u8>),
|
||||
Nested(Entries),
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
pub fn expect_value<T: DeserializeOwned>(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<String, Entry>;
|
||||
|
||||
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<preference::UpsertQuery> {
|
||||
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<T: Preferences>(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<String, Entry> = &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)
|
||||
}
|
||||
}
|
151
core/src/preferences/mod.rs
Normal file
151
core/src/preferences/mod.rs
Normal file
|
@ -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<Uuid, LocationPreferences>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<LocationViewSettings>,
|
||||
}
|
||||
|
||||
#[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<String, ListViewColumnSettings>,
|
||||
sort_col: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type, Default, Debug)]
|
||||
pub struct ListViewColumnSettings {
|
||||
hide: bool,
|
||||
size: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
|
||||
pub enum ExplorerLayout {
|
||||
Grid,
|
||||
List,
|
||||
Media,
|
||||
}
|
||||
|
||||
impl<V> Preferences for HashMap<Uuid, V>
|
||||
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;
|
||||
}
|
|
@ -303,6 +303,7 @@ impl SyncManager {
|
|||
.await?;
|
||||
}
|
||||
},
|
||||
ModelSyncData::Preference(_, _) => todo!(),
|
||||
}
|
||||
|
||||
if let CRDTOperationType::Shared(shared_op) = op.typ {
|
||||
|
|
|
@ -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": {
|
||||
|
|
1
interface/util/uuid.ts
Normal file
1
interface/util/uuid.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { stringify } from 'uuid';
|
|
@ -19,6 +19,7 @@ export type Procedures = {
|
|||
{ key: "locations.list", input: LibraryArgs<null>, 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<string | null>, result: ExplorerItem[] } |
|
||||
{ key: "preferences.get", input: LibraryArgs<null>, result: LibraryPreferences } |
|
||||
{ key: "search.objects", input: LibraryArgs<ObjectSearchArgs>, result: SearchData<ExplorerItem> } |
|
||||
{ key: "search.paths", input: LibraryArgs<FilePathSearchArgs>, result: SearchData<ExplorerItem> } |
|
||||
{ key: "sync.messages", input: LibraryArgs<null>, result: CRDTOperation[] } |
|
||||
|
@ -61,6 +62,7 @@ export type Procedures = {
|
|||
{ key: "p2p.acceptSpacedrop", input: [string, string | null], result: null } |
|
||||
{ key: "p2p.pair", input: LibraryArgs<PeerId>, result: number } |
|
||||
{ key: "p2p.spacedrop", input: SpacedropArgs, result: string | null } |
|
||||
{ key: "preferences.update", input: LibraryArgs<LibraryPreferences>, result: null } |
|
||||
{ key: "tags.assign", input: LibraryArgs<TagAssignArgs>, result: null } |
|
||||
{ key: "tags.create", input: LibraryArgs<TagCreateArgs>, result: Tag } |
|
||||
{ key: "tags.delete", input: LibraryArgs<number>, 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> = T | { not: T }
|
||||
|
|
1743
pnpm-lock.yaml
1743
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue