feat(library): tighten duplicates scope, drop in-app upload, polish loaders
- duplicates: restrict the /library/duplicates/groups query to photos whose folder path actually lives under an active SourceRoot in the user's settings. Nextcloud's "move to trash" flow was leaving .delete/purge-1 Folder rows wired to the original source_root_id, leaking those entries into the Duplicates view as ghost paths that the user never opted into. - discard: add a spinner to the "Delete N" and "Empty discard pile" buttons (and their confirm dialogs) while the destructive mutation is in flight, so the user gets immediate feedback for a slow operation. - timeline: render a bottom-of-grid "Loading more photos…" indicator while usePhotosQuery's background cursor loop is still pulling pages. Backed by a tiny Zustand store the loop drives via a balanced start/stop (counter, not boolean, so rapid filter changes can't flip the flag false while a fresh loop is alive). - remove client-side upload UI + /upload endpoint. Nextcloud is the authoritative ingress now; the duplicate path created confusion and the backend route is gone too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,12 +13,12 @@ from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, func, update, true as sa_true
|
||||
from sqlalchemy import select, func, update, or_, true as sa_true
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo
|
||||
from app.models.folders import SourceRoot
|
||||
from app.models.folders import SourceRoot, Folder
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user
|
||||
|
||||
@@ -730,6 +730,33 @@ async def get_duplicate_groups(
|
||||
* "similar" — members differ at the byte level but match perceptually
|
||||
"""
|
||||
owner = _owner_filter(current_user, scope)
|
||||
|
||||
# Restrict to photos that actually live under an active SourceRoot
|
||||
# in the user's settings. The folder→source_root link alone is not
|
||||
# enough: Nextcloud's "move to trash" flow can leave a Folder row at
|
||||
# a path like `…/files/.delete/purge-1` still wired to the original
|
||||
# source_root_id, which then leaks its photos into the duplicates
|
||||
# view even though that path is outside everything the user
|
||||
# configured. Filtering on the folder's path being a descendant of
|
||||
# the active root's path matches what the user actually expects to
|
||||
# see in Settings → Source folders.
|
||||
sr_path_query = select(SourceRoot.path).where(SourceRoot.is_active.is_(True))
|
||||
if not (scope == "global" and current_user.role == "admin"):
|
||||
sr_path_query = sr_path_query.where(SourceRoot.user_id == current_user.id)
|
||||
active_root_paths = (await db.execute(sr_path_query)).scalars().all()
|
||||
if not active_root_paths:
|
||||
return {"groups": [], "total_groups": 0, "total_members": 0}
|
||||
|
||||
# Match the source root path itself OR a strict child (path + '/').
|
||||
# `startswith` alone would accept `/files/Photos2` for a `/files/Photos`
|
||||
# root.
|
||||
folder_in_scope = or_(
|
||||
*[
|
||||
(Folder.path == p) | (Folder.path.like(p.rstrip('/') + '/%'))
|
||||
for p in active_root_paths
|
||||
]
|
||||
)
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
@@ -746,10 +773,12 @@ async def get_duplicate_groups(
|
||||
Photo.media_type,
|
||||
Photo.duplicate_group_id,
|
||||
)
|
||||
.join(Folder, Folder.id == Photo.folder_id)
|
||||
.where(owner)
|
||||
.where(Photo.duplicate_group_id.is_not(None))
|
||||
.where(Photo.is_discarded.is_(False))
|
||||
.where(Photo.is_hidden.is_(False))
|
||||
.where(folder_in_scope)
|
||||
.order_by(Photo.duplicate_group_id)
|
||||
)
|
||||
).all()
|
||||
|
||||
Reference in New Issue
Block a user