diff --git a/backend/alembic/versions/0008_photos_date_warning.py b/backend/alembic/versions/0008_photos_date_warning.py new file mode 100644 index 0000000..8f9e183 --- /dev/null +++ b/backend/alembic/versions/0008_photos_date_warning.py @@ -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") diff --git a/backend/app/models/photos.py b/backend/app/models/photos.py index 75550f6..e5ddf6c 100644 --- a/backend/app/models/photos.py +++ b/backend/app/models/photos.py @@ -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 diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 7a13d29..93bf6b0 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -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 diff --git a/backend/app/schemas/photos.py b/backend/app/schemas/photos.py index 685d6c2..fb00ff6 100644 --- a/backend/app/schemas/photos.py +++ b/backend/app/schemas/photos.py @@ -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 diff --git a/backend/app/services/date_guess.py b/backend/app/services/date_guess.py new file mode 100644 index 0000000..6ab6512 --- /dev/null +++ b/backend/app/services/date_guess.py @@ -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"(? 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 diff --git a/backend/app/services/exif_writer.py b/backend/app/services/exif_writer.py new file mode 100644 index 0000000..d86b6a3 --- /dev/null +++ b/backend/app/services/exif_writer.py @@ -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 + ``_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) diff --git a/backend/app/services/metadata.py b/backend/app/services/metadata.py index 5106163..b54cbfa 100644 --- a/backend/app/services/metadata.py +++ b/backend/app/services/metadata.py @@ -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: diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index b87658f..2420904 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -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' diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx index b86862e..8a385df 100644 --- a/frontend/src/components/filter/FilterBar.tsx +++ b/frontend/src/components/filter/FilterBar.tsx @@ -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() { )} + {/* 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. */} + + {/* Sort — always present, never "active/inactive" since there's always a value. */} diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 47534d0..de5d0b5 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -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) => + 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() + for (const id of selectedPhotos) { + if (seen.has(id)) continue + const direct = queryClient.getQueryData(['photo', id]) + if (direct) { + out.push(direct) + seen.add(id) + continue + } + const lists = queryClient.getQueriesData({ 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 (
@@ -361,11 +439,170 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) { }} />
+ + {/* 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. */} +
+ + + bulkTakenAtMutation.mutate({ ids: selectedPhotos, iso }) + } + onApplyMap={(map) => bulkTakenAtMapMutation.mutate(map)} + /> +
) } +interface BulkTakenAtEditorProps { + disabled: boolean + selectedCount: number + collectPhotos: () => Photo[] + onApplyUniform: (iso: string) => void + onApplyMap: (map: Record) => 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 = {} + 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 ( +
+ {/* Apply-one row */} +
+ 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" + /> + +
+ + {/* Guess-from-path preview */} + {preview === null ? ( + + ) : ( +
+
+ {preview.hits.length} will update ·{' '} + {preview.misses.length} skipped +
+ {preview.hits.length > 0 && ( +
    + {preview.hits.slice(0, 5).map(({ photo, guess }) => ( +
  • + {photo.filename} + {' → '} + + {format(guess.date, 'yyyy-MM-dd')} + +
  • + ))} + {preview.hits.length > 5 && ( +
  • + …and {preview.hits.length - 5} more +
  • + )} +
+ )} +
+ + +
+
+ )} +
+ ) +} + interface BulkTagsEditorProps { allTags: { id: string; name: string; color: string | null }[] tagInput: string diff --git a/frontend/src/components/preview/PreviewFilmstrip.tsx b/frontend/src/components/preview/PreviewFilmstrip.tsx index e044da8..2b0f758 100644 --- a/frontend/src/components/preview/PreviewFilmstrip.tsx +++ b/frontend/src/components/preview/PreviewFilmstrip.tsx @@ -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} > - {photo.filename} + ) })} ) } + +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 && ( +
+ )} + {errored && ( +
+
+
+ )} + setLoaded(true)} + onError={() => setErrored(true)} + className={clsx( + 'h-full w-full object-cover transition-opacity duration-200', + loaded ? 'opacity-100' : 'opacity-0' + )} + /> + + ) +} diff --git a/frontend/src/components/sidebar/PhotoInfoPanel.tsx b/frontend/src/components/sidebar/PhotoInfoPanel.tsx index 00bdd53..61c0179 100644 --- a/frontend/src/components/sidebar/PhotoInfoPanel.tsx +++ b/frontend/src/components/sidebar/PhotoInfoPanel.tsx @@ -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({ + // 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({ 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 ( -
+
{/* Edit fields */}
@@ -505,15 +573,14 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro : '—' } /> -
+ {/* 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 }) {
) } + +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 ( +
+ +
+ 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 && ( + + {sourceLabel} + + )} +
+ {showSuggestion && guess && ( + + )} +
+ ) +} diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx index 6067bda..ed4da6c 100644 --- a/frontend/src/components/timeline/PhotoThumbnail.tsx +++ b/frontend/src/components/timeline/PhotoThumbnail.tsx @@ -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({
)} + {dateWarning && ( +
+ +
+ )}
{/* TR — file-type metadata (RAW / VIDEO) */} diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index 47c6a6a..1d3194f 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -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) diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts index d8e831d..50932b4 100644 --- a/frontend/src/hooks/usePhotosQuery.ts +++ b/frontend/src/hooks/usePhotosQuery.ts @@ -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() diff --git a/frontend/src/lib/guessDateFromPath.ts b/frontend/src/lib/guessDateFromPath.ts new file mode 100644 index 0000000..070c0a2 --- /dev/null +++ b/frontend/src/lib/guessDateFromPath.ts @@ -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(/(? = { + 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 ``. */ +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())}` + ) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2155934..e90c0e3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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) => { + 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[]) => { diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index eff39ad..0aeca44 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -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((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 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 ) } diff --git a/frontend/src/types/photo.ts b/frontend/src/types/photo.ts index bda58de..4ceb705 100644 --- a/frontend/src/types/photo.ts +++ b/frontend/src/types/photo.ts @@ -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