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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<PhotoDetails> & { id: string }) => {
|
||||
onSuccess: (
|
||||
updated: Partial<PhotoDetails> & { id: string },
|
||||
variables,
|
||||
) => {
|
||||
queryClient.setQueryData<PhotoDetails>(['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<string, string> = {
|
||||
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)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user