diff --git a/core/src/api/tags.rs b/core/src/api/tags.rs index 7b469813d..2838a7233 100644 --- a/core/src/api/tags.rs +++ b/core/src/api/tags.rs @@ -1,19 +1,23 @@ use std::collections::BTreeMap; use chrono::Utc; +use itertools::{Either, Itertools}; use rspc::{alpha::AlphaRouter, ErrorCode}; -use sd_prisma::prisma_sync; +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 specta::Type; use serde_json::json; +use uuid::Uuid; use crate::{ invalidate_query, library::Library, object::tag::TagCreateArgs, - prisma::{object, tag, tag_on_object}, + prisma::{file_path, object, tag, tag_on_object}, }; use super::{utils::library, Ctx, R}; @@ -97,78 +101,161 @@ pub(crate) fn mount() -> AlphaRouter { }) .procedure("assign", { #[derive(Debug, Type, Deserialize)] - pub struct TagAssignArgs { - pub object_ids: Vec, - pub tag_id: i32, - pub unassign: bool, + #[specta(inline)] + enum Target { + Object(prisma::object::id::Type), + FilePath(prisma::file_path::id::Type), + } + + #[derive(Debug, Type, Deserialize)] + #[specta(inline)] + struct TagAssignArgs { + targets: Vec, + tag_id: i32, + unassign: bool, } R.with2(library()) .mutation(|(_, library), args: TagAssignArgs| async move { let Library { db, sync, .. } = library.as_ref(); - let (tag, objects) = db - ._batch(( - db.tag() - .find_unique(tag::id::equals(args.tag_id)) - .select(tag::select!({ pub_id })), - db.object() - .find_many(vec![object::id::in_vec(args.object_ids)]) - .select(object::select!({ id pub_id })), - )) + let tag = db + .tag() + .find_unique(tag::id::equals(args.tag_id)) + .select(tag::select!({ pub_id })) + .exec() + .await? + .ok_or_else(|| { + rspc::Error::new(ErrorCode::NotFound, "Tag not found".to_string()) + })?; + + let (objects, file_paths) = db + ._batch({ + let (objects, file_paths): (Vec<_>, Vec<_>) = args + .targets + .into_iter() + .partition_map(|target| match target { + Target::Object(id) => Either::Left(id), + Target::FilePath(id) => Either::Right(id), + }); + + ( + db.object() + .find_many(vec![object::id::in_vec(objects)]) + .select(object::select!({ + id + pub_id + })), + db.file_path() + .find_many(vec![file_path::id::in_vec(file_paths)]) + .select(file_path::select!({ + id + pub_id + object: select { id pub_id } + })), + ) + }) .await?; - let tag = tag.ok_or_else(|| { - rspc::Error::new(ErrorCode::NotFound, "Tag not found".to_string()) - })?; - macro_rules! sync_id { - ($object:ident) => { + ($pub_id:expr) => { prisma_sync::tag_on_object::SyncId { tag: prisma_sync::tag::SyncId { pub_id: tag.pub_id.clone(), }, - object: prisma_sync::object::SyncId { - pub_id: $object.pub_id.clone(), - }, + object: prisma_sync::object::SyncId { pub_id: $pub_id }, } }; } if args.unassign { - let query = db.tag_on_object().delete_many(vec![ - tag_on_object::tag_id::equals(args.tag_id), - tag_on_object::object_id::in_vec( - objects.iter().map(|o| o.id).collect(), - ), - ]); + let query = + db.tag_on_object().delete_many(vec![ + tag_on_object::tag_id::equals(args.tag_id), + tag_on_object::object_id::in_vec( + objects + .iter() + .map(|o| o.id) + .chain(file_paths.iter().filter_map(|fp| { + fp.object.as_ref().map(|o| o.id.clone()) + })) + .collect(), + ), + ]); sync.write_ops( db, ( objects .into_iter() - .map(|object| sync.relation_delete(sync_id!(object))) + .map(|o| o.pub_id) + .chain( + file_paths + .into_iter() + .filter_map(|fp| fp.object.map(|o| o.pub_id)), + ) + .map(|pub_id| sync.relation_delete(sync_id!(pub_id))) .collect(), query, ), ) .await?; } else { - let (sync_ops, db_creates) = objects.into_iter().fold( - (vec![], vec![]), - |(mut sync_ops, mut db_creates), object| { - db_creates.push(tag_on_object::CreateUnchecked { - tag_id: args.tag_id, - object_id: object.id, - _params: vec![], - }); + let (new_objects, _) = db + ._batch({ + let (left, right): (Vec<_>, Vec<_>) = file_paths + .iter() + .filter(|fp| fp.object.is_none()) + .map(|fp| { + let id = uuid_to_bytes(Uuid::new_v4()); - sync_ops.extend(sync.relation_create(sync_id!(object), [])); + ( + db.object().create( + id.clone(), + vec![ + object::date_created::set(None), + object::kind::set(Some( + ObjectKind::Folder as i32, + )), + ], + ), + db.file_path().update( + file_path::id::equals(fp.id), + vec![file_path::object::connect( + object::pub_id::equals(id), + )], + ), + ) + }) + .unzip(); - (sync_ops, db_creates) - }, - ); + (left, right) + }) + .await?; + + let (sync_ops, db_creates) = objects + .into_iter() + .map(|o| (o.id, o.pub_id)) + .chain( + file_paths + .into_iter() + .filter_map(|fp| fp.object.map(|o| (o.id, o.pub_id))), + ) + .chain(new_objects.into_iter().map(|o| (o.id, o.pub_id))) + .fold( + (vec![], vec![]), + |(mut sync_ops, mut db_creates), (id, pub_id)| { + db_creates.push(tag_on_object::CreateUnchecked { + tag_id: args.tag_id, + object_id: id, + _params: vec![], + }); + + sync_ops.extend(sync.relation_create(sync_id!(pub_id), [])); + + (sync_ops, db_creates) + }, + ); sync.write_ops(db, (sync_ops, db.tag_on_object().create_many(db_creates))) .await?; @@ -176,6 +263,7 @@ pub(crate) fn mount() -> AlphaRouter { invalidate_query!(library, "tags.getForObject"); invalidate_query!(library, "tags.getWithObjects"); + invalidate_query!(library, "search.objects"); Ok(()) }) diff --git a/core/src/object/file_identifier/mod.rs b/core/src/object/file_identifier/mod.rs index bbe778f69..a948967e6 100644 --- a/core/src/object/file_identifier/mod.rs +++ b/core/src/object/file_identifier/mod.rs @@ -210,7 +210,7 @@ async fn identifier_job_step( .map(|object| (*pub_id, object)) }) .map(|(pub_id, object)| { - let (crdt_op, db_op) = file_path_object_connect_ops( + let (crdt_op, db_op) = connect_file_path_to_object( pub_id, // SAFETY: This pub_id is generated by the uuid lib, but we have to store bytes in sqlite Uuid::from_slice(&object.pub_id).expect("uuid bytes are invalid"), @@ -277,21 +277,22 @@ async fn identifier_job_step( .into_iter() .unzip(); - let object_creation_args = ( - sync.shared_create(sync_id(), sync_params), - object::create_unchecked(uuid_to_bytes(object_pub_id), db_params), - ); + ( + ( + sync.shared_create(sync_id(), sync_params), + object::create_unchecked(uuid_to_bytes(object_pub_id), db_params), + ), + { + let (crdt_op, db_op) = connect_file_path_to_object( + *file_path_pub_id, + object_pub_id, + sync, + db, + ); - (object_creation_args, { - let (crdt_op, db_op) = file_path_object_connect_ops( - *file_path_pub_id, - object_pub_id, - sync, - db, - ); - - (crdt_op, db_op.select(file_path::select!({ pub_id }))) - }) + (crdt_op, db_op.select(file_path::select!({ pub_id }))) + }, + ) }, ) .unzip(); @@ -335,7 +336,7 @@ async fn identifier_job_step( Ok((total_created, updated_file_paths.len())) } -fn file_path_object_connect_ops<'db>( +fn connect_file_path_to_object<'db>( file_path_id: Uuid, object_id: Uuid, sync: &crate::sync::Manager, diff --git a/interface/components/AssignTagMenuItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx similarity index 55% rename from interface/components/AssignTagMenuItems.tsx rename to interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx index 575247e04..daf5f8639 100644 --- a/interface/components/AssignTagMenuItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx @@ -2,39 +2,33 @@ import { Plus } from '@phosphor-icons/react'; import { useVirtualizer } from '@tanstack/react-virtual'; import clsx from 'clsx'; import { useRef } from 'react'; -import { - Object, - useLibraryMutation, - useLibraryQuery, - usePlausibleEvent, - useRspcLibraryContext -} from '@sd/client'; +import { ExplorerItem, useLibraryQuery } from '@sd/client'; import { dialogManager, ModifierKeys } from '@sd/ui'; -import CreateDialog from '~/app/$libraryId/settings/library/tags/CreateDialog'; +import CreateDialog, { + AssignTagItems, + useAssignItemsToTag +} from '~/app/$libraryId/settings/library/tags/CreateDialog'; +import { Menu } from '~/components/Menu'; import { useOperatingSystem } from '~/hooks'; import { useScrolled } from '~/hooks/useScrolled'; import { keybindForOs } from '~/util/keybinds'; -import { Menu } from './Menu'; - -export default (props: { objects: Object[] }) => { +export default (props: { items: Array> }) => { const os = useOperatingSystem(); const keybind = keybindForOs(os); - const submitPlausibleEvent = usePlausibleEvent(); - const rspc = useRspcLibraryContext(); const tags = useLibraryQuery(['tags.list'], { suspense: true }); + // Map> const tagsWithObjects = useLibraryQuery([ 'tags.getWithObjects', - props.objects.map(({ id }) => id) + props.items + .map((item) => { + if (item.type === 'Path') return item.item.object?.id; + else if (item.type === 'Object') return item.item.id; + }) + .filter((item): item is number => item !== undefined) ]); - const assignTag = useLibraryMutation('tags.assign', { - onSuccess: () => { - submitPlausibleEvent({ event: { type: 'tagAssign' } }); - } - }); - const parentRef = useRef(null); const rowVirtualizer = useVirtualizer({ count: tags.data?.length || 0, @@ -45,6 +39,8 @@ export default (props: { objects: Object[] }) => { const { isScrolled } = useScrolled(parentRef, 10); + const assignItemsToTag = useAssignItemsToTag(); + return ( <> { iconProps={{ size: 15 }} keybind={keybind([ModifierKeys.Control], ['N'])} onClick={() => { - dialogManager.create((dp) => ); + dialogManager.create((dp) => ); }} /> {tags.data && tags.data.length > 0 ? (
{rowVirtualizer.getVirtualItems().map((virtualRow) => { const tag = tags.data[virtualRow.index]; if (!tag) return null; - const objectsWithTag = tagsWithObjects.data?.[tag?.id]; + const objectsWithTag = new Set(tagsWithObjects.data?.[tag?.id]); // only unassign if all objects have tag // this is the same functionality as finder - const unassign = objectsWithTag?.length === props.objects.length; + const unassign = props.items.every((item) => { + if (item.type === 'Object') { + return objectsWithTag.has(item.item.id); + } else { + const { object } = item.item; + + if (!object) return false; + return objectsWithTag.has(object.id); + } + }); // TODO: UI to differentiate tag assigning when some objects have tag when no objects have tag - ENG-965 return ( { e.preventDefault(); - await assignTag.mutateAsync({ - unassign, - tag_id: tag.id, - object_ids: unassign + await assignItemsToTag( + tag.id, + unassign ? // use objects that already have tag - objectsWithTag + props.items.flatMap((item) => { + if ( + item.type === 'Object' || + item.type === 'Path' + ) { + return [item]; + } + + return []; + }) : // use objects that don't have tag - props.objects - .filter( - (o) => - !objectsWithTag?.some( - (ot) => ot === o.id + props.items.flatMap( + (item) => { + if (item.type === 'Object') { + if ( + !objectsWithTag.has( + item.item.id + ) ) - ) - .map((o) => o.id) - }); - if (unassign) - rspc.queryClient.invalidateQueries(['search.objects']); + return [item]; + } else if (item.type === 'Path') { + return [item]; + } + + return []; + } + ), + unassign + ); + + tagsWithObjects.refetch(); }} >
{ style={{ backgroundColor: objectsWithTag && - objectsWithTag.length > 0 && + objectsWithTag.size > 0 && tag.color ? tag.color : 'transparent', diff --git a/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx b/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx index aa1526579..82998fbce 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx @@ -1,11 +1,11 @@ import { ArrowBendUpRight, TagSimple } from '@phosphor-icons/react'; import { useMemo } from 'react'; -import { ObjectKind, useLibraryMutation, type ObjectKindEnum } from '@sd/client'; +import { ExplorerItem, ObjectKind, useLibraryMutation, type ObjectKindEnum } from '@sd/client'; import { ContextMenu, toast } from '@sd/ui'; -import AssignTagMenuItems from '~/components/AssignTagMenuItems'; import { Menu } from '~/components/Menu'; import { isNonEmpty } from '~/util'; +import AssignTagMenuItems from '../AssignTagMenuItems'; import { ConditionalItem } from '../ConditionalItem'; import { useContextMenuContext } from '../context'; @@ -43,14 +43,24 @@ export const RemoveFromRecents = new ConditionalItem({ export const AssignTag = new ConditionalItem({ useCondition: () => { - const { selectedObjects } = useContextMenuContext(); - if (!isNonEmpty(selectedObjects)) return null; + const { selectedItems } = useContextMenuContext(); - return { selectedObjects }; + const items = selectedItems + .map((item) => { + if (item.type === 'Object' || item.type === 'Path') return item; + }) + .filter( + (item): item is Extract => + item !== undefined + ); + + if (!isNonEmpty(items)) return null; + + return { items }; }, - Component: ({ selectedObjects }) => ( + Component: ({ items }) => ( - + ) }); diff --git a/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx b/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx index 9a2f561e0..95ea43dd3 100644 --- a/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx @@ -164,7 +164,7 @@ export const FileThumb = memo((props: ThumbProps) => { }} className={clsx( 'relative flex shrink-0 items-center justify-center', - !loaded && 'invisible', + // !loaded && 'invisible', !props.size && 'h-full w-full', props.cover && 'overflow-hidden', props.className diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index 5c7fcbc3e..ec97b5e28 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -44,12 +44,12 @@ import { } from '@sd/client'; import { Button, Divider, DropdownMenu, toast, Tooltip, tw } from '@sd/ui'; import { LibraryIdParamsSchema } from '~/app/route-schemas'; -import AssignTagMenuItems from '~/components/AssignTagMenuItems'; import { useIsDark, useZodRouteParams } from '~/hooks'; import { isNonEmpty } from '~/util'; import { Folder } from '../../../../components'; import { useExplorerContext } from '../Context'; +import AssignTagMenuItems from '../ContextMenu/AssignTagMenuItems'; import { FileThumb } from '../FilePath/Thumb'; import { useQuickPreviewStore } from '../QuickPreview/store'; import { getExplorerStore, useExplorerStore } from '../store'; @@ -192,7 +192,7 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { break; } case 'SpacedropPeer': { - objectData = item.item as unknown as Object; + // objectData = item.item as unknown as Object; // filePathData = item.item.file_paths[0] ?? null; break; } @@ -347,16 +347,17 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { ))} - {objectData && ( - Add Tag} - side="left" - sideOffset={5} - alignOffset={-10} - > - - - )} + {item.type === 'Object' || + (item.type === 'Path' && ( + Add Tag} + side="left" + sideOffset={5} + alignOffset={-10} + > + + + ))} {!isDir && objectData && ( @@ -520,7 +521,12 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => { sideOffset={5} alignOffset={-10} > - + { + if (item.type === 'Object' || item.type === 'Path') return [item]; + else return []; + })} + /> )} diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx index 5a9cab64f..5406c79b3 100644 --- a/interface/app/$libraryId/Explorer/View/index.tsx +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -226,7 +226,9 @@ const useKeyDownHandlers = ({ disabled }: { disabled: boolean }) => { ) return; - dialogManager.create((dp) => ); + dialogManager.create((dp) => ( + ({ type: 'Object', item }))} /> + )); }, [os, explorer.selectedItems] ); diff --git a/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx b/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx index a0fdba8c4..b5e13c3db 100644 --- a/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx +++ b/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx @@ -1,4 +1,12 @@ -import { Object, useLibraryMutation, usePlausibleEvent, useZodForm } from '@sd/client'; +import { + FilePath, + libraryClient, + Object, + Target, + useLibraryMutation, + usePlausibleEvent, + useZodForm +} from '@sd/client'; import { Dialog, InputField, useDialog, UseDialogProps, z } from '@sd/ui'; import { ColorPicker } from '~/components'; @@ -7,7 +15,41 @@ const schema = z.object({ color: z.string() }); -export default (props: UseDialogProps & { objects?: Object[] }) => { +export type AssignTagItems = Array< + { type: 'Object'; item: Object } | { type: 'Path'; item: FilePath } +>; + +export function useAssignItemsToTag() { + const submitPlausibleEvent = usePlausibleEvent(); + + const mutation = useLibraryMutation(['tags.assign'], { + onSuccess: () => { + submitPlausibleEvent({ event: { type: 'tagAssign' } }); + } + }); + + return (tagId: number, items: AssignTagItems, unassign: boolean) => { + const targets = items.map((item) => { + if (item.type === 'Object') { + return { Object: item.item.id }; + } else { + return { FilePath: item.item.id }; + } + }); + + return mutation.mutateAsync({ + tag_id: tagId, + targets, + unassign + }); + }; +} + +export default ( + props: UseDialogProps & { + items?: AssignTagItems; + } +) => { const submitPlausibleEvent = usePlausibleEvent(); const form = useZodForm({ @@ -17,7 +59,8 @@ export default (props: UseDialogProps & { objects?: Object[] }) => { }); const createTag = useLibraryMutation('tags.create'); - const assignTag = useLibraryMutation('tags.assign'); + + const assignItemsToTag = useAssignItemsToTag(); const onSubmit = form.handleSubmit(async (data) => { try { @@ -25,13 +68,7 @@ export default (props: UseDialogProps & { objects?: Object[] }) => { submitPlausibleEvent({ event: { type: 'tagCreate' } }); - if (props.objects !== undefined) { - await assignTag.mutateAsync({ - tag_id: tag.id, - object_ids: props.objects.map((o) => o.id), - unassign: false - }); - } + if (props.items !== undefined) await assignItemsToTag(tag.id, props.items, false); } catch (e) { console.error('error', e); } diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 119820707..11c5f17fd 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -91,7 +91,7 @@ export type Procedures = { { key: "p2p.pairingResponse", input: [number, PairingDecision], result: null } | { key: "p2p.spacedrop", input: SpacedropArgs, result: string } | { key: "preferences.update", input: LibraryArgs, result: null } | - { key: "tags.assign", input: LibraryArgs, result: null } | + { key: "tags.assign", input: LibraryArgs<{ targets: Target[]; tag_id: number; unassign: boolean }>, result: null } | { key: "tags.create", input: LibraryArgs, result: Tag } | { key: "tags.delete", input: LibraryArgs, result: null } | { key: "tags.update", input: LibraryArgs, result: null } | @@ -417,12 +417,12 @@ export type SystemLocations = { desktop: string | null; documents: string | null export type Tag = { id: number; pub_id: number[]; name: string | null; color: string | null; redundancy_goal: number | null; date_created: string | null; date_modified: string | null } -export type TagAssignArgs = { object_ids: number[]; tag_id: number; unassign: boolean } - export type TagCreateArgs = { name: string; color: string } export type TagUpdateArgs = { id: number; name: string | null; color: string | null } +export type Target = { Object: number } | { FilePath: number } + export type VideoMetadata = { duration: number | null; video_codec: string | null; audio_codec: string | null } export type Volume = { name: string; mount_points: string[]; total_capacity: string; available_capacity: string; disk_type: DiskType; file_system: string | null; is_root_filesystem: boolean } diff --git a/packages/ui/src/ContextMenu.tsx b/packages/ui/src/ContextMenu.tsx index 21b77e69e..e53a2e749 100644 --- a/packages/ui/src/ContextMenu.tsx +++ b/packages/ui/src/ContextMenu.tsx @@ -130,9 +130,9 @@ const Item = ({ }: ContextMenuItemProps) => { return ( !props.disabled && onClick?.(e)} {...props} + className={clsx(contextMenuItemClassNames, props.className)} + onClick={(e) => !props.disabled && onClick?.(e)} >