diff --git a/web/src/lib/components/duplicates/CrossFolderGroupCard.svelte b/web/src/lib/components/duplicates/CrossFolderGroupCard.svelte index c1eae69..543ead8 100644 --- a/web/src/lib/components/duplicates/CrossFolderGroupCard.svelte +++ b/web/src/lib/components/duplicates/CrossFolderGroupCard.svelte @@ -179,7 +179,7 @@ bind:this={sectionEl} tabindex="0" role="application" - aria-label={`Cross-folder duplicate · ${group.files.length} copies`} + aria-label={`Duplicate group · ${group.files.length} copies`} 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" diff --git a/web/src/lib/components/duplicates/DuplicatesView.svelte b/web/src/lib/components/duplicates/DuplicatesView.svelte index 4e85564..bdd7c7c 100644 --- a/web/src/lib/components/duplicates/DuplicatesView.svelte +++ b/web/src/lib/components/duplicates/DuplicatesView.svelte @@ -65,7 +65,7 @@ toast.error( crossQuery.error instanceof Error ? crossQuery.error.message - : 'Cross-folder scan failed' + : 'Duplicates scan failed' ); } }); @@ -91,7 +91,7 @@

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 Cross-folder tab. + the Duplicates tab.

{/snippet} @@ -105,9 +105,9 @@ {/if} - + {#if activeTab === 'cross-folder'} -
+

Byte-identical files the indexer dropped at index time. Found by scanning the @@ -139,7 +139,7 @@ : 'unknown error'} /> {:else if crossCount === 0} - + {#snippet descriptionSnippet()} {#if crossQuery.data}

diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index 18b7e9a..092a789 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -18,12 +18,14 @@ listFolderCounts, listFolders, listHeaps, + listPhotosWithNotes, logout, renameFolder, renameHeap, scanCrossFolderDuplicates, triggerDownload, type CrossFolderScanResult, + type PhotoWithNote, type PpAlbum, type PpClientConfig, type PpFolder @@ -133,16 +135,15 @@ })); } - // One query per badge. Admins with no BasePath skip these + // One query per badge. Admins with no BasePath skip this // (enabled:false via `wantScoped`) and the configQuery numbers are // used directly — same chrome as before that fix, no extra - // round-trips. Review has no aggregate badge (it's a pure toggle in - // the sidebar now, like Tags), so it doesn't appear here. - const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true'); + // round-trip. Review and Hidden have no aggregate badge (pure + // toggles in the sidebar now, like Tags), so they don't appear here. const archivedCountQuery = scopedCountQuery('archived', 'archived:true'); function bucketCount( - key: 'hidden' | 'archived', + key: 'archived', query: { data: number | undefined; isPending: boolean } ): number | undefined { if (wantScoped) { @@ -175,6 +176,17 @@ staleTime: 5 * 60_000 })); + // Notes-view badge. Cheap (one list round-trip, no fan-out) so we + // fetch eagerly — sharing the queryKey with /notes means the page hits + // the warm cache, and the ['photos', …] prefix lets existing mutation + // invalidations keep both in sync. + const notesQuery = createQuery(() => ({ + queryKey: ['photos', 'with-notes'], + queryFn: listPhotosWithNotes, + enabled: isAuthenticated(), + staleTime: 60_000 + })); + const folderTree = $derived( buildTree((foldersQuery.data ?? []).map((f) => f.Path)) ); @@ -232,10 +244,9 @@ : (scopedRootCountQuery.data?.[''] ?? 0) ); - // Hidden / Archive nav entries use these derived values rather than - // peeking at configQuery directly so the scoped path is invisible to - // the manageViews[] declarations. - const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery)); + // Archive nav entry uses this derived value rather than peeking at + // configQuery directly so the scoped path is invisible to the + // manageViews[] declarations. const archivedBadge = $derived(bucketCount('archived', archivedCountQuery)); const createMut = createMutation(() => ({ @@ -347,16 +358,18 @@ })); type ReviewTabId = CauseKey | 'stacks' | 'cross-folder'; - // Stacks + Cross-folder are always present on the /review tab strip - // regardless of count (cross-folder's scan is lazy from its own panel), - // so they tail every cause-tab list the sidebar renders. + // Stacks + Duplicates are always present on the /review tab strip + // regardless of count (the cross-folder scan is lazy from its own + // panel), so they tail every cause-tab list the sidebar renders. + // The 'cross-folder' tab id is kept internal/URL-stable; the label + // the user sees is "Duplicates". const reviewTabs = $derived<{ id: ReviewTabId; label: string }[]>([ ...(reviewGroupsQuery.data ?? []).map((g) => ({ id: g.cause as ReviewTabId, label: g.meta.title })), { id: 'stacks', label: 'Stacks' }, - { id: 'cross-folder', label: 'Cross-folder' } + { id: 'cross-folder', label: 'Duplicates' } ]); const reviewActive = $derived(page.url.pathname === '/review'); @@ -537,9 +550,14 @@ // Tags is rendered as a bespoke expandable block below the // `views` loop — it has sub-categories (Labels/Keywords/Colors/ // Ratings) and a chevron, neither of which fits the flat - // section/route ViewItem shape. + // section/route ViewItem shape. Notes lives under that expandable + // alongside the tag categories. ]; + function isNotesActive(): boolean { + return page.url.pathname === '/notes'; + } + // Review is rendered separately below as a pure expandable toggle // (mirroring Tags — no /review landing entry from the sidebar, // navigation only via subitems, with Hidden tucked in alongside the @@ -862,6 +880,34 @@ {#if tagsExpanded} + + {@const notesActive = isNotesActive()} + {@const notesCount = notesQuery.data?.length} + + Notes + {#if notesCount !== undefined} + + {notesCount} + + {/if} + {#each TAG_CATEGORIES as cat (cat)} {@const active = isTagCategoryActive(cat)} navigateTo('hidden')} > Hidden - {#if hiddenBadge !== undefined} - - {hiddenBadge} - - {/if} {/if} {#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)} diff --git a/web/src/lib/components/review/CauseGroupCard.svelte b/web/src/lib/components/review/CauseGroupCard.svelte index 3b9e338..71f6009 100644 --- a/web/src/lib/components/review/CauseGroupCard.svelte +++ b/web/src/lib/components/review/CauseGroupCard.svelte @@ -1,171 +1,29 @@ - - -

