feat: editable taken_at + folder-based date repair and filter

Lets operators fix corrupted capture dates at scale. Adds an editable
Date Taken field with a folder/filename-derived suggestion hint, a bulk
Date Taken section in the multi-select sidebar that either applies one
date to the whole selection or infers a per-photo date from each path,
a warning badge on thumbnails whose stored date disagrees with the
path, and a "Date issues" filter pill so suspicious photos can be
surfaced and fixed as a group. Edits are written back to EXIF on disk
so rescans don't clobber the fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 11:48:55 +02:00
parent 339e1be510
commit 30d03d8d4d
19 changed files with 1281 additions and 26 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Star, X, ArrowDown, ArrowUp, Search } from 'lucide-react'
import { Star, X, ArrowDown, ArrowUp, Search, AlertTriangle } from 'lucide-react'
import clsx from 'clsx'
import {
useFilterStore,
@@ -42,6 +42,8 @@ export function FilterBar() {
const ratingMin = useFilterStore((s) => s.ratingMin)
const colorLabel = useFilterStore((s) => s.colorLabel)
const flag = useFilterStore((s) => s.flag)
const dateWarning = useFilterStore((s) => s.dateWarning)
const setDateWarning = useFilterStore((s) => s.setDateWarning)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
const tagIds = useFilterStore((s) => s.tagIds)
@@ -322,6 +324,38 @@ export function FilterBar() {
</FilterPill>
)}
{/* Date issues — toggle-only pill. Restricts the grid to photos
* whose path-based date guess disagrees with the stored taken_at,
* so operators can find and fix a whole library's worth of
* corrupted or missing capture dates in one pass. Backed by the
* `photos.has_date_warning` column set at scan time. */}
<button
onClick={() => setDateWarning(!dateWarning)}
className={clsx(
'flex h-7 flex-shrink-0 items-center gap-1 whitespace-nowrap rounded-full border px-2.5 text-xs transition-colors',
dateWarning
? 'border-amber-500/60 bg-amber-500/15 text-amber-300 hover:bg-amber-500/25'
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
title={
dateWarning
? 'Showing only photos with suspicious capture dates'
: 'Show only photos whose folder/filename suggests a different date'
}
>
<AlertTriangle className="h-3 w-3" />
Date issues
{dateWarning && (
<X
className="ml-0.5 h-3 w-3"
onClick={(e) => {
e.stopPropagation()
setDateWarning(false)
}}
/>
)}
</button>
{/* Sort — always present, never "active/inactive" since there's
always a value. */}
<FilterPill label="Sort" value={sortValue} isActive>

View File

@@ -2,12 +2,18 @@ import { useState } from 'react'
import { X, Star, ShoppingBasket, Trash2, Plus, PanelRightClose } from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import { usePhotoStore } from '../../store/photoStore'
import {
photos as photosApi,
heaps as heapsApi,
tags as tagsApi,
} from '../../services/api'
import type { Photo } from '../../types/photo'
import {
guessDateFromPath,
type DateGuess,
} from '../../lib/guessDateFromPath'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
@@ -59,6 +65,47 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
onSuccess: invalidatePhotoQueries,
})
// Shared report-and-invalidate tail for both bulk taken_at mutations.
// They return a partial-apply shape (updated/skipped/errors) because
// EXIF writes can fail per-photo (unsupported format, missing file)
// without wrecking the rest of the batch.
const reportBulkTakenAt = (
data: {
status: string
updated: number
skipped: number
errors: { id: string; message: string }[]
},
) => {
const errCount = data.errors?.length ?? 0
const detail =
errCount > 0
? `${data.updated} updated · ${errCount} error${errCount === 1 ? '' : 's'}`
: `${data.updated} updated`
if (errCount > 0) {
toast.error('Date update partial', detail)
} else {
toast.success('Dates updated', detail)
}
invalidatePhotoQueries()
}
const bulkTakenAtMutation = useMutation({
mutationFn: ({ ids, iso }: { ids: string[]; iso: string }) =>
photosApi.bulkSetTakenAt(ids, iso),
onSuccess: reportBulkTakenAt,
onError: (e: any) =>
toast.error('Date update failed', e?.message || 'Unknown error'),
})
const bulkTakenAtMapMutation = useMutation({
mutationFn: (map: Record<string, string>) =>
photosApi.bulkSetTakenAtMap(map),
onSuccess: reportBulkTakenAt,
onError: (e: any) =>
toast.error('Date update failed', e?.message || 'Unknown error'),
})
// Bulk tag mutations. Tag mutations also need to invalidate the tags
// query so the FilterBar / sidebar tag counts stay fresh.
const invalidateTagsAndPhotos = () => {
@@ -228,6 +275,37 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
// ── Multi-photo: bulk action panel ──────────────────────────────────
const allMembers = selectedPhotos.every((id) => activeHeapMembers.has(id))
/** Walk the react-query cache for every selected id and return the
* full Photo records. Checks the standalone `['photo', id]` entry
* first (populated whenever a single-photo view or preview opens),
* then falls back to scanning every cached timeline list for the
* id. Any id with no cached record is skipped — the selection UI
* can't act on a photo the user hasn't loaded yet anyway. */
const collectSelectedPhotos = (): Photo[] => {
const out: Photo[] = []
const seen = new Set<string>()
for (const id of selectedPhotos) {
if (seen.has(id)) continue
const direct = queryClient.getQueryData<Photo>(['photo', id])
if (direct) {
out.push(direct)
seen.add(id)
continue
}
const lists = queryClient.getQueriesData<Photo[]>({ queryKey: ['photos'] })
for (const [, list] of lists) {
if (!list) continue
const hit = list.find((p) => p.id === id)
if (hit) {
out.push(hit)
seen.add(id)
break
}
}
}
return out
}
return (
<div className="flex h-full flex-col bg-surface">
<Header />
@@ -361,11 +439,170 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
}}
/>
</div>
{/* Bulk Date Taken — lets an operator repair the capture date on
* a whole selection at once, either by applying one date to
* everything or by inferring a per-photo date from each file's
* folder path and filename. Useful for cameras that lost their
* clock (1970 epoch) and for legacy libraries where the folder
* structure is the only trustworthy date signal. */}
<div>
<label className="mb-1 block text-xs text-text-muted">Date Taken</label>
<BulkTakenAtEditor
disabled={
bulkTakenAtMutation.isPending || bulkTakenAtMapMutation.isPending
}
selectedCount={selectedPhotos.length}
collectPhotos={collectSelectedPhotos}
onApplyUniform={(iso) =>
bulkTakenAtMutation.mutate({ ids: selectedPhotos, iso })
}
onApplyMap={(map) => bulkTakenAtMapMutation.mutate(map)}
/>
</div>
</div>
</div>
)
}
interface BulkTakenAtEditorProps {
disabled: boolean
selectedCount: number
collectPhotos: () => Photo[]
onApplyUniform: (iso: string) => void
onApplyMap: (map: Record<string, string>) => void
}
/** Bulk Date Taken sub-panel used by RightSidebar in multi-select mode.
* Two modes share one UI:
* 1. Apply-one: user types a datetime, clicks Apply, every selected
* photo is rewritten to that date.
* 2. Guess-from-path: we run `guessDateFromPath` against each selected
* photo's filepath, show a preview of the hits + misses, and let
* the user commit the per-photo map in one round-trip. */
function BulkTakenAtEditor({
disabled,
selectedCount,
collectPhotos,
onApplyUniform,
onApplyMap,
}: BulkTakenAtEditorProps) {
const [uniformDraft, setUniformDraft] = useState('')
const [preview, setPreview] = useState<
| {
hits: { photo: Photo; guess: DateGuess }[]
misses: Photo[]
}
| null
>(null)
const handleGuess = () => {
const photos = collectPhotos()
const hits: { photo: Photo; guess: DateGuess }[] = []
const misses: Photo[] = []
for (const p of photos) {
const g = guessDateFromPath(p.filepath)
if (g) hits.push({ photo: p, guess: g })
else misses.push(p)
}
setPreview({ hits, misses })
}
const handleApplyPreview = () => {
if (!preview) return
const map: Record<string, string> = {}
for (const { photo, guess } of preview.hits) {
map[photo.id] = guess.date.toISOString()
}
if (Object.keys(map).length === 0) return
onApplyMap(map)
setPreview(null)
}
const handleApplyUniform = () => {
if (!uniformDraft) return
const parsed = new Date(uniformDraft)
if (Number.isNaN(parsed.getTime())) return
onApplyUniform(parsed.toISOString())
}
return (
<div className="space-y-2">
{/* Apply-one row */}
<div className="flex items-center gap-1.5">
<input
type="datetime-local"
value={uniformDraft}
onChange={(e) => setUniformDraft(e.target.value)}
disabled={disabled}
className="flex-1 rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none disabled:opacity-50"
/>
<button
onClick={handleApplyUniform}
disabled={disabled || !uniformDraft}
className="rounded bg-primary/20 px-2 py-1 text-xs text-primary hover:bg-primary/30 disabled:cursor-not-allowed disabled:opacity-40"
title={`Apply this date to all ${selectedCount} selected`}
>
Apply
</button>
</div>
{/* Guess-from-path preview */}
{preview === null ? (
<button
onClick={handleGuess}
disabled={disabled}
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
title="Scan each photo's folder + filename for a date pattern"
>
Guess from folder paths
</button>
) : (
<div className="rounded border border-border bg-bg p-2 text-[11px]">
<div className="mb-1.5 text-text-muted">
{preview.hits.length} will update ·{' '}
{preview.misses.length} skipped
</div>
{preview.hits.length > 0 && (
<ul className="mb-1.5 max-h-24 space-y-0.5 overflow-y-auto font-mono text-[10px] text-text">
{preview.hits.slice(0, 5).map(({ photo, guess }) => (
<li key={photo.id} className="truncate" title={photo.filepath}>
<span className="text-text-muted">{photo.filename}</span>
{' → '}
<span className="text-primary">
{format(guess.date, 'yyyy-MM-dd')}
</span>
</li>
))}
{preview.hits.length > 5 && (
<li className="text-text-muted">
and {preview.hits.length - 5} more
</li>
)}
</ul>
)}
<div className="flex gap-1.5">
<button
onClick={handleApplyPreview}
disabled={disabled || preview.hits.length === 0}
className="flex-1 rounded bg-primary/20 px-2 py-1 text-xs text-primary hover:bg-primary/30 disabled:cursor-not-allowed disabled:opacity-40"
>
Apply {preview.hits.length}
</button>
<button
onClick={() => setPreview(null)}
disabled={disabled}
className="rounded bg-surface-2 px-2 py-1 text-xs text-text-muted hover:bg-surface-offset disabled:opacity-50"
>
Cancel
</button>
</div>
</div>
)}
</div>
)
}
interface BulkTagsEditorProps {
allTags: { id: string; name: string; color: string | null }[]
tagInput: string

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import type { Photo } from '../../types/photo'
import { photos as photosApi } from '../../services/api'
@@ -32,7 +32,7 @@ export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilm
ref={isActive ? activeRef : null}
onClick={() => onSelect(photo.id)}
className={clsx(
'shrink-0 overflow-hidden rounded-sm transition-all',
'relative shrink-0 overflow-hidden rounded-sm transition-all',
'hover:opacity-100',
isActive
? 'ring-2 ring-primary opacity-100'
@@ -41,15 +41,45 @@ export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilm
style={{ width: CELL_SIZE, height: CELL_SIZE }}
title={photo.filename}
>
<img
src={photosApi.getThumbnailUrl(photo.id, 'small')}
alt={photo.filename}
loading="lazy"
className="h-full w-full object-cover"
/>
<FilmstripThumb photo={photo} />
</button>
)
})}
</div>
)
}
function FilmstripThumb({ photo }: { photo: Photo }) {
const [loaded, setLoaded] = useState(false)
const [errored, setErrored] = useState(false)
useEffect(() => {
setLoaded(false)
setErrored(false)
}, [photo.id])
return (
<>
{!loaded && !errored && (
<div className="absolute inset-0 animate-pulse bg-bg" />
)}
{errored && (
<div className="absolute inset-0 flex items-center justify-center bg-bg">
<div className="h-1.5 w-1.5 rounded-full bg-text-muted/50" />
</div>
)}
<img
src={photosApi.getThumbnailUrl(photo.id, 'small')}
alt=""
loading="lazy"
decoding="async"
onLoad={() => setLoaded(true)}
onError={() => setErrored(true)}
className={clsx(
'h-full w-full object-cover transition-opacity duration-200',
loaded ? 'opacity-100' : 'opacity-0'
)}
/>
</>
)
}

