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:
@@ -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);
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = /(?<!\d)(\d{4})[-_]?(\d{2})[-_]?(\d{2})(?!\d)/;
|
||||
function tryYM(y: number, m: number): YM | null {
|
||||
if (y < 1900 || y > 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 = /(?<!\d)(\d{4})[-_/](\d{2})[-_/](\d{2})(?!\d)/;
|
||||
const PATH_YM = /(?<!\d)(\d{4})[-_/](\d{2})(?!\d)/;
|
||||
// `YYYY[sep]MM[sep]DD` for basenames. sep ∈ {nothing, -, _, ., space}. The
|
||||
// non-digit lookbehind/ahead keeps a leading prefix like `PXL_` and a
|
||||
// trailing time like `_135421` from polluting the match.
|
||||
const BASENAME_YMD = /(?<!\d)(\d{4})[-_. ]?(\d{2})[-_. ]?(\d{2})(?!\d)/;
|
||||
|
||||
export function suggestDateFromPath(input: Input): string | null {
|
||||
// Path Y-M-D and Y-M. Includes `/` for directory separators and `.` for
|
||||
// rare dot-organised libraries (`Photos/2024.02/...`).
|
||||
const PATH_YMD = /(?<!\d)(\d{4})[-_/.](\d{2})[-_/.](\d{2})(?!\d)/;
|
||||
const PATH_YM = /(?<!\d)(\d{4})[-_/.](\d{2})(?!\d)/;
|
||||
|
||||
// 10- or 13-digit Unix epoch, anchored. Years widened to [1990, current+1]
|
||||
// to dodge accidental matches on phone numbers, hex hashes containing
|
||||
// digits, etc. — but 10-digit seconds still has to round-trip into a
|
||||
// plausible calendar year before we trust it.
|
||||
const BASENAME_EPOCH = /(?<!\d)(\d{10}|\d{13})(?!\d)/;
|
||||
|
||||
function parseFilenameYMD(name: string): YMD | null {
|
||||
const m = name.match(BASENAME_YMD);
|
||||
if (!m) return null;
|
||||
return tryYMD(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||
}
|
||||
|
||||
function parseUnixTimestampInName(name: string): YMD | null {
|
||||
const m = name.match(BASENAME_EPOCH);
|
||||
if (!m) return null;
|
||||
const digits = m[1];
|
||||
const ms = digits.length === 13 ? Number(digits) : Number(digits) * 1000;
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
const d = new Date(ms);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const y = d.getUTCFullYear();
|
||||
if (y < 1990 || y > 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' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user