diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts
index c890e4f..7f553ae 100644
--- a/web/src/lib/actions/gridKeyNav.ts
+++ b/web/src/lib/actions/gridKeyNav.ts
@@ -12,6 +12,7 @@ import {
} from '$lib/services/photoprism';
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
+import { photoNameAndDir } from '$lib/types/photoprism';
import { queryClient } from '$lib/queryClient';
import { filters } from '$lib/stores/filters.svelte';
import {
@@ -486,7 +487,10 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
for (const id of ids) {
const p = cachedPhoto(id);
if (!p) return;
- if (!suggestDateFromPath({ fileName: p.FileName, path: p.Path })) return;
+ const { fileName, path } = photoNameAndDir(p);
+ if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
+ return;
+ }
}
e.preventDefault();
void acceptDateAndKeep(ids);
diff --git a/web/src/lib/components/sidebar/RightSidebar.svelte b/web/src/lib/components/sidebar/RightSidebar.svelte
index 6b2c11a..23699d5 100644
--- a/web/src/lib/components/sidebar/RightSidebar.svelte
+++ b/web/src/lib/components/sidebar/RightSidebar.svelte
@@ -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 @@
/>
-
- {#if showDateSuggestion}
+
+ {#if showDateSuggestion && dateSuggestion}
- Suggested from path: {dateSuggestion}
+ Suggested from path: {dateSuggestion.iso}
+ {#if dateSuggestion.source === 'path-ym-default-day'}
+ (estimated day)
+ {/if}
{/if}
diff --git a/web/src/lib/services/photoActions.ts b/web/src/lib/services/photoActions.ts
index 23a2b18..c8b5eeb 100644
--- a/web/src/lib/services/photoActions.ts
+++ b/web/src/lib/services/photoActions.ts
@@ -25,7 +25,7 @@ import { queryClient } from '$lib/queryClient';
import { clearSelection, focusAfter } from '$lib/stores/selection.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
-import type { PpPhoto } from '$lib/types/photoprism';
+import { photoNameAndDir, type PpPhoto } from '$lib/types/photoprism';
/** Walk every cache that might hold a photo's metadata — timeline list
* (flat or infinite), review-groups bucket, per-photo detail — without
@@ -95,9 +95,14 @@ export async function acceptDateAndKeep(uids: string[]): Promise {
if (uids.length === 0) return;
const { updated, errors } = await batchEdit(uids, async (id) => {
const p = cachedPhoto(id);
- const iso = p ? suggestDateFromPath({ fileName: p.FileName, path: p.Path }) : null;
- if (p && iso) {
- await updatePhoto(p, buildTakenAtPatch(`${iso}T00:00:00Z`));
+ if (p) {
+ const { fileName, path } = photoNameAndDir(p);
+ const guess = suggestDateFromPath({
+ fileName,
+ originalName: p.OriginalName,
+ path
+ });
+ if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`));
}
await approvePhoto(id);
return id;
diff --git a/web/src/lib/types/photoprism.ts b/web/src/lib/types/photoprism.ts
index 7dc1fd7..5ac1ffe 100644
--- a/web/src/lib/types/photoprism.ts
+++ b/web/src/lib/types/photoprism.ts
@@ -213,6 +213,23 @@ export interface PpPhotoLabel {
Label?: { Slug: string; Name: string };
}
+/**
+ * Split a photo into its basename + directory portion using the
+ * primary file's relative `Name` (`'2024/02/IMG.jpg'` → `{ fileName:
+ * 'IMG.jpg', path: '2024/02' }`). Falls back to `photo.Path` when the
+ * primary file's `Name` lacks a directory prefix — that pairs with the
+ * list-endpoint shape where `Path` is its own field. Shared so date-
+ * suggestion code paths in RightSidebar / photoActions / gridKeyNav
+ * derive inputs the same way regardless of which cache shape they
+ * have on hand (list vs detail).
+ */
+export function photoNameAndDir(p: PpPhoto): { fileName: string; path: string } {
+ const full = primaryFile(p).Name ?? '';
+ const i = full.lastIndexOf('/');
+ if (i < 0) return { fileName: full, path: p.Path ?? '' };
+ return { fileName: full.slice(i + 1), path: full.slice(0, i) };
+}
+
/**
* Return the photo's primary file (the one with `Primary: true`) or the
* first file if no primary marker is set. Falls back to a synthetic entry
diff --git a/web/src/lib/utils/suggestDateFromPath.ts b/web/src/lib/utils/suggestDateFromPath.ts
index 448586c..c52e75f 100644
--- a/web/src/lib/utils/suggestDateFromPath.ts
+++ b/web/src/lib/utils/suggestDateFromPath.ts
@@ -1,68 +1,219 @@
/**
* Best-effort calendar date guessed from a photo's filename / parent
- * folders. Returns `YYYY-MM-DD`:
- * - high-confidence when year + month + day are all parseable
- * (basename or path)
- * - month-anchored when only year + month appear in the path (day
- * synthesised to `01` so the value drops cleanly into a date
- * input; the user reviews + adjusts before committing)
- * Returns `null` for year-only or unparseable inputs.
+ * folders, with a confidence label so the UI can warn the user when
+ * the day is fabricated.
*
- * Used by the EXIF Stripped review tab to pre-fill the metadata
- * sidebar's date suggestion row.
+ * Conventions we support (no false-positive risk):
+ * - Samsung / Android stock 20240226_135421.jpg
+ * - Google Pixel PXL_20240226_135421.123.jpg
+ * - WhatsApp IMG-20240226-WA0001.jpg
+ * - Telegram photo_2024-02-26_13-54-21.jpg
+ * - macOS screenshot Screen Shot 2024-02-26 at 1.54.21 PM.png
+ * - Android screenshot Screenshot_20240226-135421.png
+ * - Manual dot-format 2024.02.26 - title.jpg
+ * - WeChat mmexport1645900000000.jpeg (13-digit ms)
+ * - Facebook saves FB_IMG_1583926812.jpg (10-digit s)
+ * - Path forms 2024/02/26, 2024-02-26, 2024_02_26,
+ * 2024.02.26, plus Y-M-only 2024/02/
+ *
+ * Conventions we deliberately do NOT parse (locale-ambiguous or no
+ * recoverable signal):
+ * - DD-MM-YYYY / MM-DD-YYYY 26-02-2024.jpg, 02-26-2024.jpg
+ * - 2-digit years 24-02-26.jpg
+ * - Bare sequence numbers IMG_1234.HEIC, DSC_0123.NEF,
+ * GOPR0123.JPG, DJI_0123.JPG
+ *
+ * Used by the EXIF Stripped review tab to surface a date suggestion
+ * row in the metadata sidebar and to power the "Accept date & Keep"
+ * bulk action.
*/
import { isValidISODate } from '$lib/services/photoprism';
interface Input {
- fileName?: string;
- path?: string;
+ fileName?: string; // basename, e.g. '20240226_000000_A6D42DF3.jpg'
+ originalName?: string; // optional second filename signal (PpPhoto.OriginalName)
+ path?: string; // directory portion, e.g. '2024/02'
+}
+
+export interface DateGuess {
+ iso: string;
+ confidence: 'high' | 'medium';
+ source:
+ | 'filename-agrees-path'
+ | 'filename-only'
+ | 'unix-timestamp'
+ | 'path-ymd'
+ | 'path-ym-default-day';
+}
+
+interface YMD {
+ y: number;
+ m: number;
+ d: number;
+}
+
+interface YM {
+ y: number;
+ m: number;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
-function tryDate(y: number, m: number, d: number): string | null {
+function isoOf(ymd: YMD): string {
+ return `${ymd.y}-${pad2(ymd.m)}-${pad2(ymd.d)}`;
+}
+
+function tryYMD(y: number, m: number, d: number): YMD | 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;
+ if (!isValidISODate(`${y}-${pad2(m)}-${pad2(d)}`)) return null;
+ return { y, m, d };
}
-// `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 = /(? 2100) return null;
+ if (m < 1 || m > 12) return null;
+ return { y, m };
+}
-// Path Y-M-D first (e.g. `2007/06/12`, `2007-06-12/`). Y-M fallback
-// (`2024/01/`, `Photos/2024-01/inner`) trips when the user only foldered
-// by month — the day defaults to `01` so the field has a sensible
-// pre-filled value rather than nothing.
-const PATH_YMD = /(? new Date().getUTCFullYear() + 1) return null;
+ return tryYMD(y, d.getUTCMonth() + 1, d.getUTCDate());
+}
+
+function parsePathYMD(path: string): YMD | null {
+ const m = path.match(PATH_YMD);
+ if (!m) return null;
+ return tryYMD(Number(m[1]), Number(m[2]), Number(m[3]));
+}
+
+function parsePathYM(path: string): YM | null {
+ const m = path.match(PATH_YM);
+ if (!m) return null;
+ return tryYM(Number(m[1]), Number(m[2]));
+}
+
+function ymdAgreesWithYM(ymd: YMD, ym: YM): boolean {
+ return ymd.y === ym.y && ymd.m === ym.m;
+}
+
+function ymdEqual(a: YMD, b: YMD): boolean {
+ return a.y === b.y && a.m === b.m && a.d === b.d;
+}
+
+/** Pick the filename-derived YMD that best aligns with the path. When
+ * both `fileName` and `originalName` yield candidates, prefer the one
+ * that matches the path's year+month; ties fall back to `fileName`. */
+function pickFilenameYMD(
+ fileName: string,
+ originalName: string,
+ pathYM: YM | null
+): YMD | null {
+ const candidates: YMD[] = [];
+ const a = parseFilenameYMD(fileName);
+ if (a) candidates.push(a);
+ if (originalName && originalName !== fileName) {
+ const b = parseFilenameYMD(originalName);
+ if (b && !candidates.some((c) => ymdEqual(c, b))) candidates.push(b);
+ }
+ if (candidates.length === 0) return null;
+ if (!pathYM) return candidates[0];
+ const aligned = candidates.find((c) => ymdAgreesWithYM(c, pathYM));
+ return aligned ?? candidates[0];
+}
+
+function pickUnixTimestamp(fileName: string, originalName: string): YMD | null {
+ return (
+ parseUnixTimestampInName(fileName) ??
+ (originalName && originalName !== fileName
+ ? parseUnixTimestampInName(originalName)
+ : null)
+ );
+}
+
+export function suggestDateFromPath(input: Input): DateGuess | null {
const fileName = (input.fileName ?? '').trim();
+ const originalName = (input.originalName ?? '').trim();
const path = (input.path ?? '').trim();
- const fnMatch = fileName.match(BASENAME_YMD);
- if (fnMatch) {
- const iso = tryDate(Number(fnMatch[1]), Number(fnMatch[2]), Number(fnMatch[3]));
- if (iso) return iso;
+ const pathYMD = path ? parsePathYMD(path) : null;
+ const pathYM = path && !pathYMD ? parsePathYM(path) : null;
+
+ // 1. Filename Y-M-D corroborated by the path.
+ const fnYMD = pickFilenameYMD(fileName, originalName, pathYM);
+ if (fnYMD) {
+ if (pathYMD && ymdEqual(fnYMD, pathYMD)) {
+ return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-agrees-path' };
+ }
+ if (pathYM && ymdAgreesWithYM(fnYMD, pathYM)) {
+ return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-agrees-path' };
+ }
+ // 2. Filename Y-M-D with no path signal at all.
+ if (!pathYMD && !pathYM) {
+ return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-only' };
+ }
+ // Filename present but disagrees with path → fall through.
}
- if (path) {
- const ymd = path.match(PATH_YMD);
- if (ymd) {
- const iso = tryDate(Number(ymd[1]), Number(ymd[2]), Number(ymd[3]));
- if (iso) return iso;
+ // 3. Unix epoch in filename, optionally corroborated.
+ const epoch = pickUnixTimestamp(fileName, originalName);
+ if (epoch) {
+ if (!pathYM && !pathYMD) {
+ return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
}
- const ym = path.match(PATH_YM);
- if (ym) {
- const iso = tryDate(Number(ym[1]), Number(ym[2]), 1);
- if (iso) return iso;
+ if (pathYM && ymdAgreesWithYM(epoch, pathYM)) {
+ return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
+ }
+ if (pathYMD && ymdEqual(epoch, pathYMD)) {
+ return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
+ }
+ // disagreement → fall through to path
+ }
+
+ // 4. Path Y-M-D standalone.
+ if (pathYMD) {
+ return { iso: isoOf(pathYMD), confidence: 'high', source: 'path-ymd' };
+ }
+
+ // 5. Path Y-M with synthesised day = 01.
+ if (pathYM) {
+ const ymd = tryYMD(pathYM.y, pathYM.m, 1);
+ if (ymd) {
+ return { iso: isoOf(ymd), confidence: 'medium', source: 'path-ym-default-day' };
}
}