View File

@@ -11,7 +11,12 @@ import {
Trash2,
} from 'lucide-react'
import clsx from 'clsx'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
useQuery,
useMutation,
useQueryClient,
keepPreviousData,
} from '@tanstack/react-query'
import { format } from 'date-fns'
import {
photos as photosApi,
@@ -28,6 +33,10 @@ import {
COLOR_LABEL_OPTIONS,
type ColorLabel,
} from '../../constants/colorLabels'
import {
guessDateFromPath,
toDatetimeLocalValue,
} from '../../lib/guessDateFromPath'
interface PhotoTagSummary {
id: string
@@ -43,6 +52,7 @@ interface PhotoDetails {
height: number | null
file_size: number | null
taken_at: string | null
taken_at_source: string | null
rating: number
is_discarded: boolean
user_title: string | null
@@ -137,12 +147,16 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
setExpandedSections(next)
}
// Fetch the photo's full record (with EXIF) on demand.
const { data: photo } = useQuery<PhotoDetails>({
// Fetch the photo's full record (with EXIF) on demand. `keepPreviousData`
// holds the last photo on screen while the next one loads, so arrow-nav
// through the preview doesn't flash the "Loading…" placeholder between
// every neighbour — the panel swaps in place once the new record arrives.
const { data: photo, isPlaceholderData } = useQuery<PhotoDetails>({
queryKey: ['photo', photoId],
queryFn: () => photosApi.get(photoId),
enabled: !!photoId,
staleTime: 60_000,
placeholderData: keepPreviousData,
})
// Mutation for any patchable field. Invalidates both the photo detail
@@ -155,6 +169,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
user_title?: string | null
user_notes?: string | null
color_label?: string | null
taken_at?: string
}) => photosApi.update(photoId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
@@ -240,12 +255,22 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
const [filenameDraft, setFilenameDraft] = useState('')
const [titleDraft, setTitleDraft] = useState('')
const [notesDraft, setNotesDraft] = useState('')
const [takenAtDraft, setTakenAtDraft] = useState('')
useEffect(() => {
setFilenameDraft(photo?.filename ?? '')
setTitleDraft(photo?.user_title ?? '')
setNotesDraft(photo?.user_notes ?? '')
}, [photo?.id, photo?.filename, photo?.user_title, photo?.user_notes])
setTakenAtDraft(
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
)
}, [
photo?.id,
photo?.filename,
photo?.user_title,
photo?.user_notes,
photo?.taken_at,
])
const commitFilename = () => {
const next = filenameDraft.trim()
@@ -287,6 +312,44 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
updateMutation.mutate({ user_notes: next || null })
}
/** Commit a datetime-local draft back to the server. The backend also
* rewrites EXIF on disk, so a failure here rolls the draft back to the
* server value — we never want the UI to silently disagree with the
* file. An empty string is a no-op because the input's `required` is
* off and we don't yet have a "clear date" affordance. */
const commitTakenAt = (rawValue?: string) => {
const source = rawValue ?? takenAtDraft
if (!source) return
const parsed = new Date(source)
if (Number.isNaN(parsed.getTime())) {
toast.error('Invalid date', 'Could not parse the value')
setTakenAtDraft(
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
)
return
}
const iso = parsed.toISOString()
if (photo?.taken_at && new Date(photo.taken_at).toISOString() === iso) {
return
}
updateMutation.mutate(
{ taken_at: iso },
{
onError: (e: any) => {
toast.error(
'Date update failed',
e?.response?.data?.detail || e.message || 'Unknown error'
)
setTakenAtDraft(
photo?.taken_at
? toDatetimeLocalValue(new Date(photo.taken_at))
: ''
)
},
}
)
}
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
if (!photo) {
@@ -313,7 +376,12 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
)
return (
<div className="flex h-full flex-col">
<div
className={clsx(
'flex h-full flex-col transition-opacity duration-150',
isPlaceholderData && 'opacity-70'
)}
>
{/* Edit fields */}
<div className="space-y-2.5 border-b border-border p-3">
<div>
@@ -505,15 +573,14 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
: '—'
}
/>
<Field
label="Date Taken"
value={
photo.taken_at
? format(new Date(photo.taken_at), 'MMM d, yyyy HH:mm')
: '—'
}
/>
</div>
<TakenAtEditor
photo={photo}
draft={takenAtDraft}
onDraftChange={setTakenAtDraft}
onCommit={commitTakenAt}
darkTheme={darkTheme}
/>
{/* Filepath spans the full sidebar width — most paths are long
* enough that the two-column grid above wraps them painfully.
* Mono so each character lines up under the next, break-all
@@ -761,3 +828,114 @@ function Field({ label, value }: { label: string; value: string }) {
</div>
)
}
interface TakenAtEditorProps {
photo: PhotoDetails
draft: string
onDraftChange: (v: string) => void
onCommit: (raw?: string) => void
darkTheme: boolean
}
/** Editable Date Taken field with a source badge (EXIF / filesystem / manual)
* and a folder-guess suggestion row that only shows up when the filepath
* implies a different date than what's currently stored. The suggestion
* hint is the whole point of this feature — epoch-reset phones and
* corrupted EXIF dumps end up clustered in the wrong corner of the
* timeline until someone rewrites them from the folder name. */
function TakenAtEditor({
photo,
draft,
onDraftChange,
onCommit,
darkTheme,
}: TakenAtEditorProps) {
const source = photo.taken_at_source ?? null
const sourceLabel =
source === 'exif'
? 'EXIF'
: source === 'filesystem'
? 'FILE'
: source === 'manual'
? 'MANUAL'
: null
const guess = useMemo(
() => guessDateFromPath(photo.filepath),
[photo.filepath]
)
// Show the suggestion when:
// - there's no stored date at all, OR
// - the guess disagrees with the stored date by more than a day.
// A same-day match is treated as "already correct enough" so we don't
// nag the user on photos that happen to sit in a dated folder.
const showSuggestion = useMemo(() => {
if (!guess) return false
if (!photo.taken_at) return true
const current = new Date(photo.taken_at).getTime()
const suggested = guess.date.getTime()
return Math.abs(current - suggested) > 24 * 60 * 60 * 1000
}, [guess, photo.taken_at])
const inputClass = clsx(
'flex-1 rounded border px-2 py-1 text-xs focus:outline-none',
darkTheme
? 'border-white/15 bg-black/40 text-white focus:border-primary'
: 'border-border bg-bg text-text focus:border-primary'
)
return (
<div className="mt-2 text-xs">
<label className="mb-1 block text-text-muted">Date Taken</label>
<div className="flex items-center gap-1.5">
<input
type="datetime-local"
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
onBlur={() => onCommit()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
onDraftChange(
photo.taken_at
? toDatetimeLocalValue(new Date(photo.taken_at))
: ''
)
e.currentTarget.blur()
}
}}
className={inputClass}
/>
{sourceLabel && (
<span
className="rounded-sm bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-white"
title={`Source: ${sourceLabel.toLowerCase()}`}
>
{sourceLabel}
</span>
)}
</div>
{showSuggestion && guess && (
<button
onClick={() => {
const next = toDatetimeLocalValue(guess.date)
onDraftChange(next)
onCommit(next)
}}
className={clsx(
'mt-1.5 flex w-full items-center justify-between gap-2 rounded border border-dashed px-2 py-1 text-[11px] transition-colors',
'border-primary/50 text-primary hover:bg-primary/10'
)}
title={`Match "${guess.matched}" in path (${guess.source}, ${guess.confidence} confidence)`}
>
<span className="truncate">
Folder suggests {format(guess.date, 'MMM d, yyyy')}
</span>
<span className="shrink-0 font-semibold">Apply</span>
</button>
)}
</div>
)
}

