From dcbe640fae299f7b4fd1685c02a322345a303c68 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Tue, 16 Jun 2026 13:08:46 +0300 Subject: [PATCH] feat(frontend): duplicates view, field-by-field merge dialog, api module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the duplicate-detection UI: - api/duplicates.ts: getDuplicates / dismissDuplicate / resolveDuplicate, plus the cluster and merge-field types. - /files/duplicates: an offset-paginated list of clusters. Each cluster shows its files (auth-loaded thumbnails via a reusable Thumb component); the user clicks a file to mark it the survivor, then per other file: Merge, Delete, or "Not a dup" (dismiss). The list reloads after each action so it stays consistent with the rescan-gated server state. - DuplicateMergeDialog: a bottom sheet to merge two files field-by-field — each scalar from the kept or other file, metadata keep/other/merge, tags & pools keep-or-union, with a swap-survivor toggle and an optional trash-the-other box. - Entry point: a Duplicates action in the files Header next to Trash. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/api/duplicates.ts | 52 +++ .../file/DuplicateMergeDialog.svelte | 402 ++++++++++++++++++ frontend/src/lib/components/file/Thumb.svelte | 98 +++++ .../src/lib/components/layout/Header.svelte | 18 +- frontend/src/routes/files/+page.svelte | 1 + .../src/routes/files/duplicates/+page.svelte | 377 ++++++++++++++++ 6 files changed, 947 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/api/duplicates.ts create mode 100644 frontend/src/lib/components/file/DuplicateMergeDialog.svelte create mode 100644 frontend/src/lib/components/file/Thumb.svelte create mode 100644 frontend/src/routes/files/duplicates/+page.svelte diff --git a/frontend/src/lib/api/duplicates.ts b/frontend/src/lib/api/duplicates.ts new file mode 100644 index 0000000..e8753b3 --- /dev/null +++ b/frontend/src/lib/api/duplicates.ts @@ -0,0 +1,52 @@ +import { api } from '$lib/api/client'; +import type { File } from '$lib/api/types'; + +/** A group of mutually similar files. */ +export interface DuplicateCluster { + files: File[]; +} + +export interface DuplicateClusterPage { + items: DuplicateCluster[]; + total: number; + offset: number; + limit: number; +} + +/** Per-field source for a merge. Scalars choose keep/discard; relations + * (tags, pools) choose keep/both; metadata can also be shallow-merged. */ +export type ScalarChoice = 'keep' | 'discard'; +export type RelationChoice = 'keep' | 'both'; +export type MetadataChoice = 'keep' | 'discard' | 'merge'; + +export interface MergeFields { + original_name?: ScalarChoice; + notes?: ScalarChoice; + content_datetime?: ScalarChoice; + is_public?: ScalarChoice; + metadata?: MetadataChoice; + tags?: RelationChoice; + pools?: RelationChoice; +} + +export interface ResolveRequest { + keep: string; + discard: string; + fields?: MergeFields; + delete_discarded?: boolean; +} + +/** Fetch a page of duplicate clusters (server reads a precomputed table). */ +export function getDuplicates(limit = 20, offset = 0): Promise { + return api.get(`/files/duplicates?limit=${limit}&offset=${offset}`); +} + +/** Mark two files as "not a duplicate" so the pair stops surfacing. */ +export function dismissDuplicate(a: string, b: string): Promise { + return api.post('/files/duplicates/dismiss', { file_id_a: a, file_id_b: b }); +} + +/** Merge a duplicate pair, returning the updated survivor. */ +export function resolveDuplicate(req: ResolveRequest): Promise { + return api.post('/files/duplicates/resolve', req); +} diff --git a/frontend/src/lib/components/file/DuplicateMergeDialog.svelte b/frontend/src/lib/components/file/DuplicateMergeDialog.svelte new file mode 100644 index 0000000..22883aa --- /dev/null +++ b/frontend/src/lib/components/file/DuplicateMergeDialog.svelte @@ -0,0 +1,402 @@ + + + + + + + diff --git a/frontend/src/lib/components/file/Thumb.svelte b/frontend/src/lib/components/file/Thumb.svelte new file mode 100644 index 0000000..be70dba --- /dev/null +++ b/frontend/src/lib/components/file/Thumb.svelte @@ -0,0 +1,98 @@ + + +
+ {#if imgSrc} + + {:else if failed} +
+ {:else} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/layout/Header.svelte b/frontend/src/lib/components/layout/Header.svelte index 7c81d6c..e0c6f02 100644 --- a/frontend/src/lib/components/layout/Header.svelte +++ b/frontend/src/lib/components/layout/Header.svelte @@ -12,6 +12,7 @@ onFilterToggle: () => void; onUpload?: () => void; onTrash?: () => void; + onDuplicates?: () => void; } let { @@ -23,7 +24,8 @@ onOrderToggle, onFilterToggle, onUpload, - onTrash + onTrash, + onDuplicates }: Props = $props(); @@ -51,6 +53,20 @@ {/if} + {#if onDuplicates} + + {/if} + {#if onTrash} + Duplicates{total ? ` (${total})` : ''} + + + +
+ {#if error}{/if} + + {#if initialLoaded && clusters.length === 0 && !error} +
+

No duplicates found.

+

+ The list reflects the last dedup run. New uploads appear after the next rescan. +

+
+ {/if} + + {#each clusters as c (clusterKey(c))} + {@const keep = keeperId(c)} +
+
+ {#each c.files as f (f.id)} + +
setKeeper(c, f.id)} + title="Click to keep this one" + > + + {#if f.id === keep}Keep{/if} + {f.original_name ?? '—'} + {f.mime_type} · {f.tags?.length ?? 0} tags +
+ {/each} +
+ +
+ {#each c.files.filter((f) => f.id !== keep) as other (other.id)} +
+ {other.original_name ?? '—'} + + + +
+ {/each} +
+
+ {/each} + + {#if hasMore} + + {:else if loading && !initialLoaded} +

Loading…

+ {/if} +
+ + +{#if mergeKeep && mergeDiscard} + { + mergeKeep = null; + mergeDiscard = null; + }} + /> +{/if} + +