[ENG-443] Allow folders to be tagged (#1712)

* allow folders to be tagged

* remove bruh

* custom useAssignItems hook

* fix types
This commit is contained in:
Brendan Allan 2023-11-01 13:23:56 +08:00 committed by GitHub
parent 282e35c294
commit c91ccff37d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 305 additions and 149 deletions

View file

@ -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<Ctx> {
})
.procedure("assign", {
#[derive(Debug, Type, Deserialize)]
pub struct TagAssignArgs {
pub object_ids: Vec<i32>,
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<Target>,
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<Ctx> {
invalidate_query!(library, "tags.getForObject");
invalidate_query!(library, "tags.getWithObjects");
invalidate_query!(library, "search.objects");
Ok(())
})

View file

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

View file

@ -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<Extract<ExplorerItem, { type: 'Object' | 'Path' }>> }) => {
const os = useOperatingSystem();
const keybind = keybindForOs(os);
const submitPlausibleEvent = usePlausibleEvent();
const rspc = useRspcLibraryContext();
const tags = useLibraryQuery(['tags.list'], { suspense: true });
// Map<tag::id, Vec<object::id>>
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<HTMLDivElement>(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 (
<>
<Menu.Item
@ -54,71 +50,87 @@ export default (props: { objects: Object[] }) => {
iconProps={{ size: 15 }}
keybind={keybind([ModifierKeys.Control], ['N'])}
onClick={() => {
dialogManager.create((dp) => <CreateDialog {...dp} objects={props.objects} />);
dialogManager.create((dp) => <CreateDialog {...dp} items={props.items} />);
}}
/>
<Menu.Separator className={clsx('mx-0 mb-0 transition', isScrolled && 'shadow')} />
{tags.data && tags.data.length > 0 ? (
<div
ref={parentRef}
style={{
maxHeight: `400px`,
height: `100%`,
width: `100%`,
overflow: 'auto'
}}
className="h-full w-full overflow-auto"
style={{ maxHeight: `400px` }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}
className="relative w-full"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{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 (
<Menu.Item
key={virtualRow.index}
className="absolute left-0 top-0 w-full"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
onClick={async (e) => {
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<AssignTagItems[number]>(
(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();
}}
>
<div
@ -126,7 +138,7 @@ export default (props: { objects: Object[] }) => {
style={{
backgroundColor:
objectsWithTag &&
objectsWithTag.length > 0 &&
objectsWithTag.size > 0 &&
tag.color
? tag.color
: 'transparent',

View file

@ -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<ExplorerItem, { type: 'Object' | 'Path' }> =>
item !== undefined
);
if (!isNonEmpty(items)) return null;
return { items };
},
Component: ({ selectedObjects }) => (
Component: ({ items }) => (
<Menu.SubMenu label="Assign tag" icon={TagSimple}>
<AssignTagMenuItems objects={selectedObjects} />
<AssignTagMenuItems items={items} />
</Menu.SubMenu>
)
});

View file

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

View file

@ -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 }) => {
</NavLink>
))}
{objectData && (
<DropdownMenu.Root
trigger={<PlaceholderPill>Add Tag</PlaceholderPill>}
side="left"
sideOffset={5}
alignOffset={-10}
>
<AssignTagMenuItems objects={[objectData]} />
</DropdownMenu.Root>
)}
{item.type === 'Object' ||
(item.type === 'Path' && (
<DropdownMenu.Root
trigger={<PlaceholderPill>Add Tag</PlaceholderPill>}
side="left"
sideOffset={5}
alignOffset={-10}
>
<AssignTagMenuItems items={[item]} />
</DropdownMenu.Root>
))}
</MetaContainer>
{!isDir && objectData && (
@ -520,7 +521,12 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
sideOffset={5}
alignOffset={-10}
>
<AssignTagMenuItems objects={selectedObjects} />
<AssignTagMenuItems
items={items.flatMap((item) => {
if (item.type === 'Object' || item.type === 'Path') return [item];
else return [];
})}
/>
</DropdownMenu.Root>
)}
</MetaContainer>

View file

@ -226,7 +226,9 @@ const useKeyDownHandlers = ({ disabled }: { disabled: boolean }) => {
)
return;
dialogManager.create((dp) => <CreateDialog {...dp} objects={objects} />);
dialogManager.create((dp) => (
<CreateDialog {...dp} items={objects.map((item) => ({ type: 'Object', item }))} />
));
},
[os, explorer.selectedItems]
);

View file

@ -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<Target>((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);
}

View file

@ -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<LibraryPreferences>, result: null } |
{ key: "tags.assign", input: LibraryArgs<TagAssignArgs>, result: null } |
{ key: "tags.assign", input: LibraryArgs<{ targets: Target[]; tag_id: number; unassign: boolean }>, result: null } |
{ key: "tags.create", input: LibraryArgs<TagCreateArgs>, result: Tag } |
{ key: "tags.delete", input: LibraryArgs<number>, result: null } |
{ key: "tags.update", input: LibraryArgs<TagUpdateArgs>, 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 }

View file

@ -130,9 +130,9 @@ const Item = ({
}: ContextMenuItemProps) => {
return (
<RadixCM.Item
className={contextMenuItemClassNames}
onClick={(e) => !props.disabled && onClick?.(e)}
{...props}
className={clsx(contextMenuItemClassNames, props.className)}
onClick={(e) => !props.disabled && onClick?.(e)}
>
<ContextMenuDivItem {...{ icon, iconProps, label, keybind, variant, children }} />
</RadixCM.Item>