feat(duplicates): sidebar count + auto-run cross-folder scan
Sidebar Duplicates badge now sums stacks + cross-folder groups, with cross-folder observed from cache (no eager disk scan from the sidebar). Cross-folder tab auto-fires the scan on access; button becomes Rescan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,9 +12,10 @@
|
|||||||
mule-sidecar. Resolution moves the unwanted copies into a
|
mule-sidecar. Resolution moves the unwanted copies into a
|
||||||
`.duplicates/` quarantine folder PhotoPrism's indexer ignores.
|
`.duplicates/` quarantine folder PhotoPrism's indexer ignores.
|
||||||
|
|
||||||
The cross-folder scan is opt-in (button-triggered) rather than
|
The cross-folder scan auto-fires when its tab is active — with size
|
||||||
auto-run because it's an O(disk) operation. With size pre-filtering
|
pre-filtering it stays fast (~250ms for 400 files in practice) and a
|
||||||
the scan stays fast (~250ms for 400 files in practice).
|
long staleTime keeps tab bounces from re-running it. The button is
|
||||||
|
kept for manual "rescan after I moved files" refreshes.
|
||||||
|
|
||||||
Tabs themselves render in the parent route's Toolbar so they line up
|
Tabs themselves render in the parent route's Toolbar so they line up
|
||||||
visually with the `/tags` pill row.
|
visually with the `/tags` pill row.
|
||||||
@@ -42,23 +43,19 @@
|
|||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
// Cross-folder scan is a manually-triggered query: `enabled` stays
|
// Cross-folder scan auto-fires when the tab is active. The 5-minute
|
||||||
// false until the user clicks "Scan filesystem". Subsequent clicks
|
// staleTime means a fresh visit reuses the prior result; the
|
||||||
// invalidate the cache so each press kicks a fresh scan.
|
// "Rescan filesystem" button invalidates to force a re-scan after
|
||||||
let scanRequested = $state(false);
|
// the user has moved files around.
|
||||||
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
|
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||||
queryKey: ['duplicates-cross-folder'],
|
queryKey: ['duplicates-cross-folder'],
|
||||||
queryFn: scanCrossFolderDuplicates,
|
queryFn: scanCrossFolderDuplicates,
|
||||||
enabled: scanRequested && activeTab === 'cross-folder',
|
enabled: activeTab === 'cross-folder',
|
||||||
staleTime: 5 * 60_000
|
staleTime: 5 * 60_000
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function triggerScan() {
|
function rescan() {
|
||||||
if (scanRequested && !crossQuery.isFetching) {
|
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
|
||||||
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
|
|
||||||
} else {
|
|
||||||
scanRequested = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -114,24 +111,17 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
disabled={crossQuery.isFetching}
|
disabled={crossQuery.isFetching}
|
||||||
onclick={triggerScan}
|
onclick={rescan}
|
||||||
>
|
>
|
||||||
{#if crossQuery.isFetching}
|
{#if crossQuery.isFetching}
|
||||||
Scanning…
|
Scanning…
|
||||||
{:else if scanRequested}
|
|
||||||
Rescan filesystem
|
|
||||||
{:else}
|
{:else}
|
||||||
Scan filesystem
|
Rescan filesystem
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if !scanRequested}
|
{#if crossQuery.isFetching && !crossQuery.data}
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
Click <em>Scan filesystem</em> to look for byte-identical files spread across
|
|
||||||
folders. Pre-filtered by size, so even large libraries finish in a few seconds.
|
|
||||||
</p>
|
|
||||||
{:else if crossQuery.isFetching && !crossQuery.data}
|
|
||||||
<p class="text-sm text-muted-foreground">Hashing files under originals…</p>
|
<p class="text-sm text-muted-foreground">Hashing files under originals…</p>
|
||||||
{:else if crossQuery.isError}
|
{:else if crossQuery.isError}
|
||||||
<p class="text-sm text-destructive">
|
<p class="text-sm text-destructive">
|
||||||
|
|||||||
@@ -21,13 +21,19 @@
|
|||||||
logout,
|
logout,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
renameHeap,
|
renameHeap,
|
||||||
|
scanCrossFolderDuplicates,
|
||||||
triggerDownload,
|
triggerDownload,
|
||||||
|
type CrossFolderScanResult,
|
||||||
type ImportInfo,
|
type ImportInfo,
|
||||||
type PhotoMarksMap,
|
type PhotoMarksMap,
|
||||||
type PpAlbum,
|
type PpAlbum,
|
||||||
type PpClientConfig,
|
type PpClientConfig,
|
||||||
type PpFolder
|
type PpFolder
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
|
import {
|
||||||
|
listDuplicateGroups,
|
||||||
|
type DuplicateGroup
|
||||||
|
} from '$lib/services/adapters/duplicates';
|
||||||
import {
|
import {
|
||||||
filters,
|
filters,
|
||||||
setFolderPath,
|
setFolderPath,
|
||||||
@@ -101,6 +107,25 @@
|
|||||||
staleTime: 60_000
|
staleTime: 60_000
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Duplicates counts for the sidebar badge. Stacks is a cheap
|
||||||
|
// PhotoPrism query so we always fetch it; cross-folder is an
|
||||||
|
// O(disk) scan, so the sidebar only *observes* its cache
|
||||||
|
// (enabled:false) and the duplicates page itself is what populates
|
||||||
|
// it on first visit. Both share queryKeys with the /duplicates
|
||||||
|
// view so cache is reused.
|
||||||
|
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||||
|
queryKey: ['duplicates'],
|
||||||
|
queryFn: listDuplicateGroups,
|
||||||
|
enabled: isAuthenticated(),
|
||||||
|
staleTime: 60_000
|
||||||
|
}));
|
||||||
|
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||||
|
queryKey: ['duplicates-cross-folder'],
|
||||||
|
queryFn: scanCrossFolderDuplicates,
|
||||||
|
enabled: false,
|
||||||
|
staleTime: 5 * 60_000
|
||||||
|
}));
|
||||||
|
|
||||||
const ratingsCount = $derived(countRatings(marksQuery.data));
|
const ratingsCount = $derived(countRatings(marksQuery.data));
|
||||||
const colorsCount = $derived(countColors(marksQuery.data));
|
const colorsCount = $derived(countColors(marksQuery.data));
|
||||||
|
|
||||||
@@ -365,7 +390,9 @@
|
|||||||
// "everything visible in the main timeline" tally), so it matches what
|
// "everything visible in the main timeline" tally), so it matches what
|
||||||
// the All photos view actually renders. `places` is the count of
|
// the All photos view actually renders. `places` is the count of
|
||||||
// geocoded locations — semantically what the Map view groups by.
|
// geocoded locations — semantically what the Map view groups by.
|
||||||
// Duplicates has no precomputed counter; we omit its badge.
|
// Duplicates sums stacks + cross-folder groups; cross-folder only
|
||||||
|
// contributes once its tab has been visited (the scan is opt-in
|
||||||
|
// per-visit, not eager from the sidebar).
|
||||||
type ViewItem =
|
type ViewItem =
|
||||||
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
||||||
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
||||||
@@ -395,7 +422,16 @@
|
|||||||
|
|
||||||
const manageViews: ViewItem[] = [
|
const manageViews: ViewItem[] = [
|
||||||
{ kind: 'route', href: '/review', label: 'Review', getCount: () => configQuery.data?.count?.review },
|
{ kind: 'route', href: '/review', label: 'Review', getCount: () => configQuery.data?.count?.review },
|
||||||
{ kind: 'route', href: '/duplicates', label: 'Duplicates', getCount: () => undefined },
|
{
|
||||||
|
kind: 'route',
|
||||||
|
href: '/duplicates',
|
||||||
|
label: 'Duplicates',
|
||||||
|
getCount: () => {
|
||||||
|
const stacks = stacksQuery.data?.length;
|
||||||
|
if (stacks === undefined) return undefined;
|
||||||
|
return stacks + (crossFolderQuery.data?.groups.length ?? 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
|
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
|
||||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived }
|
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived }
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user