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:
49
backend/alembic/versions/0008_photos_date_warning.py
Normal file
49
backend/alembic/versions/0008_photos_date_warning.py
Normal 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")
|
||||||
@@ -47,6 +47,13 @@ class Photo(Base):
|
|||||||
# rows, and POST /folders/{id}/hide recomputes it on toggle.
|
# rows, and POST /folders/{id}/hide recomputes it on toggle.
|
||||||
is_hidden = Column(Boolean, nullable=False, default=False, server_default='false', index=True)
|
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
|
# Thumbnail paths
|
||||||
thumb_small = Column(String) # path to 240px thumb
|
thumb_small = Column(String) # path to 240px thumb
|
||||||
thumb_medium = Column(String) # path to 640px thumb
|
thumb_medium = Column(String) # path to 640px thumb
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ from app.models.folders import SourceRoot
|
|||||||
from app.models.heaps import heap_photos
|
from app.models.heaps import heap_photos
|
||||||
from app.models.tags import photo_tags
|
from app.models.tags import photo_tags
|
||||||
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
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
|
from app.config import settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -39,6 +41,7 @@ async def list_photos(
|
|||||||
color_label: Optional[str] = None,
|
color_label: Optional[str] = None,
|
||||||
is_discarded: Optional[bool] = False,
|
is_discarded: Optional[bool] = False,
|
||||||
is_duplicate: Optional[bool] = None,
|
is_duplicate: Optional[bool] = None,
|
||||||
|
has_date_warning: Optional[bool] = None,
|
||||||
heap_id: Optional[str] = None,
|
heap_id: Optional[str] = None,
|
||||||
sort: str = "taken_at",
|
sort: str = "taken_at",
|
||||||
order: str = "desc",
|
order: str = "desc",
|
||||||
@@ -149,6 +152,8 @@ async def list_photos(
|
|||||||
# view shows everything regardless of duplicate status.
|
# view shows everything regardless of duplicate status.
|
||||||
if is_duplicate is not None:
|
if is_duplicate is not None:
|
||||||
filters.append(Photo.is_duplicate == is_duplicate)
|
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.
|
# Heap membership filter — restrict to photos that belong to the heap.
|
||||||
if heap_id:
|
if heap_id:
|
||||||
@@ -642,6 +647,28 @@ async def update_photo(
|
|||||||
photo.filename = new_name
|
photo.filename = new_name
|
||||||
photo.filepath = new_path
|
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
|
# Apply remaining updates
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(photo, field, value)
|
setattr(photo, field, value)
|
||||||
@@ -921,6 +948,64 @@ async def bulk_action(
|
|||||||
elif action.action == 'set_color':
|
elif action.action == 'set_color':
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
photo.color_label = action.value
|
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':
|
elif action.action == 'add_tags':
|
||||||
# value is a list of tag ids. We bulk-insert (photo_id, tag_id)
|
# value is a list of tag ids. We bulk-insert (photo_id, tag_id)
|
||||||
# rows for every (photo, tag) combination that doesn't already
|
# rows for every (photo, tag) combination that doesn't already
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class PhotoResponse(PhotoBase):
|
|||||||
latitude: Optional[float] = None
|
latitude: Optional[float] = None
|
||||||
longitude: Optional[float] = None
|
longitude: Optional[float] = None
|
||||||
is_duplicate: bool = False
|
is_duplicate: bool = False
|
||||||
|
has_date_warning: bool = False
|
||||||
live_photo_video_id: Optional[str] = None
|
live_photo_video_id: Optional[str] = None
|
||||||
# tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading
|
# tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading
|
||||||
|
|
||||||
|
|||||||
214
backend/app/services/date_guess.py
Normal file
214
backend/app/services/date_guess.py
Normal 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
|
||||||
72
backend/app/services/exif_writer.py
Normal file
72
backend/app/services/exif_writer.py
Normal 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)
|
||||||
@@ -15,6 +15,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from app.models import Photo
|
from app.models import Photo
|
||||||
|
from app.services.date_guess import has_date_warning
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -239,6 +240,14 @@ async def _extract_metadata_async(photo_id: str):
|
|||||||
photo.taken_at_source = 'exif'
|
photo.taken_at_source = 'exif'
|
||||||
break
|
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
|
# Extract dimensions if not already set
|
||||||
if not photo.width:
|
if not photo.width:
|
||||||
photo.width = exif_data.get('EXIF:ImageWidth') or exif_data.get('File:ImageWidth')
|
photo.width = exif_data.get('EXIF:ImageWidth') or exif_data.get('File:ImageWidth')
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.models import Photo, Folder, SourceRoot
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.thumbs import generate_thumbnails
|
from app.tasks.thumbs import generate_thumbnails
|
||||||
from app.services.metadata import extract_metadata
|
from app.services.metadata import extract_metadata
|
||||||
|
from app.services.date_guess import has_date_warning
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
effective_hidden = await is_folder_effectively_hidden(folder)
|
||||||
|
|
||||||
# Create photo entry
|
# Create photo entry
|
||||||
|
mtime_dt = datetime.fromtimestamp(stat.st_mtime)
|
||||||
photo = Photo(
|
photo = Photo(
|
||||||
filepath=filepath,
|
filepath=filepath,
|
||||||
filename=filename,
|
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),
|
media_type=get_media_type(filepath),
|
||||||
original_format=Path(filepath).suffix.upper()[1:],
|
original_format=Path(filepath).suffix.upper()[1:],
|
||||||
file_size=stat.st_size,
|
file_size=stat.st_size,
|
||||||
taken_at=datetime.fromtimestamp(stat.st_mtime),
|
taken_at=mtime_dt,
|
||||||
taken_at_source='filesystem',
|
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_duplicate=is_dup,
|
||||||
is_hidden=effective_hidden,
|
is_hidden=effective_hidden,
|
||||||
processing_status='pending'
|
processing_status='pending'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
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 clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
useFilterStore,
|
useFilterStore,
|
||||||
@@ -42,6 +42,8 @@ export function FilterBar() {
|
|||||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||||
const flag = useFilterStore((s) => s.flag)
|
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 sortBy = useFilterStore((s) => s.sortBy)
|
||||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||||
const tagIds = useFilterStore((s) => s.tagIds)
|
const tagIds = useFilterStore((s) => s.tagIds)
|
||||||
@@ -322,6 +324,38 @@ export function FilterBar() {
|
|||||||
</FilterPill>
|
</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
|
{/* Sort — always present, never "active/inactive" since there's
|
||||||
always a value. */}
|
always a value. */}
|
||||||
<FilterPill label="Sort" value={sortValue} isActive>
|
<FilterPill label="Sort" value={sortValue} isActive>
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { useState } from 'react'
|
|||||||
import { X, Star, ShoppingBasket, Trash2, Plus, PanelRightClose } from 'lucide-react'
|
import { X, Star, ShoppingBasket, Trash2, Plus, PanelRightClose } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { format } from 'date-fns'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import {
|
import {
|
||||||
photos as photosApi,
|
photos as photosApi,
|
||||||
heaps as heapsApi,
|
heaps as heapsApi,
|
||||||
tags as tagsApi,
|
tags as tagsApi,
|
||||||
} from '../../services/api'
|
} from '../../services/api'
|
||||||
|
import type { Photo } from '../../types/photo'
|
||||||
|
import {
|
||||||
|
guessDateFromPath,
|
||||||
|
type DateGuess,
|
||||||
|
} from '../../lib/guessDateFromPath'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||||
@@ -59,6 +65,47 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
|
|||||||
onSuccess: invalidatePhotoQueries,
|
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
|
// Bulk tag mutations. Tag mutations also need to invalidate the tags
|
||||||
// query so the FilterBar / sidebar tag counts stay fresh.
|
// query so the FilterBar / sidebar tag counts stay fresh.
|
||||||
const invalidateTagsAndPhotos = () => {
|
const invalidateTagsAndPhotos = () => {
|
||||||
@@ -228,6 +275,37 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
|
|||||||
// ── Multi-photo: bulk action panel ──────────────────────────────────
|
// ── Multi-photo: bulk action panel ──────────────────────────────────
|
||||||
const allMembers = selectedPhotos.every((id) => activeHeapMembers.has(id))
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
<Header />
|
<Header />
|
||||||
@@ -361,11 +439,170 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</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 {
|
interface BulkTagsEditorProps {
|
||||||
allTags: { id: string; name: string; color: string | null }[]
|
allTags: { id: string; name: string; color: string | null }[]
|
||||||
tagInput: string
|
tagInput: string
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
@@ -32,7 +32,7 @@ export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilm
|
|||||||
ref={isActive ? activeRef : null}
|
ref={isActive ? activeRef : null}
|
||||||
onClick={() => onSelect(photo.id)}
|
onClick={() => onSelect(photo.id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'shrink-0 overflow-hidden rounded-sm transition-all',
|
'relative shrink-0 overflow-hidden rounded-sm transition-all',
|
||||||
'hover:opacity-100',
|
'hover:opacity-100',
|
||||||
isActive
|
isActive
|
||||||
? 'ring-2 ring-primary opacity-100'
|
? 'ring-2 ring-primary opacity-100'
|
||||||
@@ -41,15 +41,45 @@ export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilm
|
|||||||
style={{ width: CELL_SIZE, height: CELL_SIZE }}
|
style={{ width: CELL_SIZE, height: CELL_SIZE }}
|
||||||
title={photo.filename}
|
title={photo.filename}
|
||||||
>
|
>
|
||||||
<img
|
<FilmstripThumb photo={photo} />
|
||||||
src={photosApi.getThumbnailUrl(photo.id, 'small')}
|
|
||||||
alt={photo.filename}
|
|
||||||
loading="lazy"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</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'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
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 { format } from 'date-fns'
|
||||||
import {
|
import {
|
||||||
photos as photosApi,
|
photos as photosApi,
|
||||||
@@ -28,6 +33,10 @@ import {
|
|||||||
COLOR_LABEL_OPTIONS,
|
COLOR_LABEL_OPTIONS,
|
||||||
type ColorLabel,
|
type ColorLabel,
|
||||||
} from '../../constants/colorLabels'
|
} from '../../constants/colorLabels'
|
||||||
|
import {
|
||||||
|
guessDateFromPath,
|
||||||
|
toDatetimeLocalValue,
|
||||||
|
} from '../../lib/guessDateFromPath'
|
||||||
|
|
||||||
interface PhotoTagSummary {
|
interface PhotoTagSummary {
|
||||||
id: string
|
id: string
|
||||||
@@ -43,6 +52,7 @@ interface PhotoDetails {
|
|||||||
height: number | null
|
height: number | null
|
||||||
file_size: number | null
|
file_size: number | null
|
||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
|
taken_at_source: string | null
|
||||||
rating: number
|
rating: number
|
||||||
is_discarded: boolean
|
is_discarded: boolean
|
||||||
user_title: string | null
|
user_title: string | null
|
||||||
@@ -137,12 +147,16 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
setExpandedSections(next)
|
setExpandedSections(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the photo's full record (with EXIF) on demand.
|
// Fetch the photo's full record (with EXIF) on demand. `keepPreviousData`
|
||||||
const { data: photo } = useQuery<PhotoDetails>({
|
// 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],
|
queryKey: ['photo', photoId],
|
||||||
queryFn: () => photosApi.get(photoId),
|
queryFn: () => photosApi.get(photoId),
|
||||||
enabled: !!photoId,
|
enabled: !!photoId,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mutation for any patchable field. Invalidates both the photo detail
|
// 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_title?: string | null
|
||||||
user_notes?: string | null
|
user_notes?: string | null
|
||||||
color_label?: string | null
|
color_label?: string | null
|
||||||
|
taken_at?: string
|
||||||
}) => photosApi.update(photoId, data),
|
}) => photosApi.update(photoId, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
||||||
@@ -240,12 +255,22 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
const [filenameDraft, setFilenameDraft] = useState('')
|
const [filenameDraft, setFilenameDraft] = useState('')
|
||||||
const [titleDraft, setTitleDraft] = useState('')
|
const [titleDraft, setTitleDraft] = useState('')
|
||||||
const [notesDraft, setNotesDraft] = useState('')
|
const [notesDraft, setNotesDraft] = useState('')
|
||||||
|
const [takenAtDraft, setTakenAtDraft] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFilenameDraft(photo?.filename ?? '')
|
setFilenameDraft(photo?.filename ?? '')
|
||||||
setTitleDraft(photo?.user_title ?? '')
|
setTitleDraft(photo?.user_title ?? '')
|
||||||
setNotesDraft(photo?.user_notes ?? '')
|
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 commitFilename = () => {
|
||||||
const next = filenameDraft.trim()
|
const next = filenameDraft.trim()
|
||||||
@@ -287,6 +312,44 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
updateMutation.mutate({ user_notes: next || null })
|
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])
|
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||||||
|
|
||||||
if (!photo) {
|
if (!photo) {
|
||||||
@@ -313,7 +376,12 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Edit fields */}
|
||||||
<div className="space-y-2.5 border-b border-border p-3">
|
<div className="space-y-2.5 border-b border-border p-3">
|
||||||
<div>
|
<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>
|
</div>
|
||||||
|
<TakenAtEditor
|
||||||
|
photo={photo}
|
||||||
|
draft={takenAtDraft}
|
||||||
|
onDraftChange={setTakenAtDraft}
|
||||||
|
onCommit={commitTakenAt}
|
||||||
|
darkTheme={darkTheme}
|
||||||
|
/>
|
||||||
{/* Filepath spans the full sidebar width — most paths are long
|
{/* Filepath spans the full sidebar width — most paths are long
|
||||||
* enough that the two-column grid above wraps them painfully.
|
* enough that the two-column grid above wraps them painfully.
|
||||||
* Mono so each character lines up under the next, break-all
|
* Mono so each character lines up under the next, break-all
|
||||||
@@ -761,3 +828,114 @@ function Field({ label, value }: { label: string; value: string }) {
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
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 clsx from 'clsx'
|
||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
@@ -92,6 +100,13 @@ export function PhotoThumbnail({
|
|||||||
const baseUrl = photosApi.getThumbnailUrl(photo.id, 'medium')
|
const baseUrl = photosApi.getThumbnailUrl(photo.id, 'medium')
|
||||||
const thumbnailUrl = retryCount > 0 ? `${baseUrl}?retry=${retryCount}` : baseUrl
|
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
|
// Square cells (Lightroom-style grid). Variable-aspect cells previously
|
||||||
// overflowed their row because TanStack Virtual estimates row height as a
|
// overflowed their row because TanStack Virtual estimates row height as a
|
||||||
// single fixed value — portraits in a landscape row would overlap the row
|
// 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} />
|
<Trash2 className={THUMB_BADGE_ICON} strokeWidth={2.5} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* TR — file-type metadata (RAW / VIDEO) */}
|
{/* TR — file-type metadata (RAW / VIDEO) */}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ function parseUrl(): HydratePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sp.get('duplicates') === 'true') out.duplicates = true
|
if (sp.get('duplicates') === 'true') out.duplicates = true
|
||||||
|
if (sp.get('date_warning') === 'true') out.dateWarning = true
|
||||||
|
|
||||||
const groupBy = sp.get('group')
|
const groupBy = sp.get('group')
|
||||||
if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy
|
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.folderId) sp.set('folder_id', f.folderId)
|
||||||
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
|
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
|
||||||
if (f.duplicates) sp.set('duplicates', 'true')
|
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.groupBy !== 'date') sp.set('group', f.groupBy)
|
||||||
if (f.currentSection && f.currentSection !== 'all-photos')
|
if (f.currentSection && f.currentSection !== 'all-photos')
|
||||||
sp.set('section', f.currentSection)
|
sp.set('section', f.currentSection)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function usePhotosQuery() {
|
|||||||
const folderId = useFilterStore((s) => s.folderId)
|
const folderId = useFilterStore((s) => s.folderId)
|
||||||
const tagIds = useFilterStore((s) => s.tagIds)
|
const tagIds = useFilterStore((s) => s.tagIds)
|
||||||
const duplicates = useFilterStore((s) => s.duplicates)
|
const duplicates = useFilterStore((s) => s.duplicates)
|
||||||
|
const dateWarning = useFilterStore((s) => s.dateWarning)
|
||||||
const groupBy = useFilterStore((s) => s.groupBy)
|
const groupBy = useFilterStore((s) => s.groupBy)
|
||||||
const sortBy = useFilterStore((s) => s.sortBy)
|
const sortBy = useFilterStore((s) => s.sortBy)
|
||||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||||
@@ -58,11 +59,12 @@ export function usePhotosQuery() {
|
|||||||
folderId,
|
folderId,
|
||||||
tagIds,
|
tagIds,
|
||||||
duplicates,
|
duplicates,
|
||||||
|
dateWarning,
|
||||||
groupBy,
|
groupBy,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
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()
|
const queryClient = useQueryClient()
|
||||||
|
|||||||
253
frontend/src/lib/guessDateFromPath.ts
Normal file
253
frontend/src/lib/guessDateFromPath.ts
Normal 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())}`
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -185,6 +185,39 @@ export const photos = {
|
|||||||
return response.data
|
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
|
/** Add the listed tags to every listed photo. Idempotent — re-adding
|
||||||
* an existing (photo, tag) pair is a no-op. Returns { added: N }. */
|
* an existing (photo, tag) pair is a no-op. Returns { added: N }. */
|
||||||
bulkAddTags: async (photoIds: string[], tagIds: string[]) => {
|
bulkAddTags: async (photoIds: string[], tagIds: string[]) => {
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export interface FilterState {
|
|||||||
tagIds: string[]
|
tagIds: string[]
|
||||||
/** When true, restrict to photos flagged as duplicates by the scanner. */
|
/** When true, restrict to photos flagged as duplicates by the scanner. */
|
||||||
duplicates: boolean
|
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
|
/** Visual grouping mode. 'date' groups by month when sortBy is a date
|
||||||
* field; 'tag' groups by photo tag membership. Independent of filters. */
|
* field; 'tag' groups by photo tag membership. Independent of filters. */
|
||||||
groupBy: GroupBy
|
groupBy: GroupBy
|
||||||
@@ -68,6 +72,7 @@ interface FilterStore extends FilterState {
|
|||||||
setTagIds: (ids: string[]) => void
|
setTagIds: (ids: string[]) => void
|
||||||
toggleTagId: (id: string) => void
|
toggleTagId: (id: string) => void
|
||||||
setDuplicates: (v: boolean) => void
|
setDuplicates: (v: boolean) => void
|
||||||
|
setDateWarning: (v: boolean) => void
|
||||||
setGroupBy: (mode: GroupBy) => void
|
setGroupBy: (mode: GroupBy) => void
|
||||||
setSortBy: (field: SortField) => void
|
setSortBy: (field: SortField) => void
|
||||||
setSortOrder: (order: SortOrder) => void
|
setSortOrder: (order: SortOrder) => void
|
||||||
@@ -102,6 +107,7 @@ export const INITIAL_FILTERS: FilterState = {
|
|||||||
folderId: null,
|
folderId: null,
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
duplicates: false,
|
duplicates: false,
|
||||||
|
dateWarning: false,
|
||||||
groupBy: 'date',
|
groupBy: 'date',
|
||||||
sortBy: 'taken_at',
|
sortBy: 'taken_at',
|
||||||
sortOrder: 'desc',
|
sortOrder: 'desc',
|
||||||
@@ -124,6 +130,7 @@ function snapshotFilters(s: FilterState): FilterState {
|
|||||||
folderId: s.folderId,
|
folderId: s.folderId,
|
||||||
tagIds: [...s.tagIds],
|
tagIds: [...s.tagIds],
|
||||||
duplicates: s.duplicates,
|
duplicates: s.duplicates,
|
||||||
|
dateWarning: s.dateWarning,
|
||||||
groupBy: s.groupBy,
|
groupBy: s.groupBy,
|
||||||
sortBy: s.sortBy,
|
sortBy: s.sortBy,
|
||||||
sortOrder: s.sortOrder,
|
sortOrder: s.sortOrder,
|
||||||
@@ -159,6 +166,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
|||||||
: [...s.tagIds, id],
|
: [...s.tagIds, id],
|
||||||
})),
|
})),
|
||||||
setDuplicates: (duplicates) => set({ duplicates }),
|
setDuplicates: (duplicates) => set({ duplicates }),
|
||||||
|
setDateWarning: (dateWarning) => set({ dateWarning }),
|
||||||
setGroupBy: (groupBy) => set({ groupBy }),
|
setGroupBy: (groupBy) => set({ groupBy }),
|
||||||
setSortBy: (sortBy) => set({ sortBy }),
|
setSortBy: (sortBy) => set({ sortBy }),
|
||||||
setSortOrder: (sortOrder) => set({ sortOrder }),
|
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.folderId) params.folder_id = f.folderId
|
||||||
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
|
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
|
||||||
if (f.duplicates) params.is_duplicate = 'true'
|
if (f.duplicates) params.is_duplicate = 'true'
|
||||||
|
if (f.dateWarning) params.has_date_warning = 'true'
|
||||||
params.sort = f.sortBy
|
params.sort = f.sortBy
|
||||||
params.order = f.sortOrder
|
params.order = f.sortOrder
|
||||||
return params
|
return params
|
||||||
@@ -236,6 +245,7 @@ export function hasActiveFilters(f: FilterState): boolean {
|
|||||||
f.heapId !== null ||
|
f.heapId !== null ||
|
||||||
f.folderId !== null ||
|
f.folderId !== null ||
|
||||||
f.tagIds.length > 0 ||
|
f.tagIds.length > 0 ||
|
||||||
f.duplicates
|
f.duplicates ||
|
||||||
|
f.dateWarning
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ export interface Photo {
|
|||||||
width: number | null
|
width: number | null
|
||||||
height: number | null
|
height: number | null
|
||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
|
taken_at_source?: string | null
|
||||||
rating: number
|
rating: number
|
||||||
color_label?: string | null
|
color_label?: string | null
|
||||||
is_discarded: boolean
|
is_discarded: boolean
|
||||||
is_duplicate: boolean
|
is_duplicate: boolean
|
||||||
|
has_date_warning?: boolean
|
||||||
file_hash: string
|
file_hash: string
|
||||||
folder_id: string | null
|
folder_id: string | null
|
||||||
added_at: string | null
|
added_at: string | null
|
||||||
|
|||||||
Reference in New Issue
Block a user