From 09c12ea35b9130d1473561f1dce13362ecdbb561 Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 11 May 2026 22:29:25 +0200 Subject: [PATCH] fix(scan): only resurrect discards when file changed; add Saved toast The scan_folder resurrect path was unflagging every discarded photo on every backend boot. start_initial_scan fires scan_all_source_roots on container start, which fans out scan_folder for every source root, which walked every file and silently set is_discarded=False on rows whose file was still on disk -- so every deploy wiped the user's discard decisions. Today's series of resurrect log lines for admin/Photos came from that path, not from any actual user re-upload. Gate the resurrect on os.path.getmtime(file) > discarded_at so the WebDAV-DELETE-then-re-upload and trashbin-restore-via-PUT-overwrite flows still trigger (those rewrite the file and bump mtime), but routine sweeps respect the user's intent. Rows with discarded_at NULL (legacy) fall through to skipped -- preserve intent over cleanup. While there: add a Saved toast to the single-photo updateMutation. The previous patch made cache writes synchronous, which removed the visible save delay but also removed any signal that the change was actually persisted. Toast picks a per-field label from the patched keys (Title updated / Date updated / etc.) and falls back to a count for multi-field saves. --- backend/app/tasks/scan.py | 55 ++++++++++++++----- .../src/components/sidebar/PhotoInfoPanel.tsx | 34 +++++++++++- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index 7f533e2..27f8d9b 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -206,21 +206,50 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta ) existing_photo = existing.scalar_one_or_none() if existing_photo is not None: - # Resurrect a previously-discarded row if the - # file is back on disk. WebDAV DELETE + - # re-upload, trashbin restore via PUT-overwrite, - # and any "I removed it then put it back" flow - # all land here. Re-queue extract_metadata in - # case the bytes changed (different EXIF, new - # nextcloud_fileid). + # Resurrect a previously-discarded row only + # when the file's mtime is newer than + # discarded_at. Bare existence on disk isn't + # proof the user changed their mind: every + # backend boot fires scan_all_source_roots, + # which used to walk every file and silently + # un-discard the lot. The mtime check still + # covers the legitimate flows (WebDAV DELETE + # + re-upload, trashbin restore via PUT- + # overwrite, any "I removed it then put it + # back") because those rewrite the file and + # bump mtime past the discard time. Rows + # with discarded_at IS NULL (legacy) are + # left alone — preserve user intent over + # best-effort cleanup. if existing_photo.is_discarded: - existing_photo.is_discarded = False - existing_photo.discarded_at = None - await session.commit() - logger.info( - f"Resurrected discarded photo on rescan: {filepath}" + discarded_at = existing_photo.discarded_at + try: + mtime = os.path.getmtime(filepath) + except OSError: + mtime = 0.0 + file_modified_after_discard = ( + discarded_at is not None + and mtime + > discarded_at.replace( + tzinfo=timezone.utc + ).timestamp() ) - extract_metadata.delay(existing_photo.id) + if file_modified_after_discard: + existing_photo.is_discarded = False + existing_photo.discarded_at = None + await session.commit() + logger.info( + f"Resurrected discarded photo " + f"(file modified after discard): " + f"{filepath}" + ) + extract_metadata.delay(existing_photo.id) + else: + logger.debug( + f"Skipping discarded photo " + f"(file unchanged since discard): " + f"{filepath}" + ) else: logger.debug(f"File already indexed: {filepath}") processed_files += 1 diff --git a/frontend/src/components/sidebar/PhotoInfoPanel.tsx b/frontend/src/components/sidebar/PhotoInfoPanel.tsx index a6d0262..129ed99 100644 --- a/frontend/src/components/sidebar/PhotoInfoPanel.tsx +++ b/frontend/src/components/sidebar/PhotoInfoPanel.tsx @@ -181,7 +181,10 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro color_label?: string | null taken_at?: string }) => photosApi.update(photoId, data), - onSuccess: (updated: Partial & { id: string }) => { + onSuccess: ( + updated: Partial & { id: string }, + variables, + ) => { queryClient.setQueryData(['photo', photoId], (prev) => prev ? { ...prev, ...updated } : (updated as PhotoDetails), ) @@ -194,6 +197,35 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro ) queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) + + // Confirmation toast. Cache updates are synchronous now, so without + // a toast a successful save is invisible (the field shows the new + // value but it's the same one the user just typed). Pick a label + // from the changed field; fall back to a count for multi-field + // patches and a generic message if neither applies. + const fieldLabels: Record = { + filename: 'Filename updated', + rating: 'Rating updated', + color_label: 'Color updated', + user_title: 'Title updated', + user_notes: 'Notes updated', + taken_at: 'Date updated', + } + const fields = Object.keys(variables) + let detail: string + if (fields.length === 1) { + const k = fields[0] + if (k === 'is_discarded') { + detail = variables.is_discarded ? 'Discarded' : 'Restored' + } else { + detail = fieldLabels[k] ?? 'Updated' + } + } else if (fields.length > 1) { + detail = `${fields.length} fields updated` + } else { + detail = 'Updated' + } + toast.success('Saved', detail) }, })