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()