feat(review): fast keyboard-first Stacks & Duplicates resolve queue

Both tabs get a resolve-and-advance queue instead of independent
click-to-focus cards: ↑/↓ or j/k rove between groups, resolving a
group removes it optimistically and auto-advances focus, and a sticky
header tracks reclaimable bytes + a running "resolved this session"
tally. ⌘Z undoes via a new sidecar restore endpoint (gridKeyNav — the
usual ⌘Z owner — isn't mounted on these tabs, so DuplicatesView wires
its own).

Sidecar (handlers_dups.go, fs.go, main.go):
- POST /duplicates/restore — inverse of /duplicates/archive, moves
  quarantined files back to their original path with the same BasePath
  guards and async reindex-with-cleanup.
- Scan results now include each file's mtime so the UI can label
  older/newer copies.

Stack losers now go through the same sidecar quarantine as
cross-folder duplicates (setPrimary + archiveDuplicatePaths) instead
of a hard PhotoPrism DELETE, so both tabs share one recoverable,
undoable resolution path (services/duplicateActions.svelte.ts).

StackGroupCard: comparison-first — fact rows highlight the best
size/resolution per file, a "Suggested" badge appears when one file
wins outright, and Space opens a fullscreen CompareLightbox that flips
between candidates while preserving zoom/pan (extracted the zoom/pan
gesture handling from PreviewPane into a shared lib/actions/zoomPan.ts
action so both consumers share one implementation).

CrossFolderGroupCard: since every copy is byte-identical, the old grid
of N identical thumbnails told the user nothing — replaced with one
thumbnail plus a path list that highlights the differing folder
segment and flags the indexed/newest copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 11:00:36 +02:00
parent 0f65bfb94a
commit 75008f238a
13 changed files with 1211 additions and 519 deletions

View File

@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
)
// sanitizeFilename trims a user-supplied filename and rejects anything
@@ -168,6 +169,7 @@ type fileEntry struct {
RelPath string
AbsPath string
Size int64
ModTime time.Time
}
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism
@@ -222,6 +224,7 @@ func walkFiles(root string) ([]fileEntry, error) {
RelPath: rel,
AbsPath: p,
Size: info.Size(),
ModTime: info.ModTime(),
})
return nil
})

View File

@@ -22,6 +22,9 @@ const quarantineDir = ".duplicates"
type dupFileLite struct {
Path string `json:"path"`
Size int64 `json:"size"`
// RFC3339 mtime so the UI can label older/newer copies. Copies are
// byte-identical, so mtime is the only per-copy signal besides path.
ModTime string `json:"modTime,omitempty"`
}
type dupGroup struct {
@@ -128,7 +131,11 @@ func handleDupScan(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
}
g := dupGroup{Hash: h, Size: hashSize[h]}
for _, f := range files {
g.Files = append(g.Files, dupFileLite{Path: f.RelPath, Size: f.Size})
g.Files = append(g.Files, dupFileLite{
Path: f.RelPath,
Size: f.Size,
ModTime: f.ModTime.UTC().Format(time.RFC3339),
})
}
// Best-effort lookup; swallow errors. The hash query is cheap on
// PhotoPrism's side (indexed column).
@@ -271,3 +278,95 @@ func handleDupArchive(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"moved": moved, "errors": errs})
}
}
type dupRestoreBody struct {
// Moves mirror the archive response's {from,to} pairs verbatim; the
// handler renames each `to` (quarantine path) back to its `from`.
Moves []dupMoved `json:"moves"`
}
// handleDupRestore is the inverse of handleDupArchive: it moves files
// out of `.duplicates/<ts>/` back to their original paths. It exists so
// the web client can offer real undo for duplicate/stack resolution —
// quarantine is only trustworthy if backing out is one keystroke.
func handleDupRestore(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body dupRestoreBody
if err := c.ShouldBindJSON(&body); err != nil || len(body.Moves) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "moves[] required"})
return
}
// Authz mirrors handleDupArchive: every destination (`from`) must
// live under the caller's effective library root, and every source
// (`to`) must live inside the quarantine dir — otherwise this
// endpoint would double as an arbitrary-move tool.
root := effectiveLibraryRoot(c, db)
for _, m := range body.Moves {
src := strings.Trim(m.To, "/")
if src != quarantineDir && !strings.HasPrefix(src, quarantineDir+"/") {
c.JSON(http.StatusForbidden, gin.H{"error": "source not in quarantine"})
return
}
if root != "" {
dst := strings.Trim(m.From, "/")
if dst != root && !strings.HasPrefix(dst, root+"/") {
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library root"})
return
}
}
}
restoreOne := func(m dupMoved) error {
srcAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.To, true)
if err != nil {
return errors.New("invalid quarantine path")
}
// The destination must not exist yet — mustExist=false resolves
// the path without requiring it on disk, and the Stat below
// refuses to clobber anything that reappeared in the meantime.
dstAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.From, false)
if err != nil {
return errors.New("invalid destination path")
}
if _, err := os.Stat(dstAbs); err == nil {
return errors.New("destination already exists")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
return err
}
if err := os.Rename(srcAbs, dstAbs); err != nil {
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
return err
}
if err2 := os.Remove(srcAbs); err2 != nil {
return errors.New("restored but quarantine copy remove failed: " + err2.Error())
}
}
return nil
}
restored := []dupMoved{}
errs := []dupArchiveErr{}
for _, m := range body.Moves {
if err := restoreOne(m); err != nil {
errs = append(errs, dupArchiveErr{Path: m.To, Error: err.Error()})
continue
}
restored = append(restored, dupMoved{From: m.To, To: m.From})
slog.Info("dup.restore", "from", m.To, "to", m.From)
}
if len(restored) > 0 {
go func() {
if err := pp.reindex(context.Background(), token, "/"); err != nil {
slog.Warn("dup.restore reindex failed", "err", err)
}
}()
}
c.JSON(http.StatusOK, gin.H{"restored": restored, "errors": errs})
}
}

View File

