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:
@@ -122,6 +122,40 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
|
||||
source_root = await get_or_create_source_root(session, folder_path)
|
||||
source_root_id = source_root.id
|
||||
|
||||
# Per-scan memoization cache for "is this folder's effective
|
||||
# is_hidden true?" Populated on first lookup by walking the
|
||||
# parent_id chain up to the source root. Keyed by folder_id
|
||||
# so repeated photos in the same folder pay only one lookup.
|
||||
hidden_folder_cache: dict[str, bool] = {}
|
||||
|
||||
async def is_folder_effectively_hidden(folder_row: Folder) -> bool:
|
||||
if folder_row.id in hidden_folder_cache:
|
||||
return hidden_folder_cache[folder_row.id]
|
||||
# Walk parents. If the current folder is hidden, short-
|
||||
# circuit. Otherwise climb until we hit a root (no
|
||||
# parent_id) or a cached ancestor.
|
||||
if folder_row.is_hidden:
|
||||
hidden_folder_cache[folder_row.id] = True
|
||||
return True
|
||||
parent_id = folder_row.parent_id
|
||||
while parent_id is not None:
|
||||
if parent_id in hidden_folder_cache:
|
||||
hidden_folder_cache[folder_row.id] = hidden_folder_cache[parent_id]
|
||||
return hidden_folder_cache[folder_row.id]
|
||||
parent = (
|
||||
await session.execute(
|
||||
select(Folder).where(Folder.id == parent_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if parent is None:
|
||||
break
|
||||
if parent.is_hidden:
|
||||
hidden_folder_cache[folder_row.id] = True
|
||||
return True
|
||||
parent_id = parent.parent_id
|
||||
hidden_folder_cache[folder_row.id] = False
|
||||
return False
|
||||
|
||||
# Pre-walk to compute the total file count upfront. Without this
|
||||
# the progress bar would jump every time a new subfolder is
|
||||
# encountered because the running total kept growing.
|
||||
@@ -188,6 +222,14 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
|
||||
)).scalar() or 0
|
||||
is_dup = dup_count > 0
|
||||
|
||||
# Inherit the effective-hidden flag from the
|
||||
# folder's ancestry. If any ancestor folder
|
||||
# has is_hidden=true, the new photo is
|
||||
# immediately marked hidden so it never
|
||||
# briefly appears in cross-cutting views
|
||||
# between scan and the next manual recompute.
|
||||
effective_hidden = await is_folder_effectively_hidden(folder)
|
||||
|
||||
# Create photo entry
|
||||
photo = Photo(
|
||||
filepath=filepath,
|
||||
@@ -200,6 +242,7 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
|
||||
taken_at=datetime.fromtimestamp(stat.st_mtime),
|
||||
taken_at_source='filesystem',
|
||||
is_duplicate=is_dup,
|
||||
is_hidden=effective_hidden,
|
||||
processing_status='pending'
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user