+
@@ -248,128 +56,30 @@ ({group.photos.length})
-
- - -
- +
{group.meta.suggestion} - {#if group.meta.suggestedAction !== 'manual'} + {#if group.meta.suggestedAction === 'archive'} {/if}
-
- {#each group.photos as photo, i (photo.UID)} - {@const causes = deriveCauses(photo)} - {@const isFocused = i === focusedIdx} - -
onSelect?.(photo)} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSelect?.(photo); - } - }} - class:ring-2={isFocused} - class:ring-blue-500={isFocused} - class:ring-offset-2={isFocused} - class:ring-offset-background={isFocused} - class="group relative flex cursor-pointer flex-col overflow-hidden rounded-md border border-border bg-secondary text-left transition-shadow" - > -
- {photo.FileName - {#if dims(photo)} - - {dims(photo)} - - {/if} - -
- - -
-
-
- -
- {photo.FileName ?? photo.Name ?? ''} -
-
-
- {/each} -
+
diff --git a/web/src/lib/components/sidebar/RelatedStrip.svelte b/web/src/lib/components/sidebar/RelatedStrip.svelte deleted file mode 100644 index b60ba30..0000000 --- a/web/src/lib/components/sidebar/RelatedStrip.svelte +++ /dev/null @@ -1,109 +0,0 @@ - - - -{#if stripQuery.isPending} -
-
-{:else if stripQuery.isError} - - {null} -{:else if photos.length > 0} -
- -
- {#each photos as p (p.UID)} - - {/each} -
-
-{/if} diff --git a/web/src/lib/components/sidebar/RightSidebar.svelte b/web/src/lib/components/sidebar/RightSidebar.svelte index 8211de7..31ea364 100644 --- a/web/src/lib/components/sidebar/RightSidebar.svelte +++ b/web/src/lib/components/sidebar/RightSidebar.svelte @@ -39,15 +39,12 @@ import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte'; import { primaryFile, type PpPhoto } from '$lib/types/photoprism'; import { COLOR_SWATCHES } from '$lib/utils/tagGroups'; - import RelatedStrip from './RelatedStrip.svelte'; + import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath'; interface Props { - /** When true, append related-photo strips (folder/camera/year) below - * Keywords. Used by the /review route; left off on the timeline. */ - showRelated?: boolean; photo: PpPhoto; } - let { photo, showRelated = false }: Props = $props(); + let { photo }: Props = $props(); const qc = useQueryClient(); @@ -131,6 +128,25 @@ commit({ Caption: caption, CaptionSrc: 'manual' }); } const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt)); + // Path-based date guess. Surfaced only when the photo's stored date is + // missing or untrusted — same heuristic the review adapter uses to + // bucket photos into `stripped_exif`. Tying it to the heuristic rather + // than a specific tab means the suggestion shows up wherever the user + // lands on a date-less photo (timeline drill-in, archive, etc.). + const needsDate = $derived( + !photo.TakenSrc || photo.TakenSrc === 'name' || !photo.TakenAt + ); + const dateSuggestion = $derived( + suggestDateFromPath({ fileName: photo.FileName, path: photo.Path }) + ); + const showDateSuggestion = $derived( + needsDate && !!dateSuggestion && dateSuggestion !== takenAt + ); + function applyDateSuggestion() { + if (!dateSuggestion) return; + takenAt = dateSuggestion; + commitTakenAt(); + } function commitTakenAt() { if (!takenAt) return; if (!isValidISODate(takenAt)) { @@ -310,6 +326,29 @@ {/if}
+ + {#if showDateSuggestion} +
+ + + Suggested from path: {dateSuggestion} + + +
+ {/if} +
@@ -373,25 +412,14 @@
- -
-
Note
- -
- - +
+
+
Note
+ +
+
Score
@@ -504,35 +543,6 @@
- - {#if showRelated} -
- - {#if photo.CameraID && photo.CameraID !== 1} - - {/if} - {#if photo.Year} - - {/if} -
- {/if} - + + {/if}
diff --git a/web/src/lib/services/photoprism.ts b/web/src/lib/services/photoprism.ts index b62c7b2..419ca55 100644 --- a/web/src/lib/services/photoprism.ts +++ b/web/src/lib/services/photoprism.ts @@ -478,6 +478,33 @@ export interface AggregatedKeyword { sampleHash: string; } +/** + * Photos carrying a non-empty user note. mule-image's "Note" is + * PhotoPrism's `Caption` field (see RightSidebar's Note textarea), which + * is a top-level scalar — present on the list response, so a single + * round-trip is enough. + */ +export interface PhotoWithNote { + photo: PpPhoto; + note: string; +} + +export async function listPhotosWithNotes(): Promise { + const list = await listPhotos({ count: 1000, order: 'newest', merged: true }); + const out: PhotoWithNote[] = []; + const seen = new Set(); + for (const p of list) { + // `merged: true` can repeat a photo across file-rows; dedupe by UID + // so the same tile doesn't render twice. + if (seen.has(p.UID)) continue; + seen.add(p.UID); + const note = p.Caption?.trim(); + if (!note) continue; + out.push({ photo: p, note }); + } + return out; +} + export async function aggregateKeywords(): Promise { const list = await listPhotos({ count: 1000, merged: true }); const buckets = new Map(); diff --git a/web/src/lib/utils/suggestDateFromPath.ts b/web/src/lib/utils/suggestDateFromPath.ts new file mode 100644 index 0000000..0069786 --- /dev/null +++ b/web/src/lib/utils/suggestDateFromPath.ts @@ -0,0 +1,57 @@ +/** + * Best-effort calendar date guessed from a photo's filename / parent + * folders. Returns `YYYY-MM-DD` only when year + month + day are all + * present and form a real date; returns `null` for year-only or + * unparseable inputs. + * + * Used by the EXIF Stripped review tab to pre-fill the metadata + * sidebar's date suggestion row. + */ + +import { isValidISODate } from '$lib/services/photoprism'; + +interface Input { + fileName?: string; + path?: string; +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +function tryDate(y: number, m: number, d: number): string | null { + if (y < 1900 || y > 2100) return null; + if (m < 1 || m > 12) return null; + if (d < 1 || d > 31) return null; + const iso = `${y}-${pad2(m)}-${pad2(d)}`; + return isValidISODate(iso) ? iso : null; +} + +// `YYYY[sep]MM[sep]DD` where `sep` is an optional `-` or `_`. Anchored by +// non-digit boundaries on both sides so `IMG_20070612_135421.jpg` matches +// `20070612` cleanly without bleeding into the trailing timestamp. +const BASENAME_YMD = /(? + import { createQuery } from '@tanstack/svelte-query'; + import { + getPhoto, + listPhotosWithNotes, + type PhotoWithNote + } from '$lib/services/photoprism'; + import { isAuthenticated } from '$lib/stores/session.svelte'; + import { setRightSidebarWidth, view } from '$lib/stores/view.svelte'; + import { gridKeyNav } from '$lib/actions/gridKeyNav'; + import { resizable } from '$lib/actions/resizable'; + import { selection } from '$lib/stores/selection.svelte'; + import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte'; + import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; + import NotesPhotoGrid from '$lib/components/timeline/NotesPhotoGrid.svelte'; + import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte'; + import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte'; + import Toolbar from '$lib/components/layout/Toolbar.svelte'; + import { EmptyState, InlineLoader } from '$lib/components/feedback'; + import { AlertCircle, MousePointerClick, StickyNote } from 'lucide-svelte'; + import type { PpPhoto } from '$lib/types/photoprism'; + + // Photos with a non-empty Details.Notes. The query is shared by the + // LeftSidebar's count badge ($derived off the same key), so visiting + // /notes warms the badge and vice-versa. Keyed under the ['photos', …] + // prefix so the existing mutation invalidations cascade in. + const notesQuery = createQuery(() => ({ + queryKey: ['photos', 'with-notes'], + queryFn: listPhotosWithNotes, + enabled: isAuthenticated(), + staleTime: 60_000 + })); + + const items = $derived(notesQuery.data ?? []); + const count = $derived(items.length); + + // Right-sidebar metadata for the focused tile. Same wiring as the tag + // drill-in page so the metadata panel reads consistently. + const focusedPhotoQuery = createQuery(() => ({ + queryKey: ['photo', selection.focused ?? ''], + queryFn: () => + selection.focused ? getPhoto(selection.focused) : Promise.resolve(null), + enabled: isAuthenticated() && Boolean(selection.focused), + staleTime: 0 + })); + + + + + Notes + + {#if !notesQuery.isPending && !notesQuery.isError} + + {count} photo{count === 1 ? '' : 's'} + + {/if} + + +
+
+
+ {#if notesQuery.isPending} + + {:else if notesQuery.isError} + + {:else if items.length === 0} + + {:else} + + {/if} +
+ +
+ + {#if !view.rightSidebarCollapsed} + + {/if} +
diff --git a/web/src/routes/review/+page.svelte b/web/src/routes/review/+page.svelte index cc209bd..98aa419 100644 --- a/web/src/routes/review/+page.svelte +++ b/web/src/routes/review/+page.svelte @@ -115,7 +115,7 @@ count: g.photos.length as number | undefined })), { id: 'stacks', label: 'Stacks', count: stacksCount }, - { id: 'cross-folder', label: 'Cross-folder', count: crossFolderCount } + { id: 'cross-folder', label: 'Duplicates', count: crossFolderCount } ]); const requestedTab = $derived(page.url.searchParams.get('tab')); @@ -212,7 +212,7 @@

The indexer flags photos with a low quality score for human review. New arrivals with missing EXIF, low resolution, or unknown cameras will land - here. The Stacks and Cross-folder tabs above stay available for + here. The Stacks and Duplicates tabs above stay available for duplicate cleanup.

{/snippet} @@ -235,7 +235,7 @@ {#if selection.ids.size >= 2} {:else if focusedPhotoQuery.data} - + {:else if focusedPhotoQuery.isFetching} {/if}