@@ -102,6 +102,7 @@ func main() {
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
auth.POST("/duplicates/restore", handleDupRestore(cfg, pp, db))
// User-scoped proxies — require PpDSN connection.
if ppDb != nil {

View File

@@ -0,0 +1,130 @@
/**
* Wheel-zoom + drag-pan for an image container. Extracted from
* PreviewPane so the compare lightbox can share the exact gesture
* behavior: wheel zooms around the cursor, double-click toggles
* 1 ↔ dblClickZoom, dragging pans while zoomed.
*
* The action owns the event listeners (wheel must be non-passive for
* preventDefault; Svelte marks template wheel handlers passive) and
* reports state through `onChange`. The consumer applies the transform
* to an inner wrapper:
*
* <div use:zoomPan={{ onChange: (s) => (zp = s), resetKey: uid }}>
* <div style="transform: translate({zp.tx}px, {zp.ty}px) scale({zp.zoom});">…
*
* `resetKey` resets to 1:1 whenever it changes (e.g. per photo). Keep
* it constant to preserve zoom/pan across content swaps — that's what
* makes pixel-compare flipping work in the lightbox.
*/
export interface ZoomPanState {
zoom: number;
tx: number;
ty: number;
panning: boolean;
}
export interface ZoomPanParams {
onChange: (state: ZoomPanState) => void;
/** Reset to 1:1 when this value changes. */
resetKey?: unknown;
maxZoom?: number;
dblClickZoom?: number;
}
export function zoomPan(node: HTMLElement, params: ZoomPanParams) {
let current = params;
const state: ZoomPanState = { zoom: 1, tx: 0, ty: 0, panning: false };
let lastX = 0;
let lastY = 0;
function emit() {
current.onChange({ ...state });
}
function reset() {
state.zoom = 1;
state.tx = 0;
state.ty = 0;
state.panning = false;
emit();
}
function applyZoom(next: number, clientX: number, clientY: number) {
const max = current.maxZoom ?? 6;
const clamped = Math.min(max, Math.max(1, next));
if (clamped === state.zoom) return;
// Keep the point under the cursor fixed: translate offsets are in
// post-scale pixels around the container centre.
const rect = node.getBoundingClientRect();
const cx = clientX - rect.left - rect.width / 2;
const cy = clientY - rect.top - rect.height / 2;
const s = clamped / state.zoom;
state.tx = cx + (state.tx - cx) * s;
state.ty = cy + (state.ty - cy) * s;
state.zoom = clamped;
if (state.zoom === 1) {
state.tx = 0;
state.ty = 0;
}
emit();
}
function onWheel(e: WheelEvent) {
e.preventDefault();
applyZoom(state.zoom * Math.exp(-e.deltaY * 0.0018), e.clientX, e.clientY);
}
function onDblClick(e: MouseEvent) {
if (state.zoom > 1) {
reset();
} else {
applyZoom(current.dblClickZoom ?? 2.5, e.clientX, e.clientY);
}
}
function onPointerDown(e: PointerEvent) {
if (state.zoom === 1) return;
state.panning = true;
lastX = e.clientX;
lastY = e.clientY;
node.setPointerCapture(e.pointerId);
emit();
}
function onPointerMove(e: PointerEvent) {
if (!state.panning) return;
state.tx += e.clientX - lastX;
state.ty += e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
emit();
}
function onPointerUp() {
if (!state.panning) return;
state.panning = false;
emit();
}
node.addEventListener('wheel', onWheel, { passive: false });
node.addEventListener('dblclick', onDblClick);
node.addEventListener('pointerdown', onPointerDown);
node.addEventListener('pointermove', onPointerMove);
node.addEventListener('pointerup', onPointerUp);
node.addEventListener('pointercancel', onPointerUp);
return {
update(next: ZoomPanParams) {
const keyChanged = next.resetKey !== current.resetKey;
current = next;
if (keyChanged) reset();
},
destroy() {
node.removeEventListener('wheel', onWheel);
node.removeEventListener('dblclick', onDblClick);
node.removeEventListener('pointerdown', onPointerDown);
node.removeEventListener('pointermove', onPointerMove);
node.removeEventListener('pointerup', onPointerUp);
node.removeEventListener('pointercancel', onPointerUp);
}
};
}

View File

@@ -0,0 +1,156 @@
<!--
Fullscreen pixel-compare overlay for a duplicate stack. Shows one
candidate at a time at fit_2048; ←/→ flip between candidates while
PRESERVING zoom & pan (the whole point — zoom into an eye or a hair,
then flip to see which file is sharper). Enter picks the shown file
as the keeper and closes; Esc closes without picking.
All candidate <img>s stay mounted (stacks are 25 files) with only
the active one visible, so flips are instant once loaded and the
shared transform wrapper keeps them aligned.
Keys are intercepted at window-capture level while open so the group
card / global shortcuts underneath don't also react.
-->
<script lang="ts">
import { thumbUrl } from '$lib/stores/session.svelte';
import { zoomPan, type ZoomPanState } from '$lib/actions/zoomPan';
import type { PpFile } from '$lib/types/photoprism';
interface Props {
files: PpFile[];
/** UID of the candidate shown first. */
startUid: string;
onPick: (uid: string) => void;
onClose: () => void;
}
let { files, startUid, onPick, onClose }: Props = $props();
let index = $state(0);
$effect.pre(() => {
const i = files.findIndex((f) => f.UID === startUid);
index = i >= 0 ? i : 0;
});
let zp = $state<ZoomPanState>({ zoom: 1, tx: 0, ty: 0, panning: false });
const active = $derived(files[index]);
function flip(delta: number) {
index = (index + delta + files.length) % files.length;
}
function sizeLabel(bytes?: number): string {
if (!bytes) return '';
if (bytes > 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
return `${Math.round(bytes / 1024)} KB`;
}
function onKeydown(e: KeyboardEvent) {
// Swallow everything except modifier combos so the card / global
// shortcuts underneath stay inert while the lightbox is up.
if (e.metaKey || e.ctrlKey || e.altKey) return;
e.stopPropagation();
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
flip(-1);
return;
case 'ArrowRight':
case ' ':
e.preventDefault();
flip(1);
return;
case 'Enter':
e.preventDefault();
onPick(active.UID);
return;
case 'Escape':
e.preventDefault();
onClose();
return;
default: {
const n = Number.parseInt(e.key, 10);
if (n >= 1 && n <= files.length) {
e.preventDefault();
index = n - 1;
}
}
}
}
</script>
<svelte:window onkeydowncapture={onKeydown} />
<div
class="fixed inset-0 z-50 flex flex-col bg-black/90"
role="dialog"
aria-modal="true"
aria-label="Compare stack files"
>
<!-- Caption / controls bar -->
<div class="flex items-center justify-between gap-3 px-4 py-2 text-xs text-white/90">
<div class="min-w-0 truncate font-mono">{active?.Name ?? ''}</div>
<div class="flex shrink-0 items-center gap-3">
{#if active?.Width && active?.Height}
<span>{active.Width}×{active.Height}</span>
{/if}
{#if active?.Size}
<span>{sizeLabel(active.Size)}</span>
{/if}
<span class="text-white/60">{index + 1} / {files.length}</span>
{#if zp.zoom > 1}
<span class="text-white/60">{Math.round(zp.zoom * 100)}%</span>
{/if}
<button
type="button"
class="rounded border border-white/30 px-2 py-0.5 hover:bg-white/10"
onclick={() => onPick(active.UID)}
>
Keep this <kbd class="ml-1 rounded bg-white/10 px-1 text-[9px]">Enter</kbd>
</button>
<button
type="button"
class="rounded border border-white/30 px-2 py-0.5 hover:bg-white/10"
onclick={onClose}
aria-label="Close compare view"
>
<kbd class="ml-1 rounded bg-white/10 px-1 text-[9px]">Esc</kbd>
</button>
</div>
</div>
<!-- Image stage — shared transform so flips stay pixel-aligned -->
<div
use:zoomPan={{ onChange: (s) => (zp = s) }}
class="relative min-h-0 flex-1 overflow-hidden {zp.zoom > 1
? zp.panning
? 'cursor-grabbing'
: 'cursor-grab'
: 'cursor-zoom-in'}"
>
<div
class="flex h-full w-full items-center justify-center"
class:transition-transform={!zp.panning}
class:duration-150={!zp.panning}
style="transform: translate({zp.tx}px, {zp.ty}px) scale({zp.zoom});"
>
{#each files as file, i (file.UID)}
<img
src={thumbUrl(file.Hash, 'fit_2048')}
alt={file.Name}
draggable="false"
decoding="async"
class="absolute max-h-full max-w-full select-none object-contain {i === index
? ''
: 'invisible'}"
/>
{/each}
</div>
</div>
<!-- Flip hint -->
<div class="px-4 py-2 text-center text-[11px] text-white/50">
←/→ flip candidates (zoom is preserved) · scroll to zoom · Enter keeps the shown file
</div>
</div>

View File

@@ -1,43 +1,33 @@
<!--
One cross-folder duplicate group rendered as a card. Lists every on-disk
copy of the same byte-identical file. The user picks one to keep; the
rest are archived to `.duplicates/<timestamp>/` via the sidecar.
One cross-folder duplicate group. Every copy is byte-identical (same
sha1, same thumbnail) so the old N-identical-thumbnails grid told the
user nothing — the actual decision is entirely about *which path* to
keep. Redesigned as one thumbnail + a radio-style path list.
Differences from StackGroupCard (which operates on PhotoPrism Files in
a single Photo stack):
- These photos are NOT in PhotoPrism's DB (PhotoPrism dropped them at
index time). They're files on disk only.
- Thumbnails come via `thumbUrl(hash, ...)` — content-addressed, so we
can render every copy from the same hash even though only one Photo
entry exists.
- Resolution moves files (reversible) rather than deletes (irreversible).
Resolution moves files (reversible, quarantine + undo) rather than
deletes — logic lives in services/duplicateActions.svelte.ts.
Same keyboard contract as StackGroupCard: arrows pick the keeper,
Enter commits.
Keyboard (↑/↓/j/k bubble to DuplicatesView's group navigation):
- ←/→ or 19 move the keeper pick.
- Enter archives every other copy.
-->
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
archiveDuplicatePaths,
type CrossFolderDuplicateGroup
} from '$lib/services/photoprism';
import { resolveCrossFolder } from '$lib/services/duplicateActions.svelte';
import { thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
import type { CrossFolderDuplicateGroup } from '$lib/services/photoprism';
import { Check, Clock } from 'lucide-svelte';
interface Props {
group: CrossFolderDuplicateGroup;
/** First-card auto-focus, same pattern as StackGroupCard. */
autoFocus?: boolean;
focused?: boolean;
onFocusRequest?: () => void;
onResolved?: () => void;
}
let { group, autoFocus = false }: Props = $props();
let { group, focused = false, onFocusRequest, onResolved }: Props = $props();
const qc = useQueryClient();
let keep = $state('');
let busy = $state(false);
let sectionEl: HTMLElement | undefined = $state();
let gridEl: HTMLElement | undefined = $state();
let cols = $state(1);
// Seed `keep` from the indexed path when available; that's the safest
// default because losing it would leave PhotoPrism with no copy. Fall
@@ -48,38 +38,15 @@
keep =
group.indexedPath && validPaths.has(group.indexedPath)
? group.indexedPath
: group.files[0]?.path ?? '';
: (group.files[0]?.path ?? '');
}
});
$effect(() => {
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
});
// Column-count tracking — identical pattern to StackGroupCard.
$effect(() => {
if (!gridEl) return;
const measure = () => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(gridEl);
return () => ro.disconnect();
});
$effect(() => {
void view.thumbnailSize;
queueMicrotask(() => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
});
if (focused && sectionEl) {
sectionEl.focus({ preventScroll: true });
sectionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
function sizeLabel(bytes: number): string {
@@ -93,6 +60,38 @@
return segs.slice(0, -1).join('/');
}
/** Relative age label from mtime, e.g. "3mo older" — helps break ties
* when neither copy is the indexed one. */
function relAge(iso: string | undefined, newestMs: number): string {
if (!iso) return '';
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return '';
const diffDays = Math.round((newestMs - ms) / 86_400_000);
if (diffDays <= 0) return 'newest';
if (diffDays < 30) return `${diffDays}d older`;
if (diffDays < 365) return `${Math.round(diffDays / 30)}mo older`;
return `${Math.round(diffDays / 365)}y older`;
}
const newestMs = $derived(
Math.max(...group.files.map((f) => (f.modTime ? Date.parse(f.modTime) : 0)))
);
/** Highlight the differing folder segment(s) so the eye jumps straight
* to what's actually different between two long, mostly-shared paths. */
function highlightDiff(path: string): { prefix: string; diff: string; suffix: string } {
const common = group.files
.map((f) => f.path)
.reduce((acc, p) => {
let i = 0;
while (i < acc.length && i < p.length && acc[i] === p[i]) i++;
return acc.slice(0, i);
});
// Back up to the last '/' so we don't split mid-segment.
const cut = common.lastIndexOf('/') + 1;
return { prefix: path.slice(0, cut), diff: path.slice(cut), suffix: '' };
}
function moveKeep(delta: number) {
const i = group.files.findIndex((f) => f.path === keep);
if (i < 0) return;
@@ -102,71 +101,40 @@
function onKeydown(e: KeyboardEvent) {
if (busy) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
moveKeep(-1);
return;
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
moveKeep(1);
return;
case 'ArrowUp':
e.preventDefault();
moveKeep(-cols);
return;
case 'ArrowDown':
e.preventDefault();
moveKeep(cols);
return;
case 'Enter':
e.preventDefault();
e.stopPropagation();
void commit();
return;
case 'Escape':
(e.target as HTMLElement)?.blur();
return;
default: {
const n = Number.parseInt(e.key, 10);
if (n >= 1 && n <= group.files.length) {
e.preventDefault();
e.stopPropagation();
keep = group.files[n - 1].path;
}
}
}
}
async function commit() {
if (busy || group.files.length < 2) return;
// Defensive guard: never archive the indexed copy. The user can
// pick a different "keeper" but the archive list is computed AFTER
// resolving that into "everything except the keeper". If they pick
// a non-indexed copy as keeper, the indexed one gets archived —
// PhotoPrism will lose its photo entry on the cleanup reindex.
// That's a legitimate user choice (they wanted to move the
// canonical copy), just call it out in the toast.
const losers = group.files.filter((f) => f.path !== keep);
if (losers.length === 0) return;
const losingIndexed =
group.indexedPath && losers.some((f) => f.path === group.indexedPath);
busy = true;
try {
const result = await archiveDuplicatePaths(losers.map((f) => f.path));
if (result.errors.length > 0) {
toast.error(
`Archived ${result.moved.length}; ${result.errors.length} failed`,
{
description: result.errors[0].error
}
);
} else {
toast.success(
`Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`,
{
description: losingIndexed
? 'The previously-indexed copy was moved; the indexer will drop it on the next index pass.'
: 'Files moved to .duplicates/ inside originals.'
}
);
}
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
const ok = await resolveCrossFolder(group, keep);
if (ok) onResolved?.();
} finally {
busy = false;
}
@@ -179,87 +147,101 @@
bind:this={sectionEl}
tabindex="0"
role="application"
aria-label={`Duplicate group · ${group.files.length} copies`}
aria-label={`Duplicate group of ${group.files.length} copies — ←/→ pick which path to keep, Enter archives the rest`}
onkeydown={onKeydown}
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
focus-visible:ring-2 focus-visible:ring-primary/50"
onfocusin={() => onFocusRequest?.()}
class="flex gap-3 rounded-md border bg-card/30 p-3 outline-none transition-colors
{focused ? 'border-primary/60 ring-1 ring-primary/40' : 'border-border'}"
>
<header class="flex items-center justify-between gap-3">
<div class="min-w-0">
<!-- Single thumbnail — every copy is byte-identical, so N tiles of the
same image told the user nothing. -->
<div class="w-28 shrink-0">
<div class="aspect-square w-full overflow-hidden rounded-md border border-border bg-secondary">
<img
src={thumbUrl(group.hash, 'tile_500')}
alt=""
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
/>
</div>
<div class="mt-1 truncate text-center text-[10px] font-mono text-muted-foreground/70">
sha1 {group.hash.slice(0, 10)}
</div>
</div>
<div class="min-w-0 flex-1 space-y-2">
<header class="flex items-center justify-between gap-3">
<div class="text-sm font-medium text-foreground">
{group.files.length} copies · {sizeLabel(group.size)} each
</div>
<div class="truncate text-[10px] font-mono text-muted-foreground">
sha1 {group.hash.slice(0, 16)}
</div>
</div>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.files.length < 2}
onclick={commit}
title="Move the unselected copies to .duplicates/ (reversible)"
>
Keep selected, archive rest
<kbd
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
>
</button>
</header>
<div
bind:this={gridEl}
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each group.files as file (file.path)}
{@const isKeep = file.path === keep}
{@const isIndexed = file.path === group.indexedPath}
<button
type="button"
onclick={() => (keep = file.path)}
class:scale-95={isKeep}
class:ring-2={isKeep}
class:ring-blue-500={isKeep}
class:ring-offset-2={isKeep}
class:ring-offset-background={isKeep}
class:transition-[transform,box-shadow]={isKeep}
class:duration-300={isKeep}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isKeep}
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.files.length < 2}
onclick={commit}
title="Move the unselected copies to .duplicates/ (recoverable)"
>
<div class="relative aspect-square w-full overflow-hidden">
<img
src={thumbUrl(group.hash, 'tile_500')}
alt={file.path}
loading="lazy"
class="h-full w-full object-cover"
/>
{#if isKeep}
<span
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
>
Keep
</span>
{/if}
{#if isIndexed}
<span
class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white"
title="Currently in the library"
>
Indexed
</span>
{/if}
</div>
<div
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
title={file.path}
Keep selected path
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
>
<div class="truncate text-foreground/90">{shortFolder(file.path)}</div>
<div class="truncate font-mono">{file.path.split('/').pop()}</div>
</div>
</button>
{/each}
</header>
<!-- Radio-style path list — the actual decision surface. -->
<div class="space-y-1">
{#each group.files as file, i (file.path)}
{@const isKeep = file.path === keep}
{@const isIndexed = file.path === group.indexedPath}
{@const parts = highlightDiff(file.path)}
{@const age = relAge(file.modTime, newestMs)}
<button
type="button"
onclick={() => (keep = file.path)}
class="flex w-full items-center gap-2.5 rounded-md border px-2.5 py-2 text-left transition-colors
{isKeep
? 'border-blue-500 bg-blue-500/10'
: 'border-transparent bg-secondary/50 hover:bg-secondary'}"
>
<span
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold
{isKeep
? 'border-blue-500 bg-blue-500 text-white'
: 'border-muted-foreground/40 text-muted-foreground'}"
>
{isKeep ? '' : i + 1}
{#if isKeep}<Check class="h-2.5 w-2.5" />{/if}
</span>
<span class="min-w-0 flex-1 truncate font-mono text-xs">
<span class="text-muted-foreground">{parts.prefix}</span><span
class="font-semibold text-foreground"
>{parts.diff}</span
>
</span>
<span class="flex shrink-0 items-center gap-1.5 text-[10px]">
{#if isIndexed}
<span
class="rounded bg-emerald-600 px-1.5 py-0.5 font-semibold text-white"
title="Currently in the library — losing this moves the indexed copy"
>
Indexed
</span>
{/if}
{#if age}
<span class="flex items-center gap-0.5 text-muted-foreground" title={file.modTime}>
<Clock class="h-2.5 w-2.5" />{age}
</span>
{/if}
</span>
</button>
{/each}
</div>
{#if group.files.some((f) => f.path === group.indexedPath && f.path !== keep)}
<p class="text-[10px] text-amber-500">
Keeping a non-indexed copy — the indexed one will be archived; the indexer picks up the
survivor on its next pass.
</p>
{/if}
</div>
</div>

View File

@@ -1,24 +1,26 @@
<!--
Duplicate-resolution page body. Two panels driven by the parent
route's `activeTab` prop (URL-bound):
Duplicate-resolution queue. Two panels driven by the parent route's
`activeTab` prop (URL-bound):
1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; we list via
`stack:true` and resolve via `setPrimary` + `deleteFile`.
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; listed via
`stack:true`, resolved via resolveStack() (setPrimary + quarantine).
2. Cross-folder — files PhotoPrism silently rejected at index time
because they were byte-identical to an existing entry. PhotoPrism
never adds those rows to its DB, so we scan the filesystem via the
mule-sidecar. Resolution moves the unwanted copies into a
`.duplicates/` quarantine folder PhotoPrism's indexer ignores.
because they were byte-identical to an existing entry. Scanned via
the sidecar's filesystem walk, resolved via resolveCrossFolder()
(quarantine).
Both panels share one interaction model — a resolve-and-advance
queue: ↑/↓ or j/k rove between groups (scrollIntoView), resolving a
group removes it optimistically and auto-advances focus to whatever
now occupies that slot, so the whole queue clears without touching
the mouse. A sticky header tracks reclaimable bytes and a running
"resolved this session" tally.
The cross-folder scan auto-fires when its tab is active — with size
pre-filtering it stays fast (~250ms for 400 files in practice) and a
long staleTime keeps tab bounces from re-running it. The button is
kept for manual "rescan after I moved files" refreshes.
Tabs themselves render in the parent route's Toolbar so they line up
visually with the `/tags` pill row.
long staleTime keeps tab bounces from re-running it.
-->
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
@@ -28,11 +30,20 @@
type CrossFolderScanResult
} from '$lib/services/photoprism';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import {
dupSession,
formatBytes,
resolvedCrossHashes,
resolvedStackUids
} from '$lib/services/duplicateActions.svelte';
import { userLibraryBase } from '$lib/stores/session.svelte';
import { nearBottom } from '$lib/actions/nearBottom';
import { toggleShortcuts, view } from '$lib/stores/view.svelte';
import { popAndRun } from '$lib/stores/undo.svelte';
import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, CheckCircle2, Copy } from 'lucide-svelte';
import { AlertCircle, CheckCircle2, Copy, HardDrive } from 'lucide-svelte';
type Tab = 'stacks' | 'cross-folder';
@@ -64,97 +75,229 @@
$effect(() => {
if (crossQuery.error) {
toast.error(
crossQuery.error instanceof Error
? crossQuery.error.message
: 'Duplicates scan failed'
crossQuery.error instanceof Error ? crossQuery.error.message : 'Duplicates scan failed'
);
}
});
const crossCount = $derived(crossQuery.data?.groups.length ?? 0);
// Filter out groups resolved this session but not yet reflected by a
// server refetch (PhotoPrism's cleanup reindex is async) — otherwise
// a background refetch could resurrect a group the user just cleared.
const liveStackGroups = $derived(groups.filter((g) => !resolvedStackUids.has(g.photo.UID)));
const liveCrossGroups = $derived(
(crossQuery.data?.groups ?? []).filter((g) => !resolvedCrossHashes.has(g.hash))
);
const activeGroups = $derived(activeTab === 'stacks' ? liveStackGroups : liveCrossGroups);
// Reclaimable bytes across everything still in the queue.
const reclaimableBytes = $derived(
activeTab === 'stacks'
? liveStackGroups.reduce((sum, g) => {
const keeperSize = Math.max(...g.files.map((f) => f.Size ?? 0));
const total = g.files.reduce((s, f) => s + (f.Size ?? 0), 0);
return sum + (total - keeperSize);
}, 0)
: liveCrossGroups.reduce((sum, g) => sum + g.size * (g.files.length - 1), 0)
);
// ── Roving focus + progressive rendering ───────────────────────────
let focusedIndex = $state(0);
let renderCount = $state(30);
// Reset when the tab or the underlying list identity changes size
// class (e.g. switching tabs, or a fresh scan lands).
$effect(() => {
void activeTab;
focusedIndex = 0;
renderCount = 30;
});
function clampFocus() {
if (activeGroups.length === 0) return;
focusedIndex = Math.min(focusedIndex, activeGroups.length - 1);
}
$effect(clampFocus);
function extend() {
renderCount = Math.min(activeGroups.length, renderCount + 30);
}
function moveFocus(delta: number) {
if (activeGroups.length === 0) return;
focusedIndex = Math.min(Math.max(0, focusedIndex + delta), activeGroups.length - 1);
if (focusedIndex >= renderCount) renderCount = Math.min(activeGroups.length, focusedIndex + 10);
}
async function onQueueKeydown(e: KeyboardEvent) {
if (view.shortcutsOpen) {
if (e.key === 'Escape' || e.key === '?') {
e.preventDefault();
toggleShortcuts();
}
return;
}
// gridKeyNav (which normally owns ⌘Z) isn't mounted on these tabs —
// wire undo here so resolving a group is reversible without
// switching to a cause tab first.
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')) {
e.preventDefault();
const entry = await popAndRun();
toast[entry ? 'success' : 'message'](entry ? `Undone: ${entry.label}` : 'Nothing to undo');
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
// Group cards call stopPropagation on the keys they own (arrows
// L/R, digits, Enter, Space) — only j/k/ArrowUp/ArrowDown/? reach
// here, which is exactly the group-navigation contract.
switch (e.key) {
case 'ArrowUp':
case 'k':
e.preventDefault();
moveFocus(-1);
return;
case 'ArrowDown':
case 'j':
e.preventDefault();
moveFocus(1);
return;
case '?':
e.preventDefault();
toggleShortcuts();
return;
}
}
/** A group resolved — hold focus at the same index (the next group
* slides up into it) unless we were at the end. */
function onGroupResolved() {
if (focusedIndex >= activeGroups.length - 1) {
focusedIndex = Math.max(0, activeGroups.length - 2);
}
}
const crossCount = $derived(liveCrossGroups.length);
</script>
<!-- Stacks tab ----------------------------------------------------- -->
{#if activeTab === 'stacks'}
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
{#if pending}
<InlineLoader label="Loading stacks…" />
{:else if error}
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Could not load stacks"
description={error instanceof Error ? error.message : 'unknown error'}
/>
{:else if groups.length === 0}
<EmptyState icon={Copy} title="No stacks">
{#snippet descriptionSnippet()}
<p>
The library stacks byte-identical (or EXIF-identical) files. If you don't have
any, this tab stays empty. Cross-folder copies dropped at index time live under
the Duplicates tab.
</p>
{/snippet}
</EmptyState>
{:else}
<div class="space-y-3">
{#each groups as group, i (group.photo.UID)}
<StackGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
<!-- Sticky progress header — shared by both tabs -->
<div
class="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-background/95 px-6 py-2.5 backdrop-blur"
>
<div class="flex items-center gap-4 text-xs text-muted-foreground">
<span class="font-medium text-foreground">
{activeGroups.length}
{activeTab === 'stacks' ? 'stack' : 'group'}{activeGroups.length === 1 ? '' : 's'}
</span>
{#if reclaimableBytes > 0}
<span class="flex items-center gap-1">
<HardDrive class="h-3 w-3" />
{formatBytes(reclaimableBytes)} reclaimable
</span>
{/if}
{#if dupSession.resolved > 0}
<span class="text-emerald-500">
Resolved {dupSession.resolved} · {formatBytes(dupSession.freedBytes)} freed this session
</span>
{/if}
</div>
{/if}
{#if activeTab === 'cross-folder'}
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={crossQuery.isFetching}
onclick={rescan}
>
{crossQuery.isFetching ? 'Scanning…' : 'Rescan filesystem'}
</button>
{/if}
</div>
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
{#if activeTab === 'cross-folder'}
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
<header class="flex items-baseline justify-between gap-3">
<p class="text-[11px] text-muted-foreground">
Byte-identical files the indexer dropped at index time. Found by scanning the
originals tree directly.
</p>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={crossQuery.isFetching}
onclick={rescan}
>
{#if crossQuery.isFetching}
Scanning…
{:else}
Rescan filesystem
{/if}
</button>
</header>
{#if crossQuery.isFetching && !crossQuery.data}
<InlineLoader label="Hashing files under originals…" />
{:else if crossQuery.isError}
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Scan failed"
description={crossQuery.error instanceof Error
? crossQuery.error.message
: 'unknown error'}
/>
{:else if crossCount === 0}
<EmptyState icon={CheckCircle2} title="No duplicates found">
{#snippet descriptionSnippet()}
{#if crossQuery.data}
<p class="text-[10px] text-muted-foreground/70">
scanned in {crossQuery.data.scannedMs} ms
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onkeydown={onQueueKeydown}>
<!-- Stacks tab ----------------------------------------------------- -->
{#if activeTab === 'stacks'}
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
{#if pending}
<InlineLoader label="Loading stacks…" />
{:else if error}
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Could not load stacks"
description={error instanceof Error ? error.message : 'unknown error'}
/>
{:else if liveStackGroups.length === 0}
<EmptyState icon={Copy} title="No stacks">
{#snippet descriptionSnippet()}
<p>
The library stacks byte-identical (or EXIF-identical) files. If you don't have
any, this tab stays empty. Cross-folder copies dropped at index time live under
the Duplicates tab.
</p>
{/if}
{/snippet}
</EmptyState>
{:else}
<div class="space-y-3">
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
<CrossFolderGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if}
</div>
{/if}
{/snippet}
</EmptyState>
{:else}
<div class="space-y-3">
{#each liveStackGroups.slice(0, renderCount) as group, i (group.photo.UID)}
<StackGroupCard
{group}
focused={i === focusedIndex}
onFocusRequest={() => (focusedIndex = i)}
onResolved={onGroupResolved}
/>
{/each}
</div>
{#if renderCount < liveStackGroups.length}
<div use:nearBottom={{ onHit: extend, enabled: true }} class="h-8"></div>
{/if}
{/if}
</div>
{/if}
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
{#if activeTab === 'cross-folder'}
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
<p class="text-[11px] text-muted-foreground">
Byte-identical files the indexer dropped at index time. Found by scanning the originals
tree directly.
</p>
{#if crossQuery.isFetching && !crossQuery.data}
<InlineLoader label="Hashing files under originals…" />
{:else if crossQuery.isError}
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Scan failed"
description={crossQuery.error instanceof Error
? crossQuery.error.message
: 'unknown error'}
/>
{:else if crossCount === 0}
<EmptyState icon={CheckCircle2} title="No duplicates found">
{#snippet descriptionSnippet()}
{#if crossQuery.data}
<p class="text-[10px] text-muted-foreground/70">
scanned in {crossQuery.data.scannedMs} ms
</p>
{/if}
{/snippet}
</EmptyState>
{:else}
<div class="space-y-3">
{#each liveCrossGroups.slice(0, renderCount) as group, i (group.hash)}
<CrossFolderGroupCard
{group}
focused={i === focusedIndex}
onFocusRequest={() => (focusedIndex = i)}
onResolved={onGroupResolved}
/>
{/each}
</div>
{#if renderCount < liveCrossGroups.length}
<div use:nearBottom={{ onHit: extend, enabled: true }} class="h-8"></div>
{/if}
{/if}
</div>
{/if}
</div>

View File

@@ -1,101 +1,90 @@
<!--
One duplicate stack rendered as a card. Each variant file is a clickable
tile; clicking selects it as the candidate "best". Committing promotes
the selected file to Primary (via `setPrimary`) and deletes the rest from
the stack (via `deleteFile` — PhotoPrism's flat `DELETE /photos/:uid/
files/:fid` route).
One duplicate stack rendered as a card. Each variant file is a tile;
the selected one is the "keeper". Committing promotes the keeper to
Primary and moves every other file into the sidecar's `.duplicates/`
quarantine (recoverable, undoable via ⌘Z) — resolution logic lives in
services/duplicateActions.svelte.ts.
Why DELETE instead of unstack-then-archive (which the plan started with):
PhotoPrism's `/unstack` returns `only originals can be unstacked` for
sidecar JPGs and `Changes could not be saved` for live-photo HEIC+MOV
pairs. DELETE works for all of them — and cascades through the live-
photo group automatically, so one click resolves the whole stack. The
on-disk file is renamed with a hash suffix (not erased), so a future
manual reindex can recover it if needed.
Keyboard (card scope — ↑/↓/j/k are NOT consumed here; they bubble to
DuplicatesView's group navigation):
- ←/→ move the keeper highlight; 19 jump straight to a file.
- Space opens the fullscreen compare lightbox (zoom-preserving flips).
- Enter resolves: keep selected, quarantine the rest.
Keyboard:
- Section is tabindex=0; focusing it captures arrow keys + Enter.
- Left/Right move the "best" highlight one file; Up/Down move by the
grid's computed column count (same trick the timeline uses for
cross-row arrow nav).
- Enter commits the current selection. Esc removes focus from the card.
- The page's first card auto-focuses on mount so the user can drive
the workflow keyboard-first.
The fact rows under each thumb highlight the best value per column
(largest size, highest resolution) so the winning file is obvious at
a glance; a file that wins everything gets a "Suggested" badge.
-->
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { deleteFile, setPrimary } from '$lib/services/photoprism';
import { resolveStack } from '$lib/services/duplicateActions.svelte';
import { thumbUrl } from '$lib/stores/session.svelte';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import { view } from '$lib/stores/view.svelte';
import type { PpFile } from '$lib/types/photoprism';
import CompareLightbox from './CompareLightbox.svelte';
import { Maximize2 } from 'lucide-svelte';
interface Props {
group: DuplicateGroup;
/** When true, the section auto-focuses on mount so the user can
* arrow-key/Enter the workflow without reaching for the mouse.
* Only the page's first card should get this. */
autoFocus?: boolean;
/** Roving focus — DuplicatesView owns which card is active. */
focused?: boolean;
/** Card was clicked/focused by pointer: tell the view to move its
* roving index here. */
onFocusRequest?: () => void;
/** Resolve succeeded — view advances focus to the next group. */
onResolved?: () => void;
}
let { group, autoFocus = false }: Props = $props();
let { group, focused = false, onFocusRequest, onResolved }: Props = $props();
const qc = useQueryClient();
let best = $state('');
let busy = $state(false);
let compareOpen = $state(false);
let sectionEl: HTMLElement | undefined = $state();
let gridEl: HTMLElement | undefined = $state();
let cols = $state(1);
$effect(() => {
// Seed / re-seed `best` from the prop when the underlying group
// changes (keyed each + UID key normally keeps this stable, but
// the guard handles prop swaps without overwriting user clicks).
// changes; the guard keeps user clicks intact across prop swaps.
if (!best || !group.files.some((f) => f.UID === best)) {
best = group.bestFileUid;
}
});
$effect(() => {
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
if (focused && sectionEl && !compareOpen) {
sectionEl.focus({ preventScroll: true });
sectionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
// Track the grid's column count via ResizeObserver — same approach
// the timeline uses. Reading `gridTemplateColumns` from computed
// style is O(1) regardless of how many tiles render.
$effect(() => {
if (!gridEl) return;
const measure = () => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(gridEl);
return () => ro.disconnect();
});
// thumbnailSize changes alter cols without resizing the grid; re-
// measure on the next microtask.
$effect(() => {
void view.thumbnailSize;
queueMicrotask(() => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
});
// ── Comparison facts ────────────────────────────────────────────────
const maxSize = $derived(Math.max(...group.files.map((f) => f.Size ?? 0)));
const maxPixels = $derived(Math.max(...group.files.map((f) => pixels(f))));
const sizesDiffer = $derived(new Set(group.files.map((f) => f.Size ?? 0)).size > 1);
const pixelsDiffer = $derived(new Set(group.files.map((f) => pixels(f))).size > 1);
/** UID of the file that wins on every differing axis, if unique. */
const suggestedUid = $derived.by(() => {
const winners = group.files.filter(
(f) =>
(!sizesDiffer || (f.Size ?? 0) === maxSize) &&
(!pixelsDiffer || pixels(f) === maxPixels)
);
return winners.length === 1 && (sizesDiffer || pixelsDiffer) ? winners[0].UID : null;
});
function pixels(f: PpFile): number {
return (f.Width ?? 0) * (f.Height ?? 0);
}
function typeBadge(f: PpFile): string {
return (f.FileType ?? f.Name?.split('.').pop() ?? '').toUpperCase();
}
function shortPath(name: string): string {
const segs = name.split('/').filter(Boolean);
if (segs.length <= 2) return name;
return '…/' + segs.slice(-2).join('/');
}
function dims(f: { Width?: number; Height?: number }): string {
function dims(f: PpFile): string {
if (!f.Width || !f.Height) return '';
return `${f.Width}×${f.Height}`;
}
@@ -114,87 +103,57 @@
}
function onKeydown(e: KeyboardEvent) {
if (busy) return;
if (busy || compareOpen) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
moveBest(-1);
return;
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
moveBest(1);
return;
case 'ArrowUp':
case ' ':
e.preventDefault();
moveBest(-cols);
return;
case 'ArrowDown':
e.preventDefault();
moveBest(cols);
e.stopPropagation();
compareOpen = true;
return;
case 'Enter':
e.preventDefault();
e.stopPropagation();
void commit();
return;
case 'Escape':
(e.target as HTMLElement)?.blur();
return;
default: {
const n = Number.parseInt(e.key, 10);
if (n >= 1 && n <= group.files.length) {
e.preventDefault();
e.stopPropagation();
best = group.files[n - 1].UID;
}
}
}
}
async function commit() {
if (busy || group.files.length < 2) return;
busy = true;
const photoUid = group.photo.UID;
const losers = group.files.filter((f) => f.UID !== best);
try {
// 1. Promote the user's pick to Primary first (idempotent — if
// it's already Primary, the call is a no-op on the server).
const currentPrimary = group.files.find((f) => f.Primary)?.UID;
if (best !== currentPrimary) {
await setPrimary(photoUid, best);
}
// 2. Delete each non-best file. PhotoPrism cascades through
// related variants in the same logical group (live-photo
// pairs, sidecar companions), so a single DELETE on one
// HEIC variant clears the whole HEIC+MOV pair in one go.
// Loop tolerates partial success — if PhotoPrism already
// cleared the file via cascade, the next DELETE 404s and
// we move on.
for (const f of losers) {
try {
await deleteFile(photoUid, f.UID);
} catch (err) {
// 404 means the file's already gone (cascade) — fine.
// Any other status means we have a real problem; bubble it.
const status = (err as { response?: { status?: number } })?.response
?.status;
if (status !== 404) throw err;
}
}
toast.success(`Resolved · kept 1 of ${group.files.length}`);
void qc.invalidateQueries({ queryKey: ['duplicates'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
const msg =
err instanceof Error && err.message ? err.message : 'Resolve failed';
toast.error(msg);
const ok = await resolveStack(group, best);
if (ok) onResolved?.();
} finally {
busy = false;
}
}
</script>
<!-- Section is focusable so we can capture arrow keys + Enter. `outline-
none` because we paint our own focus ring on .focus-visible below
(otherwise the browser default outline would clash with the tile
selection ring). -->
<!--
`role="application"` declares this as a custom keyboard widget (arrow
keys + Enter, not standard reading order). The element below is a
`<div>` rather than `<section>` because Svelte's a11y linter treats
`<section>` as strictly non-interactive even with an explicit
application role.
keys + Enter, not standard reading order). `<div>` rather than
`<section>` because Svelte's a11y linter treats `<section>` as
strictly non-interactive even with an explicit application role.
-->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
@@ -202,10 +161,11 @@
bind:this={sectionEl}
tabindex="0"
role="application"
aria-label={`Duplicate stack of ${group.files.length} files — arrow keys pick the file to keep, Enter resolves`}
aria-label={`Duplicate stack of ${group.files.length} files — ←/→ pick the keeper, Space compares, Enter resolves`}
onkeydown={onKeydown}
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
focus-visible:ring-2 focus-visible:ring-primary/50"
onfocusin={() => onFocusRequest?.()}
class="space-y-2 rounded-md border bg-card/30 p-3 outline-none transition-colors
{focused ? 'border-primary/60 ring-1 ring-primary/40' : 'border-border'}"
>
<header class="flex items-center justify-between gap-3">
<div class="min-w-0">
@@ -216,74 +176,115 @@
{group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''}
</div>
</div>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.files.length < 2}
onclick={commit}
title="Promote the selected file and delete the rest from this stack"
>
Keep selected, delete rest
<kbd
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={() => (compareOpen = true)}
title="Compare candidates fullscreen (zoom-preserving flips)"
>
</button>
<Maximize2 class="h-3 w-3" /> Compare
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Space</kbd
>
</button>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.files.length < 2}
onclick={commit}
title="Promote the selected file; the rest move to the recoverable .duplicates/ quarantine"
>
Keep selected
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
>
</button>
</div>
</header>
<div
bind:this={gridEl}
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each group.files as file (file.UID)}
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));">
{#each group.files as file, i (file.UID)}
{@const isBest = file.UID === best}
{@const sizeStr = sizeLabel(file.Size)}
{@const bestSize = sizesDiffer && (file.Size ?? 0) === maxSize}
{@const bestRes = pixelsDiffer && pixels(file) === maxPixels && pixels(file) > 0}
<button
type="button"
onclick={() => (best = file.UID)}
class:scale-95={isBest}
ondblclick={() => {
best = file.UID;
compareOpen = true;
}}
class:ring-2={isBest}
class:ring-blue-500={isBest}
class:ring-offset-2={isBest}
class:ring-offset-background={isBest}
class:transition-[transform,box-shadow]={isBest}
class:duration-300={isBest}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isBest}
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none transition-shadow focus:outline-none"
>
<div class="relative aspect-square w-full overflow-hidden">
<img
src={thumbUrl(file.Hash, 'tile_500')}
alt={file.Name}
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
/>
{#if isBest}
<span
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
>
Best
Keep
</span>
{/if}
{#if dims(file)}
{:else if file.UID === suggestedUid}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
class="absolute left-1.5 top-1.5 rounded bg-emerald-600/90 px-1.5 py-0.5 text-[10px] font-semibold text-white"
title="Largest and highest-resolution file in this stack"
>
{dims(file)}
Suggested
</span>
{/if}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] font-semibold text-foreground/90"
>
{i + 1}
</span>
</div>
<div
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
class="flex flex-col gap-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
title={`${file.Name}${sizeStr ? ` · ${sizeStr}` : ''}`}
>
<div class="truncate text-foreground/90">{shortPath(file.Name)}</div>
{#if sizeStr}
<div>{sizeStr}</div>
{/if}
<div class="flex items-center gap-1.5">
{#if typeBadge(file)}
<span class="rounded bg-muted px-1 py-px font-medium">{typeBadge(file)}</span>
{/if}
{#if dims(file)}
<span class={bestRes ? 'font-semibold text-emerald-500' : ''}>{dims(file)}</span>
{/if}
{#if sizeStr}
<span class={bestSize ? 'font-semibold text-emerald-500' : ''}>{sizeStr}</span>
{/if}
</div>
</div>
</button>
{/each}
</div>
</div>
{#if compareOpen}
<CompareLightbox
files={group.files}
startUid={best}
onPick={(uid) => {
best = uid;
compareOpen = false;
sectionEl?.focus({ preventScroll: true });
}}
onClose={() => {
compareOpen = false;
sectionEl?.focus({ preventScroll: true });
}}
/>
{/if}

View File

@@ -58,6 +58,17 @@
{ keys: ['I'], desc: 'Toggle info sidebar' },
{ keys: ['?'], desc: 'This overlay' }
]
},
{
title: 'Stacks & Duplicates',
rows: [
{ keys: ['↑', '↓', 'j', 'k'], desc: 'Move between groups' },
{ keys: ['←', '→'], desc: 'Pick which file/copy to keep' },
{ keys: ['1', '…', '9'], desc: 'Jump straight to a file/copy' },
{ keys: ['Space'], desc: 'Compare candidates fullscreen (stacks)' },
{ keys: ['Enter'], desc: 'Resolve: keep selected, quarantine rest' },
{ keys: ['⌘', 'Z'], desc: 'Undo — restores quarantined files' }
]
}
];
</script>

View File

@@ -16,6 +16,7 @@
import { view } from '$lib/stores/view.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
import { zoomPan, type ZoomPanState } from '$lib/actions/zoomPan';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
@@ -103,88 +104,12 @@
}
// ── Zoom & pan ───────────────────────────────────────────────────────
// Wheel zooms around the cursor, double-click toggles 1↔2.5, drag pans
// while zoomed. Transform lives on a wrapper so the LQIP layer and the
// sharp image scale together. Resets on photo change. Past 1.25× the
// sharp <img> switches to fit_2048 so zoomed pixels stay crisp.
const MAX_ZOOM = 6;
let zoom = $state(1);
let tx = $state(0);
let ty = $state(0);
let zoomHost = $state<HTMLElement | undefined>();
let panning = $state(false);
let lastX = 0;
let lastY = 0;
$effect(() => {
void uid;
zoom = 1;
tx = 0;
ty = 0;
});
function applyZoom(next: number, clientX: number, clientY: number) {
if (!zoomHost) return;
const clamped = Math.min(MAX_ZOOM, Math.max(1, next));
if (clamped === zoom) return;
// Keep the point under the cursor fixed: translate offsets are in
// post-scale pixels around the container centre.
const rect = zoomHost.getBoundingClientRect();
const cx = clientX - rect.left - rect.width / 2;
const cy = clientY - rect.top - rect.height / 2;
const s = clamped / zoom;
tx = cx + (tx - cx) * s;
ty = cy + (ty - cy) * s;
zoom = clamped;
if (zoom === 1) {
tx = 0;
ty = 0;
}
}
function onWheel(e: WheelEvent) {
e.preventDefault();
applyZoom(zoom * Math.exp(-e.deltaY * 0.0018), e.clientX, e.clientY);
}
/** Svelte marks wheel handlers passive; zooming needs preventDefault,
* so the listener is attached manually as non-passive. */
function wheelZoom(node: HTMLElement) {
node.addEventListener('wheel', onWheel, { passive: false });
return {
destroy() {
node.removeEventListener('wheel', onWheel);
}
};
}
function onDblClickZoom(e: MouseEvent) {
if (zoom > 1) {
zoom = 1;
tx = 0;
ty = 0;
} else {
applyZoom(2.5, e.clientX, e.clientY);
}
}
function onPointerDown(e: PointerEvent) {
if (zoom === 1) return;
panning = true;
lastX = e.clientX;
lastY = e.clientY;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!panning) return;
tx += e.clientX - lastX;
ty += e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
}
function onPointerUp() {
panning = false;
}
// Gesture handling lives in the shared zoomPan action (also used by
// the duplicates compare lightbox). Transform lives on a wrapper so
// the LQIP layer and the sharp image scale together. Resets on photo
// change via resetKey. Past 1.25× the sharp <img> switches to
// fit_2048 so zoomed pixels stay crisp.
let zp = $state<ZoomPanState>({ zoom: 1, tx: 0, ty: 0, panning: false });
</script>
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
@@ -229,26 +154,19 @@
photoQuery.data.OriginalName ??
pf.Name ??
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={zoomHost}
use:wheelZoom
ondblclick={onDblClickZoom}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
class="relative flex h-full w-full items-center justify-center overflow-hidden {zoom > 1
? panning
use:zoomPan={{ onChange: (s) => (zp = s), resetKey: uid }}
class="relative flex h-full w-full items-center justify-center overflow-hidden {zp.zoom > 1
? zp.panning
? 'cursor-grabbing'
: 'cursor-grab'
: 'cursor-zoom-in'}"
>
<div
class="relative flex h-full w-full items-center justify-center"
class:transition-transform={!panning}
class:duration-150={!panning}
style="transform: translate({tx}px, {ty}px) scale({zoom});"
class:transition-transform={!zp.panning}
class:duration-150={!zp.panning}
style="transform: translate({zp.tx}px, {zp.ty}px) scale({zp.zoom});"
>
{#if pf.Width && pf.Height}
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
@@ -268,7 +186,7 @@
/>
{/if}
<img
src={thumbUrl(pf.Hash, zoom > 1.25 ? 'fit_2048' : 'fit_1280')}
src={thumbUrl(pf.Hash, zp.zoom > 1.25 ? 'fit_2048' : 'fit_1280')}
alt={altText}
fetchpriority="high"
decoding="async"
@@ -276,11 +194,11 @@
class="relative max-h-full max-w-full select-none rounded-md object-contain shadow-2xl"
/>
</div>
{#if zoom > 1}
{#if zp.zoom > 1}
<span
class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded bg-background/80 px-2 py-0.5 text-[11px] text-foreground"
>
{Math.round(zoom * 100)}% · double-click to reset
{Math.round(zp.zoom * 100)}% · double-click to reset
</span>
{/if}
</div>

View File

@@ -31,7 +31,7 @@ export async function listDuplicateGroups(basePath?: string): Promise<DuplicateG
const photos = await listPhotos({
q,
count: 200,
count: 500,
merged: true,
order: 'newest'
});

View File

@@ -0,0 +1,230 @@
/**
* Resolve/undo logic for the Stacks & Duplicates review tabs.
*
* Both tabs share one loser fate: files move to the sidecar's
* `.duplicates/<timestamp>/` quarantine (recoverable), never a hard
* delete. Stacks additionally promote the keeper to Primary first so
* the surviving Photo row stays coherent while PhotoPrism's async
* cleanup reindex catches up.
*
* Optimistic model: the resolved group is removed from the TanStack
* cache immediately (no refetch), and its identity is remembered in a
* session-level `resolved*` set. The set matters because quarantined
* stack files linger in PhotoPrism's DB until the async reindex
* completes — a plain refetch inside that window would resurrect the
* group. Undo reverses all three: restores the files via the sidecar,
* re-inserts the group into the cache, and forgets the identity.
*/
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { queryClient } from '$lib/queryClient';
import {
archiveDuplicatePaths,
restoreDuplicatePaths,
setPrimary,
type CrossFolderDuplicateGroup,
type CrossFolderScanResult
} from '$lib/services/photoprism';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import { userLibraryBase } from '$lib/stores/session.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
/** Stack photo UIDs / cross-folder hashes resolved this session. Views
* filter refetched lists through these so groups don't resurrect while
* PhotoPrism's cleanup reindex is still running. SvelteSet so the
* filtering is reactive to undo. */
export const resolvedStackUids = new SvelteSet<string>();
export const resolvedCrossHashes = new SvelteSet<string>();
/** Running session tally for the progress header. */
export const dupSession = $state({ resolved: 0, freedBytes: 0 });
function stacksKey(): (string | undefined)[] {
return ['duplicates', userLibraryBase()];
}
function crossKey(): (string | undefined)[] {
return ['duplicates-cross-folder', userLibraryBase()];
}
export function formatBytes(bytes: number): string {
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
if (bytes > 0) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return '0 KB';
}
function bumpSession(freed: number, dir: 1 | -1): void {
dupSession.resolved = Math.max(0, dupSession.resolved + dir);
dupSession.freedBytes = Math.max(0, dupSession.freedBytes + dir * freed);
}
/** Remove/re-insert a stack group in the cached list. */
function patchStacksCache(mutate: (list: DuplicateGroup[]) => DuplicateGroup[]): void {
queryClient.setQueryData<DuplicateGroup[]>(stacksKey(), (list) =>
list ? mutate(list) : list
);
}
function patchCrossCache(
mutate: (groups: CrossFolderDuplicateGroup[]) => CrossFolderDuplicateGroup[]
): void {
queryClient.setQueryData<CrossFolderScanResult>(crossKey(), (res) =>
res ? { ...res, groups: mutate(res.groups) } : res
);
}
function insertAt<T>(list: T[], item: T, index: number): T[] {
const i = Math.min(Math.max(0, index), list.length);
return [...list.slice(0, i), item, ...list.slice(i)];
}
/**
* Resolve a stack: promote `keeperUid` to Primary, quarantine every
* other file's on-disk copy. Returns true on success (view advances
* focus on true).
*/
export async function resolveStack(group: DuplicateGroup, keeperUid: string): Promise<boolean> {
const uid = group.photo.UID;
const losers = group.files.filter((f) => f.UID !== keeperUid);
if (losers.length === 0) return false;
const loserPaths = losers.map((f) => f.Name).filter((n): n is string => !!n);
const freed = losers.reduce((s, f) => s + (f.Size ?? 0), 0);
// Optimistic removal + session bookkeeping.
let removedIndex = 0;
patchStacksCache((list) => {
removedIndex = Math.max(0, list.findIndex((g) => g.photo.UID === uid));
return list.filter((g) => g.photo.UID !== uid);
});
resolvedStackUids.add(uid);
bumpSession(freed, 1);
const rollback = () => {
resolvedStackUids.delete(uid);
bumpSession(freed, -1);
patchStacksCache((list) =>
list.some((g) => g.photo.UID === uid) ? list : insertAt(list, group, removedIndex)
);
};
try {
const currentPrimary = group.files.find((f) => f.Primary)?.UID;
if (keeperUid !== currentPrimary) {
await setPrimary(uid, keeperUid);
}
const result = await archiveDuplicatePaths(loserPaths);
if (result.moved.length === 0) {
rollback();
toast.error('Resolve failed', { description: result.errors[0]?.error });
return false;
}
const undo = async () => {
try {
const res = await restoreDuplicatePaths(result.moved);
if (res.errors.length > 0) {
toast.error(`Restore failed for ${res.errors.length} file(s)`, {
description: res.errors[0].error
});
}
// Restoring undid a real filesystem move even if some files
// failed partway — reflect it in the list either way.
rollback();
} catch (err) {
// Network/HTTP failure — the quarantine move is still intact
// on disk, so don't resurrect the group in the UI; the files
// remain safely recoverable under .duplicates/ by hand.
toast.error(err instanceof Error ? err.message : 'Undo failed');
}
};
pushUndo(`Resolved stack (${group.files.length} files)`, undo);
if (result.errors.length > 0) {
toast.warning(`Kept 1 · quarantined ${result.moved.length}, ${result.errors.length} failed`, {
description: result.errors[0].error
});
} else {
toast.success(`Kept 1 of ${group.files.length} · ${formatBytes(freed)} freed`, {
action: { label: 'Undo', onClick: () => void undo() }
});
}
// Files moved on disk; photo counts/thumbs may shift once the
// cleanup reindex lands. Background-invalidate the timeline only.
void queryClient.invalidateQueries({ queryKey: ['photos'] });
return true;
} catch (err) {
rollback();
toast.error(err instanceof Error ? err.message : 'Resolve failed');
return false;
}
}
/**
* Resolve a cross-folder group: quarantine every copy except
* `keeperPath`. Returns true on success.
*/
export async function resolveCrossFolder(
group: CrossFolderDuplicateGroup,
keeperPath: string
): Promise<boolean> {
const losers = group.files.filter((f) => f.path !== keeperPath);
if (losers.length === 0) return false;
const freed = losers.reduce((s, f) => s + f.size, 0);
const losingIndexed = !!group.indexedPath && losers.some((f) => f.path === group.indexedPath);
let removedIndex = 0;
patchCrossCache((groups) => {
removedIndex = Math.max(0, groups.findIndex((g) => g.hash === group.hash));
return groups.filter((g) => g.hash !== group.hash);
});
resolvedCrossHashes.add(group.hash);
bumpSession(freed, 1);
const rollback = () => {
resolvedCrossHashes.delete(group.hash);
bumpSession(freed, -1);
patchCrossCache((groups) =>
groups.some((g) => g.hash === group.hash) ? groups : insertAt(groups, group, removedIndex)
);
};
try {
const result = await archiveDuplicatePaths(losers.map((f) => f.path));
if (result.moved.length === 0) {
rollback();
toast.error('Archive failed', { description: result.errors[0]?.error });
return false;
}
const undo = async () => {
try {
const res = await restoreDuplicatePaths(result.moved);
if (res.errors.length > 0) {
toast.error(`Restore failed for ${res.errors.length} file(s)`, {
description: res.errors[0].error
});
}
rollback();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Undo failed');
}
};
pushUndo(`Archived ${result.moved.length} duplicate(s)`, undo);
if (result.errors.length > 0) {
toast.warning(`Archived ${result.moved.length}, ${result.errors.length} failed`, {
description: result.errors[0].error
});
} else {
toast.success(`Archived ${result.moved.length} · ${formatBytes(freed)} freed`, {
description: losingIndexed
? 'The previously-indexed copy was moved; the indexer drops it on the next pass.'
: undefined,
action: { label: 'Undo', onClick: () => void undo() }
});
}
void queryClient.invalidateQueries({ queryKey: ['photos'] });
return true;
} catch (err) {
rollback();
toast.error(err instanceof Error ? err.message : 'Archive failed');
return false;
}
}

View File

@@ -949,6 +949,9 @@ export async function deleteFolder(relPath: string): Promise<{ path: string }> {
export interface DupFileEntry {
path: string;
size: number;
/** RFC3339 mtime — the only per-copy signal besides path, since all
* copies in a group are byte-identical. */
modTime?: string;
}
export interface CrossFolderDuplicateGroup {
@@ -981,6 +984,21 @@ export async function archiveDuplicatePaths(
return callSidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
}
export interface RestoreDuplicatesResult {
restored: { from: string; to: string }[];
errors: { path: string; error: string }[];
}
/** Inverse of archiveDuplicatePaths: pass the `moved` pairs from the
* archive response verbatim and the sidecar renames each quarantined
* file back to its original path. Powers undo for duplicate/stack
* resolution. */
export async function restoreDuplicatePaths(
moves: { from: string; to: string }[]
): Promise<RestoreDuplicatesResult> {
return callSidecar('POST', '/duplicates/restore', { moves }) as Promise<RestoreDuplicatesResult>;
}
// ── Heap convert (move/copy heap photos to a folder) ────────────────────────
// Lives on the sidecar because moving the underlying files is a filesystem
// operation PhotoPrism's API doesn't expose. The sidecar lists album members