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

@@ -0,0 +1,49 @@
"""photos has_date_warning flag
Revision ID: 0008_photos_date_warning
Revises: 0007_folder_hidden
Create Date: 2026-04-11
Adds `photos.has_date_warning` — a denormalized boolean that's true when
the scanner's folder/filename date guesser disagrees with the stored
taken_at by more than 24h (or taken_at is missing and the path would
provide a date). Surfacing this as a real column means the filter bar
can restrict the timeline to suspicious photos without the client
recomputing the heuristic for every row.
Indexed because the filter is meant to run on top of the existing
taken_at / folder queries that dominate the timeline, and we want the
partial `WHERE has_date_warning` scan to stay cheap as the library
grows.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0008_photos_date_warning"
down_revision: Union[str, None] = "0007_folder_hidden"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"photos",
sa.Column(
"has_date_warning",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.create_index(
"ix_photos_has_date_warning",
"photos",
["has_date_warning"],
)
def downgrade() -> None:
op.drop_index("ix_photos_has_date_warning", table_name="photos")
op.drop_column("photos", "has_date_warning")

View File

@@ -46,6 +46,13 @@ class Photo(Base):
# column is maintained by two places: the scanner sets it on new
# rows, and POST /folders/{id}/hide recomputes it on toggle.
is_hidden = Column(Boolean, nullable=False, default=False, server_default='false', index=True)
# "Capture date is probably wrong" — denormalized from the folder/filename
# date-guesser. Set at scan time and recomputed on every taken_at edit so
# the filter bar can query it directly. See services/date_guess.py for
# the heuristic; kept as a stored column because recomputing on every
# list query would mean running the regex stack across thousands of rows.
has_date_warning = Column(Boolean, nullable=False, default=False, server_default='false', index=True)
# Thumbnail paths
thumb_small = Column(String) # path to 240px thumb

View File

@@ -22,6 +22,8 @@ from app.models.folders import SourceRoot
from app.models.heaps import heap_photos
from app.models.tags import photo_tags
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
from app.services.exif_writer import ExifWriteError, write_taken_at
from app.services.date_guess import has_date_warning as compute_date_warning
from app.config import settings
router = APIRouter()
@@ -39,6 +41,7 @@ async def list_photos(
color_label: Optional[str] = None,
is_discarded: Optional[bool] = False,
is_duplicate: Optional[bool] = None,
has_date_warning: Optional[bool] = None,
heap_id: Optional[str] = None,
sort: str = "taken_at",
order: str = "desc",
@@ -149,6 +152,8 @@ async def list_photos(
# view shows everything regardless of duplicate status.
if is_duplicate is not None:
filters.append(Photo.is_duplicate == is_duplicate)
if has_date_warning is not None:
filters.append(Photo.has_date_warning == has_date_warning)
# Heap membership filter — restrict to photos that belong to the heap.
if heap_id:
@@ -642,6 +647,28 @@ async def update_photo(
photo.filename = new_name
photo.filepath = new_path
# taken_at edits write EXIF first, DB second — we'd rather surface a
# failure than leave the DB ahead of the file on disk. On success the
# source flips to 'manual' so the UI can render a badge and the next
# rescan knows not to overwrite it.
if 'taken_at' in update_data:
new_dt = update_data.pop('taken_at')
if new_dt is not None:
try:
await write_taken_at(photo.filepath, new_dt)
except ExifWriteError as exc:
raise HTTPException(
status_code=500,
detail=f"Failed to write EXIF: {exc}",
)
photo.taken_at = new_dt
photo.taken_at_source = 'manual'
# Recompute warning: a manual edit usually clears it (user just
# told us the right date), but if they set it to something that
# still disagrees with the folder path we'd rather keep the
# flag up than pretend the problem's gone.
photo.has_date_warning = compute_date_warning(photo.filepath, new_dt)
# Apply remaining updates
for field, value in update_data.items():
setattr(photo, field, value)
@@ -921,6 +948,64 @@ async def bulk_action(
elif action.action == 'set_color':
for photo in photos:
photo.color_label = action.value
elif action.action in ('set_taken_at', 'set_taken_at_map'):
# Two shapes share one code path:
# set_taken_at → value is one ISO datetime, applied to every id
# set_taken_at_map → value is {photo_id: iso datetime}, per-photo
# The per-photo variant is what the "guess from folder" bulk flow
# uses when every selected photo gets a different date.
if action.action == 'set_taken_at':
if not isinstance(action.value, str) or not action.value:
raise HTTPException(
status_code=400,
detail="set_taken_at requires an ISO datetime string",
)
try:
uniform_dt = datetime.fromisoformat(action.value)
except ValueError:
raise HTTPException(
status_code=400,
detail="Invalid ISO datetime for set_taken_at",
)
date_map = {p.id: uniform_dt for p in photos}
else:
if not isinstance(action.value, dict) or not action.value:
raise HTTPException(
status_code=400,
detail="set_taken_at_map requires a {id: iso} mapping",
)
date_map = {}
for pid, raw in action.value.items():
if not isinstance(raw, str):
continue
try:
date_map[pid] = datetime.fromisoformat(raw)
except ValueError:
continue
updated = 0
errors: list[dict[str, str]] = []
for photo in photos:
new_dt = date_map.get(photo.id)
if new_dt is None:
continue
try:
await write_taken_at(photo.filepath, new_dt)
except ExifWriteError as exc:
errors.append({"id": photo.id, "message": str(exc)})
continue
photo.taken_at = new_dt
photo.taken_at_source = 'manual'
photo.has_date_warning = compute_date_warning(photo.filepath, new_dt)
updated += 1
await db.commit()
return {
"status": "success",
"updated": updated,
"skipped": len(photos) - updated - len(errors),
"errors": errors,
}
elif action.action == 'add_tags':
# value is a list of tag ids. We bulk-insert (photo_id, tag_id)
# rows for every (photo, tag) combination that doesn't already

View File

@@ -39,6 +39,7 @@ class PhotoResponse(PhotoBase):
latitude: Optional[float] = None
longitude: Optional[float] = None
is_duplicate: bool = False
has_date_warning: bool = False
live_photo_video_id: Optional[str] = None
# tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading

View File

@@ -0,0 +1,214 @@
"""
Folder/filename-based date guessing and "taken_at looks wrong" detection.
Direct Python port of `frontend/src/lib/guessDateFromPath.ts` — the logic
must stay in sync because the frontend renders the suggestion hint in the
info panel while the backend owns the `has_date_warning` flag that the
filter bar queries. Any heuristic change has to be applied to both files.
The guesser walks a filepath, tries the filename first as the source of
truth, then falls back to folder segments (deepest first) and multi-
segment layouts. Returns ``None`` when no recognisable date can be
extracted. `has_date_warning()` compares the guess to a stored `taken_at`
and reports whether the difference is large enough to flag.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Optional
Confidence = Literal["high", "medium", "low"]
Source = Literal["folder", "filename"]
@dataclass(frozen=True)
class DateGuess:
date: datetime
confidence: Confidence
matched: str
source: Source
_MIN_YEAR = 1970
# Bump the ceiling annually via `datetime.now()` rather than a literal so
# we don't ship a time bomb. `+1` allows near-future timestamps (cameras
# with a slightly advanced clock at year end) without opening the door to
# 4-digit serial numbers that happen to start with "30xx".
def _max_year() -> int:
return datetime.now().year + 1
def _valid_year(y: int) -> bool:
return _MIN_YEAR <= y <= _max_year()
def _make_date(y: int, m: int, d: int) -> Optional[datetime]:
if not _valid_year(y):
return None
if not (1 <= m <= 12):
return None
if not (1 <= d <= 31):
return None
try:
# Noon local so downstream day-bucketing is stable across timezone
# rounding. The frontend mirrors this.
return datetime(y, m, d, 12, 0, 0)
except ValueError:
return None
def _segments(filepath: str) -> list[str]:
return [s for s in re.split(r"[\\/]+", filepath) if s]
_COMPACT_RE = re.compile(r"(?<!\d)(\d{4})(\d{2})(\d{2})(?!\d)")
_DASHED_RE = re.compile(r"(?<!\d)(\d{4})[-_.](\d{1,2})[-_.](\d{1,2})(?!\d)")
_MONTH_RE = re.compile(r"(?<!\d)(\d{4})[-_.](\d{1,2})(?!\d)")
_YEAR_RE = re.compile(r"(?<!\d)(\d{4})(?!\d)")
_FOUR_DIGITS = re.compile(r"^\d{4}$")
_ONE_OR_TWO = re.compile(r"^\d{1,2}$")
def _guess_from_string(
input: str,
source: Source,
allow_year_only: bool,
) -> Optional[DateGuess]:
if not input:
return None
m = _COMPACT_RE.search(input)
if m:
d = _make_date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
if d:
return DateGuess(
date=d,
confidence="high",
matched=f"{m.group(1)}-{m.group(2)}-{m.group(3)}",
source=source,
)
m = _DASHED_RE.search(input)
if m:
d = _make_date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
if d:
return DateGuess(
date=d,
confidence="high",
matched=f"{m.group(1)}-{m.group(2)}-{m.group(3)}",
source=source,
)
m = _MONTH_RE.search(input)
if m:
d = _make_date(int(m.group(1)), int(m.group(2)), 15)
if d:
return DateGuess(
date=d,
confidence="medium",
matched=f"{m.group(1)}-{m.group(2)}",
source=source,
)
if allow_year_only:
m = _YEAR_RE.search(input)
if m:
d = _make_date(int(m.group(1)), 7, 1)
if d:
return DateGuess(
date=d,
confidence="low",
matched=m.group(1),
source=source,
)
return None
def _guess_from_folder_layout(folders: list[str]) -> Optional[DateGuess]:
# YYYY / MM / DD
for i in range(len(folders) - 2):
a, b, c = folders[i], folders[i + 1], folders[i + 2]
if _FOUR_DIGITS.match(a) and _ONE_OR_TWO.match(b) and _ONE_OR_TWO.match(c):
d = _make_date(int(a), int(b), int(c))
if d:
return DateGuess(
date=d,
confidence="high",
matched=f"{a}/{b}/{c}",
source="folder",
)
# YYYY / MM
for i in range(len(folders) - 1):
a, b = folders[i], folders[i + 1]
if _FOUR_DIGITS.match(a) and _ONE_OR_TWO.match(b):
d = _make_date(int(a), int(b), 15)
if d:
return DateGuess(
date=d,
confidence="medium",
matched=f"{a}/{b}",
source="folder",
)
return None
_CONFIDENCE_RANK: dict[Confidence, int] = {"high": 3, "medium": 2, "low": 1}
def guess_date_from_path(filepath: str) -> Optional[DateGuess]:
"""Filename wins when it has any viable match; otherwise walk folder
segments deepest-first and pick the strongest hit."""
if not filepath:
return None
segs = _segments(filepath)
if not segs:
return None
filename = segs[-1]
folders = segs[:-1]
from_filename = _guess_from_string(filename, "filename", allow_year_only=False)
if from_filename:
return from_filename
best: Optional[DateGuess] = None
for seg in reversed(folders):
hit = _guess_from_string(seg, "folder", allow_year_only=True)
if not hit:
continue
if not best or _CONFIDENCE_RANK[hit.confidence] > _CONFIDENCE_RANK[best.confidence]:
best = hit
if hit.confidence == "high":
break
from_layout = _guess_from_folder_layout(folders)
if from_layout and (
not best or _CONFIDENCE_RANK[from_layout.confidence] > _CONFIDENCE_RANK[best.confidence]
):
best = from_layout
return best
_ONE_DAY = 24 * 60 * 60
def has_date_warning(filepath: str, taken_at: Optional[datetime]) -> bool:
"""True when the path-based guess disagrees with ``taken_at`` by more
than 24h, or when ``taken_at`` is missing and the path would supply
one. This is the authoritative flag stored on `photos.has_date_warning`
and queried by the timeline filter."""
guess = guess_date_from_path(filepath)
if not guess:
return False
if taken_at is None:
return True
try:
diff = abs((taken_at - guess.date).total_seconds())
except (TypeError, ValueError):
return False
return diff > _ONE_DAY

