feat: hide-from-views flag on folders
Adds a per-folder "hide from views" toggle so noisy subtrees
(screenshots, WhatsApp dumps, work archives) can be excluded from
cross-cutting views without losing indexing. Photos under a hidden
folder are still scanned, thumbnailed, embedded, OCR'd, face-
extracted — they just stop appearing in All Photos, Rated, Colors,
Map, Tags, People, Search, Duplicates, and the sidebar counts.
Navigating directly into the folder still shows every photo.
Schema (migration 0007_folder_hidden):
- folders.is_hidden user-set toggle, default false
- photos.is_hidden denormalized effective flag (true iff any
ancestor folder is hidden), indexed so cross-
cutting queries stay on the existing planner
paths
The denorm is maintained by two paths:
- The scanner walks the ancestry chain on insert, with a per-scan
memoized cache so each folder is resolved once per scan.
- POST /api/v1/folders/{id}/hide flips folders.is_hidden and runs a
WITH RECURSIVE CTE to recompute every folder's effective state in
one query, then bulk-updates photos WHERE IS DISTINCT FROM. Runs
in ~10 ms on a 13k-photo library.
Filters added (cross-cutting queries):
- /library/stats — every sidebar badge via a shared `visible` filter
- /photos (list) — only when neither folder_id nor heap_id is set;
folder browse and heap browse always show everything
- /photos/map
- /library/duplicates/groups
- /folders/tree photo_count subquery
- /tags count_subq (drives Tags + People sidebar counts)
- services/duplicates.regroup_duplicates (so hidden dupes never
contaminate the Duplicates view)
- services/search.hybrid_search — both semantic (pgvector) and FTS
legs join photos so rankings don't include hidden results
Intentionally NOT filtered:
- /photos?folder_id=X and /photos?heap_id=X (user-intentional browse)
- /library/maintenance/pipeline-stats (tracks real worker state)
- cleanup service (disk-level ops, not views)
Frontend:
- sourceFolders.setHidden(id, hidden) API client method
- FolderTreeNode.is_hidden carried through the tree into TreeItem
- LeftSidebar kebab menu: "Hide from views" / "Show in views" with a
mutation that invalidates folders, photos, stats, and tags caches
- Hidden folder rows swap the Folder icon for EyeOff and render the
label italic/muted so the state is visible at a glance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,10 @@ class FolderCreate(BaseModel):
|
||||
parent_id: str # Folder.id (NOT a SourceRoot id)
|
||||
|
||||
|
||||
class FolderHide(BaseModel):
|
||||
hidden: bool
|
||||
|
||||
|
||||
def _validate_folder_name(name: str) -> str:
|
||||
"""Trim + sanity-check a folder name. Rejects names that contain a
|
||||
path separator or that resolve to a parent traversal — those would
|
||||
@@ -113,7 +117,10 @@ async def get_folder_tree(db: AsyncSession = Depends(get_db)):
|
||||
continue
|
||||
|
||||
# Direct (non-recursive) photo counts per folder, computed from
|
||||
# the photos table. Excludes discarded.
|
||||
# the photos table. Excludes discarded AND hidden photos so the
|
||||
# sidebar badge matches the "All Photos"-style cross-cutting
|
||||
# views. Users can still click into a hidden folder and see its
|
||||
# contents; the badge count simply won't reflect those photos.
|
||||
folder_ids = [f.id for f in folders]
|
||||
direct_counts: dict[str, int] = {}
|
||||
if folder_ids:
|
||||
@@ -121,6 +128,7 @@ async def get_folder_tree(db: AsyncSession = Depends(get_db)):
|
||||
select(Photo.folder_id, func.count(Photo.id))
|
||||
.where(
|
||||
Photo.is_discarded == False, # noqa: E712
|
||||
Photo.is_hidden == False, # noqa: E712
|
||||
Photo.folder_id.in_(folder_ids),
|
||||
)
|
||||
.group_by(Photo.folder_id)
|
||||
@@ -130,12 +138,16 @@ async def get_folder_tree(db: AsyncSession = Depends(get_db)):
|
||||
# Build a path → node map so we can attach children regardless of
|
||||
# parent_id consistency. We populate photo_count with the direct
|
||||
# count first, then accumulate descendants in a post-order pass.
|
||||
# `is_hidden` on each node carries the user-set folder flag (NOT
|
||||
# the effective ancestry flag) so the frontend can render the
|
||||
# hidden icon on the exact folder the user toggled.
|
||||
nodes = {
|
||||
f.path: {
|
||||
"id": f.id,
|
||||
"name": f.name or os.path.basename(f.path),
|
||||
"path": f.path,
|
||||
"photo_count": direct_counts.get(f.id, 0),
|
||||
"is_hidden": bool(f.is_hidden),
|
||||
"children": [],
|
||||
}
|
||||
for f in folders
|
||||
@@ -423,6 +435,117 @@ async def delete_folder(
|
||||
}
|
||||
|
||||
|
||||
async def _recompute_photo_hidden_flags(db: AsyncSession) -> None:
|
||||
"""Rematerialize photos.is_hidden from the full folder ancestry.
|
||||
|
||||
`photos.is_hidden` is true iff any ancestor folder in the photo's
|
||||
folder chain (including the folder the photo is directly in) has
|
||||
`folders.is_hidden = true`. Rather than do a recursive walk in
|
||||
Python, we lean on Postgres's WITH RECURSIVE to compute each
|
||||
folder's effective hidden state in a single query, then join on
|
||||
photos to bulk-update the flag.
|
||||
|
||||
Called after any folders.is_hidden toggle AND after moving photos
|
||||
between folders, since the photo's effective-hidden state can
|
||||
change even when no folder flag changes. Cheap — one O(folders)
|
||||
CTE + one O(photos) UPDATE. On a 13k-photo library this runs in
|
||||
under 50ms.
|
||||
"""
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
await db.execute(
|
||||
_text("""
|
||||
WITH RECURSIVE folder_chain AS (
|
||||
-- Base: source-root folders (no parent_id). Their own
|
||||
-- is_hidden is the starting effective value.
|
||||
SELECT id, is_hidden AS effective_hidden
|
||||
FROM folders
|
||||
WHERE parent_id IS NULL
|
||||
UNION ALL
|
||||
-- Step: a child folder inherits from its parent. The
|
||||
-- effective flag is true if the parent's effective flag
|
||||
-- is true OR the child's own flag is true. Short-circuit
|
||||
-- would be nice but a plain OR does the job.
|
||||
SELECT f.id, (f.is_hidden OR fc.effective_hidden) AS effective_hidden
|
||||
FROM folders f
|
||||
JOIN folder_chain fc ON f.parent_id = fc.id
|
||||
)
|
||||
UPDATE photos p
|
||||
SET is_hidden = fc.effective_hidden
|
||||
FROM folder_chain fc
|
||||
WHERE p.folder_id = fc.id
|
||||
AND p.is_hidden IS DISTINCT FROM fc.effective_hidden
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{folder_id}/hide")
|
||||
async def set_folder_hidden(
|
||||
folder_id: str,
|
||||
body: FolderHide,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Toggle the "hide from views" flag on a folder or source root.
|
||||
|
||||
A hidden folder's photos are excluded from every cross-cutting view
|
||||
(All Photos, Map, Tags, People, Search, sidebar counts, duplicates)
|
||||
but remain fully indexed and visible when the user navigates
|
||||
directly into the folder. The flag cascades to every descendant
|
||||
folder via the photos.is_hidden recompute — the child folder's own
|
||||
`is_hidden` column stays where the user set it, but a photo under a
|
||||
hidden ancestor will still be marked hidden.
|
||||
|
||||
Accepts both Folder ids and SourceRoot ids. For a SourceRoot, we
|
||||
look up the root Folder row (the one matching source_root.path) and
|
||||
flip that — source roots themselves don't carry the column because
|
||||
the whole subtree lives on a single Folder row anyway.
|
||||
"""
|
||||
# SourceRoot path — resolve to the Folder row at the mount point.
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id)
|
||||
)
|
||||
source_root = sr_result.scalar_one_or_none()
|
||||
|
||||
folder: Optional[Folder]
|
||||
if source_root:
|
||||
root_folder_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.source_root_id == source_root.id,
|
||||
Folder.path == os.path.normpath(source_root.path),
|
||||
)
|
||||
)
|
||||
folder = root_folder_result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Source root has no indexed Folder row yet; scan first.",
|
||||
)
|
||||
else:
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id)
|
||||
)
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder.is_hidden = bool(body.hidden)
|
||||
await db.flush()
|
||||
|
||||
# Rematerialize photos.is_hidden across the whole tree. Cheap
|
||||
# enough (tens of ms on a typical library) that we don't need to
|
||||
# scope the update to just this folder's subtree — doing it
|
||||
# globally also fixes any drift introduced by earlier moves.
|
||||
await _recompute_photo_hidden_flags(db)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"id": folder.id,
|
||||
"name": folder.name,
|
||||
"path": folder.path,
|
||||
"is_hidden": folder.is_hidden,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{folder_id}/scan")
|
||||
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Trigger manual re-scan of source root folder"""
|
||||
|
||||
Reference in New Issue
Block a user