From 501bfe2916b571065822731c3b519dd00a490ee7 Mon Sep 17 00:00:00 2001 From: drjones Date: Thu, 23 Jul 2026 11:22:55 -0700 Subject: [PATCH] Add per-folder recursive/top-level-only scanning, fix a dangerous empty-folder-list fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each source folder now has its own recursive flag (checkbox on add, toggle badge on its chip). scan_folder/sync_metadata_from_folder both honor it, so "top-level only" actually excludes subfolders end-to-end instead of just the tagging queue while sync still scanned recursively. - New /api/set_folder_recursive to flip an existing folder's mode without removing/re-adding it. - SAFETY FIX: found while testing — when the configured folder list is empty, the app fell back to the parent directory of wherever server.py lives, which for a typical install (PHOTON dropped directly in the user's home folder) means defaulting to the entire home directory. Caught this before any scan/start actually ran against it. Fixed so an explicitly-configured empty list (config file exists, list is empty) is respected as "zero folders" instead of silently substituted with a guessed default — the guessed default now only applies on a genuinely fresh install where no config file exists yet at all. Co-Authored-By: Claude Sonnet 5 --- index.html | 33 ++++++++-- server.py | 185 ++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 161 insertions(+), 57 deletions(-) diff --git a/index.html b/index.html index 7dcf781..fc18ce1 100644 --- a/index.html +++ b/index.html @@ -89,6 +89,14 @@ button:disabled{opacity:.3;cursor:not-allowed} .folderChip .rm{cursor:pointer;color:var(--dim);border:1px solid var(--line);border-radius:50%; width:16px;height:16px;display:flex;align-items:center;justify-content:center;font-size:11px;line-height:1} .folderChip .rm:hover{color:var(--red);border-color:var(--red)} +.folderChip .mode{cursor:pointer;color:var(--dim);border:1px solid var(--line);border-radius:8px; + padding:1px 7px;font-size:10px;white-space:nowrap} +.folderChip .mode:hover{color:var(--cyan);border-color:var(--cyan)} +.folderChip .mode.rec{color:var(--cyan-dim)} + +.chkRecursiveLabel{display:flex;align-items:center;gap:5px;font-family:var(--font-mono); + font-size:11px;color:var(--dim);white-space:nowrap;padding:0 4px} +.chkRecursiveLabel input{accent-color:var(--cyan)} .grid{display:grid;grid-template-columns:330px 1fr;gap:14px} .grid-three{display:grid;grid-template-columns:330px 1fr 320px;gap:14px} @@ -352,6 +360,9 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
+ @@ -1021,7 +1032,7 @@ async function api(path, body){ } /* ---------- multi-folder source list ---------- */ -let currentFolders = []; +let currentFolders = []; // [{path, recursive}, ...] function renderFolderChips(){ const box = $("folderChips"); @@ -1030,8 +1041,11 @@ function renderFolderChips(){ currentFolders.forEach(f=>{ const chip = document.createElement("div"); chip.className = "folderChip"; - chip.innerHTML = `${escapeHtml(f)}×`; - chip.querySelector(".rm").onclick = ()=>removeFolder(f); + chip.innerHTML = `${escapeHtml(f.path)} + ${f.recursive?'⊞ subfolders':'▭ top-level only'} + ×`; + chip.querySelector(".rm").onclick = ()=>removeFolder(f.path); + chip.querySelector(".mode").onclick = ()=>toggleFolderRecursive(f); box.appendChild(chip); }); } @@ -1045,8 +1059,13 @@ function applyFolderList(folders){ } } -async function removeFolder(f){ - const j = await api("/api/remove_folder", {folder: f}); +async function removeFolder(path){ + const j = await api("/api/remove_folder", {folder: path}); + if(j.ok) applyFolderList(j.folders); +} + +async function toggleFolderRecursive(f){ + const j = await api("/api/set_folder_recursive", {folder: f.path, recursive: !f.recursive}); if(j.ok) applyFolderList(j.folders); } @@ -1055,7 +1074,7 @@ if($("btnAddFolderTyped")){ const val = $("folder").value.trim(); if(!val) return; $("btnAddFolderTyped").disabled = true; - const j = await api("/api/add_folder", {folder: val}); + const j = await api("/api/add_folder", {folder: val, recursive: $("chkRecursive").checked}); $("btnAddFolderTyped").disabled = false; if(j.ok){ $("folder").value = ""; @@ -1069,7 +1088,7 @@ if($("btnBrowseFolder")){ $("btnBrowseFolder").onclick = async ()=>{ $("btnBrowseFolder").disabled = true; showToast("Opening folder chooser…", "info"); - const j = await api("/api/choose_folder", {}); + const j = await api("/api/choose_folder", {recursive: $("chkRecursive").checked}); $("btnBrowseFolder").disabled = false; if(j.ok && j.folder){ showToast("Added folder: " + j.folder, "ok"); diff --git a/server.py b/server.py index 22b04e8..d19758a 100644 --- a/server.py +++ b/server.py @@ -33,8 +33,12 @@ PORT = int(os.environ.get("PHOTON_PORT", 8765)) FOLDER_CONFIG_FILE = os.path.join(APP_DIR, "photon_folder.json") def load_saved_folders(): - """Returns the list of configured source folders, migrating transparently - from the old single-folder config format if that's all that's on disk.""" + """Returns the explicitly configured folder list (as {"path", "recursive"} + dicts) if a config file exists — even if that list is empty, because the + user deliberately removed every folder and that must be respected, NOT + silently overridden by a fallback default. Returns None only when there + is no config file at all (a genuinely fresh install), which is the one + case the zero-config fallback below is allowed to kick in for.""" if os.path.exists(FOLDER_CONFIG_FILE): try: with open(FOLDER_CONFIG_FILE, "r", encoding="utf-8") as f: @@ -42,18 +46,24 @@ def load_saved_folders(): folders = data.get("folders") if folders is None and data.get("folder"): folders = [data["folder"]] - if folders: - existing = [f for f in folders if os.path.isdir(f)] - if existing: - return existing + if folders is not None: + out = [] + for f in folders: + if isinstance(f, str): + f = {"path": f, "recursive": True} + if f.get("path") and os.path.isdir(f["path"]): + out.append({"path": f["path"], "recursive": bool(f.get("recursive", True))}) + return out # explicit list, possibly empty — respected as-is except Exception: pass - return [] + return None -def save_folders_config(folders): +def save_folders_config(folders, recursive_map): try: with open(FOLDER_CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump({"folders": folders}, f) + json.dump({"folders": [ + {"path": p, "recursive": recursive_map.get(p, True)} for p in folders + ]}, f) except Exception: pass @@ -66,21 +76,23 @@ def resolve_default_folders(): workflow is dropping the PHOTON folder directly inside a photo collection, so its parent IS that collection. Always overridable from the Console tab's folder list. + Returns a list of {"path", "recursive"} dicts. """ if len(sys.argv) > 1: arg = os.path.abspath(sys.argv[1]) if os.path.isdir(arg): - return [arg] + return [{"path": arg, "recursive": True}] env = os.environ.get("PHOTON_FOLDER") if env and os.path.isdir(env): - return [os.path.abspath(env)] + return [{"path": os.path.abspath(env), "recursive": True}] saved = load_saved_folders() - if saved: - return saved - return [os.path.dirname(APP_DIR)] + if saved is not None: + return saved # respect an explicit (even empty) prior configuration + return [{"path": os.path.dirname(APP_DIR), "recursive": True}] -DEFAULT_FOLDERS = resolve_default_folders() -DEFAULT_FOLDER = DEFAULT_FOLDERS[0] +DEFAULT_FOLDERS_FULL = resolve_default_folders() +DEFAULT_FOLDERS = [f["path"] for f in DEFAULT_FOLDERS_FULL] +DEFAULT_FOLDER = DEFAULT_FOLDERS[0] if DEFAULT_FOLDERS else None JOURNAL = os.path.join(APP_DIR, "photon_journal.jsonl") CATEGORIES_FILE = os.path.join(APP_DIR, "photon_categories.json") @@ -175,6 +187,7 @@ class State: self.lock = threading.RLock() self.status = "idle" # idle | scanning | running | paused | stopping | done self.folders = list(DEFAULT_FOLDERS) # every configured source folder + self.folder_recursive = {f["path"]: f["recursive"] for f in DEFAULT_FOLDERS_FULL} # path -> include subfolders? self.files = [] # pending image paths (after scan) self.video_files = [] # video paths found on last scan self.total_images = 0 @@ -762,31 +775,54 @@ def ollama_generate(model, prompt, img_b64, opts, keep_alive, think=None, schema # ---------------------------------------------------------------- pipeline -def scan_folder(folder): +def scan_folder(folder, recursive=True): images, videos, sidecars = [], [], 0 - for root, dirs, files in os.walk(folder): - dirs[:] = [d for d in dirs if not d.startswith(".") and not d.startswith("_")] - for name in sorted(files): - if name.startswith("."): # ._AppleDouble & hidden files: never touch + if recursive: + for root, dirs, files in os.walk(folder): + dirs[:] = [d for d in dirs if not d.startswith(".") and not d.startswith("_")] + for name in sorted(files): + if name.startswith("."): # ._AppleDouble & hidden files: never touch + sidecars += 1 + continue + ext = os.path.splitext(name)[1].lower() + if ext in IMAGE_EXTS: + images.append(os.path.join(root, name)) + elif ext in VIDEO_EXTS: + videos.append(os.path.join(root, name)) + else: + try: + names = sorted(os.listdir(folder)) + except OSError: + names = [] + for name in names: + full = os.path.join(folder, name) + if not os.path.isfile(full): + continue + if name.startswith("."): sidecars += 1 continue ext = os.path.splitext(name)[1].lower() if ext in IMAGE_EXTS: - images.append(os.path.join(root, name)) + images.append(full) elif ext in VIDEO_EXTS: - videos.append(os.path.join(root, name)) + videos.append(full) return images, videos, sidecars +def folders_payload(): + with S.lock: + return [{"path": f, "recursive": S.folder_recursive.get(f, True)} for f in S.folders] + def rescan_configured_folders(): - """Scan every folder in S.folders and merge the results into the - pending-work state, deduping any path that shows up under more than - one configured folder (e.g. one nested inside another).""" + """Scan every folder in S.folders (honoring each one's recursive flag) + and merge the results into the pending-work state, deduping any path + that shows up under more than one configured folder.""" with S.lock: folders = list(S.folders) + recursive_map = dict(S.folder_recursive) all_images, all_videos, total_sidecars = [], [], 0 seen = set() for folder in folders: - images, videos, sidecars = scan_folder(folder) + images, videos, sidecars = scan_folder(folder, recursive_map.get(folder, True)) for p in images: if p not in seen: seen.add(p) @@ -1015,9 +1051,10 @@ def write_metadata(path, desc, category, keep_backup, preserve_date): if r.returncode != 0: raise RuntimeError(f"exiftool: {r.stderr.decode(errors='replace')[:300]}") -def sync_metadata_from_folder(folder): - log("info", f"Syncing journal with folder metadata: {folder} ...") - cmd = ["exiftool", "-r", "-json", "-if", +def sync_metadata_from_folder(folder, recursive=True): + log("info", f"Syncing journal with folder metadata: {folder} " + f"({'with subfolders' if recursive else 'top-level only'}) ...") + cmd = ["exiftool"] + (["-r"] if recursive else []) + ["-json", "-if", "$Subject =~ /photon-tagged/ or $Keywords =~ /photon-tagged/", "-EXIF:ImageDescription", "-XMP-dc:Description", "-IPTC:Caption-Abstract", "-Description", "-XMP-dc:Subject", "-IPTC:Keywords", folder] @@ -1424,7 +1461,10 @@ class Handler(BaseHTTPRequestHandler): elif self.path == "/api/state": with S.lock: payload = stats_payload() - payload.update({"folders": S.folders, + payload.update({"folders": [ + {"path": f, "recursive": S.folder_recursive.get(f, True)} + for f in S.folders + ], "folder": S.folders[0] if S.folders else "", "log": S.log_ring[-200:], "categories": CATEGORIES, "current": S.current, @@ -1658,8 +1698,9 @@ class Handler(BaseHTTPRequestHandler): if re_read: with S.lock: folders = list(S.folders) + recursive_map = dict(S.folder_recursive) for folder in folders: - sync_metadata_from_folder(folder) + sync_metadata_from_folder(folder, recursive_map.get(folder, True)) push_stats() filtered = filtered_search_results(q, cat, date_from, date_to, sort, ftype) @@ -1895,30 +1936,43 @@ class Handler(BaseHTTPRequestHandler): if self.path == "/api/scan": # {"folders": [...]} replaces the whole list; {"folder": "..."} # (legacy single-folder form) replaces it with just that one. + # Each entry may be a plain path string (recursive=True) or + # {"path", "recursive"}. requested = body.get("folders") + default_recursive = bool(body.get("recursive", True)) if not requested: single = body.get("folder") or DEFAULT_FOLDER - requested = [single] - valid = [os.path.abspath(f) for f in requested if f and os.path.isdir(f)] - if not valid: + requested = [{"path": single, "recursive": default_recursive}] + normalized = [] + for f in requested: + if isinstance(f, str): + f = {"path": f, "recursive": default_recursive} + if f.get("path") and os.path.isdir(f["path"]): + normalized.append({"path": os.path.abspath(f["path"]), + "recursive": bool(f.get("recursive", True))}) + if not normalized: self._json({"error": f"no valid folder in: {requested}"}, 400); return with S.lock: if S.status == "running": self._json({"error": "stop the run before rescanning"}, 400); return - S.status = "scanning"; S.folders = valid - save_folders_config(valid) + S.status = "scanning" + S.folders = [f["path"] for f in normalized] + S.folder_recursive = {f["path"]: f["recursive"] for f in normalized} + save_folders_config(S.folders, S.folder_recursive) broadcast("state", {"status": "scanning"}) - log("info", f"scanning {len(valid)} folder(s): {', '.join(valid)} …") + log("info", f"scanning {len(normalized)} folder(s): " + f"{', '.join(f['path'] + ('' if f['recursive'] else ' [top-level only]') for f in normalized)} …") images, videos, sidecars, done = rescan_configured_folders() log("ok", f"scan complete: {len(images)} images | {len(videos)} videos found | " f"{sidecars} hidden/sidecar files ignored | {done} already tagged") broadcast("state", {"status": "idle"}) push_stats() - self._json({"folders": valid, "images": len(images), "videos": len(videos), + self._json({"folders": folders_payload(), "images": len(images), "videos": len(videos), "sidecars": sidecars, "alreadyDone": done}) elif self.path == "/api/add_folder": folder = body.get("folder") + recursive = bool(body.get("recursive", True)) if not folder or not os.path.isdir(folder): self._json({"error": f"not a folder: {folder}"}, 400); return with S.lock: @@ -1929,14 +1983,16 @@ class Handler(BaseHTTPRequestHandler): already = any(os.path.abspath(f) == folder for f in S.folders) if not already: S.folders.append(folder) + S.folder_recursive[folder] = recursive folders = list(S.folders) - save_folders_config(folders) - log("info", f"{'already tracking' if already else 'added'} folder: {folder} — rescanning all …") + save_folders_config(folders, S.folder_recursive) + log("info", f"{'already tracking' if already else 'added'} folder: {folder} " + f"({'with subfolders' if recursive else 'top-level only'}) — rescanning all …") images, videos, sidecars, done = rescan_configured_folders() - sync_metadata_from_folder(folder) + sync_metadata_from_folder(folder, recursive) load_journal() push_stats() - self._json({"ok": True, "folders": folders, "images": len(images), + self._json({"ok": True, "folders": folders_payload(), "images": len(images), "videos": len(videos), "sidecars": sidecars, "alreadyDone": done}) elif self.path == "/api/remove_folder": @@ -1949,15 +2005,43 @@ class Handler(BaseHTTPRequestHandler): folder = os.path.abspath(folder) with S.lock: S.folders = [f for f in S.folders if os.path.abspath(f) != folder] + S.folder_recursive.pop(folder, None) folders = list(S.folders) - save_folders_config(folders) + save_folders_config(folders, S.folder_recursive) log("info", f"removed folder: {folder} — rescanning remaining {len(folders)} folder(s) …") images, videos, sidecars, done = rescan_configured_folders() push_stats() - self._json({"ok": True, "folders": folders, "images": len(images), + self._json({"ok": True, "folders": folders_payload(), "images": len(images), + "videos": len(videos), "sidecars": sidecars, "alreadyDone": done}) + + elif self.path == "/api/set_folder_recursive": + folder = body.get("folder") + recursive = bool(body.get("recursive", True)) + if not folder: + self._json({"error": "folder is required"}, 400); return + with S.lock: + if S.status == "running": + self._json({"error": "stop the run before changing a folder's scan mode"}, 400); return + folder = os.path.abspath(folder) + with S.lock: + if not any(os.path.abspath(f) == folder for f in S.folders): + self._json({"error": "that folder isn't configured"}, 400); return + S.folder_recursive[folder] = recursive + folders = list(S.folders) + save_folders_config(folders, S.folder_recursive) + log("info", f"{folder} set to {'include subfolders' if recursive else 'top-level only'} — rescanning …") + images, videos, sidecars, done = rescan_configured_folders() + if recursive: + # newly in-scope subfolder files might already carry embedded + # PHOTON tags (e.g. from a previous session) — detect them now + sync_metadata_from_folder(folder, recursive) + load_journal() + push_stats() + self._json({"ok": True, "folders": folders_payload(), "images": len(images), "videos": len(videos), "sidecars": sidecars, "alreadyDone": done}) elif self.path == "/api/choose_folder": + recursive = bool(body.get("recursive", True)) try: cmd = ["osascript", "-e", 'tell application "System Events" to set frontmost of (first process whose background only is false) to true', "-e", 'POSIX path of (choose folder with prompt "Add a photo folder:")'] r = subprocess.run(cmd, capture_output=True, timeout=60) @@ -1969,13 +2053,14 @@ class Handler(BaseHTTPRequestHandler): already = any(os.path.abspath(f) == chosen for f in S.folders) if not already: S.folders.append(chosen) + S.folder_recursive[chosen] = recursive folders = list(S.folders) - save_folders_config(folders) + save_folders_config(folders, S.folder_recursive) images, videos, sidecars, done = rescan_configured_folders() - sync_metadata_from_folder(chosen) + sync_metadata_from_folder(chosen, recursive) load_journal() push_stats() - self._json({"ok": True, "folder": chosen, "folders": folders, + self._json({"ok": True, "folder": chosen, "folders": folders_payload(), "images": len(images), "videos": len(videos), "alreadyDone": done}) return self._json({"error": "No folder selected or dialog cancelled"}, 400) @@ -2367,8 +2452,8 @@ def main(): load_failures() load_phash_cache() print(f"PHOTON console → http://localhost:{PORT}") - print(f"photo folder(s) → {', '.join(DEFAULT_FOLDERS)}" - + (" (auto-detected — change it anytime from the Console tab)" if len(sys.argv) <= 1 and not os.environ.get("PHOTON_FOLDER") else "")) + print(f"photo folder(s) → {', '.join(DEFAULT_FOLDERS) if DEFAULT_FOLDERS else '(none configured — add one from the Console tab)'}" + + (" (auto-detected — change it anytime from the Console tab)" if DEFAULT_FOLDERS and len(sys.argv) <= 1 and not os.environ.get("PHOTON_FOLDER") else "")) if n: print(f"journal loaded: {n} photos already tagged (will be skipped on resume)") ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()