View File

@@ -0,0 +1,72 @@
"""
EXIF write-back helpers.
The rest of the app reads EXIF at scan time and stashes the result in Postgres
(see `services/metadata.py`). This module handles the reverse direction: when
the user corrects a date in the UI we also rewrite the relevant EXIF tags on
disk so a later rescan won't clobber the fix and external tools see the same
truth the DB does.
"""
import asyncio
import logging
import subprocess
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
EXIFTOOL_TIMEOUT_SECONDS = 30
class ExifWriteError(RuntimeError):
"""Raised when exiftool fails to write tags to a file."""
def _format_exif_dt(dt: datetime) -> str:
return dt.strftime("%Y:%m:%d %H:%M:%S")
async def write_taken_at(filepath: str, dt: datetime) -> None:
"""Rewrite DateTimeOriginal / CreateDate / ModifyDate on the file.
- ``-overwrite_original`` so we don't litter the library with
``<name>_original`` sidecars.
- ``-P`` preserves the file's mtime so the scanner's mtime-based
change detection stays quiet.
- We set all three common date tags together because different viewers
read different ones; keeping them in lockstep avoids confusing
downstream tools and our own re-extraction pass.
"""
if not Path(filepath).exists():
raise ExifWriteError(f"File not found: {filepath}")
stamp = _format_exif_dt(dt)
cmd = [
"exiftool",
"-overwrite_original",
"-P",
f"-DateTimeOriginal={stamp}",
f"-CreateDate={stamp}",
f"-ModifyDate={stamp}",
filepath,
]
def _run() -> subprocess.CompletedProcess:
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=EXIFTOOL_TIMEOUT_SECONDS,
)
try:
result = await asyncio.to_thread(_run)
except subprocess.TimeoutExpired as exc:
raise ExifWriteError(f"exiftool timed out writing {filepath}") from exc
except FileNotFoundError as exc:
raise ExifWriteError("exiftool binary not available") from exc
if result.returncode != 0:
msg = (result.stderr or result.stdout or "unknown error").strip()
logger.warning("exiftool write failed for %s: %s", filepath, msg)
raise ExifWriteError(msg)

