diff --git a/sidecar/fs.go b/sidecar/fs.go index 4c5a9d7..3d363e8 100644 --- a/sidecar/fs.go +++ b/sidecar/fs.go @@ -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 }) diff --git a/sidecar/handlers_dups.go b/sidecar/handlers_dups.go index 6e960dc..c6513c3 100644 --- a/sidecar/handlers_dups.go +++ b/sidecar/handlers_dups.go @@ -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//` 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}) + } +} diff --git a/sidecar/main.go b/sidecar/main.go index bd65f0f..d0c3d8a 100644 --- a/sidecar/main.go +++ b/sidecar/main.go @@ -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 { diff --git a/web/src/lib/actions/zoomPan.ts b/web/src/lib/actions/zoomPan.ts new file mode 100644 index 0000000..f68a125 --- /dev/null +++ b/web/src/lib/actions/zoomPan.ts @@ -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: + * + *
(zp = s), resetKey: uid }}> + *
… + * + * `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); + } + }; +} diff --git a/web/src/lib/components/duplicates/CompareLightbox.svelte b/web/src/lib/components/duplicates/CompareLightbox.svelte new file mode 100644 index 0000000..e75df03 --- /dev/null +++ b/web/src/lib/components/duplicates/CompareLightbox.svelte @@ -0,0 +1,156 @@ + + + + + + diff --git a/web/src/lib/components/duplicates/CrossFolderGroupCard.svelte b/web/src/lib/components/duplicates/CrossFolderGroupCard.svelte index 543ead8..a870eb8 100644 --- a/web/src/lib/components/duplicates/CrossFolderGroupCard.svelte +++ b/web/src/lib/components/duplicates/CrossFolderGroupCard.svelte @@ -1,43 +1,33 @@ - -{#if activeTab === 'stacks'} -
- {#if pending} - - {:else if error} - - {:else if groups.length === 0} - - {#snippet descriptionSnippet()} -

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

- {/snippet} -
- {:else} -
- {#each groups as group, i (group.photo.UID)} - - {/each} -
+ +
+
+ + {activeGroups.length} + {activeTab === 'stacks' ? 'stack' : 'group'}{activeGroups.length === 1 ? '' : 's'} + + {#if reclaimableBytes > 0} + + + {formatBytes(reclaimableBytes)} reclaimable + + {/if} + {#if dupSession.resolved > 0} + + Resolved {dupSession.resolved} · {formatBytes(dupSession.freedBytes)} freed this session + {/if}
-{/if} + {#if activeTab === 'cross-folder'} + + {/if} +
- -{#if activeTab === 'cross-folder'} -
-
-

- Byte-identical files the indexer dropped at index time. Found by scanning the - originals tree directly. -

- -
- - {#if crossQuery.isFetching && !crossQuery.data} - - {:else if crossQuery.isError} - - {:else if crossCount === 0} - - {#snippet descriptionSnippet()} - {#if crossQuery.data} -

- scanned in {crossQuery.data.scannedMs} ms + +

+ + {#if activeTab === 'stacks'} +
+ {#if pending} + + {:else if error} + + {:else if liveStackGroups.length === 0} + + {#snippet descriptionSnippet()} +

+ 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.

- {/if} - {/snippet} -
- {:else} -
- {#each crossQuery.data?.groups ?? [] as group, i (group.hash)} - - {/each} -
- {/if} -
-{/if} + {/snippet} + + {:else} +
+ {#each liveStackGroups.slice(0, renderCount) as group, i (group.photo.UID)} + (focusedIndex = i)} + onResolved={onGroupResolved} + /> + {/each} +
+ {#if renderCount < liveStackGroups.length} +
+ {/if} + {/if} +
+ {/if} + + + {#if activeTab === 'cross-folder'} +
+

+ Byte-identical files the indexer dropped at index time. Found by scanning the originals + tree directly. +

+ + {#if crossQuery.isFetching && !crossQuery.data} + + {:else if crossQuery.isError} + + {:else if crossCount === 0} + + {#snippet descriptionSnippet()} + {#if crossQuery.data} +

+ scanned in {crossQuery.data.scannedMs} ms +

+ {/if} + {/snippet} +
+ {:else} +
+ {#each liveCrossGroups.slice(0, renderCount) as group, i (group.hash)} + (focusedIndex = i)} + onResolved={onGroupResolved} + /> + {/each} +
+ {#if renderCount < liveCrossGroups.length} +
+ {/if} + {/if} +
+ {/if} +
diff --git a/web/src/lib/components/duplicates/StackGroupCard.svelte b/web/src/lib/components/duplicates/StackGroupCard.svelte index 656e96e..3c7b146 100644 --- a/web/src/lib/components/duplicates/StackGroupCard.svelte +++ b/web/src/lib/components/duplicates/StackGroupCard.svelte @@ -1,101 +1,90 @@ - @@ -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'}" >
@@ -216,74 +176,115 @@ {group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''}
- + Compare + Space + + +
-
- {#each group.files as file (file.UID)} +
+ {#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} {/each}
+ +{#if compareOpen} + { + best = uid; + compareOpen = false; + sectionEl?.focus({ preventScroll: true }); + }} + onClose={() => { + compareOpen = false; + sectionEl?.focus({ preventScroll: true }); + }} + /> +{/if} diff --git a/web/src/lib/components/layout/ShortcutsDialog.svelte b/web/src/lib/components/layout/ShortcutsDialog.svelte index 3afd17c..b8d616c 100644 --- a/web/src/lib/components/layout/ShortcutsDialog.svelte +++ b/web/src/lib/components/layout/ShortcutsDialog.svelte @@ -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' } + ] } ]; diff --git a/web/src/lib/components/preview/PreviewPane.svelte b/web/src/lib/components/preview/PreviewPane.svelte index 304427e..47e2d88 100644 --- a/web/src/lib/components/preview/PreviewPane.svelte +++ b/web/src/lib/components/preview/PreviewPane.svelte @@ -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 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(); - 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 switches to + // fit_2048 so zoomed pixels stay crisp. + let zp = $state({ zoom: 1, tx: 0, ty: 0, panning: false });
@@ -229,26 +154,19 @@ photoQuery.data.OriginalName ?? pf.Name ?? (isVideo(photoQuery.data) ? 'Video' : 'Photo')} -
1 + ? zp.panning ? 'cursor-grabbing' : 'cursor-grab' : 'cursor-zoom-in'}" >
{#if pf.Width && pf.Height}