web(review): scope date suggestion to EXIF Stripped tab + 'a' shortcut
- Gate the sidebar's "Suggested from path" row on /review?tab=stripped_exif instead of a per-photo TakenSrc heuristic — PhotoPrism stores a guessed TakenAt for stripped-EXIF photos too, so the heuristic was hiding the row even when a path-derived date was available. - Same gate on the BulkActionBar's "Accept date & Keep" button. - Extract acceptDateAndKeep() + cachedPhoto() into photoActions so the bar button and a new bare-'a' shortcut in gridKeyNav share one path. - Show an 'A' kbd hint on the bar button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
removeFromHeap,
|
removeFromHeap,
|
||||||
type PpAlbum
|
type PpAlbum
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
|
import { acceptDateAndKeep } from '$lib/services/photoActions';
|
||||||
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 {
|
||||||
@@ -498,6 +499,30 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
if (meta) {
|
if (meta) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
for (const id of selection.order) selection.ids.add(id);
|
for (const id of selection.order) selection.ids.add(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shift) return;
|
||||||
|
// Bare `a` on the EXIF Stripped review tab fires the same
|
||||||
|
// "Accept date & Keep" flow the bar button uses. Scoped to
|
||||||
|
// that tab so the key stays free everywhere else (where a
|
||||||
|
// path-derived date wouldn't make sense as a one-shot
|
||||||
|
// 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 (
|
||||||
|
filters.section === 'review' &&
|
||||||
|
new URL(window.location.href).searchParams.get('tab') === 'stripped_exif'
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) {
|
||||||
|
toast.message('Nothing to keep', {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void acceptDateAndKeep(ids);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case 'x':
|
case 'x':
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
PUT (Details fields need the full body).
|
PUT (Details fields need the full body).
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import {
|
import {
|
||||||
@@ -128,19 +129,21 @@
|
|||||||
commit({ Caption: caption, CaptionSrc: 'manual' });
|
commit({ Caption: caption, CaptionSrc: 'manual' });
|
||||||
}
|
}
|
||||||
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
|
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
|
||||||
// Path-based date guess. Surfaced only when the photo's stored date is
|
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
|
||||||
// missing or untrusted — same heuristic the review adapter uses to
|
// are the photos with definitionally-untrusted dates, and showing the
|
||||||
// bucket photos into `stripped_exif`. Tying it to the heuristic rather
|
// row anywhere else would compete with the existing TakenAt.
|
||||||
// than a specific tab means the suggestion shows up wherever the user
|
// PhotoPrism stores a TakenAt for stripped-EXIF photos too (filename
|
||||||
// lands on a date-less photo (timeline drill-in, archive, etc.).
|
// guess or file mtime), so a per-photo "needs date" heuristic would
|
||||||
const needsDate = $derived(
|
// silently hide the suggestion — the tab is the more reliable signal.
|
||||||
!photo.TakenSrc || photo.TakenSrc === 'name' || !photo.TakenAt
|
const onExifStrippedTab = $derived(
|
||||||
|
page.url.pathname === '/review' &&
|
||||||
|
page.url.searchParams.get('tab') === 'stripped_exif'
|
||||||
);
|
);
|
||||||
const dateSuggestion = $derived(
|
const dateSuggestion = $derived(
|
||||||
suggestDateFromPath({ fileName: photo.FileName, path: photo.Path })
|
suggestDateFromPath({ fileName: photo.FileName, path: photo.Path })
|
||||||
);
|
);
|
||||||
const showDateSuggestion = $derived(
|
const showDateSuggestion = $derived(
|
||||||
needsDate && !!dateSuggestion && dateSuggestion !== takenAt
|
onExifStrippedTab && !!dateSuggestion && dateSuggestion !== takenAt
|
||||||
);
|
);
|
||||||
function applyDateSuggestion() {
|
function applyDateSuggestion() {
|
||||||
if (!dateSuggestion) return;
|
if (!dateSuggestion) return;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import {
|
import {
|
||||||
@@ -7,15 +8,13 @@
|
|||||||
batchArchive,
|
batchArchive,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
batchRestore,
|
batchRestore,
|
||||||
buildTakenAtPatch,
|
|
||||||
listHeaps,
|
listHeaps,
|
||||||
removeFromHeap,
|
removeFromHeap,
|
||||||
updatePhoto,
|
|
||||||
type PpAlbum
|
type PpAlbum
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { batchEdit } from '$lib/services/batch';
|
import { batchEdit } from '$lib/services/batch';
|
||||||
|
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
||||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
import type { PpPhoto } from '$lib/types/photoprism';
|
|
||||||
import {
|
import {
|
||||||
clearBulkToFirst,
|
clearBulkToFirst,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
@@ -50,43 +49,6 @@
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Walk every cache that might hold this UID's metadata: the timeline
|
|
||||||
* list (flat or infinite), the review-groups bucket, and the
|
|
||||||
* per-photo detail. Mirrors `gridKeyNav`'s `cachedPhoto` so the date-
|
|
||||||
* suggestion lookup behaves consistently with the rest of the
|
|
||||||
* selection plumbing. */
|
|
||||||
function cachedPhoto(uid: string): PpPhoto | undefined {
|
|
||||||
const lists = qc.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const review = qc.getQueryData<{ photos?: PpPhoto[] }[]>(['review-groups']);
|
|
||||||
if (review) {
|
|
||||||
for (const group of review) {
|
|
||||||
const hit = group.photos?.find((p) => p.UID === uid);
|
|
||||||
if (hit) return hit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return qc.getQueryData<PpPhoto>(['photo', uid]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function suggestionFor(p: PpPhoto): string | null {
|
|
||||||
const needs = !p.TakenSrc || p.TakenSrc === 'name' || !p.TakenAt;
|
|
||||||
if (!needs) return null;
|
|
||||||
return suggestDateFromPath({ fileName: p.FileName, path: p.Path });
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetCount = $derived(
|
const targetCount = $derived(
|
||||||
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
||||||
);
|
);
|
||||||
@@ -96,12 +58,15 @@
|
|||||||
// heap-adding / restoring. The S keybinding is rerouted to approve
|
// heap-adding / restoring. The S keybinding is rerouted to approve
|
||||||
// from gridKeyNav for the same reason.
|
// from gridKeyNav for the same reason.
|
||||||
const isReview = $derived(filters.section === 'review');
|
const isReview = $derived(filters.section === 'review');
|
||||||
// "Accept date & Keep" surfaces only on review section, and only when
|
// "Accept date & Keep" is scoped to the EXIF Stripped review tab —
|
||||||
// at least one targeted photo has a derivable date suggestion that it
|
// that's where path-derived dates are the most useful fix. Outside the
|
||||||
// would actually benefit from (no trusted TakenAt). Reads strictly from
|
// tab the button stays hidden even if a selected photo would otherwise
|
||||||
// query caches — uids missing from cache count as "no suggestion".
|
// have a path-parseable date, to keep other tabs uncluttered.
|
||||||
|
const onExifStrippedTab = $derived(
|
||||||
|
isReview && page.url.searchParams.get('tab') === 'stripped_exif'
|
||||||
|
);
|
||||||
const hasAnySuggestion = $derived.by(() => {
|
const hasAnySuggestion = $derived.by(() => {
|
||||||
if (!isReview) return false;
|
if (!onExifStrippedTab) return false;
|
||||||
const ids =
|
const ids =
|
||||||
selection.ids.size > 0
|
selection.ids.size > 0
|
||||||
? Array.from(selection.ids)
|
? Array.from(selection.ids)
|
||||||
@@ -110,7 +75,9 @@
|
|||||||
: [];
|
: [];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const p = cachedPhoto(id);
|
const p = cachedPhoto(id);
|
||||||
if (p && suggestionFor(p)) return true;
|
if (p && suggestDateFromPath({ fileName: p.FileName, path: p.Path })) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
@@ -161,29 +128,7 @@
|
|||||||
async function onAcceptDateAndKeep() {
|
async function onAcceptDateAndKeep() {
|
||||||
const ids = snapshotIds();
|
const ids = snapshotIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
await withBusy(async () => {
|
await withBusy(() => acceptDateAndKeep(ids));
|
||||||
// For each id: if there's a path-derived date and the photo lacks a
|
|
||||||
// trusted TakenAt, apply the date patch first; then approve. UIDs
|
|
||||||
// without a usable suggestion just get approved. Errors are tallied
|
|
||||||
// per-id rather than aborting the loop.
|
|
||||||
const { updated, errors } = await batchEdit(ids, async (id) => {
|
|
||||||
const p = cachedPhoto(id);
|
|
||||||
const iso = p ? suggestionFor(p) : null;
|
|
||||||
if (p && iso) {
|
|
||||||
await updatePhoto(p, buildTakenAtPatch(`${iso}T00:00:00Z`));
|
|
||||||
}
|
|
||||||
await approvePhoto(id);
|
|
||||||
return id;
|
|
||||||
});
|
|
||||||
if (errors.length) {
|
|
||||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
|
|
||||||
} else {
|
|
||||||
toast.success(`Kept ${ids.length}`);
|
|
||||||
}
|
|
||||||
focusAfter(ids);
|
|
||||||
clearSelection();
|
|
||||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onArchive() {
|
async function onArchive() {
|
||||||
@@ -339,6 +284,7 @@
|
|||||||
title="Accept the date suggested from the file/folder path, then keep"
|
title="Accept the date suggested from the file/folder path, then keep"
|
||||||
>
|
>
|
||||||
📅 Accept date & Keep
|
📅 Accept date & Keep
|
||||||
|
<kbd class="rounded bg-amber-200/40 px-1 text-[9px] font-medium text-amber-900 dark:bg-amber-500/30 dark:text-amber-100">A</kbd>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -14,10 +14,49 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { batchEdit } from './batch';
|
import { batchEdit } from './batch';
|
||||||
import { invalidatePhotos } from './bulk';
|
import { invalidatePhotos } from './bulk';
|
||||||
import { approvePhoto, batchArchive, batchRestore } from './photoprism';
|
import {
|
||||||
|
approvePhoto,
|
||||||
|
batchArchive,
|
||||||
|
batchRestore,
|
||||||
|
buildTakenAtPatch,
|
||||||
|
updatePhoto
|
||||||
|
} from './photoprism';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
import { clearSelection, focusAfter } from '$lib/stores/selection.svelte';
|
import { clearSelection, focusAfter } from '$lib/stores/selection.svelte';
|
||||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
|
import 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
|
||||||
|
* forcing a refetch. Returns undefined when the uid hasn't been seen.
|
||||||
|
* Shared by callers that need to look up photo state by uid from
|
||||||
|
* outside a component (gridKeyNav, photoActions). */
|
||||||
|
export 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 pg of pages) {
|
||||||
|
const hit = pg?.find?.((p) => p.UID === uid);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const review = queryClient.getQueryData<{ photos?: PpPhoto[] }[]>(['review-groups']);
|
||||||
|
if (review) {
|
||||||
|
for (const group of review) {
|
||||||
|
const hit = group.photos?.find((p) => p.UID === uid);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dismiss photos out of the review queue by bumping their quality
|
* Dismiss photos out of the review queue by bumping their quality
|
||||||
@@ -44,6 +83,38 @@ export async function dismissPhotos(uids: string[]): Promise<void> {
|
|||||||
toast.success(`Dismissed ${uids.length}`);
|
toast.success(`Dismissed ${uids.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the selected uids, applying each photo's path-derived date
|
||||||
|
* suggestion (when one exists) before approving it. UIDs without a
|
||||||
|
* suggestion fall through to a plain approve. Used by the EXIF Stripped
|
||||||
|
* review tab — the `📅 Accept date & Keep` button and the bare `a`
|
||||||
|
* keyboard shortcut both route here so wording / focus / toast
|
||||||
|
* behaviour stay in lockstep.
|
||||||
|
*/
|
||||||
|
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`));
|
||||||
|
}
|
||||||
|
await approvePhoto(id);
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
focusAfter(uids);
|
||||||
|
clearSelection();
|
||||||
|
invalidatePhotos(uids);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||||
|
if (errors.length) {
|
||||||
|
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||||
|
description: errors[0].message
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`Kept ${uids.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
|
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user