View File

@@ -15,6 +15,7 @@ from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import Photo
from app.services.date_guess import has_date_warning
logger = logging.getLogger(__name__)
@@ -238,6 +239,14 @@ async def _extract_metadata_async(photo_id: str):
photo.taken_at = taken_at
photo.taken_at_source = 'exif'
break
# Re-run the path-vs-date heuristic now that we know
# whether EXIF provided a real capture date. A true EXIF
# date that matches the folder clears the warning the
# scanner set during the filesystem-mtime pass.
photo.has_date_warning = has_date_warning(
photo.filepath, photo.taken_at
)
# Extract dimensions if not already set
if not photo.width:

View File

@@ -21,6 +21,7 @@ from app.models import Photo, Folder, SourceRoot
from app.config import settings
from app.tasks.thumbs import generate_thumbnails
from app.services.metadata import extract_metadata
from app.services.date_guess import has_date_warning
logger = logging.getLogger(__name__)
@@ -231,6 +232,7 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
effective_hidden = await is_folder_effectively_hidden(folder)
# Create photo entry
mtime_dt = datetime.fromtimestamp(stat.st_mtime)
photo = Photo(
filepath=filepath,
filename=filename,
@@ -239,8 +241,13 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
media_type=get_media_type(filepath),
original_format=Path(filepath).suffix.upper()[1:],
file_size=stat.st_size,
taken_at=datetime.fromtimestamp(stat.st_mtime),
taken_at=mtime_dt,
taken_at_source='filesystem',
# First-pass flag based on the filesystem mtime;
# metadata.extract_metadata re-runs this once
# EXIF has been parsed so a real DateTimeOriginal
# can clear the warning.
has_date_warning=has_date_warning(filepath, mtime_dt),
is_duplicate=is_dup,
is_hidden=effective_hidden,
processing_status='pending'

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