web(review): YYYY/MM path fallback + suggestion row below date + tighten 'a' gate
- suggestDateFromPath: when only year+month appear in the path (e.g. 2024/01/), synthesize day=01 so date-only foldering yields a usable suggestion instead of null. - RightSidebar: move the suggestion row below the Taken-at input. - BulkActionBar + gridKeyNav: show the "Accept date & Keep" button and fire the bare 'a' shortcut only when EVERY targeted photo has a path-derivable date — no more silent approve-without-fix for mixed selections. - gridKeyNav: drop local cachedPhoto duplicate, use the shared one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,8 @@ import {
|
|||||||
removeFromHeap,
|
removeFromHeap,
|
||||||
type PpAlbum
|
type PpAlbum
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { acceptDateAndKeep } from '$lib/services/photoActions';
|
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
||||||
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
import { filters } from '$lib/stores/filters.svelte';
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +27,6 @@ import {
|
|||||||
} from '$lib/stores/selection.svelte';
|
} from '$lib/stores/selection.svelte';
|
||||||
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
|
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
||||||
import type { PpPhoto } from '$lib/types/photoprism';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
||||||
@@ -161,35 +161,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Look up a photo's current cached state without forcing a refetch.
|
|
||||||
* Walks every `['photos', …]` cache entry first, then the per-photo
|
|
||||||
* cache. Lets `x` decide "archive vs restore" based on the actual current
|
|
||||||
* state instead of always sending Archived=true.
|
|
||||||
*
|
|
||||||
* The `['photos', …]` namespace holds two shapes: a flat `PpPhoto[]`
|
|
||||||
* (e.g. ratings/colors pools) and TanStack's `InfiniteData` envelope
|
|
||||||
* (`{pages: PpPhoto[][], pageParams}`) used by the timeline's infinite
|
|
||||||
* scroll. Walk both — assuming a flat array on the timeline cache used
|
|
||||||
* to throw `list.find is not a function` and abort the F/X handlers. */
|
|
||||||
function cachedPhoto(uid: string): PpPhoto | undefined {
|
|
||||||
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
|
|
||||||
for (const [, data] of lists) {
|
|
||||||
if (!data) continue;
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
|
||||||
if (hit) return hit;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
|
||||||
if (!Array.isArray(pages)) continue;
|
|
||||||
for (const page of pages) {
|
|
||||||
const hit = page?.find?.((p) => p.UID === uid);
|
|
||||||
if (hit) return hit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
|
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
|
||||||
const ids = cullTargets();
|
const ids = cullTargets();
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
@@ -503,25 +474,21 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
}
|
}
|
||||||
if (shift) return;
|
if (shift) return;
|
||||||
// Bare `a` on the EXIF Stripped review tab fires the same
|
// Bare `a` on the EXIF Stripped review tab fires the same
|
||||||
// "Accept date & Keep" flow the bar button uses. Scoped to
|
// "Accept date & Keep" flow as the bar button. Mirrors the
|
||||||
// that tab so the key stays free everywhere else (where a
|
// bar's all-targets-have-a-suggestion gate so the shortcut
|
||||||
// path-derived date wouldn't make sense as a one-shot
|
// can't silently approve photos without a date fix.
|
||||||
// shortcut). Reads location directly because actions sit
|
|
||||||
// outside the component tree where `$app/state` is
|
|
||||||
// idiomatic — same approach used by `filters.section`
|
|
||||||
// elsewhere in this file.
|
|
||||||
if (
|
if (
|
||||||
filters.section === 'review' &&
|
filters.section === 'review' &&
|
||||||
new URL(window.location.href).searchParams.get('tab') === 'stripped_exif'
|
new URL(window.location.href).searchParams.get('tab') === 'stripped_exif'
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
|
||||||
const ids = cullTargets();
|
const ids = cullTargets();
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) return;
|
||||||
toast.message('Nothing to keep', {
|
for (const id of ids) {
|
||||||
description: 'Click a photo or select some first'
|
const p = cachedPhoto(id);
|
||||||
});
|
if (!p) return;
|
||||||
return;
|
if (!suggestDateFromPath({ fileName: p.FileName, path: p.Path })) return;
|
||||||
}
|
}
|
||||||
|
e.preventDefault();
|
||||||
void acceptDateAndKeep(ids);
|
void acceptDateAndKeep(ids);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -329,11 +329,25 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Date suggestion derived from the file/folder path. Shown only
|
<!-- Taken at -->
|
||||||
when the photo's stored date is missing or untrusted (the
|
<div class="flex items-center gap-2">
|
||||||
`stripped_exif` heuristic). Amber styling marks it as
|
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
unconfirmed — clicking Apply commits as a manual TakenAt
|
<input
|
||||||
edit. -->
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
pattern="\d{4}-\d{2}-\d{2}"
|
||||||
|
aria-invalid={!takenAtValid}
|
||||||
|
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:text-destructive aria-invalid:focus:ring-destructive"
|
||||||
|
bind:value={takenAt}
|
||||||
|
onblur={commitTakenAt}
|
||||||
|
/>
|
||||||
|
</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}
|
{#if showDateSuggestion}
|
||||||
<div
|
<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"
|
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"
|
||||||
@@ -352,21 +366,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Taken at -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputmode="numeric"
|
|
||||||
placeholder="YYYY-MM-DD"
|
|
||||||
pattern="\d{4}-\d{2}-\d{2}"
|
|
||||||
aria-invalid={!takenAtValid}
|
|
||||||
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:text-destructive aria-invalid:focus:ring-destructive"
|
|
||||||
bind:value={takenAt}
|
|
||||||
onblur={commitTakenAt}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input
|
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input
|
||||||
padding on filename / date so the read-only text starts at the
|
padding on filename / date so the read-only text starts at the
|
||||||
same x-offset as the editable rows above — otherwise spans
|
same x-offset as the editable rows above — otherwise spans
|
||||||
|
|||||||
@@ -65,7 +65,12 @@
|
|||||||
const onExifStrippedTab = $derived(
|
const onExifStrippedTab = $derived(
|
||||||
isReview && page.url.searchParams.get('tab') === 'stripped_exif'
|
isReview && page.url.searchParams.get('tab') === 'stripped_exif'
|
||||||
);
|
);
|
||||||
const hasAnySuggestion = $derived.by(() => {
|
// Surface the button only when EVERY targeted photo has a derivable
|
||||||
|
// suggestion — otherwise clicking it would silently approve some
|
||||||
|
// photos without a date fix, which contradicts the verb. A uid not in
|
||||||
|
// any cache also counts as "no suggestion" so we don't promise
|
||||||
|
// something we can't verify.
|
||||||
|
const allHaveSuggestion = $derived.by(() => {
|
||||||
if (!onExifStrippedTab) return false;
|
if (!onExifStrippedTab) return false;
|
||||||
const ids =
|
const ids =
|
||||||
selection.ids.size > 0
|
selection.ids.size > 0
|
||||||
@@ -73,13 +78,13 @@
|
|||||||
: selection.focused
|
: selection.focused
|
||||||
? [selection.focused]
|
? [selection.focused]
|
||||||
: [];
|
: [];
|
||||||
|
if (ids.length === 0) return false;
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const p = cachedPhoto(id);
|
const p = cachedPhoto(id);
|
||||||
if (p && suggestDateFromPath({ fileName: p.FileName, path: p.Path })) {
|
if (!p) return false;
|
||||||
return true;
|
if (!suggestDateFromPath({ fileName: p.FileName, path: p.Path })) return false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
return true;
|
||||||
});
|
});
|
||||||
// Archive section is the parallel two-button flow: Keep (restore back
|
// Archive section is the parallel two-button flow: Keep (restore back
|
||||||
// to the timeline) or Delete (permanent, no undo). X is repurposed
|
// to the timeline) or Delete (permanent, no undo). X is repurposed
|
||||||
@@ -271,12 +276,11 @@
|
|||||||
✓ Keep
|
✓ Keep
|
||||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
||||||
</button>
|
</button>
|
||||||
{#if hasAnySuggestion}
|
{#if allHaveSuggestion}
|
||||||
<!-- Surfaces only when at least one selected photo has a
|
<!-- Visible only when every selected photo has a path-
|
||||||
derivable date from its path AND lacks a trusted
|
derivable date. Clicking applies each photo's
|
||||||
TakenAt. Applies the suggested date patch then
|
suggestion then approves it; mirrored by the bare
|
||||||
approves in one go; uids without a usable suggestion
|
`a` shortcut in gridKeyNav. -->
|
||||||
are just approved. -->
|
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 rounded border border-amber-400/60 bg-amber-100/40 px-2 py-0.5 text-[11px] text-amber-800 hover:bg-amber-100 disabled:opacity-50 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
|
class="inline-flex items-center gap-1 rounded border border-amber-400/60 bg-amber-100/40 px-2 py-0.5 text-[11px] text-amber-800 hover:bg-amber-100 disabled:opacity-50 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Best-effort calendar date guessed from a photo's filename / parent
|
* Best-effort calendar date guessed from a photo's filename / parent
|
||||||
* folders. Returns `YYYY-MM-DD` only when year + month + day are all
|
* folders. Returns `YYYY-MM-DD`:
|
||||||
* present and form a real date; returns `null` for year-only or
|
* - high-confidence when year + month + day are all parseable
|
||||||
* unparseable inputs.
|
* (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.
|
||||||
*
|
*
|
||||||
* Used by the EXIF Stripped review tab to pre-fill the metadata
|
* Used by the EXIF Stripped review tab to pre-fill the metadata
|
||||||
* sidebar's date suggestion row.
|
* sidebar's date suggestion row.
|
||||||
@@ -32,8 +36,12 @@ function tryDate(y: number, m: number, d: number): string | null {
|
|||||||
// `20070612` cleanly without bleeding into the trailing timestamp.
|
// `20070612` cleanly without bleeding into the trailing timestamp.
|
||||||
const BASENAME_YMD = /(?<!\d)(\d{4})[-_]?(\d{2})[-_]?(\d{2})(?!\d)/;
|
const BASENAME_YMD = /(?<!\d)(\d{4})[-_]?(\d{2})[-_]?(\d{2})(?!\d)/;
|
||||||
|
|
||||||
// Path variant accepts `/` as a separator too — `2007/06/12`, `2007-06-12/`.
|
// 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_YMD = /(?<!\d)(\d{4})[-_/](\d{2})[-_/](\d{2})(?!\d)/;
|
||||||
|
const PATH_YM = /(?<!\d)(\d{4})[-_/](\d{2})(?!\d)/;
|
||||||
|
|
||||||
export function suggestDateFromPath(input: Input): string | null {
|
export function suggestDateFromPath(input: Input): string | null {
|
||||||
const fileName = (input.fileName ?? '').trim();
|
const fileName = (input.fileName ?? '').trim();
|
||||||
@@ -46,9 +54,14 @@ export function suggestDateFromPath(input: Input): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (path) {
|
if (path) {
|
||||||
const pMatch = path.match(PATH_YMD);
|
const ymd = path.match(PATH_YMD);
|
||||||
if (pMatch) {
|
if (ymd) {
|
||||||
const iso = tryDate(Number(pMatch[1]), Number(pMatch[2]), Number(pMatch[3]));
|
const iso = tryDate(Number(ymd[1]), Number(ymd[2]), Number(ymd[3]));
|
||||||
|
if (iso) return iso;
|
||||||
|
}
|
||||||
|
const ym = path.match(PATH_YM);
|
||||||
|
if (ym) {
|
||||||
|
const iso = tryDate(Number(ym[1]), Number(ym[2]), 1);
|
||||||
if (iso) return iso;
|
if (iso) return iso;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user