web(review): confidence-aware date guesser with combined filename + path signals
Refactor suggestDateFromPath to combine multiple signals instead of
trying patterns in priority order:
- Filename Y-M-D corroborated by path Y-M/Y-M-D → HIGH (filename-
agrees-path). Fixes the case where a Samsung-style 20240226_xxx.jpg
under 2024/02/ was returning the path-only 2024-02-01.
- Filename Y-M-D with no path signal → HIGH (filename-only).
- 10/13-digit Unix epoch in basename → HIGH (unix-timestamp) —
covers WeChat (mmexport...) and FB saves.
- Path Y-M-D → HIGH (path-ymd).
- Path Y-M only → MEDIUM (path-ym-default-day, synthesised day=01).
Sidebar row labels these "(estimated day)" so the user knows.
Filename parser now accepts `.` and space separators (covers macOS
screenshots, manual 2024.02.26 renames). Path parser accepts `.` too.
OriginalName participates as a secondary filename signal when present
and different from the on-disk basename.
Patterns we explicitly DO NOT parse, to avoid silent date flips:
DD-MM-YYYY / MM-DD-YYYY, 2-digit years, bare camera sequence numbers.
Add photoNameAndDir(p) helper next to primaryFile so RightSidebar,
BulkActionBar, photoActions, and gridKeyNav all derive {fileName,
path} the same way — fixes the bug where photo.FileName was
undefined on the single-photo detail endpoint and the basename branch
was being skipped entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
|
||||
@@ -139,15 +139,20 @@
|
||||
page.url.pathname === '/review' &&
|
||||
page.url.searchParams.get('tab') === 'stripped_exif'
|
||||
);
|
||||
const dateSuggestion = $derived(
|
||||
suggestDateFromPath({ fileName: photo.FileName, path: photo.Path })
|
||||
);
|
||||
const dateSuggestion = $derived.by(() => {
|
||||
const { fileName, path } = photoNameAndDir(photo);
|
||||
return suggestDateFromPath({
|
||||
fileName,
|
||||
originalName: photo.OriginalName,
|
||||
path
|
||||
});
|
||||
});
|
||||
const showDateSuggestion = $derived(
|
||||
onExifStrippedTab && !!dateSuggestion && dateSuggestion !== takenAt
|
||||
onExifStrippedTab && !!dateSuggestion && dateSuggestion.iso !== takenAt
|
||||
);
|
||||
function applyDateSuggestion() {
|
||||
if (!dateSuggestion) return;
|
||||
takenAt = dateSuggestion;
|
||||
takenAt = dateSuggestion.iso;
|
||||
commitTakenAt();
|
||||
}
|
||||
function commitTakenAt() {
|
||||
@@ -344,17 +349,23 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date suggestion derived from the file/folder path. Only shown
|
||||
on the EXIF Stripped review tab; amber styling marks it as
|
||||
unconfirmed. Apply writes the value into the date input above
|
||||
and commits as a manual TakenAt edit. -->
|
||||
{#if showDateSuggestion}
|
||||
<!-- Date suggestion derived from filename / folder signals. Only
|
||||
shown on the EXIF Stripped review tab; amber styling marks
|
||||
it as unconfirmed. `(estimated day)` hint appears when the
|
||||
day was synthesised because only Y-M was available — same
|
||||
row, just so the user knows that part is fabricated. Apply
|
||||
writes the value into the date input above and commits as
|
||||
a manual TakenAt edit. -->
|
||||
{#if showDateSuggestion && dateSuggestion}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded border border-amber-300/70 bg-amber-50/40 px-1.5 py-1 text-[11px] text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
|
||||
>
|
||||
<Folder class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
Suggested from path: <span class="font-medium">{dateSuggestion}</span>
|
||||
Suggested from path: <span class="font-medium">{dateSuggestion.iso}</span>
|
||||
{#if dateSuggestion.source === 'path-ym-default-day'}
|
||||
<span class="text-amber-600/80 dark:text-amber-400/70">(estimated day)</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { batchEdit } from '$lib/services/batch';
|
||||
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
import { photoNameAndDir } from '$lib/types/photoprism';
|
||||
import {
|
||||
clearBulkToFirst,
|
||||
clearSelection,
|
||||
@@ -82,7 +83,10 @@
|
||||
for (const id of ids) {
|
||||
const p = cachedPhoto(id);
|
||||
if (!p) return false;
|
||||
if (!suggestDateFromPath({ fileName: p.FileName, path: p.Path })) return false;
|
||||
const { fileName, path } = photoNameAndDir(p);
|
||||
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -268,13 +272,13 @@
|
||||
the archive section. Everything else (heap, restore)
|
||||
is hidden so the choice reads as decisive. -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onApprove}
|
||||
title="Keep — accept into timeline"
|
||||
>
|
||||
✓ Keep
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
||||
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
|
||||
</button>
|
||||
{#if allHaveSuggestion}
|
||||
<!-- Visible only when every selected photo has a path-
|
||||
@@ -292,7 +296,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onArchive}
|
||||
title="Archive"
|
||||
@@ -307,13 +311,13 @@
|
||||
photo is already archived; the destructive styling
|
||||
reinforces the irreversibility. -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onRestore}
|
||||
title="Keep — restore to timeline"
|
||||
>
|
||||
✓ Keep
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
||||
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-destructive/40 bg-destructive/5 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
@@ -327,13 +331,13 @@
|
||||
{:else}
|
||||
<div class="relative">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={() => (heapPickerOpen = !heapPickerOpen)}
|
||||
title="Add to heap (S then 1–9 picks a heap)"
|
||||
>
|
||||
+ Add to heap
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground"
|
||||
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90"
|
||||
>S N</kbd
|
||||
>
|
||||
</button>
|
||||
@@ -372,7 +376,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onArchive}
|
||||
title="Archive"
|
||||
@@ -382,7 +386,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
||||
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onclick={clearAll}
|
||||
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user