View File

@@ -1,5 +1,13 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Star, ShoppingBasket, Trash2, RefreshCw, Check, Copy } from 'lucide-react'
import {
Star,
ShoppingBasket,
Trash2,
RefreshCw,
Check,
Copy,
AlertTriangle,
} from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
import type { Photo } from '../../types/photo'
@@ -92,6 +100,13 @@ export function PhotoThumbnail({
const baseUrl = photosApi.getThumbnailUrl(photo.id, 'medium')
const thumbnailUrl = retryCount > 0 ? `${baseUrl}?retry=${retryCount}` : baseUrl
// "Capture date probably wrong" — read straight from the stored
// `has_date_warning` flag rather than recomputing the heuristic
// client-side. The backend sets this column at scan time and
// refreshes it on any taken_at edit, so the UI, the filter, and the
// thumbnail badge all read from one source of truth.
const dateWarning = photo.has_date_warning === true
// Square cells (Lightroom-style grid). Variable-aspect cells previously
// overflowed their row because TanStack Virtual estimates row height as a
// single fixed value — portraits in a landscape row would overlap the row
@@ -323,6 +338,21 @@ export function PhotoThumbnail({
<Trash2 className={THUMB_BADGE_ICON} strokeWidth={2.5} />
</div>
)}
{dateWarning && (
<div
className={clsx(
THUMB_BADGE_BASE,
THUMB_BADGE_SQUARE,
// Amber fill with the same chiseled frame as other affirmative
// badges so it reads as a first-class warning rather than a
// neutral info chip.
'bg-amber-500 shadow-[0_0_0_1px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.5)]'
)}
title="Capture date may be wrong — folder/filename suggests a different date"
>
<AlertTriangle className={THUMB_BADGE_ICON} strokeWidth={2.5} />
</div>
)}
</div>
{/* TR — file-type metadata (RAW / VIDEO) */}

View File

@@ -89,6 +89,7 @@ function parseUrl(): HydratePayload {
}
if (sp.get('duplicates') === 'true') out.duplicates = true
if (sp.get('date_warning') === 'true') out.dateWarning = true
const groupBy = sp.get('group')
if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy
@@ -123,6 +124,7 @@ function writeUrl(f: FilterState & { currentSection?: string }) {
if (f.folderId) sp.set('folder_id', f.folderId)
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
if (f.duplicates) sp.set('duplicates', 'true')
if (f.dateWarning) sp.set('date_warning', 'true')
if (f.groupBy !== 'date') sp.set('group', f.groupBy)
if (f.currentSection && f.currentSection !== 'all-photos')
sp.set('section', f.currentSection)

View File

@@ -39,6 +39,7 @@ export function usePhotosQuery() {
const folderId = useFilterStore((s) => s.folderId)
const tagIds = useFilterStore((s) => s.tagIds)
const duplicates = useFilterStore((s) => s.duplicates)
const dateWarning = useFilterStore((s) => s.dateWarning)
const groupBy = useFilterStore((s) => s.groupBy)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
@@ -58,11 +59,12 @@ export function usePhotosQuery() {
folderId,
tagIds,
duplicates,
dateWarning,
groupBy,
sortBy,
sortOrder,
}),
[q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
[q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, dateWarning, groupBy, sortBy, sortOrder]
)
const queryClient = useQueryClient()

View File

@@ -0,0 +1,253 @@
/**
* Best-effort date detection from a photo's filesystem path.
*
* Used by the Date Taken repair flow to suggest a capture date when the
* stored `taken_at` looks wrong (epoch zero, wildly off, missing). Libraries
* collected by humans tend to be sorted into dated folders like
* `2019-07-12_vacation/` or dumped with camera filenames like
* `IMG_20190712_153045.jpg` — both are stronger signals than a reset EXIF
* timestamp when the file is clearly misfiled.
*
* Pure, deterministic, no I/O. Returns `null` when no recognisable date
* can be extracted.
*/
export type DateGuessConfidence = 'high' | 'medium' | 'low'
export type DateGuessSource = 'folder' | 'filename'
export interface DateGuess {
/** The guessed capture date, noon local time for non-specific matches so
* timeline-day bucketing isn't ambiguous around midnight. */
date: Date
/** How specific the match was — day-level matches are `high`, month-level
* `medium`, year-only `low`. */
confidence: DateGuessConfidence
/** The substring of the filepath that produced the match — shown in the
* UI so the user can sanity-check the guess. */
matched: string
/** Whether the date came from the file's basename or an ancestor folder. */
source: DateGuessSource
}
const MIN_YEAR = 1970
const MAX_YEAR = new Date().getFullYear() + 1
function validYear(y: number): boolean {
return Number.isInteger(y) && y >= MIN_YEAR && y <= MAX_YEAR
}
function validMonth(m: number): boolean {
return Number.isInteger(m) && m >= 1 && m <= 12
}
function validDay(d: number): boolean {
return Number.isInteger(d) && d >= 1 && d <= 31
}
/** Construct a Date at local noon (avoids midnight/timezone rounding
* into the previous day when the UI formats to YYYY-MM-DD). Returns
* null when the (y, m, d) combo rolls over (e.g. Feb 30). */
function makeDate(y: number, m: number, d: number): Date | null {
if (!validYear(y) || !validMonth(m) || !validDay(d)) return null
const dt = new Date(y, m - 1, d, 12, 0, 0, 0)
if (
dt.getFullYear() !== y ||
dt.getMonth() !== m - 1 ||
dt.getDate() !== d
) {
return null
}
return dt
}
/** Split a path into its segments regardless of OS separator. */
function segments(filepath: string): string[] {
return filepath.split(/[\\/]+/).filter((s) => s.length > 0)
}
/** Run every pattern against a single string and return the strongest
* match. Day-level > month-level > year-only; within a tier the first
* pattern that fires wins (patterns are written in order of specificity).
* `source` is stamped onto the returned guess so the caller can tell
* filename hits from folder hits. `allowYearOnly` is off for filenames
* to avoid treating a camera serial like `DSC2019` as a year match. */
function guessFromString(
input: string,
source: DateGuessSource,
allowYearOnly: boolean,
): DateGuess | null {
if (!input) return null
// Day-level: YYYYMMDD run bounded by non-digits ─ `IMG_20190712_153045`.
const compact = input.match(/(?<!\d)(\d{4})(\d{2})(\d{2})(?!\d)/)
if (compact) {
const date = makeDate(+compact[1], +compact[2], +compact[3])
if (date) {
return {
date,
confidence: 'high',
matched: `${compact[1]}-${compact[2]}-${compact[3]}`,
source,
}
}
}
// Day-level: YYYY-MM-DD / YYYY_MM_DD / YYYY.MM.DD ─ `2019-07-12_vacation`.
const dashed = input.match(/(?<!\d)(\d{4})[-_.](\d{1,2})[-_.](\d{1,2})(?!\d)/)
if (dashed) {
const date = makeDate(+dashed[1], +dashed[2], +dashed[3])
if (date) {
return {
date,
confidence: 'high',
matched: `${dashed[1]}-${dashed[2]}-${dashed[3]}`,
source,
}
}
}
// Month-level: YYYY-MM / YYYY_MM ─ snapped to day 15. Requires an
// explicit separator so a filename digit run doesn't misfire.
const monthOnly = input.match(/(?<!\d)(\d{4})[-_.](\d{1,2})(?!\d)/)
if (monthOnly) {
const date = makeDate(+monthOnly[1], +monthOnly[2], 15)
if (date) {
return {
date,
confidence: 'medium',
matched: `${monthOnly[1]}-${monthOnly[2]}`,
source,
}
}
}
// Year-only: only enabled for folder segments. A bare year in a
// filename is too easily confused with a camera serial number.
if (allowYearOnly) {
const yearOnly = input.match(/(?<!\d)(\d{4})(?!\d)/)
if (yearOnly) {
const date = makeDate(+yearOnly[1], 7, 1)
if (date) {
return {
date,
confidence: 'low',
matched: yearOnly[1],
source,
}
}
}
}
return null
}
/** Walk three consecutive path segments looking for `YYYY/MM/DD` or
* `YYYY/MM` layouts. These patterns span segment boundaries so the
* single-segment scanner above can't see them. */
function guessFromFolderLayout(folders: string[]): DateGuess | null {
// YYYY / MM / DD — day-level, preferred.
for (let i = 0; i <= folders.length - 3; i++) {
const a = folders[i]
const b = folders[i + 1]
const c = folders[i + 2]
if (/^\d{4}$/.test(a) && /^\d{1,2}$/.test(b) && /^\d{1,2}$/.test(c)) {
const date = makeDate(+a, +b, +c)
if (date) {
return {
date,
confidence: 'high',
matched: `${a}/${b}/${c}`,
source: 'folder',
}
}
}
}
// YYYY / MM — month-level.
for (let i = 0; i <= folders.length - 2; i++) {
const a = folders[i]
const b = folders[i + 1]
if (/^\d{4}$/.test(a) && /^\d{1,2}$/.test(b)) {
const date = makeDate(+a, +b, 15)
if (date) {
return {
date,
confidence: 'medium',
matched: `${a}/${b}`,
source: 'folder',
}
}
}
}
return null
}
const CONFIDENCE_RANK: Record<DateGuessConfidence, number> = {
high: 3,
medium: 2,
low: 1,
}
/**
* Inspect the filename AND the folder chain for date signals and return
* the best candidate. **Filename is the source of truth**: if the basename
* yields any valid match at all, it wins — even a year-only filename hit
* beats a day-level folder hit. Camera firmwares bake the shutter date
* into the filename and operators tend to sort photos into broad
* year/month buckets later, so the filename signal is almost always
* closer to the real capture date than the folder signal.
*
* When the filename has nothing, we fall back to a folder scan:
* deepest-folder-first for single-segment hits (e.g. `2019-07-12_trip`),
* then cross-segment layouts (`/2010/07/12/`, `/2010/07/`), then a bare
* year folder as the weakest last resort.
*/
export function guessDateFromPath(filepath: string): DateGuess | null {
if (!filepath) return null
const segs = segments(filepath)
if (segs.length === 0) return null
const filename = segs[segs.length - 1]
const folders = segs.slice(0, -1)
// Filename is the source of truth: any filename hit (even month-level)
// wins over anything the folder tree can offer. Year-only is disabled
// for filenames so camera serials don't masquerade as years.
const fromFilename = guessFromString(filename, 'filename', false)
if (fromFilename) return fromFilename
// Folder fallback: walk deepest-first so a nested dated folder beats
// an ancestor year folder. First hit wins; we keep walking only if it
// was weaker than day-level, in case a shallower segment has a
// stronger match (rare, but e.g. `/archive/2019-07-12/month3/`).
let bestFolder: DateGuess | null = null
for (let i = folders.length - 1; i >= 0; i--) {
const hit = guessFromString(folders[i], 'folder', true)
if (!hit) continue
if (!bestFolder || CONFIDENCE_RANK[hit.confidence] > CONFIDENCE_RANK[bestFolder.confidence]) {
bestFolder = hit
if (hit.confidence === 'high') break
}
}
// Cross-segment layouts like `/2010/07/12/` can only be found by a
// multi-segment scanner — try it and keep whichever is stronger.
const fromLayout = guessFromFolderLayout(folders)
if (
fromLayout &&
(!bestFolder ||
CONFIDENCE_RANK[fromLayout.confidence] > CONFIDENCE_RANK[bestFolder.confidence])
) {
bestFolder = fromLayout
}
return bestFolder
}
/** Format a Date as the `value` of an `<input type="datetime-local">`. */
export function toDatetimeLocalValue(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
`T${pad(d.getHours())}:${pad(d.getMinutes())}`
)
}

View File

@@ -185,6 +185,39 @@ export const photos = {
return response.data
},
/** Bulk set taken_at — one ISO datetime applied to every listed photo.
* Backend rewrites EXIF on disk per-photo and surfaces per-photo errors
* in the `errors` array so the UI can report a partial apply. */
bulkSetTakenAt: async (photoIds: string[], isoDatetime: string) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'set_taken_at',
value: isoDatetime,
})
return response.data as {
status: string
updated: number
skipped: number
errors: { id: string; message: string }[]
}
},
/** Bulk set taken_at with a per-photo map. Used by the "guess from folder"
* flow where every selected photo gets its own suggested date. */
bulkSetTakenAtMap: async (map: Record<string, string>) => {
const response = await api.post('/photos/bulk', {
ids: Object.keys(map),
action: 'set_taken_at_map',
value: map,
})
return response.data as {
status: string
updated: number
skipped: number
errors: { id: string; message: string }[]
}
},
/** Add the listed tags to every listed photo. Idempotent — re-adding
* an existing (photo, tag) pair is a no-op. Returns { added: N }. */
bulkAddTags: async (photoIds: string[], tagIds: string[]) => {

View File

@@ -31,6 +31,10 @@ export interface FilterState {
tagIds: string[]
/** When true, restrict to photos flagged as duplicates by the scanner. */
duplicates: boolean
/** When true, restrict to photos whose path-based date guess disagrees
* with the stored taken_at (or taken_at is missing). Backed by the
* `photos.has_date_warning` column. */
dateWarning: boolean
/** Visual grouping mode. 'date' groups by month when sortBy is a date
* field; 'tag' groups by photo tag membership. Independent of filters. */
groupBy: GroupBy
@@ -68,6 +72,7 @@ interface FilterStore extends FilterState {
setTagIds: (ids: string[]) => void
toggleTagId: (id: string) => void
setDuplicates: (v: boolean) => void
setDateWarning: (v: boolean) => void
setGroupBy: (mode: GroupBy) => void
setSortBy: (field: SortField) => void
setSortOrder: (order: SortOrder) => void
@@ -102,6 +107,7 @@ export const INITIAL_FILTERS: FilterState = {
folderId: null,
tagIds: [],
duplicates: false,
dateWarning: false,
groupBy: 'date',
sortBy: 'taken_at',
sortOrder: 'desc',
@@ -124,6 +130,7 @@ function snapshotFilters(s: FilterState): FilterState {
folderId: s.folderId,
tagIds: [...s.tagIds],
duplicates: s.duplicates,
dateWarning: s.dateWarning,
groupBy: s.groupBy,
sortBy: s.sortBy,
sortOrder: s.sortOrder,
@@ -159,6 +166,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
: [...s.tagIds, id],
})),
setDuplicates: (duplicates) => set({ duplicates }),
setDateWarning: (dateWarning) => set({ dateWarning }),
setGroupBy: (groupBy) => set({ groupBy }),
setSortBy: (sortBy) => set({ sortBy }),
setSortOrder: (sortOrder) => set({ sortOrder }),
@@ -217,6 +225,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
if (f.folderId) params.folder_id = f.folderId
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
if (f.duplicates) params.is_duplicate = 'true'
if (f.dateWarning) params.has_date_warning = 'true'
params.sort = f.sortBy
params.order = f.sortOrder
return params
@@ -236,6 +245,7 @@ export function hasActiveFilters(f: FilterState): boolean {
f.heapId !== null ||
f.folderId !== null ||
f.tagIds.length > 0 ||
f.duplicates
f.duplicates ||
f.dateWarning
)
}

View File

@@ -12,10 +12,12 @@ export interface Photo {
width: number | null
height: number | null
taken_at: string | null
taken_at_source?: string | null
rating: number
color_label?: string | null
is_discarded: boolean
is_duplicate: boolean
has_date_warning?: boolean
file_hash: string
folder_id: string | null
added_at: string | null