feat: editable filename in RightSidebar (renames file on disk)

The first phase-11 file operation. Lightroom-style inline rename
of a single photo, in place, in its current directory.

Backend (PATCH /photos/{id})
- PhotoUpdate schema accepts an optional `filename`.
- When set, the handler validates: non-empty, no path separators,
  no `..`/`.`, target name doesn't already exist in the directory,
  source file exists on disk.
- os.renames the file inside its current directory, then updates
  photo.filename + photo.filepath atomically. The DB only changes
  after a successful rename — a filesystem failure leaves the
  rest of the row untouched.
- Other PhotoUpdate fields still apply afterwards in the same
  request.

Frontend (RightSidebar)
- Filename is now an editable monospace input above the Title
  input. Same draft + commit pattern as title/notes (local draft,
  resync on photo.id change, on-blur or Enter commits).
- Esc reverts to the server value.
- Client-side validation mirrors the backend (rejects path
  separators and dot-segments) and shows a toast on backend
  errors with the FastAPI detail message, then rolls the draft
  back so the input matches the still-on-disk filename.
- Removed the old read-only Filename Field from the Basic Info
  section to avoid showing the same value twice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:48:31 +02:00
parent cf7c72d437
commit 7c003bc92e
4 changed files with 92 additions and 7 deletions

View File

@@ -410,20 +410,53 @@ async def update_photo(
update: PhotoUpdate,
db: AsyncSession = Depends(get_db)
):
"""Update photo metadata"""
"""Update photo metadata. If `filename` is included, also rename the
file on disk in its current directory (no cross-folder moves through
this endpoint).
"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
# Apply updates
update_data = update.dict(exclude_unset=True)
# Filename rename: validate, rename on disk, then update both filename
# and filepath atomically. Done before any other field changes so a
# filesystem failure leaves the rest of the row untouched.
if 'filename' in update_data:
new_name = (update_data.pop('filename') or '').strip()
if not new_name:
raise HTTPException(status_code=400, detail="Filename cannot be empty")
# Reject path separators and parent traversal — same-directory only.
if '/' in new_name or '\\' in new_name or new_name in ('.', '..'):
raise HTTPException(status_code=400, detail="Invalid filename")
if new_name != photo.filename:
current_dir = os.path.dirname(photo.filepath)
new_path = os.path.join(current_dir, new_name)
if not os.path.exists(photo.filepath):
raise HTTPException(status_code=404, detail="Source file missing on disk")
if os.path.exists(new_path):
raise HTTPException(status_code=409, detail="A file with that name already exists")
try:
os.rename(photo.filepath, new_path)
except OSError as e:
logger.error(f"Failed to rename {photo.filepath} -> {new_path}: {e}")
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
photo.filename = new_name
photo.filepath = new_path
# Apply remaining updates
for field, value in update_data.items():
setattr(photo, field, value)
await db.commit()
await db.refresh(photo)

View File

@@ -46,6 +46,7 @@ class PhotoResponse(PhotoBase):
class PhotoUpdate(BaseModel):
"""Photo update schema"""
filename: Optional[str] = None
user_title: Optional[str] = None
user_notes: Optional[str] = None
rating: Optional[int] = Field(None, ge=0, le=5)