[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:
Brendan Allan 2023-07-12 11:32:53 +07:00 committed by GitHub
parent 456a642a1f
commit 07c00a8b7f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1254 additions and 868 deletions

11
Cargo.lock generated
View file

@ -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",

View file

@ -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';

View file

@ -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"] }

View file

@ -0,0 +1,5 @@
-- CreateTable
CREATE TABLE "preference" (
"key" TEXT NOT NULL PRIMARY KEY,
"value" BLOB
);

View file

@ -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")
}

View file

@ -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

View 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?)
})
})
}

View file

@ -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
View 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
View 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;
}

View file

@ -303,6 +303,7 @@ impl SyncManager {
.await?;
}
},
ModelSyncData::Preference(_, _) => todo!(),
}
if let CRDTOperationType::Shared(shared_op) = op.typ {

View file

@ -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
View file

@ -0,0 +1 @@
export { stringify } from 'uuid';

View file

@ -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 }

File diff suppressed because it is too large Load diff