[ENG-1400] Normalised caching (#1734)

* prototype

* `.normalise` helper + only `String` keys

* implement it for 'search.paths'

* redux devtools

* fix

* refactor backend

* wip: upgrade to rspc fork

* mega cursed

* Upgrade Specta-related stuff

* Upgrade Typescript

* Cache debug page

* bruh

* Fix optimistic library setting

* Cache clearing

* better timeout

* Fix tags page

* bit of cleanup

---------

Co-authored-by: Brendan Allan <brendonovich@outlook.com>
This commit is contained in:
Oscar Beaumont
2023-12-05 21:16:03 +08:00
committed by GitHub
parent c320dd2a9d
commit 89a7f735e5
88 changed files with 1844 additions and 646 deletions

1
.gitignore vendored
View File

@@ -14,7 +14,6 @@ cli/turbo-new.exe
cli/turbo.exe
storybook-static/
.DS_Store
cache
.env*
vendor/
data

View File

@@ -10,9 +10,11 @@
"dotenv",
"dotenvy",
"fontsource",
"ianvs",
"ipfs",
"Keepsafe",
"nodestate",
"normalise",
"overscan",
"pathctx",
"prismjs",
@@ -26,7 +28,6 @@
"tailwindcss",
"tanstack",
"titlebar",
"ianvs",
"tsparticles",
"unlisten",
"upsert",

BIN
Cargo.lock generated
View File

Binary file not shown.

View File

@@ -21,19 +21,19 @@ edition = "2021"
repository = "https://github.com/spacedriveapp/spacedrive"
[workspace.dependencies]
prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust", branch = "spacedrive", features = [
prisma-client-rust = { git = "https://github.com/spacedriveapp/prisma-client-rust", rev = "9f8ac122e8f2b2e4957b71f48a37e06565adba40", features = [
"rspc",
"sqlite-create-many",
"migrations",
"sqlite",
], default-features = false }
prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client-rust", branch = "spacedrive", features = [
prisma-client-rust-cli = { git = "https://github.com/spacedriveapp/prisma-client-rust", rev = "9f8ac122e8f2b2e4957b71f48a37e06565adba40", features = [
"rspc",
"sqlite-create-many",
"migrations",
"sqlite",
], default-features = false }
prisma-client-rust-sdk = { git = "https://github.com/Brendonovich/prisma-client-rust", branch = "spacedrive", features = [
prisma-client-rust-sdk = { git = "https://github.com/spacedriveapp/prisma-client-rust", rev = "9f8ac122e8f2b2e4957b71f48a37e06565adba40", features = [
"sqlite",
], default-features = false }
@@ -44,8 +44,8 @@ tracing-subscriber = { git = "https://github.com/tokio-rs/tracing", rev = "29146
tracing-appender = { git = "https://github.com/tokio-rs/tracing", rev = "29146260fb4615d271d2e899ad95a753bb42915e" } # Unreleased changes for rolling log deletion
rspc = { version = "0.1.4" }
specta = { version = "1.0.5" }
tauri-specta = { version = "1.0.2" }
specta = { version = "=2.0.0-rc.7" }
tauri-specta = { version = "=2.0.0-rc.4" }
swift-rs = { version = "1.0.6" }
@@ -59,6 +59,4 @@ serde_json = { version = "1.0" }
if-watch = { git = "https://github.com/oscartbeaumont/if-watch.git", rev = "f732786057e57250e863a9ea0b1874e4cc9907c2" }
# Beta features
specta = { git = "https://github.com/oscartbeaumont/specta", rev = "4bc6e46fc8747cd8d8a07597c1fe13c52aa16a41" }
rspc = { git = "https://github.com/oscartbeaumont/rspc", rev = "adebce542049b982dd251466d4144f4d57e92177" }
tauri-specta = { git = "https://github.com/oscartbeaumont/tauri-specta", rev = "c964bef228a90a66effc18cefcba6859c45a8e08" }
rspc = { git = "https://github.com/spacedriveapp/rspc.git", rev = "f3347e2e8bfe3f37bfacc437ca329fe71cdcb048" }

View File

@@ -13,6 +13,7 @@ use tauri::{
WindowEvent,
};
use tauri_plugins::{sd_error_plugin, sd_server_plugin};
use tauri_specta::ts;
use tokio::time::sleep;
use tracing::error;
@@ -143,16 +144,6 @@ async fn open_logs_dir(node: tauri::State<'_, Arc<Node>>) -> Result<(), ()> {
})
}
// TODO(@Oscar): A helper like this should probs exist in tauri-specta
macro_rules! tauri_handlers {
($($name:path),+) => {{
#[cfg(debug_assertions)]
tauri_specta::ts::export(specta::collect_types![$($name),+], "../src/commands.ts").unwrap();
tauri::generate_handler![$($name),+]
}};
}
const CLIENT_ID: &str = "2abb241e-40b8-4517-a3e3-5594375c8fbb";
#[tokio::main]
@@ -219,9 +210,41 @@ async fn main() -> tauri::Result<()> {
}
});
let specta_builder = {
let specta_builder = ts::builder()
.commands(tauri_specta::collect_commands![
app_ready,
reset_spacedrive,
open_logs_dir,
refresh_menu_bar,
reload_webview,
set_menu_bar_item_state,
request_fda_macos,
file::open_file_paths,
file::open_ephemeral_files,
file::get_file_path_open_with_apps,
file::get_ephemeral_files_open_with_apps,
file::open_file_path_with,
file::open_ephemeral_file_with,
file::reveal_items,
theme::lock_app_theme,
// TODO: move to plugin w/tauri-specta
updater::check_for_update,
updater::install_update
])
// .events(tauri_specta::collect_events![])
.config(specta::ts::ExportConfig::default().formatter(specta::ts::formatter::prettier));
#[cfg(debug_assertions)]
let specta_builder = specta_builder.path("../src/commands.ts");
specta_builder.into_plugin()
};
let app = app
.plugin(updater::plugin())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(specta_builder)
.setup(move |app| {
let app = app.handle();
@@ -300,26 +323,6 @@ async fn main() -> tauri::Result<()> {
})
.menu(menu::get_menu())
.manage(updater::State::default())
.invoke_handler(tauri_handlers![
app_ready,
reset_spacedrive,
open_logs_dir,
refresh_menu_bar,
reload_webview,
set_menu_bar_item_state,
request_fda_macos,
file::open_file_paths,
file::open_ephemeral_files,
file::get_file_path_open_with_apps,
file::get_ephemeral_files_open_with_apps,
file::open_file_path_with,
file::open_ephemeral_file_with,
file::reveal_items,
theme::lock_app_theme,
// TODO: move to plugin w/tauri-specta
updater::check_for_update,
updater::install_update
])
.build(tauri::generate_context!())?;
app.run(|_, _| {});

View File

@@ -4,7 +4,7 @@ import { listen } from '@tauri-apps/api/event';
import { appWindow } from '@tauri-apps/api/window';
import { startTransition, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { RspcProvider } from '@sd/client';
import { CacheProvider, createCache, RspcProvider } from '@sd/client';
import {
createRoutes,
ErrorPage,
@@ -19,7 +19,7 @@ import { getSpacedropState } from '@sd/interface/hooks/useSpacedropState';
import '@sd/ui/style/style.scss';
import * as commands from './commands';
import { commands } from './commands';
import { platform } from './platform';
import { queryClient } from './query';
import { createMemoryRouterWithHistory } from './router';
@@ -80,7 +80,9 @@ export default function App() {
// we have a minimum delay between creating new tabs as react router can't handle creating tabs super fast
const TAB_CREATE_DELAY = 150;
const routes = createRoutes(platform);
const cache = createCache();
const routes = createRoutes(platform, cache);
function AppInner() {
const [tabs, setTabs] = useState(() => [createTab()]);
@@ -142,84 +144,86 @@ function AppInner() {
}, [tab.element]);
return (
<RouteTitleContext.Provider
value={useMemo(
() => ({
setTitle(title) {
setTabs((oldTabs) => {
const tabs = [...oldTabs];
const tab = tabs[tabIndex];
if (!tab) return tabs;
tabs[tabIndex] = { ...tab, title };
return tabs;
});
}
}),
[tabIndex]
)}
>
<TabsContext.Provider
value={{
tabIndex,
setTabIndex,
tabs: tabs.map(({ router, title }) => ({ router, title })),
createTab() {
createTabPromise.current = createTabPromise.current.then(
() =>
new Promise((res) => {
startTransition(() => {
setTabs((tabs) => {
const newTabs = [...tabs, createTab()];
setTabIndex(newTabs.length - 1);
return newTabs;
});
});
setTimeout(res, TAB_CREATE_DELAY);
})
);
},
removeTab(index: number) {
startTransition(() => {
setTabs((tabs) => {
const tab = tabs[index];
<CacheProvider cache={cache}>
<RouteTitleContext.Provider
value={useMemo(
() => ({
setTitle(title) {
setTabs((oldTabs) => {
const tabs = [...oldTabs];
const tab = tabs[tabIndex];
if (!tab) return tabs;
tab.dispose();
tabs[tabIndex] = { ...tab, title };
tabs.splice(index, 1);
setTabIndex(Math.min(tabIndex, tabs.length - 1));
return [...tabs];
return tabs;
});
});
}
}}
}
}),
[tabIndex]
)}
>
<SpacedriveInterfaceRoot>
{tabs.map((tab) =>
createPortal(
<SpacedriveRouterProvider
key={tab.id}
routing={{
routes,
visible: tabIndex === tabs.indexOf(tab),
router: tab.router,
currentIndex: tab.currentIndex,
maxIndex: tab.maxIndex
}}
/>,
tab.element
)
)}
<div ref={ref} />
</SpacedriveInterfaceRoot>
</TabsContext.Provider>
</RouteTitleContext.Provider>
<TabsContext.Provider
value={{
tabIndex,
setTabIndex,
tabs: tabs.map(({ router, title }) => ({ router, title })),
createTab() {
createTabPromise.current = createTabPromise.current.then(
() =>
new Promise((res) => {
startTransition(() => {
setTabs((tabs) => {
const newTabs = [...tabs, createTab()];
setTabIndex(newTabs.length - 1);
return newTabs;
});
});
setTimeout(res, TAB_CREATE_DELAY);
})
);
},
removeTab(index: number) {
startTransition(() => {
setTabs((tabs) => {
const tab = tabs[index];
if (!tab) return tabs;
tab.dispose();
tabs.splice(index, 1);
setTabIndex(Math.min(tabIndex, tabs.length - 1));
return [...tabs];
});
});
}
}}
>
<SpacedriveInterfaceRoot>
{tabs.map((tab) =>
createPortal(
<SpacedriveRouterProvider
key={tab.id}
routing={{
routes,
visible: tabIndex === tabs.indexOf(tab),
router: tab.router,
currentIndex: tab.currentIndex,
maxIndex: tab.maxIndex
}}
/>,
tab.element
)
)}
<div ref={ref} />
</SpacedriveInterfaceRoot>
</TabsContext.Provider>
</RouteTitleContext.Provider>
</CacheProvider>
);
}

View File

@@ -1,86 +1,230 @@
/* eslint-disable */
/** tauri-specta globals **/
import { invoke as TAURI_INVOKE } from '@tauri-apps/api';
import * as TAURI_API_EVENT from '@tauri-apps/api/event';
import { type WebviewWindowHandle as __WebviewWindowHandle__ } from '@tauri-apps/api/window';
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
declare global {
interface Window {
__TAURI_INVOKE__<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
}
export const commands = {
async appReady(): Promise<null> {
return await TAURI_INVOKE('plugin:tauri-specta|app_ready');
},
async resetSpacedrive(): Promise<null> {
return await TAURI_INVOKE('plugin:tauri-specta|reset_spacedrive');
},
async openLogsDir(): Promise<__Result__<null, null>> {
try {
return { status: 'ok', data: await TAURI_INVOKE('plugin:tauri-specta|open_logs_dir') };
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
},
async refreshMenuBar(): Promise<__Result__<null, null>> {
try {
return {
status: 'ok',
data: await TAURI_INVOKE('plugin:tauri-specta|refresh_menu_bar')
};
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
},
async reloadWebview(): Promise<null> {
return await TAURI_INVOKE('plugin:tauri-specta|reload_webview');
},
async setMenuBarItemState(id: string, enabled: boolean): Promise<null> {
return await TAURI_INVOKE('plugin:tauri-specta|set_menu_bar_item_state', { id, enabled });
},
async requestFdaMacos(): Promise<null> {
return await TAURI_INVOKE('plugin:tauri-specta|request_fda_macos');
},
async openFilePaths(
library: string,
ids: number[]
): Promise<
__Result__<
(
| { t: 'NoLibrary' }
| { t: 'NoFile'; c: number }
| { t: 'OpenError'; c: [number, string] }
| { t: 'AllGood'; c: number }
| { t: 'Internal'; c: string }
)[],
null
>
> {
try {
return {
status: 'ok',
data: await TAURI_INVOKE('plugin:tauri-specta|open_file_paths', { library, ids })
};
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
},
async openEphemeralFiles(
paths: string[]
): Promise<__Result__<({ t: 'Ok'; c: string } | { t: 'Err'; c: string })[], null>> {
try {
return {
status: 'ok',
data: await TAURI_INVOKE('plugin:tauri-specta|open_ephemeral_files', { paths })
};
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
},
async getFilePathOpenWithApps(
library: string,
ids: number[]
): Promise<__Result__<{ url: string; name: string }[], null>> {
try {
return {
status: 'ok',
data: await TAURI_INVOKE('plugin:tauri-specta|get_file_path_open_with_apps', {
library,
ids
})
};
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
},
async getEphemeralFilesOpenWithApps(
paths: string[]
): Promise<__Result__<{ url: string; name: string }[], null>> {
try {
return {
status: 'ok',
data: await TAURI_INVOKE('plugin:tauri-specta|get_ephemeral_files_open_with_apps', {
paths
})
};
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
},
async openFilePathWith(
library: string,
fileIdsAndUrls: [number, string][]
): Promise<__Result__<null, null>> {
try {
return {
status: 'ok',
data: await TAURI_INVOKE('plugin:tauri-specta|open_file_path_with', {
library,
fileIdsAndUrls
})
};
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
},
async openEphemeralFileWith(pathsAndUrls: [string, string][]): Promise<__Result__<null, null>> {
try {
return {
status: 'ok',
data: await TAURI_INVOKE('plugin:tauri-specta|open_ephemeral_file_with', {
pathsAndUrls
})
};
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
},
async revealItems(library: string, items: RevealItem[]): Promise<__Result__<null, null>> {
try {
return {
status: 'ok',
data: await TAURI_INVOKE('plugin:tauri-specta|reveal_items', { library, items })
};
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
},
async lockAppTheme(themeType: AppThemeType): Promise<null> {
return await TAURI_INVOKE('plugin:tauri-specta|lock_app_theme', { themeType });
},
async checkForUpdate(): Promise<
__Result__<{ version: string; body: string | null } | null, string>
> {
try {
return {
status: 'ok',
data: await TAURI_INVOKE('plugin:tauri-specta|check_for_update')
};
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
},
async installUpdate(): Promise<__Result__<null, string>> {
try {
return { status: 'ok', data: await TAURI_INVOKE('plugin:tauri-specta|install_update') };
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
}
};
/** user-defined types **/
export type AppThemeType = 'Auto' | 'Light' | 'Dark';
export type RevealItem =
| { Location: { id: number } }
| { FilePath: { id: number } }
| { Ephemeral: { path: string } };
type __EventObj__<T> = {
listen: (cb: TAURI_API_EVENT.EventCallback<T>) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
once: (cb: TAURI_API_EVENT.EventCallback<T>) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
emit: T extends null
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
};
type __Result__<T, E> = { status: 'ok'; data: T } | { status: 'error'; error: E };
function __makeEvents__<T extends Record<string, any>>(mappings: Record<keyof T, string>) {
return new Proxy(
{} as unknown as {
[K in keyof T]: __EventObj__<T[K]> & {
(handle: __WebviewWindowHandle__): __EventObj__<T[K]>;
};
},
{
get: (_, event) => {
const name = mappings[event as keyof T];
return new Proxy((() => {}) as any, {
apply: (_, __, [window]: [__WebviewWindowHandle__]) => ({
listen: (arg: any) => window.listen(name, arg),
once: (arg: any) => window.once(name, arg),
emit: (arg: any) => window.emit(name, arg)
}),
get: (_, command: keyof __EventObj__<any>) => {
switch (command) {
case 'listen':
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
case 'once':
return (arg: any) => TAURI_API_EVENT.once(name, arg);
case 'emit':
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
}
}
});
}
}
);
}
// Function avoids 'window not defined' in SSR
const invoke = () => window.__TAURI_INVOKE__;
export function appReady() {
return invoke()<null>("app_ready")
}
export function resetSpacedrive() {
return invoke()<null>("reset_spacedrive")
}
export function openLogsDir() {
return invoke()<null>("open_logs_dir")
}
export function refreshMenuBar() {
return invoke()<null>("refresh_menu_bar")
}
export function reloadWebview() {
return invoke()<null>("reload_webview")
}
export function setMenuBarItemState(id: string, enabled: boolean) {
return invoke()<null>("set_menu_bar_item_state", { id,enabled })
}
export function requestFdaMacos() {
return invoke()<null>("request_fda_macos")
}
export function openFilePaths(library: string, ids: number[]) {
return invoke()<OpenFilePathResult[]>("open_file_paths", { library,ids })
}
export function openEphemeralFiles(paths: string[]) {
return invoke()<EphemeralFileOpenResult[]>("open_ephemeral_files", { paths })
}
export function getFilePathOpenWithApps(library: string, ids: number[]) {
return invoke()<OpenWithApplication[]>("get_file_path_open_with_apps", { library,ids })
}
export function getEphemeralFilesOpenWithApps(paths: string[]) {
return invoke()<OpenWithApplication[]>("get_ephemeral_files_open_with_apps", { paths })
}
export function openFilePathWith(library: string, fileIdsAndUrls: ([number, string])[]) {
return invoke()<null>("open_file_path_with", { library,fileIdsAndUrls })
}
export function openEphemeralFileWith(pathsAndUrls: ([string, string])[]) {
return invoke()<null>("open_ephemeral_file_with", { pathsAndUrls })
}
export function revealItems(library: string, items: RevealItem[]) {
return invoke()<null>("reveal_items", { library,items })
}
export function lockAppTheme(themeType: AppThemeType) {
return invoke()<null>("lock_app_theme", { themeType })
}
export function checkForUpdate() {
return invoke()<Update | null>("check_for_update")
}
export function installUpdate() {
return invoke()<null>("install_update")
}
export type Update = { version: string; body: string | null }
export type OpenWithApplication = { url: string; name: string }
export type AppThemeType = "Auto" | "Light" | "Dark"
export type EphemeralFileOpenResult = { t: "Ok"; c: string } | { t: "Err"; c: string }
export type OpenFilePathResult = { t: "NoLibrary" } | { t: "NoFile"; c: number } | { t: "OpenError"; c: [number, string] } | { t: "AllGood"; c: number } | { t: "Internal"; c: string }
export type RevealItem = { Location: { id: number } } | { FilePath: { id: number } } | { Ephemeral: { path: string } }

View File

@@ -4,7 +4,7 @@ import { homeDir } from '@tauri-apps/api/path';
import { open } from '@tauri-apps/api/shell';
import { OperatingSystem, Platform } from '@sd/interface';
import * as commands from './commands';
import { commands } from './commands';
import { env } from './env';
import { createUpdater } from './updater';

View File

@@ -3,7 +3,7 @@ import { proxy, useSnapshot } from 'valtio';
import { UpdateStore } from '@sd/interface';
import { toast, ToastId } from '@sd/ui';
import * as commands from './commands';
import { commands } from './commands';
declare global {
interface Window {
@@ -27,9 +27,15 @@ export function createUpdater() {
const onInstallCallbacks = new Set<() => void>();
async function checkForUpdate() {
const update = await commands.checkForUpdate();
const result = await commands.checkForUpdate();
if (!update) return null;
if (result.status === 'error') {
console.error('UPDATER ERROR', result.error);
// TODO: Show some UI?
return null;
}
if (!result.data) return null;
const update = result.data;
let id: ToastId | null = null;

View File

@@ -2,7 +2,7 @@ import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript
import { useNavigation } from '@react-navigation/native';
import { useRef } from 'react';
import { Pressable, Text, View } from 'react-native';
import { useLibraryQuery } from '@sd/client';
import { useCache, useLibraryQuery, useNodes } from '@sd/client';
import { ModalRef } from '~/components/layout/Modal';
import { tw, twStyle } from '~/lib/tailwind';
@@ -39,7 +39,9 @@ const DrawerLocations = ({ stackName }: DrawerLocationsProp) => {
const modalRef = useRef<ModalRef>(null);
const { data: locations } = useLibraryQuery(['locations.list'], { keepPreviousData: true });
const result = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(result.data?.nodes);
const locations = useCache(result.data?.items);
return (
<>

View File

@@ -2,7 +2,7 @@ import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript
import { useNavigation } from '@react-navigation/native';
import { useRef } from 'react';
import { ColorValue, Pressable, Text, View } from 'react-native';
import { useLibraryQuery } from '@sd/client';
import { useCache, useLibraryQuery, useNodes } from '@sd/client';
import { ModalRef } from '~/components/layout/Modal';
import { tw, twStyle } from '~/lib/tailwind';
@@ -37,6 +37,8 @@ const DrawerTags = ({ stackName }: DrawerTagsProp) => {
const navigation = useNavigation<DrawerNavigationHelpers>();
const tags = useLibraryQuery(['tags.list']);
useNodes(tags.data?.nodes);
const tagData = useCache(tags.data?.items);
const modalRef = useRef<ModalRef>(null);
@@ -47,7 +49,7 @@ const DrawerTags = ({ stackName }: DrawerTagsProp) => {
containerStyle={tw`mb-3 ml-1 mt-6`}
>
<View style={tw`mt-2`}>
{tags.data?.map((tag) => (
{tagData?.map((tag) => (
<DrawerTagItem
key={tag.id}
tagName={tag.name!}

View File

@@ -6,7 +6,9 @@ import {
getItemFilePath,
getItemObject,
isPath,
useLibraryQuery
useCache,
useLibraryQuery,
useNodes
} from '@sd/client';
import { InfoPill, PlaceholderPill } from '~/components/primitive/InfoPill';
import { tw, twStyle } from '~/lib/tailwind';
@@ -23,6 +25,8 @@ const InfoTagPills = ({ data, style }: Props) => {
const tagsQuery = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1], {
enabled: objectData != null
});
useNodes(tagsQuery.data?.nodes);
const items = useCache(tagsQuery.data?.items);
const isDir = data && isPath(data) ? data.item.is_dir : false;
@@ -35,7 +39,7 @@ const InfoTagPills = ({ data, style }: Props) => {
<InfoPill text={filePath.extension} containerStyle={tw`mr-1`} />
)}
{/* TODO: What happens if I have too many? */}
{tagsQuery.data?.map((tag) => (
{items?.map((tag) => (
<InfoPill
key={tag.id}
text={tag.name ?? 'Unnamed Tag'}

View File

@@ -1,7 +1,12 @@
import { useQueryClient } from '@tanstack/react-query';
import { forwardRef, useState } from 'react';
import { Text, View } from 'react-native';
import { useBridgeMutation, usePlausibleEvent } from '@sd/client';
import {
insertLibrary,
useBridgeMutation,
useNormalisedCache,
usePlausibleEvent
} from '@sd/client';
import { ModalInput } from '~/components/form/Input';
import { Modal, ModalRef } from '~/components/layout/Modal';
import { Button } from '~/components/primitive/Button';
@@ -13,6 +18,7 @@ const CreateLibraryModal = forwardRef<ModalRef, unknown>((_, ref) => {
const modalRef = useForwardedRef(ref);
const queryClient = useQueryClient();
const cache = useNormalisedCache();
const [libName, setLibName] = useState('');
const submitPlausibleEvent = usePlausibleEvent();
@@ -20,17 +26,15 @@ const CreateLibraryModal = forwardRef<ModalRef, unknown>((_, ref) => {
const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeMutation(
'library.create',
{
onSuccess: (lib) => {
onSuccess: (libRaw) => {
cache.withNodes(libRaw.nodes);
const lib = cache.withCache(libRaw.item);
// Reset form
setLibName('');
// We do this instead of invalidating the query because it triggers a full app re-render??
queryClient.setQueryData(['library.list'], (libraries: any) => {
// The invalidation system beat us to it
if (libraries.find((l: any) => l.uuid === lib.uuid)) return libraries;
return [...(libraries || []), lib];
});
insertLibrary(queryClient, lib);
// Switch to the new library
currentLibraryStore.id = lib.uuid;

View File

@@ -61,9 +61,9 @@ const FileInfoModal = forwardRef<ModalRef, FileInfoModalProps>((props, ref) => {
const objectData = data && getItemObject(data);
const filePathData = data && getItemFilePath(data);
const fullObjectData = useLibraryQuery(['files.get', { id: objectData?.id || -1 }], {
enabled: objectData?.id !== undefined
});
// const fullObjectData = useLibraryQuery(['files.get', objectData?.id || -1], {
// enabled: objectData?.id !== undefined
// });
return (
<Modal

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { useLibraryQuery } from '@sd/client';
import React, { useEffect, useMemo } from 'react';
import { useCache, useLibraryQuery, useNodes } from '@sd/client';
import Explorer from '~/components/explorer/Explorer';
import { SharedScreenProps } from '~/navigation/SharedScreens';
import { getExplorerStore } from '~/stores/explorerStore';
@@ -8,6 +8,8 @@ export default function LocationScreen({ navigation, route }: SharedScreenProps<
const { id, path } = route.params;
const location = useLibraryQuery(['locations.get', route.params.id]);
useNodes(location.data?.nodes);
const locationData = useCache(location.data?.item);
const { data } = useLibraryQuery([
'search.paths',
@@ -23,6 +25,8 @@ export default function LocationScreen({ navigation, route }: SharedScreenProps<
take: 100
}
]);
const pathsItemsReferences = useMemo(() => data?.items ?? [], [data]);
const pathsItems = useCache(pathsItemsReferences);
useEffect(() => {
// Set screen title to location.
@@ -36,15 +40,15 @@ export default function LocationScreen({ navigation, route }: SharedScreenProps<
});
} else {
navigation.setOptions({
title: location.data?.name ?? 'Location'
title: locationData?.name ?? 'Location'
});
}
}, [location.data?.name, navigation, path]);
}, [locationData?.name, navigation, path]);
useEffect(() => {
getExplorerStore().locationId = id;
getExplorerStore().path = path ?? '';
}, [id, path]);
return <Explorer items={data?.items} />;
return <Explorer items={pathsItems} />;
}

View File

@@ -2,7 +2,7 @@ import { MagnifyingGlass } from 'phosphor-react-native';
import { Suspense, useDeferredValue, useMemo, useState } from 'react';
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { getExplorerItemData, SearchFilterArgs, useLibraryQuery } from '@sd/client';
import { getExplorerItemData, SearchFilterArgs, useCache, useLibraryQuery } from '@sd/client';
import Explorer from '~/components/explorer/Explorer';
import { tw, twStyle } from '~/lib/tailwind';
import { RootStackScreenProps } from '~/navigation';
@@ -45,19 +45,20 @@ const SearchScreen = ({ navigation }: RootStackScreenProps<'Search'>) => {
}
);
const items = useMemo(() => {
const items = query.data?.items;
const pathsItemsReferences = useMemo(() => query.data?.items ?? [], [query.data]);
const pathsItems = useCache(pathsItemsReferences);
const items = useMemo(() => {
// Mobile does not thave media layout
// if (explorerStore.layoutMode !== 'media') return items;
// if (explorerStore.layoutMode !== 'media') return pathsItems;
return (
items?.filter((item) => {
pathsItems?.filter((item) => {
const { kind } = getExplorerItemData(item);
return kind === 'Video' || kind === 'Image';
}) ?? []
);
}, [query.data]);
}, [pathsItems]);
return (
<View style={twStyle('flex-1', { marginTop: top + 10 })}>

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useLibraryQuery } from '@sd/client';
import { useCache, useLibraryQuery, useNodes } from '@sd/client';
import Explorer from '~/components/explorer/Explorer';
import { SharedScreenProps } from '~/navigation/SharedScreens';
@@ -13,15 +13,19 @@ export default function TagScreen({ navigation, route }: SharedScreenProps<'Tag'
take: 100
}
]);
useNodes(search.data?.nodes);
const searchData = useCache(search.data?.items);
const tag = useLibraryQuery(['tags.get', id]);
useNodes(tag.data?.nodes);
const tagData = useCache(tag.data?.item);
useEffect(() => {
// Set screen title to tag name.
navigation.setOptions({
title: tag.data?.name ?? 'Tag'
title: tagData?.name ?? 'Tag'
});
}, [tag.data?.name, navigation]);
}, [tagData?.name, navigation]);
return <Explorer items={search.data?.items} />;
return <Explorer items={searchData} />;
}

View File

@@ -5,11 +5,13 @@ import { z } from 'zod';
import {
currentLibraryCache,
getOnboardingStore,
insertLibrary,
resetOnboardingStore,
telemetryStore,
useBridgeMutation,
useCachedLibraries,
useMultiZodForm,
useNormalisedCache,
useOnboardingStore,
usePlausibleEvent
} from '@sd/client';
@@ -66,13 +68,14 @@ const useFormState = () => {
const submitPlausibleEvent = usePlausibleEvent();
const queryClient = useQueryClient();
const cache = useNormalisedCache();
const createLibrary = useBridgeMutation('library.create', {
onSuccess: (lib) => {
onSuccess: (libRaw) => {
cache.withNodes(libRaw.nodes);
const lib = cache.withCache(libRaw.item);
// We do this instead of invalidating the query because it triggers a full app re-render??
queryClient.setQueryData(['library.list'], (libraries: any) => [
...(libraries || []),
lib
]);
insertLibrary(queryClient, lib);
}
});
@@ -86,13 +89,15 @@ const useFormState = () => {
try {
// show creation screen for a bit for smoothness
const [library] = await Promise.all([
const [libraryRaw] = await Promise.all([
createLibrary.mutateAsync({
name: data.NewLibrary.name,
default_locations: null
}),
new Promise((res) => setTimeout(res, 500))
]);
cache.withNodes(libraryRaw.nodes);
const library = cache.withCache(libraryRaw.item);
if (telemetryStore.shareFullTelemetry) {
submitPlausibleEvent({ event: { type: 'libraryCreate' } });

View File

@@ -2,7 +2,7 @@ import { CaretRight, Pen, Trash } from 'phosphor-react-native';
import React, { useEffect, useRef } from 'react';
import { Animated, FlatList, Text, View } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import { LibraryConfigWrapped, useBridgeQuery } from '@sd/client';
import { LibraryConfigWrapped, useBridgeQuery, useCache, useNodes } from '@sd/client';
import { ModalRef } from '~/components/layout/Modal';
import DeleteLibraryModal from '~/components/modal/confirmModals/DeleteLibraryModal';
import { AnimatedButton, FakeButton } from '~/components/primitive/Button';
@@ -68,7 +68,9 @@ function LibraryItem({
}
const LibrarySettingsScreen = ({ navigation }: SettingsStackScreenProps<'LibrarySettings'>) => {
const { data: libraries } = useBridgeQuery(['library.list']);
const libraryList = useBridgeQuery(['library.list']);
useNodes(libraryList.data?.nodes);
const libraries = useCache(libraryList.data?.items);
useEffect(() => {
navigation.setOptions({

View File

@@ -4,7 +4,7 @@ import { useEffect } from 'react';
import { Controller } from 'react-hook-form';
import { Alert, ScrollView, Text, View } from 'react-native';
import { z } from 'zod';
import { useLibraryMutation, useLibraryQuery, useZodForm } from '@sd/client';
import { useLibraryMutation, useLibraryQuery, useNormalisedCache, useZodForm } from '@sd/client';
import { Input } from '~/components/form/Input';
import { Switch } from '~/components/form/Switch';
import DeleteLocationModal from '~/components/modal/confirmModals/DeleteLocationModal';
@@ -36,6 +36,7 @@ const EditLocationSettingsScreen = ({
const { id } = route.params;
const queryClient = useQueryClient();
const cache = useNormalisedCache();
const form = useZodForm({ schema });
@@ -93,12 +94,15 @@ const EditLocationSettingsScreen = ({
}, [form, navigation, onSubmit]);
useLibraryQuery(['locations.getWithRules', id], {
onSuccess: (data) => {
onSuccess: (dataRaw) => {
cache.withNodes(dataRaw?.nodes);
const data = cache.withCache(dataRaw?.item);
if (data && !form.formState.isDirty)
form.reset({
displayName: data.name,
localPath: data.path,
indexer_rules_ids: data.indexer_rules.map((i) => i.indexer_rule.id.toString()),
indexer_rules_ids: data.indexer_rules.map((i) => i.id.toString()),
generatePreviewMedia: data.generate_preview_media,
syncPreviewMedia: data.sync_preview_media,
hidden: data.hidden

View File

@@ -5,8 +5,10 @@ import { Swipeable } from 'react-native-gesture-handler';
import {
arraysEqual,
Location,
useCache,
useLibraryMutation,
useLibraryQuery,
useNodes,
useOnlineLocations
} from '@sd/client';
import FolderIcon from '~/components/icons/FolderIcon';
@@ -130,7 +132,9 @@ function LocationItem({ location, index, navigation }: LocationItemProps) {
}
const LocationSettingsScreen = ({ navigation }: SettingsStackScreenProps<'LocationSettings'>) => {
const { data: locations } = useLibraryQuery(['locations.list']);
const result = useLibraryQuery(['locations.list']);
useNodes(result.data?.nodes);
const locations = useCache(result.data?.items);
useEffect(() => {
navigation.setOptions({

View File

@@ -2,7 +2,7 @@ import { ArrowLeft, CaretRight, Pen, Trash } from 'phosphor-react-native';
import { useEffect, useRef } from 'react';
import { Animated, FlatList, Text, View } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import { Tag, useLibraryQuery } from '@sd/client';
import { Tag, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { ModalRef } from '~/components/layout/Modal';
import DeleteTagModal from '~/components/modal/confirmModals/DeleteTagModal';
import CreateTagModal from '~/components/modal/tag/CreateTagModal';
@@ -70,7 +70,9 @@ function TagItem({ tag, index }: { tag: Tag; index: number }) {
// TODO: Add "New Tag" button
const TagsSettingsScreen = ({ navigation }: SettingsStackScreenProps<'TagsSettings'>) => {
const { data: tags } = useLibraryQuery(['tags.list']);
const result = useLibraryQuery(['tags.list']);
useNodes(result.data?.nodes);
const tags = useCache(result.data?.items);
useEffect(() => {
navigation.setOptions({

View File

@@ -1,7 +1,7 @@
import { hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { createBrowserRouter } from 'react-router-dom';
import { RspcProvider } from '@sd/client';
import { CacheProvider, createCache, RspcProvider } from '@sd/client';
import {
createRoutes,
Platform,
@@ -81,7 +81,9 @@ const queryClient = new QueryClient({
}
});
const routes = createRoutes(platform);
const cache = createCache();
const routes = createRoutes(platform, cache);
function App() {
const router = useRouter();
@@ -102,21 +104,23 @@ function App() {
return (
<ScreenshotWrapper showControls={!!showControls}>
<div ref={domEl} className="App">
<RspcProvider queryClient={queryClient}>
<PlatformProvider platform={platform}>
<QueryClientProvider client={queryClient}>
<SpacedriveInterfaceRoot>
<SpacedriveRouterProvider
routing={{
...router,
routes,
visible: true
}}
/>
</SpacedriveInterfaceRoot>
</QueryClientProvider>
</PlatformProvider>
</RspcProvider>
<CacheProvider cache={cache}>
<RspcProvider queryClient={queryClient}>
<PlatformProvider platform={platform}>
<QueryClientProvider client={queryClient}>
<SpacedriveInterfaceRoot>
<SpacedriveRouterProvider
routing={{
...router,
routes,
visible: true
}}
/>
</SpacedriveInterfaceRoot>
</QueryClientProvider>
</PlatformProvider>
</RspcProvider>
</CacheProvider>
</div>
</ScreenshotWrapper>
);
@@ -126,7 +130,7 @@ export default App;
function useRouter() {
const [router, setRouter] = useState(() => {
const router = createBrowserRouter(createRoutes(platform));
const router = createBrowserRouter(routes);
router.subscribe((event) => {
setRouter((router) => {

View File

@@ -22,16 +22,16 @@ sd-media-metadata = { path = "../crates/media-metadata" }
sd-prisma = { path = "../crates/prisma" }
sd-ffmpeg = { path = "../crates/ffmpeg", optional = true }
sd-crypto = { path = "../crates/crypto", features = [
"rspc",
"specta",
"serde",
"keymanager",
"rspc",
"specta",
"serde",
"keymanager",
] }
sd-cache = { path = "../crates/cache" }
sd-images = { path = "../crates/images", features = [
"rspc",
"serde",
"specta",
"rspc",
"serde",
"specta",
] }
sd-file-ext = { path = "../crates/file-ext" }
sd-sync = { path = "../crates/sync" }
@@ -40,21 +40,21 @@ sd-utils = { path = "../crates/utils" }
sd-core-sync = { path = "./crates/sync" }
rspc = { workspace = true, features = [
"uuid",
"chrono",
"tracing",
"alpha",
"unstable",
"uuid",
"chrono",
"tracing",
"alpha",
"unstable",
] }
prisma-client-rust = { workspace = true }
specta = { workspace = true }
tokio = { workspace = true, features = [
"sync",
"rt-multi-thread",
"io-util",
"macros",
"time",
"process",
"sync",
"rt-multi-thread",
"io-util",
"macros",
"time",
"process",
] }
serde = { version = "1.0", features = ["derive"] }
chrono = { version = "0.4.31", features = ["serde"] }
@@ -82,7 +82,7 @@ http-range = "0.1.5"
mini-moka = "0.10.2"
serde_with = "3.4.0"
notify = { version = "=5.2.0", default-features = false, features = [
"macos_fsevent",
"macos_fsevent",
], optional = true }
static_assertions = "1.1.0"
serde-hashkey = "0.4.5"

View File

@@ -115,12 +115,17 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
})
.procedure("backup", {
R.with2(library())
.mutation(|(node, library), _: ()| start_backup(node, library))
.mutation(
|(node, library), _: ()| async move { Ok(start_backup(node, library).await) },
)
})
.procedure("restore", {
R
// TODO: Paths as strings is bad but here we want the flexibility of the frontend allowing any path
.mutation(|node, path: String| start_restore(node, path.into()))
.mutation(|node, path: String| async move {
start_restore(node, path.into()).await;
Ok(())
})
})
.procedure("delete", {
R

View File

@@ -1,47 +1,49 @@
use crate::library::Category;
// TODO: Ensure this file has normalised caching setup before reenabling
use std::{collections::BTreeMap, str::FromStr};
// use crate::library::Category;
use rspc::{alpha::AlphaRouter, ErrorCode};
use strum::VariantNames;
// use std::{collections::BTreeMap, str::FromStr};
use super::{utils::library, Ctx, R};
// use rspc::{alpha::AlphaRouter, ErrorCode};
// use strum::VariantNames;
pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router().procedure("list", {
R.with2(library()).query(|(_, library), _: ()| async move {
let (categories, queries): (Vec<_>, Vec<_>) = Category::VARIANTS
.iter()
.map(|category| {
let category = Category::from_str(category)
.expect("it's alright this category string exists");
(
category,
library.db.object().count(vec![category.to_where_param()]),
)
})
.unzip();
// use super::{utils::library, Ctx, R};
Ok(categories
.into_iter()
.zip(
library
.db
._batch(queries)
.await?
.into_iter()
// TODO(@Oscar): rspc bigint support
.map(|count| {
i32::try_from(count).map_err(|_| {
rspc::Error::new(
ErrorCode::InternalServerError,
"category item count overflowed 'i32'!".into(),
)
})
})
.collect::<Result<Vec<_>, _>>()?,
)
.collect::<BTreeMap<_, _>>())
})
})
}
// pub(crate) fn mount() -> AlphaRouter<Ctx> {
// R.router().procedure("list", {
// R.with2(library()).query(|(_, library), _: ()| async move {
// let (categories, queries): (Vec<_>, Vec<_>) = Category::VARIANTS
// .iter()
// .map(|category| {
// let category = Category::from_str(category)
// .expect("it's alright this category string exists");
// (
// category,
// library.db.object().count(vec![category.to_where_param()]),
// )
// })
// .unzip();
// Ok(categories
// .into_iter()
// .zip(
// library
// .db
// ._batch(queries)
// .await?
// .into_iter()
// // TODO(@Oscar): rspc bigint support
// .map(|count| {
// i32::try_from(count).map_err(|_| {
// rspc::Error::new(
// ErrorCode::InternalServerError,
// "category item count overflowed 'i32'!".into(),
// )
// })
// })
// .collect::<Result<Vec<_>, _>>()?,
// )
// .collect::<BTreeMap<_, _>>())
// })
// })
// }

View File

@@ -28,7 +28,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
}
mod library {
use chrono::{DateTime, Utc};
use chrono::Utc;
use crate::api::libraries::LibraryConfigWrapped;

View File

@@ -52,7 +52,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
return Ok(None);
}
match extract_media_data(full_path).await {
match extract_media_data(full_path.clone()).await {
Ok(img_media_data) => Ok(Some(MediaMetadata::Image(Box::new(img_media_data)))),
Err(MediaDataError::MediaData(sd_media_metadata::Error::NoExifDataOnPath(
_,

View File

@@ -1,5 +1,5 @@
use crate::{
api::utils::library,
api::{locations::object_with_file_paths, utils::library},
invalidate_query,
job::Job,
library::Library,
@@ -21,6 +21,7 @@ use crate::{
util::{db::maybe_missing, error::FileIOError},
};
use sd_cache::{CacheNode, Model, NormalisedResult, Reference};
use sd_file_ext::kind::ObjectKind;
use sd_images::ConvertableExtension;
use sd_media_metadata::MediaMetadata;
@@ -31,11 +32,11 @@ use std::{
sync::Arc,
};
use chrono::Utc;
use chrono::{DateTime, FixedOffset, Utc};
use futures::future::join_all;
use regex::Regex;
use rspc::{alpha::AlphaRouter, ErrorCode};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use specta::Type;
use tokio::{fs, io, task::spawn_blocking};
use tracing::{error, warn};
@@ -47,19 +48,76 @@ const UNTITLED_FOLDER_STR: &str = "Untitled Folder";
pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router()
.procedure("get", {
#[derive(Type, Deserialize)]
pub struct GetArgs {
#[derive(Type, Serialize)]
pub struct ObjectWithFilePaths2 {
pub id: i32,
pub pub_id: Vec<u8>,
pub kind: Option<i32>,
pub key_id: Option<i32>,
pub hidden: Option<bool>,
pub favorite: Option<bool>,
pub important: Option<bool>,
pub note: Option<String>,
pub date_created: Option<DateTime<FixedOffset>>,
pub date_accessed: Option<DateTime<FixedOffset>>,
pub file_paths: Vec<Reference<file_path::Data>>,
}
impl Model for ObjectWithFilePaths2 {
fn name() -> &'static str {
"Object" // is a duplicate because it's the same entity but with a relation
}
}
impl ObjectWithFilePaths2 {
pub fn from_db(
nodes: &mut Vec<CacheNode>,
item: object_with_file_paths::Data,
) -> Reference<Self> {
let this = Self {
id: item.id,
pub_id: item.pub_id,
kind: item.kind,
key_id: item.key_id,
hidden: item.hidden,
favorite: item.favorite,
important: item.important,
note: item.note,
date_created: item.date_created,
date_accessed: item.date_accessed,
file_paths: item
.file_paths
.into_iter()
.map(|i| {
let id = i.id.to_string();
nodes.push(CacheNode::new(id.clone(), i));
Reference::new(id)
})
.collect(),
};
let id = this.id.to_string();
nodes.push(CacheNode::new(id.clone(), this));
Reference::new(id)
}
}
R.with2(library())
.query(|(_, library), args: GetArgs| async move {
.query(|(_, library), object_id: i32| async move {
Ok(library
.db
.object()
.find_unique(object::id::equals(args.id))
.include(object::include!({ file_paths }))
.find_unique(object::id::equals(object_id))
.include(object_with_file_paths::include())
.exec()
.await?)
.await?
.map(|item| {
let mut nodes = Vec::new();
NormalisedResult {
item: ObjectWithFilePaths2::from_db(&mut nodes, item),
nodes,
}
}))
})
})
.procedure("getMediaData", {

View File

@@ -1,3 +1,5 @@
// TODO: Ensure this file has normalised caching setup before reenabling
// use rspc::alpha::AlphaRouter;
// use rspc::ErrorCode;
// use sd_crypto::keys::keymanager::{StoredKey, StoredKeyType};

View File

@@ -6,6 +6,7 @@ use crate::{
Node,
};
use sd_cache::{Model, Normalise, NormalisedResult, NormalisedResults};
use sd_p2p::spacetunnel::RemoteIdentity;
use sd_prisma::prisma::{indexer_rule, statistics};
@@ -35,6 +36,12 @@ pub struct LibraryConfigWrapped {
pub config: LibraryConfig,
}
impl Model for LibraryConfigWrapped {
fn name() -> &'static str {
"LibraryConfigWrapped"
}
}
impl LibraryConfigWrapped {
pub async fn from_library(library: &Library) -> Self {
Self {
@@ -50,7 +57,8 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router()
.procedure("list", {
R.query(|node, _: ()| async move {
node.libraries
let libraries = node
.libraries
.get_all()
.await
.into_iter()
@@ -64,7 +72,11 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
})
.collect::<Vec<_>>()
.join()
.await
.await;
let (nodes, items) = libraries.normalise(|i| i.uuid.to_string());
Ok(NormalisedResults { nodes, items })
})
})
.procedure("statistics", {
@@ -279,7 +291,10 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.await?;
}
Ok(LibraryConfigWrapped::from_library(&library).await)
Ok(NormalisedResult::from(
LibraryConfigWrapped::from_library(&library).await,
|l| l.uuid.to_string(),
))
},
)
})

View File

@@ -17,9 +17,10 @@ use crate::{
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use chrono::{DateTime, FixedOffset, Utc};
use directories::UserDirs;
use rspc::{self, alpha::AlphaRouter, ErrorCode};
use sd_cache::{CacheNode, Model, Normalise, NormalisedResult, NormalisedResults, Reference};
use serde::{Deserialize, Serialize};
use specta::Type;
use tracing::error;
@@ -58,6 +59,34 @@ pub enum ExplorerItem {
item: PeerMetadata,
},
}
// TODO: Really this shouldn't be a `Model` but it's easy for now.
// In the future we should store the inner data of the variant on behalf of it's existing model so it works cross queries.
impl Model for ExplorerItem {
fn name() -> &'static str {
"ExplorerItem"
}
}
impl ExplorerItem {
pub fn id(&self) -> String {
let ty = match self {
ExplorerItem::Path { .. } => "FilePath",
ExplorerItem::Object { .. } => "Object",
ExplorerItem::Location { .. } => "Location",
ExplorerItem::NonIndexedPath { .. } => "NonIndexedPath",
ExplorerItem::SpacedropPeer { .. } => "SpacedropPeer",
};
match self {
ExplorerItem::Path { item, .. } => format!("{ty}:{}", item.id),
ExplorerItem::Object { item, .. } => format!("{ty}:{}", item.id),
ExplorerItem::Location { item, .. } => format!("{ty}:{}", item.id),
ExplorerItem::NonIndexedPath { item, .. } => format!("{ty}:{}", item.path),
ExplorerItem::SpacedropPeer { item, .. } => format!("{ty}:{}", item.name), // TODO: Use a proper primary key
}
}
}
#[derive(Serialize, Type, Debug)]
pub struct SystemLocations {
desktop: Option<PathBuf>,
@@ -172,13 +201,17 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router()
.procedure("list", {
R.with2(library()).query(|(_, library), _: ()| async move {
Ok(library
let locations = library
.db
.location()
.find_many(vec![])
.order_by(location::date_created::order(SortOrder::Desc))
.exec()
.await?)
.await?;
let (nodes, items) = locations.normalise(|i| i.id.to_string());
Ok(NormalisedResults { items, nodes })
})
})
.procedure("get", {
@@ -189,10 +222,72 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.location()
.find_unique(location::id::equals(location_id))
.exec()
.await?)
.await?
.map(|i| NormalisedResult::from(i, |i| i.id.to_string())))
})
})
.procedure("getWithRules", {
#[derive(Type, Serialize)]
struct LocationWithIndexerRule {
pub id: i32,
pub pub_id: Vec<u8>,
pub name: Option<String>,
pub path: Option<String>,
pub total_capacity: Option<i32>,
pub available_capacity: Option<i32>,
pub size_in_bytes: Option<Vec<u8>>,
pub is_archived: Option<bool>,
pub generate_preview_media: Option<bool>,
pub sync_preview_media: Option<bool>,
pub hidden: Option<bool>,
pub date_created: Option<DateTime<FixedOffset>>,
pub instance_id: Option<i32>,
pub indexer_rules: Vec<Reference<indexer_rule::Data>>,
}
impl Model for LocationWithIndexerRule {
fn name() -> &'static str {
"Location" // This is a duplicate identifier as `location::Data` but it's fine because because they are the same entity
}
}
impl LocationWithIndexerRule {
pub fn from_db(
nodes: &mut Vec<CacheNode>,
value: location_with_indexer_rules::Data,
) -> Reference<Self> {
let this = Self {
id: value.id,
pub_id: value.pub_id,
name: value.name,
path: value.path,
total_capacity: value.total_capacity,
available_capacity: value.available_capacity,
size_in_bytes: value.size_in_bytes,
is_archived: value.is_archived,
generate_preview_media: value.generate_preview_media,
sync_preview_media: value.sync_preview_media,
hidden: value.hidden,
date_created: value.date_created,
instance_id: value.instance_id,
indexer_rules: value
.indexer_rules
.into_iter()
.map(|i| {
let id = i.indexer_rule.id.to_string();
nodes.push(CacheNode::new(id.clone(), i.indexer_rule));
Reference::new(id)
})
.collect(),
};
let id = this.id.to_string();
nodes.push(CacheNode::new(id.clone(), this));
Reference::new(id)
}
}
R.with2(library())
.query(|(_, library), location_id: location::id::Type| async move {
Ok(library
@@ -201,7 +296,14 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.find_unique(location::id::equals(location_id))
.include(location_with_indexer_rules::include())
.exec()
.await?)
.await?
.map(|location| {
let mut nodes = Vec::new();
NormalisedResult {
item: LocationWithIndexerRule::from_db(&mut nodes, location),
nodes,
}
}))
})
})
.procedure("create", {
@@ -477,32 +579,34 @@ fn mount_indexer_rule_routes() -> AlphaRouter<Ctx> {
format!("Indexer rule <id={indexer_rule_id}> not found"),
)
})
.map(|i| NormalisedResult::from(i, |i| i.id.to_string()))
})
})
.procedure("list", {
R.with2(library()).query(|(_, library), _: ()| async move {
library
.db
.indexer_rule()
.find_many(vec![])
.exec()
.await
.map_err(Into::into)
let rules = library.db.indexer_rule().find_many(vec![]).exec().await?;
let (nodes, items) = rules.normalise(|i| i.id.to_string());
Ok(NormalisedResults { items, nodes })
})
})
// list indexer rules for location, returning the indexer rule
.procedure("listForLocation", {
R.with2(library())
.query(|(_, library), location_id: location::id::Type| async move {
library
let rules = library
.db
.indexer_rule()
.find_many(vec![indexer_rule::locations::some(vec![
indexer_rules_in_location::location_id::equals(location_id),
])])
.exec()
.await
.map_err(Into::into)
.await?;
let (nodes, items) = rules.normalise(|i| i.id.to_string());
Ok(NormalisedResults { items, nodes })
})
})
}

View File

@@ -1,14 +1,14 @@
use std::sync::{atomic::Ordering, Arc};
use crate::{
invalidate_query,
job::JobProgressEvent,
node::config::{NodeConfig, NodePreferences},
Node,
};
use sd_cache::patch_typedef;
use sd_p2p::P2PStatus;
use std::sync::{atomic::Ordering, Arc};
use itertools::Itertools;
use rspc::{alpha::Rspc, Config, ErrorCode};
use serde::{Deserialize, Serialize};
@@ -121,9 +121,11 @@ pub(crate) fn mount() -> Arc<Router> {
commit: &'static str,
}
R.query(|_, _: ()| BuildInfo {
version: env!("CARGO_PKG_VERSION"),
commit: env!("GIT_HASH"),
R.query(|_, _: ()| {
Ok(BuildInfo {
version: env!("CARGO_PKG_VERSION"),
commit: env!("GIT_HASH"),
})
})
})
.procedure("nodeState", {
@@ -198,6 +200,18 @@ pub(crate) fn mount() -> Arc<Router> {
.merge("notifications.", notifications::mount())
.merge("backups.", backups::mount())
.merge("invalidation.", utils::mount_invalidate())
.sd_patch_types_dangerously(|type_map| {
patch_typedef(type_map);
let def =
<sd_prisma::prisma::object::Data as specta::NamedType>::definition_named_data_type(
type_map,
);
type_map.insert(
<sd_prisma::prisma::object::Data as specta::NamedType>::SID,
def,
);
})
.build(
#[allow(clippy::let_and_return)]
{

View File

@@ -162,6 +162,8 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.procedure("test", {
R.mutation(|node, _: ()| async move {
node.emit_notification(NotificationData::Test, None).await;
Ok(())
})
})
.procedure("testLibrary", {
@@ -170,6 +172,8 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
library
.emit_notification(NotificationData::Test, None)
.await;
Ok(())
})
})
}

View File

@@ -51,9 +51,10 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
})
})
})
.procedure("state", {
R.query(|node, _: ()| async move { node.p2p.state() })
})
// TODO: This has a potentially invalid map key and Specta don't like that. Can bring back in another PR.
// .procedure("state", {
// R.query(|node, _: ()| async move { Ok(node.p2p.state()) })
// })
.procedure("spacedrop", {
#[derive(Type, Deserialize)]
pub struct SpacedropArgs {
@@ -81,20 +82,23 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
match path {
Some(path) => node.p2p.accept_spacedrop(id, path).await,
None => node.p2p.reject_spacedrop(id).await,
}
};
Ok(())
})
})
.procedure("cancelSpacedrop", {
R.mutation(|node, id: Uuid| async move { node.p2p.cancel_spacedrop(id).await })
R.mutation(|node, id: Uuid| async move { Ok(node.p2p.cancel_spacedrop(id).await) })
})
.procedure("pair", {
R.mutation(|node, id: RemoteIdentity| async move {
node.p2p.pairing.clone().originator(id, node).await
Ok(node.p2p.pairing.clone().originator(id, node).await)
})
})
.procedure("pairingResponse", {
R.mutation(|node, (pairing_id, decision): (u16, PairingDecision)| {
node.p2p.pairing.decision(pairing_id, decision);
Ok(())
})
})
}

View File

@@ -19,6 +19,7 @@ use crate::{
use std::path::PathBuf;
use rspc::{alpha::AlphaRouter, ErrorCode};
use sd_cache::{CacheNode, Model, Normalise, Reference};
use sd_prisma::prisma::{self, PrismaClient};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -28,9 +29,16 @@ use super::{Ctx, R};
const MAX_TAKE: u8 = 100;
#[derive(Serialize, Type, Debug)]
struct SearchData<T> {
struct SearchData<T: Model> {
cursor: Option<Vec<u8>>,
items: Vec<T>,
items: Vec<Reference<T>>,
nodes: Vec<CacheNode>,
}
impl<T: Model> Model for SearchData<T> {
fn name() -> &'static str {
T::name()
}
}
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
@@ -91,6 +99,13 @@ pub fn mount() -> AlphaRouter<Ctx> {
order: Option<EphemeralPathOrder>,
}
#[derive(Serialize, Type, Debug)]
struct EphemeralPathsResult {
pub entries: Vec<Reference<ExplorerItem>>,
pub errors: Vec<rspc::Error>,
pub nodes: Vec<CacheNode>,
}
R.with2(library()).query(
|(node, library),
EphemeralPathSearchArgs {
@@ -133,7 +148,13 @@ pub fn mount() -> AlphaRouter<Ctx> {
)
}
Ok(paths)
let (nodes, entries) = paths.entries.normalise(|item| item.id());
Ok(EphemeralPathsResult {
entries,
errors: paths.errors,
nodes,
})
},
)
})
@@ -217,9 +238,12 @@ pub fn mount() -> AlphaRouter<Ctx> {
})
}
let (nodes, items) = items.normalise(|item| item.id());
Ok(SearchData {
items,
cursor: None,
nodes,
})
},
)
@@ -333,7 +357,13 @@ pub fn mount() -> AlphaRouter<Ctx> {
});
}
Ok(SearchData { items, cursor })
let (nodes, items) = items.normalise(|item| item.id());
Ok(SearchData {
nodes,
items,
cursor,
})
},
)
})

View File

@@ -1,13 +1,14 @@
use std::collections::BTreeMap;
use chrono::Utc;
use chrono::{DateTime, Utc};
use itertools::{Either, Itertools};
use rspc::{alpha::AlphaRouter, ErrorCode};
use sd_cache::{CacheNode, Normalise, NormalisedResult, NormalisedResults, Reference};
use sd_file_ext::kind::ObjectKind;
use sd_prisma::{prisma, prisma_sync};
use sd_sync::OperationFactory;
use sd_utils::uuid_to_bytes;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use specta::Type;
use serde_json::json;
@@ -26,23 +27,43 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router()
.procedure("list", {
R.with2(library()).query(|(_, library), _: ()| async move {
Ok(library.db.tag().find_many(vec![]).exec().await?)
let tags = library.db.tag().find_many(vec![]).exec().await?;
let (nodes, items) = tags.normalise(|i| i.id.to_string());
Ok(NormalisedResults { nodes, items })
})
})
.procedure("getForObject", {
R.with2(library())
.query(|(_, library), object_id: i32| async move {
Ok(library
let tags = library
.db
.tag()
.find_many(vec![tag::tag_objects::some(vec![
tag_on_object::object_id::equals(object_id),
])])
.exec()
.await?)
.await?;
let (nodes, items) = tags.normalise(|i| i.id.to_string());
Ok(NormalisedResults { nodes, items })
})
})
.procedure("getWithObjects", {
#[derive(Serialize, Type)]
pub struct GetWithObjectsResult {
pub data: BTreeMap<u32, Vec<Reference<tag::Data>>>,
pub nodes: Vec<CacheNode>,
}
#[derive(Serialize, Type)]
pub struct ObjectWithDateCreated {
object: Reference<object::Data>,
date_created: DateTime<Utc>,
}
R.with2(library()).query(
|(_, library), object_ids: Vec<object::id::Type>| async move {
let Library { db, .. } = library.as_ref();
@@ -64,6 +85,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.exec()
.await?;
// This doesn't need normalised caching because it doesn't return whole models.
Ok(tags_with_objects
.into_iter()
.map(|tag| (tag.id, tag.tag_objects))
@@ -79,7 +101,8 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.tag()
.find_unique(tag::id::equals(tag_id))
.exec()
.await?)
.await?
.map(|tag| NormalisedResult::from(tag, |i| i.id.to_string())))
})
})
.procedure("create", {

View File

@@ -282,7 +282,7 @@ pub(crate) fn mount_invalidate() -> AlphaRouter<Ctx> {
R.router()
.procedure(
"test-invalidate",
R.query(move |_, _: ()| count.fetch_add(1, Ordering::SeqCst)),
R.query(move |_, _: ()| Ok(count.fetch_add(1, Ordering::SeqCst))),
)
.procedure(
"test-invalidate-mutation",

View File

@@ -1,4 +1,5 @@
use rspc::alpha::AlphaRouter;
use sd_cache::{Normalise, NormalisedResults};
use crate::volume::get_volumes;
@@ -6,6 +7,23 @@ use super::{Ctx, R};
pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router().procedure("list", {
R.query(|_, _: ()| async move { Ok(get_volumes().await) })
R.query(|_, _: ()| async move {
let volumes = get_volumes().await;
let (nodes, items) = volumes.normalise(|i| {
// TODO: This is a really bad key. Once we hook up volumes with the DB fix this!
blake3::hash(
&i.mount_points
.iter()
.map(|mp| mp.as_os_str().to_string_lossy().as_bytes().to_vec())
.flatten()
.collect::<Vec<u8>>(),
)
.to_hex()
.to_string()
});
Ok(NormalisedResults { nodes, items })
})
})
}

View File

@@ -8,9 +8,9 @@ use crate::node::Platform;
#[derive(Debug, Clone, Type, Serialize, Deserialize)]
pub struct PeerMetadata {
pub(super) name: String,
pub(super) operating_system: Option<OperatingSystem>,
pub(super) version: Option<String>,
pub name: String,
pub operating_system: Option<OperatingSystem>,
pub version: Option<String>,
}
impl Metadata for PeerMetadata {

View File

@@ -7,6 +7,7 @@ use std::{
sync::OnceLock,
};
use sd_cache::Model;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr};
use specta::Type;
@@ -56,6 +57,12 @@ pub struct Volume {
pub is_root_filesystem: bool,
}
impl Model for Volume {
fn name() -> &'static str {
"Volume"
}
}
impl Hash for Volume {
fn hash<H: Hasher>(&self, state: &mut H) {
self.name.hash(state);

11
crates/cache/Cargo.toml vendored Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "sd-cache"
version = "0.0.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
specta.workspace = true

198
crates/cache/src/lib.rs vendored Normal file
View File

@@ -0,0 +1,198 @@
use std::{marker::PhantomData, sync::Arc};
use serde::{ser::SerializeMap, Serialize, Serializer};
use specta::{Any, DataType, NamedType, Type, TypeMap};
/// A type that can be used to return a group of `Reference<T>` and `CacheNode`'s
///
/// You don't need to use this, it's just a shortcut to avoid having to write out the full type everytime.
#[derive(Serialize, Type, Debug)]
pub struct NormalisedResults<T: Model + Type> {
pub items: Vec<Reference<T>>,
pub nodes: Vec<CacheNode>,
}
/// A type that can be used to return a group of `Reference<T>` and `CacheNode`'s
///
/// You don't need to use this, it's just a shortcut to avoid having to write out the full type everytime.
#[derive(Serialize, Type, Debug)]
pub struct NormalisedResult<T: Model + Type> {
pub item: Reference<T>,
pub nodes: Vec<CacheNode>,
}
impl<T: Model + Serialize + Type> NormalisedResult<T> {
pub fn from(item: T, id_fn: impl Fn(&T) -> String) -> Self {
let id = id_fn(&item);
Self {
item: Reference::new(id.clone()),
nodes: vec![CacheNode::new(id, item)],
}
}
}
/// A type which can be stored in the cache.
pub trait Model {
/// Must return a unique identifier for this model within the cache.
fn name() -> &'static str;
}
/// A reference to a `CacheNode`.
///
/// This does not contain the actual data, but instead a reference to it.
/// This allows the CacheNode's to be switched out and the query recomputed without any backend communication.
///
/// If you use a `Reference` in a query, you *must* ensure the corresponding `CacheNode` is also in the query.
#[derive(Type, Debug, Clone, Hash, PartialEq, Eq)]
pub struct Reference<T> {
__type: &'static str,
__id: String,
#[specta(rename = "#type")]
ty: PhantomType<T>,
}
impl<T: Model + Type> Reference<T> {
pub fn new(key: String) -> Self {
Self {
__type: "", // This is just to fake the field for Specta
__id: key,
ty: PhantomType(PhantomData),
}
}
}
impl<T: Model> Serialize for Reference<T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(Some(2))?;
map.serialize_entry("__type", T::name())?;
map.serialize_entry("__id", &self.__id)?;
map.end()
}
}
/// A node in the cache.
/// This holds the data and is identified by it's type and id.
#[derive(Debug, Clone)] // TODO: `Hash, PartialEq, Eq`
pub struct CacheNode(
&'static str,
serde_json::Value,
Result<serde_json::Value, Arc<serde_json::Error>>,
);
impl CacheNode {
pub fn new<T: Model + Serialize + Type>(key: String, value: T) -> Self {
Self(
T::name(),
key.into(),
serde_json::to_value(value).map_err(Arc::new),
)
}
}
#[derive(Type, Default)]
#[specta(rename = "CacheNode", remote = CacheNode)]
#[allow(unused)]
struct CacheNodeTy {
__type: String,
__id: String,
#[specta(rename = "#node")]
node: Any,
}
#[derive(Serialize)]
struct NodeSerdeRepr<'a> {
__type: &'static str,
__id: &'a serde_json::Value,
#[serde(flatten)]
v: &'a serde_json::Value,
}
impl Serialize for CacheNode {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
NodeSerdeRepr {
__type: self.0,
__id: &self.1,
v: self.2.as_ref().map_err(|err| {
serde::ser::Error::custom(format!("Failed to serialise node: {}", err))
})?,
}
.serialize(serializer)
}
}
/// A helper for easily normalising data.
pub trait Normalise {
type Item: Model + Type;
fn normalise(
self,
id_fn: impl Fn(&Self::Item) -> String,
) -> (Vec<CacheNode>, Vec<Reference<Self::Item>>);
}
impl<T: Model + Serialize + Type> Normalise for Vec<T> {
type Item = T;
fn normalise(
self,
id_fn: impl Fn(&Self::Item) -> String,
) -> (Vec<CacheNode>, Vec<Reference<Self::Item>>) {
let mut nodes = Vec::with_capacity(self.len());
let mut references = Vec::with_capacity(self.len());
for item in self.into_iter() {
let id = id_fn(&item);
nodes.push(CacheNode::new(id.clone(), item));
references.push(Reference::new(id));
}
(nodes, references)
}
}
/// Basically `PhantomData`.
///
/// With Specta `PhantomData` is exported as `null`.
/// This will export as `T` but serve the same purpose as `PhantomData` (holding a type without it being instantiated).
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct PhantomType<T>(PhantomData<T>);
/// WARNING: This type is surgically updated within `Reference` in the final typedefs due it being impossible to properly implement.
/// Be careful changing it!
impl<T: Type> Type for PhantomType<T> {
fn inline(type_map: &mut TypeMap, generics: &[DataType]) -> DataType {
T::inline(type_map, generics)
}
fn reference(type_map: &mut TypeMap, generics: &[DataType]) -> specta::reference::Reference {
T::reference(type_map, generics)
}
fn definition(type_map: &mut TypeMap) -> DataType {
T::definition(type_map)
}
}
// This function is cursed.
pub fn patch_typedef(type_map: &mut TypeMap) {
#[derive(Type)]
#[specta(rename = "Reference")]
#[allow(unused)]
struct ReferenceTy<T> {
__type: &'static str,
__id: String,
#[specta(rename = "#type")]
ty: T,
}
let mut def = <Reference<()> as NamedType>::definition_named_data_type(type_map);
def.inner = ReferenceTy::<Any>::definition(type_map);
type_map.insert(<Reference<()> as NamedType>::SID, def)
}

View File

@@ -62,8 +62,9 @@ impl Identity {
}
}
#[derive(Copy, Clone, PartialEq, Eq)]
pub struct RemoteIdentity(ed25519_dalek::VerifyingKey);
#[derive(Copy, Clone, PartialEq, Eq, Type)]
#[specta(transparent)]
pub struct RemoteIdentity(#[specta(type = String)] ed25519_dalek::VerifyingKey);
impl Hash for RemoteIdentity {
fn hash<H: Hasher>(&self, state: &mut H) {
@@ -141,15 +142,6 @@ impl FromStr for RemoteIdentity {
}
}
impl Type for RemoteIdentity {
fn inline(
_: specta::DefOpts,
_: &[specta::DataType],
) -> Result<specta::DataType, specta::ExportError> {
Ok(specta::DataType::Primitive(specta::PrimitiveType::String))
}
}
impl RemoteIdentity {
pub fn from_bytes(bytes: &[u8]) -> Result<Self, IdentityErr> {
Ok(Self(ed25519_dalek::VerifyingKey::from_bytes(

View File

@@ -8,3 +8,4 @@ prisma-client-rust = { workspace = true }
serde = "1.0"
serde_json = "1.0"
sd-sync = { path = "../sync" }
sd-cache = { path = "../cache" }

View File

@@ -2,3 +2,33 @@
pub mod prisma;
#[allow(warnings, unused)]
pub mod prisma_sync;
impl sd_cache::Model for prisma::tag::Data {
fn name() -> &'static str {
"Tag"
}
}
impl sd_cache::Model for prisma::object::Data {
fn name() -> &'static str {
"Object"
}
}
impl sd_cache::Model for prisma::location::Data {
fn name() -> &'static str {
"Location"
}
}
impl sd_cache::Model for prisma::indexer_rule::Data {
fn name() -> &'static str {
"IndexerRule"
}
}
impl sd_cache::Model for prisma::file_path::Data {
fn name() -> &'static str {
"FilePath"
}
}

View File

@@ -2,9 +2,9 @@ import { Plus } from '@phosphor-icons/react';
import { useQueryClient } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import { forwardRef, MutableRefObject, RefObject, useMemo, useRef } from 'react';
import { RefObject, useMemo, useRef } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ExplorerItem, useLibraryQuery } from '@sd/client';
import { ExplorerItem, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { Button, dialogManager, ModifierKeys, tw } from '@sd/ui';
import CreateDialog, {
AssignTagItems,
@@ -23,6 +23,7 @@ interface Props {
function useData({ items }: Props) {
const tags = useLibraryQuery(['tags.list'], { suspense: true });
useNodes(tags.data?.nodes);
// Map<tag::id, Vec<object::id>>
const tagsWithObjects = useLibraryQuery(
@@ -38,7 +39,13 @@ function useData({ items }: Props) {
{ suspense: true }
);
return { tags, tagsWithObjects };
return {
tags: {
...tags,
data: useCache(tags.data?.items)
},
tagsWithObjects
};
}
export default (props: Props) => {

View File

@@ -37,8 +37,10 @@ import {
ObjectKindEnum,
ObjectWithFilePaths,
useBridgeQuery,
useCache,
useItemsAsObjects,
useLibraryQuery,
useNodes,
type ExplorerItem
} from '@sd/client';
import { Button, Divider, DropdownMenu, toast, Tooltip, tw } from '@sd/ui';
@@ -172,7 +174,9 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
let filePathData: FilePath | FilePathWithObject | null = null;
let ephemeralPathData: NonIndexedPathItem | null = null;
const locations = useLibraryQuery(['locations.list']);
const result = useLibraryQuery(['locations.list']);
useNodes(result.data?.nodes);
const locations = useCache(result.data?.items);
switch (item.type) {
case 'NonIndexedPath': {
@@ -209,12 +213,15 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
}, [item]);
const fileLocations =
locations.data?.filter((location) => uniqueLocationIds.includes(location.id)) || [];
locations?.filter((location) => uniqueLocationIds.includes(location.id)) || [];
const readyToFetch = useIsFetchReady(item);
const tags = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1], {
const tagsQuery = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1], {
enabled: objectData != null && readyToFetch
});
useNodes(tagsQuery.data?.nodes);
const tags = useCache(tagsQuery.data?.items);
const { libraryId } = useZodRouteParams(LibraryIdParamsSchema);
const queriedFullPath = useLibraryQuery(['files.getPath', filePathData?.id ?? -1], {
@@ -332,7 +339,7 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
{extension && <InfoPill>{extension}</InfoPill>}
{tags.data?.map((tag) => (
{tags?.map((tag) => (
<NavLink key={tag.id} to={`/${libraryId}/tag/${tag.id}`}>
<Tooltip label={tag.name || ''} className="flex overflow-hidden">
<InfoPill
@@ -392,10 +399,12 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
const { libraryId } = useZodRouteParams(LibraryIdParamsSchema);
const tags = useLibraryQuery(['tags.list'], {
const tagsQuery = useLibraryQuery(['tags.list'], {
enabled: readyToFetch && !explorerStore.isDragging,
suspense: true
});
useNodes(tagsQuery.data?.nodes);
const tags = useCache(tagsQuery.data?.items);
const tagsWithObjects = useLibraryQuery(
['tags.getWithObjects', selectedObjects.map(({ id }) => id)],
@@ -487,7 +496,7 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
<InfoPill key={kind}>{`${kind} (${items.length})`}</InfoPill>
))}
{tags.data?.map((tag) => {
{tags?.map((tag) => {
const objectsWithTag = tagsWithObjects.data?.[tag.id] || [];
if (objectsWithTag.length === 0) return null;

View File

@@ -1,6 +1,6 @@
import { UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { SearchData } from '@sd/client';
import { SearchData, useCache } from '@sd/client';
export function useExplorerQuery<Q>(
query: UseInfiniteQueryResult<SearchData<Q>>,
@@ -14,7 +14,7 @@ export function useExplorerQuery<Q>(
}
}, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
return { query, items, loadMore, count: count.data };
return { query, items: useCache(items), loadMore, count: count.data };
}
export type UseExplorerQuery<Q> = ReturnType<typeof useExplorerQuery<Q>>;

View File

@@ -1,10 +1,12 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import {
ExplorerItem,
ObjectCursor,
ObjectOrder,
ObjectSearchArgs,
useLibraryContext,
useNodes,
useRspcLibraryContext
} from '@sd/client';
@@ -23,7 +25,7 @@ export function useObjectsInfiniteQuery({
arg.orderAndPagination = { orderOnly: settings.order };
}
return useInfiniteQuery({
const query = useInfiniteQuery({
queryKey: ['search.objects', { library_id: library.uuid, arg }] as const,
queryFn: ({ pageParam, queryKey: [_, { arg }] }) => {
const cItem: Extract<ExplorerItem, { type: 'Object' }> = pageParam;
@@ -66,4 +68,13 @@ export function useObjectsInfiniteQuery({
},
...args
});
const nodes = useMemo(
() => query.data?.pages.flatMap((page) => page.nodes) ?? [],
[query.data?.pages]
);
useNodes(nodes);
return query;
}

View File

@@ -1,4 +1,5 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import {
ExplorerItem,
FilePathCursorVariant,
@@ -6,6 +7,8 @@ import {
FilePathOrder,
FilePathSearchArgs,
useLibraryContext,
useNodes,
useNormalisedCache,
useRspcLibraryContext
} from '@sd/client';
@@ -20,15 +23,16 @@ export function usePathsInfiniteQuery({
const { library } = useLibraryContext();
const ctx = useRspcLibraryContext();
const settings = explorerSettings.useSettingsSnapshot();
const cache = useNormalisedCache();
if (settings.order) {
arg.orderAndPagination = { orderOnly: settings.order };
if (arg.orderAndPagination.orderOnly.field === 'sizeInBytes') delete arg.take;
}
return useInfiniteQuery({
const query = useInfiniteQuery({
queryKey: ['search.paths', { library_id: library.uuid, arg }] as const,
queryFn: ({ pageParam, queryKey: [_, { arg }] }) => {
queryFn: async ({ pageParam, queryKey: [_, { arg }] }) => {
const cItem: Extract<ExplorerItem, { type: 'Path' }> = pageParam;
const { order } = settings;
@@ -120,7 +124,9 @@ export function usePathsInfiniteQuery({
arg.orderAndPagination = orderAndPagination;
return ctx.client.query(['search.paths', arg]);
const result = await ctx.client.query(['search.paths', arg]);
cache.withNodes(result.nodes);
return result;
},
getNextPageParam: (lastPage) => {
if (arg.take === null || arg.take === undefined) return undefined;
@@ -130,4 +136,13 @@ export function usePathsInfiniteQuery({
onSuccess: () => getExplorerStore().resetNewThumbnails(),
...args
});
const nodes = useMemo(
() => query.data?.pages.flatMap((page) => page.nodes) ?? [],
[query.data?.pages]
);
useNodes(nodes);
return query;
}

View File

@@ -1,4 +1,5 @@
import { CheckSquare } from '@phosphor-icons/react';
import { useNavigate } from 'react-router';
import {
backendFeatures,
features,
@@ -31,6 +32,7 @@ export default () => {
const debugState = useDebugState();
const platform = usePlatform();
const navigate = useNavigate();
return (
<Popover
@@ -144,6 +146,9 @@ export default () => {
<FeatureFlagSelector />
<InvalidateDebugPanel />
<TestNotifications />
<Button size="sm" variant="gray" onClick={() => navigate('./debug/cache')}>
Cache Debug
</Button>
{/* {platform.showDevtools && (
<SettingContainer

View File

@@ -1,7 +1,7 @@
import { EjectSimple } from '@phosphor-icons/react';
import clsx from 'clsx';
import { useMemo } from 'react';
import { useBridgeQuery, useLibraryQuery } from '@sd/client';
import { useBridgeQuery, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { Button, toast, tw } from '@sd/ui';
import { Icon, IconName } from '~/components';
import { useHomeDir } from '~/hooks/useHomeDir';
@@ -28,19 +28,23 @@ const SidebarIcon = ({ name }: { name: IconName }) => {
};
export const EphemeralSection = () => {
const locations = useLibraryQuery(['locations.list']);
const locationsQuery = useLibraryQuery(['locations.list']);
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const homeDir = useHomeDir();
const volumes = useBridgeQuery(['volumes.list']);
const result = useBridgeQuery(['volumes.list']);
useNodes(result.data?.nodes);
const volumes = useCache(result.data?.items);
// this will return an array of location ids that are also volumes
// { "/Mount/Point": 1, "/Mount/Point2": 2"}
const locationIdsForVolumes = useMemo(() => {
if (!locations.data || !volumes.data) return {};
if (!locations || !volumes) return {};
const volumePaths = volumes.data.map((volume) => volume.mount_points[0] ?? null);
const volumePaths = volumes.map((volume) => volume.mount_points[0] ?? null);
const matchedLocations = locations.data.filter((location) =>
const matchedLocations = locations.filter((location) =>
volumePaths.includes(location.path)
);
@@ -57,9 +61,9 @@ export const EphemeralSection = () => {
);
return locationIdsMap;
}, [locations.data, volumes.data]);
}, [locations, volumes]);
const mountPoints = (volumes.data || []).flatMap((volume, volumeIndex) =>
const mountPoints = (volumes || []).flatMap((volume, volumeIndex) =>
volume.mount_points.map((mountPoint, index) =>
mountPoint !== homeDir.data
? { type: 'volume', volume, mountPoint, volumeIndex, index }

View File

@@ -5,9 +5,11 @@ import { Link, NavLink } from 'react-router-dom';
import {
arraysEqual,
useBridgeQuery,
useCache,
useFeatureFlag,
useLibraryMutation,
useLibraryQuery,
useNodes,
useOnlineLocations
} from '@sd/client';
import { Button, Tooltip } from '@sd/ui';
@@ -138,6 +140,8 @@ function Devices() {
function Locations() {
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const onlineLocations = useOnlineLocations();
return (
@@ -150,7 +154,7 @@ function Locations() {
}
>
<SeeMore>
{locationsQuery.data?.map((location) => (
{locations?.map((location) => (
<LocationsContextMenu key={location.id} locationId={location.id}>
<SidebarLink
className="borderradix-state-closed:border-transparent group relative w-full radix-state-open:border-accent"
@@ -179,9 +183,11 @@ function Locations() {
}
function Tags() {
const tags = useLibraryQuery(['tags.list'], { keepPreviousData: true });
const result = useLibraryQuery(['tags.list'], { keepPreviousData: true });
useNodes(result.data?.nodes);
const tags = useCache(result.data?.items);
if (!tags.data?.length) return;
if (!tags?.length) return;
return (
<Section
@@ -193,7 +199,7 @@ function Tags() {
}
>
<SeeMore>
{tags.data?.map((tag) => (
{tags?.map((tag) => (
<TagsContextMenu tagId={tag.id} key={tag.id}>
<SidebarLink
className="border radix-state-closed:border-transparent radix-state-open:border-accent"

View File

@@ -1,6 +1,14 @@
import { CircleDashed, Cube, Folder, Icon, SelectionSlash, Textbox } from '@phosphor-icons/react';
import { useState } from 'react';
import { InOrNotIn, ObjectKind, SearchFilterArgs, TextMatch, useLibraryQuery } from '@sd/client';
import {
InOrNotIn,
ObjectKind,
SearchFilterArgs,
TextMatch,
useCache,
useLibraryQuery,
useNodes
} from '@sd/client';
import { Button, Input } from '@sd/ui';
import { SearchOptionItem, SearchOptionSubMenu } from '.';
@@ -421,8 +429,10 @@ export const filterRegistry = [
},
useOptions: () => {
const query = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(query.data?.nodes);
const locations = useCache(query.data?.items);
return (query.data ?? []).map((location) => ({
return (locations ?? []).map((location) => ({
name: location.name!,
value: location.id,
icon: 'Folder' // Spacedrive folder icon
@@ -455,8 +465,10 @@ export const filterRegistry = [
},
useOptions: () => {
const query = useLibraryQuery(['tags.list'], { keepPreviousData: true });
useNodes(query.data?.nodes);
const tags = useCache(query.data?.items);
return (query.data ?? []).map((tag) => ({
return (tags ?? []).map((tag) => ({
name: tag.name!,
value: tag.id,
icon: tag.color || 'CircleDashed'

View File

@@ -1,46 +0,0 @@
import { useBridgeQuery, useLibraryQuery } from '@sd/client';
import { CodeBlock } from '~/components/Codeblock';
import { useRouteTitle } from '~/hooks/useRouteTitle';
// TODO: Bring this back with a button in the sidebar near settings at the bottom
export const Component = () => {
useRouteTitle('Debug');
const { data: nodeState } = useBridgeQuery(['nodeState']);
const { data: libraryState } = useBridgeQuery(['library.list']);
// const { data: jobs } = useLibraryQuery(['jobs.getRunning']);
// const { data: jobHistory } = useLibraryQuery(['jobs.getHistory']);
// const { mutate: purgeDB } = useBridgeCommand('PurgeDatabase', {
// onMutate: () => {
// alert('Database purged');
// }
// });
// const { mutate: identifyFiles } = useLibraryMutation('jobs.identifyUniqueFiles');
return (
<div className="flex flex-col space-y-5 p-5 pb-7 pt-2">
<h1 className="text-lg font-bold ">Developer Debugger</h1>
{/* <div className="flex flex-row pb-4 space-x-2">
<Button
className="w-40"
variant="gray"
size="sm"
onClick={() => {
if (nodeState && appPropsContext?.onOpen) {
appPropsContext.onOpen(nodeState.data_path);
}
}}
>
Open data folder
</Button>
</div> */}
{/* <h1 className="text-sm font-bold ">Running Jobs</h1>
<CodeBlock src={{ ...jobs }} />
<h1 className="text-sm font-bold ">Job History</h1>
<CodeBlock src={{ ...jobHistory }} /> */}
<h1 className="text-sm font-bold ">Node State</h1>
<CodeBlock src={{ ...nodeState }} />
<h1 className="text-sm font-bold ">Libraries</h1>
<CodeBlock src={{ ...libraryState }} />
</div>
);
};

View File

@@ -0,0 +1,13 @@
import { snapshot } from 'valtio';
import { useNormalisedCache } from '@sd/client';
export function Component() {
const cache = useNormalisedCache();
const data = snapshot(cache['#cache']);
return (
<div className="p-4">
<h1>Cache Debug</h1>
<pre className="pt-4">{JSON.stringify(data, null, 2)}</pre>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { RouteObject } from 'react-router';
export const debugRoutes: RouteObject = {
children: [{ path: 'cache', lazy: () => import('./cache') }]
};

View File

@@ -7,7 +7,9 @@ import { useLocation } from 'react-router';
import {
ExplorerItem,
getExplorerItemData,
useCache,
useLibraryQuery,
useNodes,
type EphemeralPathOrder
} from '@sd/client';
import { Button, Tooltip } from '@sd/ui';
@@ -189,13 +191,15 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => {
onSuccess: () => getExplorerStore().resetNewThumbnails()
}
);
useNodes(query.data?.nodes);
const entries = useCache(query.data?.entries);
const items = useMemo(() => {
if (!query.data) return [];
if (!entries) return [];
const ret: ExplorerItem[] = [];
for (const item of query.data.entries) {
for (const item of entries) {
if (settingsSnapshot.layoutMode !== 'media') ret.push(item);
else {
const { kind } = getExplorerItemData(item);
@@ -205,7 +209,7 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => {
}
return ret;
}, [query.data, settingsSnapshot.layoutMode]);
}, [entries, settingsSnapshot.layoutMode]);
const explorer = useExplorer({
items,

View File

@@ -3,6 +3,7 @@ import { Navigate, type RouteObject } from 'react-router-dom';
import { useHomeDir } from '~/hooks/useHomeDir';
import { Platform } from '~/util/Platform';
import { debugRoutes } from './debug';
import settingsRoutes from './settings';
// Routes that should be contained within the standard Page layout
@@ -12,9 +13,9 @@ const pageRoutes: RouteObject = {
{ path: 'people', lazy: () => import('./people') },
{ path: 'media', lazy: () => import('./media') },
{ path: 'spaces', lazy: () => import('./spaces') },
{ path: 'debug', lazy: () => import('./debug') },
{ path: 'sync', lazy: () => import('./sync') },
{ path: 'cloud', lazy: () => import('./cloud') }
{ path: 'cloud', lazy: () => import('./cloud') },
{ path: 'debug', children: [debugRoutes] }
]
};

View File

@@ -7,9 +7,11 @@ import {
FilePathOrder,
Location,
ObjectKindEnum,
useCache,
useLibraryMutation,
useLibraryQuery,
useLibrarySubscription,
useNodes,
useOnlineLocations,
useRspcLibraryContext
} from '@sd/client';
@@ -41,12 +43,14 @@ import LocationOptions from './LocationOptions';
export const Component = () => {
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
const location = useLibraryQuery(['locations.get', locationId], {
const result = useLibraryQuery(['locations.get', locationId], {
keepPreviousData: true,
suspense: true
});
useNodes(result.data?.nodes);
const location = useCache(result.data?.item);
return <LocationExplorer location={location.data!} />;
return <LocationExplorer location={location!} />;
};
const LocationExplorer = ({ location }: { location: Location; path?: string }) => {

View File

@@ -1,7 +1,13 @@
import { MagnifyingGlass } from '@phosphor-icons/react';
import { getIcon, iconNames } from '@sd/assets/util';
import { useMemo } from 'react';
import { FilePathOrder, SearchFilterArgs, useLibraryMutation, useLibraryQuery } from '@sd/client';
import {
FilePathOrder,
SearchFilterArgs,
useCache,
useLibraryMutation,
useLibraryQuery
} from '@sd/client';
import { Button } from '@sd/ui';
import { SearchIdParamsSchema } from '~/app/route-schemas';
import { useRouteTitle, useZodRouteParams } from '~/hooks';

View File

@@ -1,6 +1,6 @@
import { iconNames } from '@sd/assets/util';
import { memo, useEffect, useMemo, useState } from 'react';
import { byteSize, useDiscoveredPeers, useLibraryQuery } from '@sd/client';
import { byteSize, useDiscoveredPeers, useLibraryQuery, useNodes } from '@sd/client';
import { Card } from '@sd/ui';
import { Icon } from '~/components';
import { useCounter } from '~/hooks';
@@ -15,6 +15,9 @@ export const Component = () => {
const locations = useLibraryQuery(['locations.list'], {
refetchOnWindowFocus: false
});
useNodes(locations.data?.nodes);
// const locations = useCache(result.data?.items);
const discoveredPeers = useDiscoveredPeers();
const info = useMemo(() => {
if (locations.data && discoveredPeers) {
@@ -33,8 +36,8 @@ export const Component = () => {
}[] = [
{
icon: 'Folder',
title: locations.data.length === 1 ? 'Location' : 'Locations',
titleCount: locations.data?.length ?? 0,
title: locations.data?.items.length === 1 ? 'Location' : 'Locations',
titleCount: locations.data?.items.length ?? 0,
sub: 'indexed directories'
},
{

View File

@@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { Suspense } from 'react';
import { Controller } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useLibraryMutation, useLibraryQuery, useZodForm } from '@sd/client';
import { useCache, useLibraryMutation, useLibraryQuery, useNodes, useZodForm } from '@sd/client';
import {
Button,
dialogManager,
@@ -54,21 +54,22 @@ const EditLocationForm = () => {
const fullRescan = useLibraryMutation('locations.fullRescan');
const queryClient = useQueryClient();
const locationData = useLibraryQuery(['locations.getWithRules', locationId], {
const locationDataQuery = useLibraryQuery(['locations.getWithRules', locationId], {
suspense: true
});
useNodes(locationDataQuery.data?.nodes);
const locationData = useCache(locationDataQuery.data?.item);
const form = useZodForm({
schema,
defaultValues: {
indexerRulesIds:
locationData.data?.indexer_rules.map((rule) => rule.indexer_rule.id) ?? [],
indexerRulesIds: locationData?.indexer_rules.map((rule) => rule.id) ?? [],
locationType: 'normal',
name: locationData.data?.name ?? '',
path: locationData.data?.path ?? '',
hidden: locationData.data?.hidden ?? false,
syncPreviewMedia: locationData.data?.sync_preview_media ?? false,
generatePreviewMedia: locationData.data?.generate_preview_media ?? false
name: locationData?.name ?? '',
path: locationData?.path ?? '',
hidden: locationData?.hidden ?? false,
syncPreviewMedia: locationData?.sync_preview_media ?? false,
generatePreviewMedia: locationData?.generate_preview_media ?? false
}
});

View File

@@ -4,8 +4,10 @@ import { useDebouncedCallback } from 'use-debounce';
import {
extractInfoRSPCError,
UnionToTuple,
useCache,
useLibraryMutation,
useLibraryQuery,
useNodes,
usePlausibleEvent,
useZodForm
} from '@sd/client';
@@ -57,13 +59,15 @@ export const AddLocationDialog = ({
const listLocations = useLibraryQuery(['locations.list']);
const createLocation = useLibraryMutation('locations.create');
const relinkLocation = useLibraryMutation('locations.relink');
const listIndexerRules = useLibraryQuery(['locations.indexer_rules.list']);
const listIndexerRulesQuery = useLibraryQuery(['locations.indexer_rules.list']);
useNodes(listIndexerRulesQuery.data?.nodes);
const listIndexerRules = useCache(listIndexerRulesQuery.data?.items);
const addLocationToLibrary = useLibraryMutation('locations.addLibrary');
// This is required because indexRules is undefined on first render
const indexerRulesIds = useMemo(
() => listIndexerRules.data?.filter((rule) => rule.default).map((rule) => rule.id) ?? [],
[listIndexerRules.data]
() => listIndexerRules?.filter((rule) => rule.default).map((rule) => rule.id) ?? [],
[listIndexerRules]
);
const form = useZodForm({

View File

@@ -2,7 +2,7 @@ import { Trash } from '@phosphor-icons/react';
import clsx from 'clsx';
import { MouseEventHandler, useState } from 'react';
import { ControllerRenderProps } from 'react-hook-form';
import { IndexerRule, useLibraryMutation, useLibraryQuery } from '@sd/client';
import { IndexerRule, useCache, useLibraryMutation, useLibraryQuery, useNodes } from '@sd/client';
import { Button, Divider, Label, toast } from '@sd/ui';
import { InfoText } from '@sd/ui/src/forms';
import { showAlertDialog } from '~/components';
@@ -33,7 +33,8 @@ export default function IndexerRuleEditor<T extends IndexerRuleIdFieldType>({
...props
}: IndexerRuleEditorProps<T>) {
const listIndexerRules = useLibraryQuery(['locations.indexer_rules.list']);
const indexRules = listIndexerRules.data;
useNodes(listIndexerRules.data?.nodes);
const indexRules = useCache(listIndexerRules.data?.items);
const [isDeleting, setIsDeleting] = useState(false);
const [selectedRule, setSelectedRule] = useState<IndexerRule | undefined>(undefined);
const [toggleNewRule, setToggleNewRule] = useState(false);

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { useDebounce } from 'use-debounce';
import { useLibraryQuery } from '@sd/client';
import { useCache, useLibraryQuery, useNodes } from '@sd/client';
import { SearchInput } from '@sd/ui';
import { Heading } from '../../Layout';
@@ -8,17 +8,19 @@ import { AddLocationButton } from './AddLocationButton';
import ListItem from './ListItem';
export const Component = () => {
const locations = useLibraryQuery(['locations.list']);
const locationsQuery = useLibraryQuery(['locations.list']);
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 200);
const filteredLocations = useMemo(
() =>
locations.data?.filter(
locations?.filter(
(location) => location.name?.toLowerCase().includes(debouncedSearch.toLowerCase())
) ?? [],
[debouncedSearch, locations.data]
[debouncedSearch, locations]
);
return (

View File

@@ -1,9 +1,11 @@
import {
useBridgeMutation,
useBridgeQuery,
useCache,
useConnectedPeers,
useDiscoveredPeers,
useFeatureFlag
useFeatureFlag,
useNodes
} from '@sd/client';
import { Button } from '@sd/ui';
import { startPairing } from '~/app/p2p/pairing';
@@ -41,10 +43,17 @@ function IncorrectP2PPairingPane() {
console.log(data);
}
});
const nlmState = useBridgeQuery(['p2p.state'], {
refetchInterval: 1000
});
const libraries = useBridgeQuery(['library.list']);
const nlmState = {
data: JSON.stringify('lol no')
};
// TODO: Bring this back
// useBridgeQuery(['p2p.state'], {
// refetchInterval: 1000
// });
const result = useBridgeQuery(['library.list']);
useNodes(result.data?.nodes);
const libraries = useCache(result.data?.items);
return (
<>
@@ -86,7 +95,7 @@ function IncorrectP2PPairingPane() {
</div>
<div>
<p>Libraries:</p>
{libraries.data?.map((v) => (
{libraries?.map((v) => (
<div key={v.uuid} className="pb-2">
<p>
{v.config.name} - {v.uuid}

View File

@@ -1,6 +1,6 @@
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { Tag, useLibraryQuery } from '@sd/client';
import { Tag, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { Button, Card, dialogManager } from '@sd/ui';
import { Heading } from '~/app/$libraryId/settings/Layout';
import { TagsSettingsParamsSchema } from '~/app/route-schemas';
@@ -10,11 +10,14 @@ import CreateDialog from './CreateDialog';
import EditForm from './EditForm';
export const Component = () => {
const tags = useLibraryQuery(['tags.list']);
const result = useLibraryQuery(['tags.list']);
useNodes(result.data?.nodes);
const tags = useCache(result.data?.items);
const { id: locationId } = useZodRouteParams(TagsSettingsParamsSchema);
const tagSelectedParam = tags.data?.find((tag) => tag.id === locationId);
const tagSelectedParam = tags?.find((tag) => tag.id === locationId);
const [selectedTag, setSelectedTag] = useState<null | Tag>(
tagSelectedParam ?? tags.data?.[0] ?? null
tagSelectedParam ?? tags?.[0] ?? null
);
// Update selected tag when the route param changes
@@ -24,7 +27,7 @@ export const Component = () => {
// Set the first tag as selected when the tags list data is first loaded
useEffect(() => {
if (tags?.data?.length || (0 > 1 && !selectedTag)) setSelectedTag(tags.data?.[0] ?? null);
if (tags?.length || (0 > 1 && !selectedTag)) setSelectedTag(tags?.[0] ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -49,7 +52,7 @@ export const Component = () => {
/>
<Card className="!px-2">
<div className="m-1 flex flex-wrap gap-2">
{tags.data?.map((tag) => (
{tags?.map((tag) => (
<div
onClick={() => setSelectedTag(tag.id === selectedTag?.id ? null : tag)}
key={tag.id}

View File

@@ -1,6 +1,12 @@
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { LibraryConfigWrapped, useBridgeMutation, usePlausibleEvent, useZodForm } from '@sd/client';
import {
insertLibrary,
useBridgeMutation,
useNormalisedCache,
usePlausibleEvent,
useZodForm
} from '@sd/client';
import { Dialog, InputField, useDialog, UseDialogProps, z } from '@sd/ui';
import { usePlatform } from '~/util/Platform';
@@ -23,18 +29,18 @@ export default (props: UseDialogProps) => {
const createLibrary = useBridgeMutation('library.create');
const form = useZodForm({ schema });
const cache = useNormalisedCache();
const onSubmit = form.handleSubmit(async (data) => {
try {
const library = await createLibrary.mutateAsync({
const libraryRaw = await createLibrary.mutateAsync({
name: data.name,
default_locations: null
});
cache.withNodes(libraryRaw.nodes);
const library = cache.withCache(libraryRaw.item);
queryClient.setQueryData<LibraryConfigWrapped[]>(['library.list'], (libraries) => [
...(libraries || []),
library
]);
insertLibrary(queryClient, library);
submitPlausibleEvent({
event: { type: 'libraryCreate' }

View File

@@ -1,4 +1,4 @@
import { useBridgeQuery, useLibraryContext } from '@sd/client';
import { useBridgeQuery, useCache, useLibraryContext, useNodes } from '@sd/client';
import { Button, dialogManager } from '@sd/ui';
import { Heading } from '../../Layout';
@@ -6,7 +6,9 @@ import CreateDialog from './CreateDialog';
import ListItem from './ListItem';
export const Component = () => {
const libraries = useBridgeQuery(['library.list']);
const librariesQuery = useBridgeQuery(['library.list']);
useNodes(librariesQuery.data?.nodes);
const libraries = useCache(librariesQuery.data?.items);
const { library } = useLibraryContext();
@@ -31,7 +33,7 @@ export const Component = () => {
/>
<div className="space-y-2">
{libraries.data
{libraries
?.sort((a, b) => {
if (a.uuid === library.uuid) return -1;
if (b.uuid === library.uuid) return 1;

View File

@@ -114,7 +114,7 @@ function calculateGroups(messages: CRDTOperation[]) {
const { typ } = curr;
if ('model' in typ) {
const id = stringify(typ.record_id.pub_id);
const id = stringify((typ.record_id as any).pub_id);
const latest = (() => {
const latest = acc[acc.length - 1];
@@ -146,8 +146,8 @@ function calculateGroups(messages: CRDTOperation[]) {
});
} else {
const id = {
item: stringify(typ.relation_item.pub_id),
group: stringify(typ.relation_group.pub_id)
item: stringify((typ.relation_item as any).pub_id),
group: stringify((typ.relation_group as any).pub_id)
};
const latest = (() => {

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { ObjectKindEnum, ObjectOrder, useLibraryQuery } from '@sd/client';
import { ObjectKindEnum, ObjectOrder, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { LocationIdParamsSchema } from '~/app/route-schemas';
import { Icon } from '~/components';
import { useRouteTitle, useZodRouteParams } from '~/hooks';
@@ -17,9 +17,11 @@ import { TopBarPortal } from '../TopBar/Portal';
export function Component() {
const { id: tagId } = useZodRouteParams(LocationIdParamsSchema);
const tag = useLibraryQuery(['tags.get', tagId], { suspense: true });
const result = useLibraryQuery(['tags.get', tagId], { suspense: true });
useNodes(result.data?.nodes);
const tag = useCache(result.data?.item);
useRouteTitle(tag.data!.name ?? 'Tag');
useRouteTitle(tag!.name ?? 'Tag');
const explorerSettings = useExplorerSettings({
settings: useMemo(() => {
@@ -32,12 +34,12 @@ export function Component() {
const fixedFilters = useMemo(
() => [
{ object: { tags: { in: [tag.data!.id] } } },
{ object: { tags: { in: [tag!.id] } } },
...(explorerSettingsSnapshot.layoutMode === 'media'
? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }]
: [])
],
[tag.data, explorerSettingsSnapshot.layoutMode]
[tag, explorerSettingsSnapshot.layoutMode]
);
const search = useSearch({
@@ -53,7 +55,7 @@ export function Component() {
...objects,
isFetchingNextPage: objects.query.isFetchingNextPage,
settings: explorerSettings,
parent: { type: 'Tag', tag: tag.data! }
parent: { type: 'Tag', tag: tag! }
});
return (
@@ -65,9 +67,9 @@ export function Component() {
<div className="flex flex-row items-center gap-2">
<div
className="h-[14px] w-[14px] shrink-0 rounded-full"
style={{ backgroundColor: tag.data!.color || '#efefef' }}
style={{ backgroundColor: tag!.color || '#efefef' }}
/>
<span className="truncate text-sm font-medium">{tag?.data?.name}</span>
<span className="truncate text-sm font-medium">{tag?.name}</span>
</div>
}
right={<DefaultTopBarOptions />}

View File

@@ -1,9 +1,13 @@
import { useMemo } from 'react';
import { Navigate, Outlet, redirect, useMatches, type RouteObject } from 'react-router-dom';
import { currentLibraryCache, getCachedLibraries, useCachedLibraries } from '@sd/client';
import {
currentLibraryCache,
getCachedLibraries,
NormalisedCache,
useCachedLibraries
} from '@sd/client';
import { Dialogs, Toaster } from '@sd/ui';
import { RouterErrorBoundary } from '~/ErrorFallback';
import { useOperatingSystem } from '~/hooks';
import { useRoutingContext } from '~/RoutingContext';
import { Platform } from '..';
@@ -17,7 +21,7 @@ import './style.scss';
// the `usePlausiblePageViewMonitor` hook, as early as possible (ideally within the layout itself).
// the hook should only be included if there's a valid `ClientContext` (so not onboarding)
export const createRoutes = (platform: Platform) =>
export const createRoutes = (platform: Platform, cache: NormalisedCache) =>
[
{
Component: () => {
@@ -54,7 +58,7 @@ export const createRoutes = (platform: Platform) =>
return <Navigate to={`${libraryId}`} replace />;
},
loader: async () => {
const libraries = await getCachedLibraries();
const libraries = await getCachedLibraries(cache);
const currentLibrary = libraries.find(
(l) => l.uuid === currentLibraryCache.id
@@ -76,7 +80,7 @@ export const createRoutes = (platform: Platform) =>
path: ':libraryId',
lazy: () => import('./$libraryId/Layout'),
loader: async ({ params: { libraryId } }) => {
const libraries = await getCachedLibraries();
const libraries = await getCachedLibraries(cache);
const library = libraries.find((l) => l.uuid === libraryId);
if (!library) {

View File

@@ -5,11 +5,13 @@ import {
currentLibraryCache,
getOnboardingStore,
getUnitFormatStore,
insertLibrary,
resetOnboardingStore,
telemetryStore,
useBridgeMutation,
useCachedLibraries,
useMultiZodForm,
useNormalisedCache,
useOnboardingStore,
usePlausibleEvent
} from '@sd/client';
@@ -95,6 +97,7 @@ const useFormState = () => {
}
const createLibrary = useBridgeMutation('library.create');
const cache = useNormalisedCache();
const submit = handleSubmit(
async (data) => {
@@ -106,20 +109,16 @@ const useFormState = () => {
try {
// show creation screen for a bit for smoothness
const [library] = await Promise.all([
const [libraryRaw] = await Promise.all([
createLibrary.mutateAsync({
name: data['new-library'].name,
default_locations: data.locations.locations
}),
new Promise((res) => setTimeout(res, 500))
]);
queryClient.setQueryData(['library.list'], (libraries: any) => {
// The invalidation system beat us to it
if (libraries.find((l: any) => l.uuid === library.uuid)) return libraries;
return [...(libraries || []), library];
});
cache.withNodes(libraryRaw.nodes);
const library = cache.withCache(libraryRaw.item);
insertLibrary(queryClient, library);
platform.refreshMenuBar && platform.refreshMenuBar();

View File

@@ -0,0 +1,24 @@
import { defaultContext } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useDebugState } from '@sd/client';
export const Devtools = () => {
const debugState = useDebugState();
return (
<>
{debugState.reactQueryDevtools !== 'disabled' ? (
<ReactQueryDevtools
position="bottom-right"
// The `context={defaultContext}` part is required for this to work on Windows.
// Why, idk, don't question it
context={defaultContext}
toggleButtonProps={{
tabIndex: -1,
className: debugState.reactQueryDevtools === 'invisible' ? 'opacity-0' : ''
}}
/>
) : null}
</>
);
};

View File

@@ -16,7 +16,7 @@ export const useIsLocationIndexing = (locationId: number): boolean => {
group.jobs.some((job) => {
if (
job.name === 'indexer' &&
job.metadata?.location.id === locationId &&
(job.metadata as any)?.location.id === locationId &&
(job.status === 'Running' || job.status === 'Queued')
) {
return job.completed_task_count === 0;

View File

@@ -25,7 +25,7 @@ export const useRedirectToNewLocation = () => {
.some(
(j) =>
j.name === 'indexer' &&
j.metadata?.location.id === newLocation &&
(j.metadata as any)?.location.id === newLocation &&
(j.completed_task_count > 0 || j.completed_at != null)
);

View File

@@ -1,8 +1,6 @@
import '@fontsource/inter/variable.css';
import { init, Integrations } from '@sentry/browser';
import { defaultContext } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import dayjs from 'dayjs';
import advancedFormat from 'dayjs/plugin/advancedFormat';
import duration from 'dayjs/plugin/duration';
@@ -10,9 +8,9 @@ import relativeTime from 'dayjs/plugin/relativeTime';
import { PropsWithChildren, Suspense } from 'react';
import { RouterProvider, RouterProviderProps } from 'react-router-dom';
import {
CacheProvider,
NotificationContextProvider,
P2PContextProvider,
useDebugState,
useInvalidateQuery,
useLoadBackendFeatureFlags
} from '@sd/client';
@@ -20,6 +18,7 @@ import { TooltipProvider } from '@sd/ui';
import { createRoutes } from './app';
import { P2P, useP2PErrorToast } from './app/p2p';
import { Devtools } from './components/Devtools';
import { WithPrismTheme } from './components/TextViewer/prism';
import ErrorFallback, { BetterErrorBoundary } from './ErrorFallback';
import { useTheme } from './hooks';
@@ -42,23 +41,6 @@ init({
integrations: [new Integrations.HttpContext(), new Integrations.Dedupe()]
});
const Devtools = () => {
const debugState = useDebugState();
// The `context={defaultContext}` part is required for this to work on Windows.
// Why, idk, don't question it
return debugState.reactQueryDevtools !== 'disabled' ? (
<ReactQueryDevtools
position="bottom-right"
context={defaultContext}
toggleButtonProps={{
tabIndex: -1,
className: debugState.reactQueryDevtools === 'invisible' ? 'opacity-0' : ''
}}
/>
) : null;
};
export type Router = RouterProviderProps['router'];
export function SpacedriveRouterProvider(props: {

View File

@@ -0,0 +1,251 @@
import { useQueryClient } from '@tanstack/react-query';
import {
createContext,
PropsWithChildren,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore
} from 'react';
import { proxy, snapshot, subscribe } from 'valtio';
import { type CacheNode } from './core';
declare global {
interface Window {
__REDUX_DEVTOOLS_EXTENSION__: any;
}
}
type Store = ReturnType<typeof defaultStore>;
type Context = ReturnType<typeof createCache>;
export type NormalisedCache = ReturnType<typeof createCache>;
const defaultStore = () => ({
nodes: {} as Record<string, Record<string, unknown>>
});
const Context = createContext<Context>(undefined!);
export function createCache() {
const cache = proxy(defaultStore());
return {
cache,
withNodes(data: CacheNode[] | undefined) {
updateNodes(cache, data);
},
withCache<T>(data: T | undefined): UseCacheResult<T> {
return restore(cache, new Map(), data) as any;
}
};
}
export function CacheProvider({ cache, children }: PropsWithChildren<{ cache: NormalisedCache }>) {
useEffect(() => {
if ('__REDUX_DEVTOOLS_EXTENSION__' in window === false) return;
const devtools = window.__REDUX_DEVTOOLS_EXTENSION__.connect({});
const unsub = devtools.subscribe((_message: any) => {
// console.log(message);
});
devtools.init();
subscribe(cache.cache, () => devtools.send('change', snapshot(cache.cache)));
return () => {
unsub();
window.__REDUX_DEVTOOLS_EXTENSION__.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const queryClient = useQueryClient();
useEffect(() => {
const interval = setInterval(() => {
const requiredKeys = new StableSet<[string, string]>();
for (const query of queryClient.getQueryCache().getAll()) {
if (query.state.data) scanDataForKeys(cache.cache, requiredKeys, query.state.data);
}
const existingKeys = new StableSet<[string, string]>();
Object.entries(cache.cache.nodes).map(([type, value]) => {
Object.keys(value).map((id) => existingKeys.add([type, id]));
});
for (const [type, id] of existingKeys.entries()) {
// If key is not required. Eg. not in any query within the React Query cache.
if (!requiredKeys.has([type, id])) {
// Yeet the imposter
console.debug('Removing Cache Key: ', type, id);
delete cache.cache.nodes?.[type]?.[id];
}
}
console.debug('Normalised Cache Cleanup', requiredKeys.size, existingKeys.size);
}, 60 * 1000);
return () => clearInterval(interval);
}, [cache.cache, queryClient]);
return <Context.Provider value={cache}>{children}</Context.Provider>;
}
export function useCacheContext() {
const context = useContext(Context);
if (!context) throw new Error('Missing `CacheContext` provider!');
return context;
}
function scanDataForKeys(cache: Store, keys: StableSet<[string, string]>, item: unknown) {
if (item === undefined || item === null) return;
if (Array.isArray(item)) {
for (const v of item) {
scanDataForKeys(cache, keys, v);
}
} else if (typeof item === 'object') {
if ('__type' in item && '__id' in item) {
if (typeof item.__type !== 'string') throw new Error('Invalid `__type`');
if (typeof item.__id !== 'string') throw new Error('Invalid `__id`');
keys.add([item.__type, item.__id]);
const result = cache.nodes?.[item.__type]?.[item.__id];
if (result) scanDataForKeys(cache, keys, result);
}
for (const [_k, value] of Object.entries(item)) {
scanDataForKeys(cache, keys, value);
}
}
}
function restore(cache: Store, subscribed: Map<string, Set<unknown>>, item: unknown): unknown {
if (item === undefined || item === null) {
return item;
} else if (Array.isArray(item)) {
return item.map((v) => restore(cache, subscribed, v));
} else if (typeof item === 'object') {
if ('__type' in item && '__id' in item) {
if (typeof item.__type !== 'string') throw new Error('Invalid `__type`');
if (typeof item.__id !== 'string') throw new Error('Invalid `__id`');
const result = cache.nodes?.[item.__type]?.[item.__id];
if (!result)
throw new Error(`Missing node for id '${item.__id}' of type '${item.__type}'`);
const v = subscribed.get(item.__type);
if (v) {
v.add(item.__id);
} else {
subscribed.set(item.__type, new Set([item.__id]));
}
return result;
}
return Object.fromEntries(
Object.entries(item).map(([key, value]) => [key, restore(cache, subscribed, value)])
);
}
return item;
}
export function useNodes(data: CacheNode[] | undefined) {
const cache = useCacheContext();
// `useMemo` instead of `useEffect` here is cursed but it needs to run before the `useMemo` in the `useCache` hook.
useMemo(() => {
updateNodes(cache.cache, data);
}, [cache, data]);
}
// Methods to interact with the cache outside of the React lifecycle.
export function useNormalisedCache() {
const cache = useCacheContext();
return {
'#cache': cache.cache,
'withNodes': cache.withNodes,
'withCache': cache.withCache
};
}
function updateNodes(cache: Store, data: CacheNode[] | undefined) {
if (!data) return;
for (const item of data) {
if (!('__type' in item && '__id' in item)) throw new Error('Missing `__type` or `__id`');
if (typeof item.__type !== 'string') throw new Error('Invalid `__type`');
if (typeof item.__id !== 'string') throw new Error('Invalid `__id`');
const copy = { ...item } as any;
delete copy.__type;
delete copy.__id;
if (!cache.nodes[item.__type]) cache.nodes[item.__type] = {};
// TODO: This should be a deepmerge but that would break stuff like `size_in_bytes` or `inode` as the arrays are joined.
cache.nodes[item.__type]![item.__id] = copy;
}
}
export type UseCacheResult<T> = T extends (infer A)[]
? UseCacheResult<A>[]
: T extends object
? T extends { '__type': any; '__id': string; '#type': infer U }
? UseCacheResult<U>
: { [K in keyof T]: UseCacheResult<T[K]> }
: { [K in keyof T]: UseCacheResult<T[K]> };
export function useCache<T>(data: T | undefined) {
const cache = useCacheContext();
const subscribed = useRef(new Map<string, Set<unknown>>()).current;
const [i, setI] = useState(0); // TODO: Remove this
const state = useMemo(
() => restore(cache.cache, subscribed, data) as UseCacheResult<T>,
// eslint-disable-next-line react-hooks/exhaustive-deps
[cache, data, i]
);
return useSyncExternalStore(
(onStoreChange) => {
return subscribe(cache.cache, (ops) => {
for (const [_, key] of ops) {
const key_type = key[1] as string;
const key_id = key[2] as string;
const v = subscribed.get(key_type);
if (v && v.has(key_id)) {
setI((i) => i + 1);
onStoreChange();
break; // We only need to trigger re-render once so we can break
}
}
});
},
() => state
);
}
class StableSet<T> {
set = new Set<string>();
get size() {
return this.set.size;
}
add(value: T) {
this.set.add(JSON.stringify(value));
}
has(value: T) {
return this.set.has(JSON.stringify(value));
}
*entries() {
for (const v of this.set) {
yield JSON.parse(v);
}
}
}

View File

@@ -8,43 +8,42 @@ export type Procedures = {
{ key: "buildInfo", input: never, result: BuildInfo } |
{ key: "cloud.library.get", input: LibraryArgs<null>, result: { uuid: string; name: string; ownerId: string; instances: { id: string; uuid: string; identity: string }[] } | null } |
{ key: "cloud.library.list", input: never, result: { uuid: string; name: string; ownerId: string; instances: { id: string; uuid: string }[] }[] } |
{ key: "ephemeralFiles.getMediaData", input: string, result: MediaMetadata | null } |
{ key: "files.get", input: LibraryArgs<GetArgs>, result: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: FilePath[] } | null } |
{ key: "ephemeralFiles.getMediaData", input: string, result: ({ type: "Image" } & ImageMetadata) | ({ type: "Video" } & VideoMetadata) | ({ type: "Audio" } & AudioMetadata) | null } |
{ key: "files.get", input: LibraryArgs<number>, result: { item: Reference<ObjectWithFilePaths2>; nodes: CacheNode[] } | null } |
{ key: "files.getConvertableImageExtensions", input: never, result: string[] } |
{ key: "files.getMediaData", input: LibraryArgs<number>, result: MediaMetadata } |
{ key: "files.getPath", input: LibraryArgs<number>, result: string | null } |
{ key: "invalidation.test-invalidate", input: never, result: number } |
{ key: "jobs.isActive", input: LibraryArgs<null>, result: boolean } |
{ key: "jobs.reports", input: LibraryArgs<null>, result: JobGroup[] } |
{ key: "library.list", input: never, result: LibraryConfigWrapped[] } |
{ key: "library.list", input: never, result: NormalisedResults<LibraryConfigWrapped> } |
{ key: "library.statistics", input: LibraryArgs<null>, result: Statistics } |
{ key: "locations.get", input: LibraryArgs<number>, result: Location | null } |
{ key: "locations.getWithRules", input: LibraryArgs<number>, result: LocationWithIndexerRules | null } |
{ key: "locations.indexer_rules.get", input: LibraryArgs<number>, result: IndexerRule } |
{ key: "locations.indexer_rules.list", input: LibraryArgs<null>, result: IndexerRule[] } |
{ key: "locations.indexer_rules.listForLocation", input: LibraryArgs<number>, result: IndexerRule[] } |
{ key: "locations.list", input: LibraryArgs<null>, result: Location[] } |
{ key: "locations.get", input: LibraryArgs<number>, result: { item: Reference<Location>; nodes: CacheNode[] } | null } |
{ key: "locations.getWithRules", input: LibraryArgs<number>, result: { item: Reference<LocationWithIndexerRule>; nodes: CacheNode[] } | null } |
{ key: "locations.indexer_rules.get", input: LibraryArgs<number>, result: NormalisedResult<IndexerRule> } |
{ key: "locations.indexer_rules.list", input: LibraryArgs<null>, result: NormalisedResults<IndexerRule> } |
{ key: "locations.indexer_rules.listForLocation", input: LibraryArgs<number>, result: NormalisedResults<IndexerRule> } |
{ key: "locations.list", input: LibraryArgs<null>, result: NormalisedResults<Location> } |
{ key: "locations.systemLocations", input: never, result: SystemLocations } |
{ key: "nodeState", input: never, result: NodeState } |
{ key: "nodes.listLocations", input: LibraryArgs<string | null>, result: ExplorerItem[] } |
{ key: "notifications.dismiss", input: NotificationId, result: null } |
{ key: "notifications.dismissAll", input: never, result: null } |
{ key: "notifications.get", input: never, result: Notification[] } |
{ key: "p2p.state", input: never, result: P2PState } |
{ key: "preferences.get", input: LibraryArgs<null>, result: LibraryPreferences } |
{ key: "search.ephemeralPaths", input: LibraryArgs<EphemeralPathSearchArgs>, result: NonIndexedFileSystemEntries } |
{ key: "search.ephemeralPaths", input: LibraryArgs<EphemeralPathSearchArgs>, result: EphemeralPathsResult } |
{ key: "search.objects", input: LibraryArgs<ObjectSearchArgs>, result: SearchData<ExplorerItem> } |
{ key: "search.objectsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } |
{ key: "search.paths", input: LibraryArgs<FilePathSearchArgs>, result: SearchData<ExplorerItem> } |
{ key: "search.pathsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } |
{ key: "search.saved.get", input: LibraryArgs<number>, result: SavedSearch | null } |
{ key: "search.saved.get", input: LibraryArgs<number>, result: { id: number; pub_id: number[]; search: string | null; filters: string | null; name: string | null; icon: string | null; description: string | null; date_created: string | null; date_modified: string | null } | null } |
{ key: "search.saved.list", input: LibraryArgs<null>, result: SavedSearch[] } |
{ key: "sync.messages", input: LibraryArgs<null>, result: CRDTOperation[] } |
{ key: "tags.get", input: LibraryArgs<number>, result: Tag | null } |
{ key: "tags.getForObject", input: LibraryArgs<number>, result: Tag[] } |
{ key: "tags.getWithObjects", input: LibraryArgs<number[]>, result: { [key: number]: { date_created: string | null; object: { id: number } }[] } } |
{ key: "tags.list", input: LibraryArgs<null>, result: Tag[] } |
{ key: "volumes.list", input: never, result: Volume[] },
{ key: "tags.get", input: LibraryArgs<number>, result: { item: Reference<Tag>; nodes: CacheNode[] } | null } |
{ key: "tags.getForObject", input: LibraryArgs<number>, result: NormalisedResults<Tag> } |
{ key: "tags.getWithObjects", input: LibraryArgs<number[]>, result: { [key in number]: ({ date_created: string | null; object: { id: number } })[] } } |
{ key: "tags.list", input: LibraryArgs<null>, result: NormalisedResults<Tag> } |
{ key: "volumes.list", input: never, result: NormalisedResults<Volume> },
mutations:
{ key: "api.sendFeedback", input: Feedback, result: null } |
{ key: "auth.logout", input: never, result: null } |
@@ -78,7 +77,7 @@ export type Procedures = {
{ key: "jobs.objectValidator", input: LibraryArgs<ObjectValidatorArgs>, result: null } |
{ key: "jobs.pause", input: LibraryArgs<string>, result: null } |
{ key: "jobs.resume", input: LibraryArgs<string>, result: null } |
{ key: "library.create", input: CreateLibraryArgs, result: LibraryConfigWrapped } |
{ key: "library.create", input: CreateLibraryArgs, result: NormalisedResult<LibraryConfigWrapped> } |
{ key: "library.delete", input: string, result: null } |
{ key: "library.edit", input: EditLibraryArgs, result: null } |
{ key: "locations.addLibrary", input: LibraryArgs<LocationCreateArgs>, result: number | null } |
@@ -96,7 +95,7 @@ export type Procedures = {
{ key: "notifications.testLibrary", input: LibraryArgs<null>, result: null } |
{ key: "p2p.acceptSpacedrop", input: [string, string | null], result: null } |
{ key: "p2p.cancelSpacedrop", input: string, result: null } |
{ key: "p2p.pair", input: string, result: number } |
{ key: "p2p.pair", input: RemoteIdentity, result: number } |
{ key: "p2p.pairingResponse", input: [number, PairingDecision], result: null } |
{ key: "p2p.spacedrop", input: SpacedropArgs, result: string } |
{ key: "preferences.update", input: LibraryArgs<LibraryPreferences>, result: null } |
@@ -139,13 +138,31 @@ export type CRDTOperation = { instance: string; timestamp: number; id: string; t
export type CRDTOperationType = SharedOperation | RelationOperation
export type CacheNode = { __type: string; __id: string; "#node": any }
export type CameraData = { device_make: string | null; device_model: string | null; color_space: string | null; color_profile: ColorProfile | null; focal_length: number | null; shutter_speed: number | null; flash: Flash | null; orientation: Orientation; lens_make: string | null; lens_model: string | null; bit_depth: number | null; red_eye: boolean | null; zoom: number | null; iso: number | null; software: string | null; serial_number: string | null; lens_serial_number: string | null; contrast: number | null; saturation: number | null; sharpness: number | null; composite: Composite | null }
export type ChangeNodeNameArgs = { name: string | null; p2p_enabled: boolean | null; p2p_port: MaybeUndefined<number> }
export type ColorProfile = "Normal" | "Custom" | "HDRNoOriginal" | "HDRWithOriginal" | "OriginalForHDR" | "Panorama" | "PortraitHDR" | "Portrait"
export type Composite = "Unknown" | "False" | "General" | "Live"
export type Composite =
/**
* The data is present, but we're unable to determine what they mean
*/
"Unknown" |
/**
* Not a composite image
*/
"False" |
/**
* A general composite image
*/
"General" |
/**
* The composite image was captured while shooting
*/
"Live"
export type ConvertImageArgs = { location_id: number; file_path_id: number; delete_src: boolean; desired_extension: ConvertableExtension; quality_percentage: number | null }
@@ -173,6 +190,8 @@ export type EphemeralPathOrder = { field: "name"; value: SortOrder } | { field:
export type EphemeralPathSearchArgs = { path: string; withHiddenFiles: boolean; order?: EphemeralPathOrder | null }
export type EphemeralPathsResult = { entries: Reference<ExplorerItem>[]; errors: Error[]; nodes: CacheNode[] }
export type EphemeralRenameFileArgs = { kind: EphemeralRenameKind }
export type EphemeralRenameKind = { One: EphemeralRenameOne } | { Many: EphemeralRenameMany }
@@ -192,7 +211,7 @@ export type ExplorerItem = { type: "Path"; has_local_thumbnail: boolean; thumbna
export type ExplorerLayout = "grid" | "list" | "media"
export type ExplorerSettings<TOrder> = { layoutMode: ExplorerLayout | null; gridItemSize: number | null; gridGap: number | null; mediaColumns: number | null; mediaAspectSquare: boolean | null; mediaViewWithDescendants: boolean | null; openOnDoubleClick: DoubleClickAction | null; showBytesInGridView: boolean | null; colVisibility: { [key: string]: boolean } | null; colSizes: { [key: string]: number } | null; order?: TOrder | null; showHiddenFiles?: boolean }
export type ExplorerSettings<TOrder> = { layoutMode: ExplorerLayout | null; gridItemSize: number | null; gridGap: number | null; mediaColumns: number | null; mediaAspectSquare: boolean | null; mediaViewWithDescendants: boolean | null; openOnDoubleClick: DoubleClickAction | null; showBytesInGridView: boolean | null; colVisibility: { [key in string]: boolean } | null; colSizes: { [key in string]: number } | null; order?: TOrder | null; showHiddenFiles?: boolean }
export type Feedback = { message: string; emoji: number }
@@ -218,11 +237,52 @@ export type FilePathOrder = { field: "name"; value: SortOrder } | { field: "size
export type FilePathSearchArgs = { take?: number | null; orderAndPagination?: OrderAndPagination<number, FilePathOrder, FilePathCursor> | null; filters?: SearchFilterArgs[]; groupDirectories?: boolean }
export type FilePathWithObject = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: Object | null }
export type FilePathWithObject = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null } | null }
export type Flash = { mode: FlashMode; fired: boolean | null; returned: boolean | null; red_eye_reduction: boolean | null }
export type Flash = {
/**
* Specifies how flash was used (on, auto, off, forced, onvalid)
*
* [`FlashMode::Unknown`] isn't a valid EXIF state, but it's included as the default,
* just in case we're unable to correctly match it to a known (valid) state.
*
* This type should only ever be evaluated if flash EXIF data is present, so having this as a non-option shouldn't be an issue.
*/
mode: FlashMode;
/**
* Did the flash actually fire?
*/
fired: boolean | null;
/**
* Did flash return to the camera? (Unsure of the meaning)
*/
returned: boolean | null;
/**
* Was red eye reduction used?
*/
red_eye_reduction: boolean | null }
export type FlashMode = "Unknown" | "On" | "Off" | "Auto" | "Forced"
export type FlashMode =
/**
* The data is present, but we're unable to determine what they mean
*/
"Unknown" |
/**
* FLash was on
*/
"On" |
/**
* Flash was off
*/
"Off" |
/**
* Flash was set to automatically fire in certain conditions
*/
"Auto" |
/**
* Flash was forcefully fired
*/
"Forced"
export type FromPattern = { pattern: string; replace_all: boolean }
@@ -232,10 +292,6 @@ export type GenerateThumbsForLocationArgs = { id: number; path: string; regenera
export type GetAll = { backups: Backup[]; directory: string }
export type GetArgs = { id: number }
export type Header = { id: string; timestamp: string; library_id: string; library_name: string }
export type IdentifyUniqueFilesArgs = { id: number; path: string }
export type ImageMetadata = { resolution: Resolution; date_taken: MediaDate | null; location: MediaLocation | null; camera_data: CameraData; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null }
@@ -262,10 +318,12 @@ export type JobGroup = { id: string; action: string | null; status: JobStatus; c
export type JobProgressEvent = { id: string; library_id: string; task_count: number; completed_task_count: number; phase: string; message: string; estimated_completion: string }
export type JobReport = { id: string; name: string; action: string | null; data: number[] | null; metadata: { [key: string]: any } | null; is_background: boolean; errors_text: string[]; created_at: string | null; started_at: string | null; completed_at: string | null; parent_id: string | null; status: JobStatus; task_count: number; completed_task_count: number; phase: string; message: string; estimated_completion: string }
export type JobReport = { id: string; name: string; action: string | null; data: number[] | null; metadata: { [key in string]: JsonValue } | null; is_background: boolean; errors_text: string[]; created_at: string | null; started_at: string | null; completed_at: string | null; parent_id: string | null; status: JobStatus; task_count: number; completed_task_count: number; phase: string; message: string; estimated_completion: string }
export type JobStatus = "Queued" | "Running" | "Completed" | "Canceled" | "Failed" | "Paused" | "CompletedWithErrors"
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key in string]: JsonValue }
/**
* Can wrap a query argument to require it to contain a `library_id` and provide helpers for working with libraries.
*/
@@ -274,15 +332,27 @@ export type LibraryArgs<T> = { library_id: string; arg: T }
/**
* LibraryConfig holds the configuration for a specific library. This is stored as a '{uuid}.sdlibrary' file.
*/
export type LibraryConfig = { name: LibraryName; description: string | null; instance_id: number; version: LibraryConfigVersion }
export type LibraryConfig = {
/**
* name is the display name of the library. This is used in the UI and is set by the user.
*/
name: LibraryName;
/**
* description is a user set description of the library. This is used in the UI and is set by the user.
*/
description: string | null;
/**
* id of the current instance so we know who this `.db` is. This can be looked up within the `Instance` table.
*/
instance_id: number; version: LibraryConfigVersion }
export type LibraryConfigVersion = "V0" | "V1" | "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9"
export type LibraryConfigWrapped = { uuid: string; instance_id: string; instance_public_key: string; config: LibraryConfig }
export type LibraryConfigWrapped = { uuid: string; instance_id: string; instance_public_key: RemoteIdentity; config: LibraryConfig }
export type LibraryName = string
export type LibraryPreferences = { location?: { [key: string]: LocationSettings } }
export type LibraryPreferences = { location?: { [key in string]: LocationSettings } }
export type LightScanArgs = { location_id: number; sub_path: string }
@@ -309,16 +379,9 @@ export type LocationSettings = { explorer: ExplorerSettings<FilePathOrder> }
*/
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[]; path: string | null }
export type LocationWithIndexerRules = { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; size_in_bytes: number[] | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; instance_id: number | null; indexer_rules: { indexer_rule: IndexerRule }[] }
export type LocationWithIndexerRule = { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; size_in_bytes: number[] | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; instance_id: number | null; indexer_rules: Reference<IndexerRule>[] }
/**
* The configuration for the P2P Manager
* DO NOT MAKE BREAKING CHANGES - This is embedded in the `node_config.json`
* For future me: `Keypair` is not on here cause hot reloading it hard.
*/
export type ManagerConfig = { enabled: boolean; port?: number | null }
export type MaybeUndefined<T> = null | null | T
export type MaybeUndefined<T> = null | T
export type MediaDataOrder = { field: "epochTime"; value: SortOrder }
@@ -326,7 +389,7 @@ export type MediaDataOrder = { field: "epochTime"; value: SortOrder }
* This can be either naive with no TZ (`YYYY-MM-DD HH-MM-SS`) or UTC (`YYYY-MM-DD HH-MM-SS ±HHMM`),
* where `±HHMM` is the timezone data. It may be negative if West of the Prime Meridian, or positive if East.
*/
export type MediaDate = string | string
export type MediaDate = string
export type MediaLocation = { latitude: number; longitude: number; pluscode: PlusCode; altitude: number | null; direction: number | null }
@@ -334,12 +397,32 @@ export type MediaMetadata = ({ type: "Image" } & ImageMetadata) | ({ type: "Vide
export type NodePreferences = { thumbnailer: ThumbnailerPreferences }
export type NodeState = ({ id: string; name: string; p2p_enabled: boolean; p2p_port: number | null; features: BackendFeature[]; preferences: NodePreferences }) & { data_path: string; p2p: P2PStatus }
export type NonIndexedFileSystemEntries = { entries: ExplorerItem[]; errors: Error[] }
export type NodeState = ({
/**
* 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).
*/
id: string;
/**
* 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
*/
name: string; p2p_enabled: boolean; p2p_port: number | null; features: BackendFeature[]; preferences: NodePreferences }) & { data_path: string; p2p: P2PStatus }
export type NonIndexedPathItem = { path: string; name: string; extension: string; kind: number; is_dir: boolean; date_created: string; date_modified: string; size_in_bytes_bytes: number[]; hidden: boolean }
/**
* A type that can be used to return a group of `Reference<T>` and `CacheNode`'s
*
* You don't need to use this, it's just a shortcut to avoid having to write out the full type everytime.
*/
export type NormalisedResult<T> = { item: Reference<T>; nodes: CacheNode[] }
/**
* A type that can be used to return a group of `Reference<T>` and `CacheNode`'s
*
* You don't need to use this, it's just a shortcut to avoid having to write out the full type everytime.
*/
export type NormalisedResults<T> = { items: Reference<T>[]; nodes: CacheNode[] }
/**
* Represents a single notification.
*/
@@ -369,6 +452,8 @@ export type ObjectValidatorArgs = { id: number; path: string }
export type ObjectWithFilePaths = { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: FilePath[] }
export type ObjectWithFilePaths2 = { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: Reference<FilePath>[] }
/**
* Represents the operating system which the remote peer is running.
* This is not used internally and predominantly is designed to be used for display purposes by the embedding application.
@@ -382,9 +467,7 @@ export type Orientation = "Normal" | "CW90" | "CW180" | "CW270" | "MirroredVerti
/**
* TODO: P2P event for the frontend
*/
export type P2PEvent = { type: "DiscoveredPeer"; identity: string; metadata: PeerMetadata } | { type: "ExpiredPeer"; identity: string } | { type: "ConnectedPeer"; identity: string } | { type: "DisconnectedPeer"; identity: string } | { type: "SpacedropRequest"; id: string; identity: string; peer_name: string; files: string[] } | { type: "SpacedropProgress"; id: string; percent: number } | { type: "SpacedropTimedout"; id: string } | { type: "SpacedropRejected"; id: string } | { type: "PairingRequest"; id: number; name: string; os: OperatingSystem } | { type: "PairingProgress"; id: number; status: PairingStatus }
export type P2PState = { node: { [key: string]: PeerStatus }; libraries: ([string, { [key: string]: PeerStatus }])[]; self_peer_id: PeerId; self_identity: string; config: ManagerConfig; manager_connected: { [key: PeerId]: string }; manager_connections: PeerId[]; dicovery_services: { [key: string]: { [key: string]: string } | null }; discovery_discovered: { [key: string]: { [key: string]: [PeerId, { [key: string]: string }, string[]] } }; discovery_known: { [key: string]: string[] } }
export type P2PEvent = { type: "DiscoveredPeer"; identity: RemoteIdentity; metadata: PeerMetadata } | { type: "ExpiredPeer"; identity: RemoteIdentity } | { type: "ConnectedPeer"; identity: RemoteIdentity } | { type: "DisconnectedPeer"; identity: RemoteIdentity } | { type: "SpacedropRequest"; id: string; identity: RemoteIdentity; peer_name: string; files: string[] } | { type: "SpacedropProgress"; id: string; percent: number } | { type: "SpacedropTimedout"; id: string } | { type: "SpacedropRejected"; id: string } | { type: "PairingRequest"; id: number; name: string; os: OperatingSystem } | { type: "PairingProgress"; id: number; status: PairingStatus }
export type P2PStatus = { ipv4: ListenerStatus; ipv6: ListenerStatus }
@@ -392,19 +475,27 @@ export type PairingDecision = { decision: "accept"; libraryId: string } | { deci
export type PairingStatus = { type: "EstablishingConnection" } | { type: "PairingRequested" } | { type: "LibraryAlreadyExists" } | { type: "PairingDecisionRequest" } | { type: "PairingInProgress"; data: { library_name: string; library_description: string | null } } | { type: "InitialSyncProgress"; data: number } | { type: "PairingComplete"; data: string } | { type: "PairingRejected" }
export type PeerId = string
export type PeerMetadata = { name: string; operating_system: OperatingSystem | null; version: string | null }
export type PeerStatus = "Unavailable" | "Discovered" | "Connected"
export type PlusCode = string
export type Range<T> = { from: T } | { to: T }
export type RelationOperation = { relation_item: any; relation_group: any; relation: string; data: RelationOperationData }
/**
* A reference to a `CacheNode`.
*
* This does not contain the actual data, but instead a reference to it.
* This allows the CacheNode's to be switched out and the query recomputed without any backend communication.
*
* If you use a `Reference` in a query, you *must* ensure the corresponding `CacheNode` is also in the query.
*/
export type Reference<T> = { __type: string; __id: string; "#type": T }
export type RelationOperationData = "c" | { u: { field: string; value: any } } | "d"
export type RelationOperation = { relation_item: JsonValue; relation_group: JsonValue; relation: string; data: RelationOperationData }
export type RelationOperationData = "c" | { u: { field: string; value: JsonValue } } | "d"
export type RemoteIdentity = string
export type RenameFileArgs = { location_id: number; kind: RenameKind }
@@ -422,11 +513,9 @@ export type Response = { Start: { user_code: string; verification_url: string; v
export type RuleKind = "AcceptFilesByGlob" | "RejectFilesByGlob" | "AcceptIfChildrenDirectoriesArePresent" | "RejectIfChildrenDirectoriesArePresent"
export type SanitisedNodeConfig = { id: string; name: string; p2p_enabled: boolean; p2p_port: number | null; features: BackendFeature[]; preferences: NodePreferences }
export type SavedSearch = { id: number; pub_id: number[]; search: string | null; filters: string | null; name: string | null; icon: string | null; description: string | null; date_created: string | null; date_modified: string | null }
export type SearchData<T> = { cursor: number[] | null; items: T[] }
export type SearchData<T> = { cursor: number[] | null; items: Reference<T>[]; nodes: CacheNode[] }
export type SearchFilterArgs = { filePath: FilePathFilterArgs } | { object: ObjectFilterArgs }
@@ -434,15 +523,19 @@ export type SetFavoriteArgs = { id: number; favorite: boolean }
export type SetNoteArgs = { id: number; note: string | null }
export type SharedOperation = { record_id: any; model: string; data: SharedOperationData }
export type SharedOperation = { record_id: JsonValue; model: string; data: SharedOperationData }
export type SharedOperationData = "c" | { u: { field: string; value: any } } | "d"
export type SharedOperationData = "c" | { u: { field: string; value: JsonValue } } | "d"
export type SingleInvalidateOperationEvent = { key: string; arg: any; result: any | null }
export type SingleInvalidateOperationEvent = {
/**
* This fields are intentionally private.
*/
key: string; arg: JsonValue; result: JsonValue | null }
export type SortOrder = "Asc" | "Desc"
export type SpacedropArgs = { identity: string; file_path: string[] }
export type SpacedropArgs = { identity: RemoteIdentity; file_path: string[] }
export type Statistics = { id: number; date_captured: string; total_object_count: number; library_db_size: string; total_bytes_used: string; total_bytes_capacity: string; total_unique_bytes: string; total_bytes_free: string; preview_media_bytes: string }

View File

@@ -1,14 +1,15 @@
import { createContext, PropsWithChildren, useContext, useMemo } from 'react';
import { createContext, PropsWithChildren, useContext, useEffect, useMemo } from 'react';
import { NormalisedCache, useCache, useNodes } from '../cache';
import { LibraryConfigWrapped } from '../core';
import { valtioPersist } from '../lib';
import { nonLibraryClient, useBridgeQuery } from '../rspc';
// The name of the localStorage key for caching library data
const libraryCacheLocalStorageKey = 'sd-library-list';
const libraryCacheLocalStorageKey = 'sd-library-list2'; // `2` is because the format of this underwent a breaking change when introducing normalised caching
export const useCachedLibraries = () =>
useBridgeQuery(['library.list'], {
export const useCachedLibraries = () => {
const result = useBridgeQuery(['library.list'], {
keepPreviousData: true,
initialData: () => {
const cachedData = localStorage.getItem(libraryCacheLocalStorageKey);
@@ -26,22 +27,33 @@ export const useCachedLibraries = () =>
},
onSuccess: (data) => localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(data))
});
useNodes(result.data?.nodes);
export async function getCachedLibraries() {
return {
...result,
data: useCache(result.data?.items)
};
};
export async function getCachedLibraries(cache: NormalisedCache) {
const cachedData = localStorage.getItem(libraryCacheLocalStorageKey);
if (cachedData) {
// If we fail to load cached data, it's fine
try {
return JSON.parse(cachedData) as LibraryConfigWrapped[];
const data = JSON.parse(cachedData);
cache.withNodes(data.nodes);
return cache.withCache(data.items) as LibraryConfigWrapped[];
} catch (e) {
console.error("Error loading cached 'sd-library-list' data", e);
}
}
const libraries = await nonLibraryClient.query(['library.list']);
const result = await nonLibraryClient.query(['library.list']);
cache.withNodes(result.nodes);
const libraries = cache.withCache(result.items);
localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(libraries));
localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(result));
return libraries;
}

View File

@@ -27,3 +27,4 @@ export * from './core';
export * from './utils';
export * from './lib';
export * from './form';
export * from './cache';

View File

@@ -100,7 +100,7 @@ export function useInvalidateQuery() {
for (const op of ops) {
match(op)
.with({ type: 'single', data: P.select() }, (op) => {
let key = [op.key];
let key: any[] = [op.key];
if (op.arg !== null) {
key = key.concat(op.arg);
}

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import type { ExplorerItem, FilePath, NonIndexedPathItem, Object } from '../core';
import type { Object } from '..';
import type { ExplorerItem, FilePath, NonIndexedPathItem } from '../core';
import { byteSize } from '../lib';
import { ObjectKind } from './objectKind';

View File

@@ -1,4 +1,6 @@
import { ExplorerItem } from '../core';
import { QueryClient } from '@tanstack/react-query';
import { ExplorerItem, LibraryConfigWrapped } from '../core';
export * from './objectKind';
export * from './explorerItem';
@@ -39,3 +41,28 @@ export function formatNumber(n: number) {
if (!n) return '0';
return Intl.NumberFormat().format(n);
}
export function insertLibrary(queryClient: QueryClient, library: LibraryConfigWrapped) {
queryClient.setQueryData(['library.list'], (libraries: any) => {
// The invalidation system beat us to it
if (libraries.items.find((l: any) => l.__id === library.uuid)) return libraries;
return {
items: [
...(libraries.items || []),
{
__type: 'LibraryConfigWrapped',
__id: library.uuid
}
],
nodes: [
...(libraries.nodes || []),
{
__type: 'LibraryConfigWrapped',
__id: library.uuid,
...library
}
]
};
});
}

View File

@@ -34,7 +34,7 @@ export function getTotalTasks(jobs: JobReport[]) {
}
export function getJobNiceActionName(action: string, completed: boolean, job?: JobReport) {
const name = job?.metadata?.location?.name || 'Unknown';
const name = (job?.metadata?.location as any)?.name || 'Unknown';
switch (action) {
case 'scan_location':
return completed ? `Added location "${name}"` : `Adding location "${name}"`;

View File

@@ -20,12 +20,12 @@ export function useJobInfo(job: JobReport, realtimeUpdate: JobProgressEvent | nu
const isRunning = job.status === 'Running',
isQueued = job.status === 'Queued',
isPaused = job.status === 'Paused',
indexedPath = job.metadata?.data?.location.path,
indexedPath = (job.metadata?.data as any)?.location.path,
taskCount = realtimeUpdate?.task_count || job.task_count,
completedTaskCount = realtimeUpdate?.completed_task_count || job.completed_task_count,
phase = realtimeUpdate?.phase,
meta = job.metadata,
output = meta?.output?.run_metadata;
output = (meta?.output as any)?.run_metadata;
const data = {
isRunning,