Add support for multiple source folders

- Server: S.folder (singular) -> S.folders (list). New /api/add_folder
  and /api/remove_folder endpoints; /api/scan and /api/choose_folder
  updated to work with the list (choose_folder now appends instead of
  replacing). Legacy single-folder config migrates transparently.
- All folders feed one combined pending queue/journal/search, per the
  simpler of the two designs discussed.
- organize_alias now resolves each photo's own source folder via
  find_owning_folder(), so category aliases land next to the original
  instead of in one designated folder -- verified live with two
  configured folders that the alias for each photo lands in its own
  folder and nothing leaks into the other.
- Delete path-safety, thumbnail/photo serving, resolve_portable_path,
  undo_all, and sync-on-scan all extended to check/act across every
  configured folder instead of just one.
- Frontend: folder list renders as removable chips; Add (typed path) and
  Browse (native picker, now appends) both wired to the new endpoints;
  Rescan All re-scans the current set without changing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-07-23 04:22:31 -07:00
parent 00e86dc758
commit 1f41ee5352
2 changed files with 270 additions and 117 deletions

View File

@@ -80,6 +80,16 @@ button:disabled{opacity:.3;cursor:not-allowed}
font-family:var(--font-mono);font-size:12px;padding:9px 12px;border-radius:4px} font-family:var(--font-mono);font-size:12px;padding:9px 12px;border-radius:4px}
.folderRow input:focus{outline:none;border-color:var(--cyan)} .folderRow input:focus{outline:none;border-color:var(--cyan)}
.folderChips{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px}
.folderChips:empty{display:none}
.folderChip{display:flex;align-items:center;gap:8px;background:var(--panel);
border:1px solid var(--line);border-radius:14px;padding:5px 6px 5px 12px;
font-family:var(--font-mono);font-size:11px;color:var(--txt)}
.folderChip .path{max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.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)}
.grid{display:grid;grid-template-columns:330px 1fr;gap:14px} .grid{display:grid;grid-template-columns:330px 1fr;gap:14px}
.grid-three{display:grid;grid-template-columns:330px 1fr 320px;gap:14px} .grid-three{display:grid;grid-template-columns:330px 1fr 320px;gap:14px}
@media(max-width:1200px){.grid,.grid-three{grid-template-columns:1fr}} @media(max-width:1200px){.grid,.grid-three{grid-template-columns:1fr}}
@@ -338,12 +348,14 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
<!-- ==================== CONSOLE TAB ==================== --> <!-- ==================== CONSOLE TAB ==================== -->
<div id="tab-console" class="tab-content active"> <div id="tab-console" class="tab-content active">
<div id="folderChips" class="folderChips"></div>
<div class="controls"> <div class="controls">
<div class="folderRow"> <div class="folderRow">
<input id="folder" spellcheck="false" placeholder="/path/to/your/photos"> <input id="folder" spellcheck="false" placeholder="/path/to/your/photos — add as many source folders as you like">
<button id="btnBrowseFolder" title="Open native macOS folder chooser">📁 Browse</button> <button id="btnAddFolderTyped" title="Add this typed path as a source folder">+ Add</button>
<button id="btnBrowseFolder" title="Open native macOS folder chooser to add a source folder">📁 Browse</button>
<input type="file" id="fallbackFolderInput" webkitdirectory style="display:none"> <input type="file" id="fallbackFolderInput" webkitdirectory style="display:none">
<button id="btnScan">◈ Scan Directory</button> <button id="btnScan" title="Re-scan every configured source folder now">⟳ Rescan All</button>
</div> </div>
<button id="btnStart" class="btn-primary" disabled>▶ Start Pipeline</button> <button id="btnStart" class="btn-primary" disabled>▶ Start Pipeline</button>
<button id="btnPause" disabled>❚❚ Pause</button> <button id="btnPause" disabled>❚❚ Pause</button>
@@ -1008,6 +1020,51 @@ async function api(path, body){
return j; return j;
} }
/* ---------- multi-folder source list ---------- */
let currentFolders = [];
function renderFolderChips(){
const box = $("folderChips");
if(!box) return;
box.innerHTML = "";
currentFolders.forEach(f=>{
const chip = document.createElement("div");
chip.className = "folderChip";
chip.innerHTML = `<span class="path" title="${escapeHtml(f)}">${escapeHtml(f)}</span><span class="rm" title="Remove this folder">×</span>`;
chip.querySelector(".rm").onclick = ()=>removeFolder(f);
box.appendChild(chip);
});
}
function applyFolderList(folders){
if(folders && folders.length){
currentFolders = folders;
renderFolderChips();
scanned = true;
setBadge("idle");
}
}
async function removeFolder(f){
const j = await api("/api/remove_folder", {folder: f});
if(j.ok) applyFolderList(j.folders);
}
if($("btnAddFolderTyped")){
$("btnAddFolderTyped").onclick = async ()=>{
const val = $("folder").value.trim();
if(!val) return;
$("btnAddFolderTyped").disabled = true;
const j = await api("/api/add_folder", {folder: val});
$("btnAddFolderTyped").disabled = false;
if(j.ok){
$("folder").value = "";
showToast("Added folder: " + val, "ok");
applyFolderList(j.folders);
}
};
}
if($("btnBrowseFolder")){ if($("btnBrowseFolder")){
$("btnBrowseFolder").onclick = async ()=>{ $("btnBrowseFolder").onclick = async ()=>{
$("btnBrowseFolder").disabled = true; $("btnBrowseFolder").disabled = true;
@@ -1015,10 +1072,8 @@ if($("btnBrowseFolder")){
const j = await api("/api/choose_folder", {}); const j = await api("/api/choose_folder", {});
$("btnBrowseFolder").disabled = false; $("btnBrowseFolder").disabled = false;
if(j.ok && j.folder){ if(j.ok && j.folder){
$("folder").value = j.folder; showToast("Added folder: " + j.folder, "ok");
showToast("Selected folder: " + j.folder, "ok"); applyFolderList(j.folders);
scanned = true;
setBadge("idle");
} else if($("fallbackFolderInput")){ } else if($("fallbackFolderInput")){
$("fallbackFolderInput").click(); $("fallbackFolderInput").click();
} }
@@ -1026,8 +1081,8 @@ if($("btnBrowseFolder")){
} }
$("btnScan").onclick = async ()=>{ $("btnScan").onclick = async ()=>{
const j = await api("/api/scan",{folder:$("folder").value}); const j = await api("/api/scan", currentFolders.length ? {folders: currentFolders} : {});
if(j.images!=null){ scanned=true; setBadge("idle"); } if(j.images!=null){ applyFolderList(j.folders); }
}; };
$("btnStart").onclick = ()=>{ $("btnStart").onclick = ()=>{
@@ -1061,7 +1116,7 @@ $("btnRetryFailed").onclick = async ()=>{
}; };
$("btnUndoAll").onclick = async ()=>{ $("btnUndoAll").onclick = async ()=>{
if(confirm("☣ WARNING ☣\nThis will remove all PHOTON embedded descriptions/categories/keywords from ALL files in the scanned folder. It will also clear organized symlinks, reset the database and delete the failures log.\n\nTHIS ACTION CANNOT BE UNDONE.\n\nDo you want to proceed?")){ if(confirm("☣ WARNING ☣\nThis will remove all PHOTON embedded descriptions/categories/keywords from ALL files in every configured source folder ("+(currentFolders.length||"all")+"). It will also clear organized symlinks, reset the database and delete the failures log.\n\nTHIS ACTION CANNOT BE UNDONE.\n\nDo you want to proceed?")){
$("btnUndoAll").disabled = true; $("btnUndoAll").disabled = true;
const j = await api("/api/undo_all", {}); const j = await api("/api/undo_all", {});
$("btnUndoAll").disabled = false; $("btnUndoAll").disabled = false;
@@ -2052,7 +2107,8 @@ function connect(){
CATS = st.categories||[]; CATS = st.categories||[];
renderCats(st.catCounts||{}); renderCats(st.catCounts||{});
(st.log||[]).forEach(addLog); (st.log||[]).forEach(addLog);
if(st.folder) $("folder").value = st.folder; currentFolders = st.folders || [];
renderFolderChips();
applyStats(st); applyStats(st);
setBadge(st.status||"idle"); setBadge(st.status||"idle");
if(st.total>0) scanned=true, setBadge(st.status||"idle"); if(st.total>0) scanned=true, setBadge(st.status||"idle");

253
server.py
View File

@@ -32,47 +32,55 @@ PORT = int(os.environ.get("PHOTON_PORT", 8765))
FOLDER_CONFIG_FILE = os.path.join(APP_DIR, "photon_folder.json") FOLDER_CONFIG_FILE = os.path.join(APP_DIR, "photon_folder.json")
def load_saved_folder(): 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."""
if os.path.exists(FOLDER_CONFIG_FILE): if os.path.exists(FOLDER_CONFIG_FILE):
try: try:
with open(FOLDER_CONFIG_FILE, "r", encoding="utf-8") as f: with open(FOLDER_CONFIG_FILE, "r", encoding="utf-8") as f:
folder = json.load(f).get("folder") data = json.load(f)
if folder and os.path.isdir(folder): folders = data.get("folders")
return folder 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
except Exception: except Exception:
pass pass
return None return []
def save_folder_config(folder): def save_folders_config(folders):
try: try:
with open(FOLDER_CONFIG_FILE, "w", encoding="utf-8") as f: with open(FOLDER_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump({"folder": folder}, f) json.dump({"folders": folders}, f)
except Exception: except Exception:
pass pass
def resolve_default_folder(): def resolve_default_folders():
"""Zero-config folder detection, in priority order: """Zero-config folder detection, in priority order:
1. `python3 server.py /path/to/photos` — explicit CLI argument 1. `python3 server.py /path/to/photos` — explicit CLI argument
2. PHOTON_FOLDER env var 2. PHOTON_FOLDER env var
3. the last folder scanned in a previous session (remembered automatically) 3. the folders configured in a previous session (remembered automatically)
4. the parent directory of wherever this app folder lives — the intended 4. the parent directory of wherever this app folder lives — the intended
workflow is dropping the PHOTON folder directly inside a photo workflow is dropping the PHOTON folder directly inside a photo
collection, so its parent IS that collection. collection, so its parent IS that collection.
Always overridable from the Console tab's folder field + Scan button. Always overridable from the Console tab's folder list.
""" """
if len(sys.argv) > 1: if len(sys.argv) > 1:
arg = os.path.abspath(sys.argv[1]) arg = os.path.abspath(sys.argv[1])
if os.path.isdir(arg): if os.path.isdir(arg):
return arg return [arg]
env = os.environ.get("PHOTON_FOLDER") env = os.environ.get("PHOTON_FOLDER")
if env and os.path.isdir(env): if env and os.path.isdir(env):
return os.path.abspath(env) return [os.path.abspath(env)]
saved = load_saved_folder() saved = load_saved_folders()
if saved: if saved:
return saved return saved
return os.path.dirname(APP_DIR) return [os.path.dirname(APP_DIR)]
DEFAULT_FOLDER = resolve_default_folder() DEFAULT_FOLDERS = resolve_default_folders()
DEFAULT_FOLDER = DEFAULT_FOLDERS[0]
JOURNAL = os.path.join(APP_DIR, "photon_journal.jsonl") JOURNAL = os.path.join(APP_DIR, "photon_journal.jsonl")
CATEGORIES_FILE = os.path.join(APP_DIR, "photon_categories.json") CATEGORIES_FILE = os.path.join(APP_DIR, "photon_categories.json")
@@ -166,7 +174,7 @@ class State:
def __init__(self): def __init__(self):
self.lock = threading.RLock() self.lock = threading.RLock()
self.status = "idle" # idle | scanning | running | paused | stopping | done self.status = "idle" # idle | scanning | running | paused | stopping | done
self.folder = DEFAULT_FOLDER self.folders = list(DEFAULT_FOLDERS) # every configured source folder
self.files = [] # pending image paths (after scan) self.files = [] # pending image paths (after scan)
self.video_files = [] # video paths found on last scan self.video_files = [] # video paths found on last scan
self.total_images = 0 self.total_images = 0
@@ -220,13 +228,43 @@ def resolve_portable_path(path):
if os.path.exists(path): if os.path.exists(path):
return path return path
with S.lock: with S.lock:
curr_folder = S.folder folders = list(S.folders)
if curr_folder and os.path.isdir(curr_folder): for folder in folders:
cand = os.path.join(curr_folder, os.path.basename(path)) if folder and os.path.isdir(folder):
cand = os.path.join(folder, os.path.basename(path))
if os.path.exists(cand): if os.path.exists(cand):
return cand return cand
return path return path
def is_inside_any_folder(abs_path, folders=None):
if folders is None:
with S.lock:
folders = list(S.folders)
for folder in folders:
try:
f = os.path.abspath(folder)
if os.path.commonpath([f, abs_path]) == f:
return True
except ValueError:
continue
return False
def find_owning_folder(path):
"""Which configured source folder is this file physically under? Used so
a photo's _organized alias lands next to its own original, even when
several source folders (possibly on different drives) are configured."""
abs_path = os.path.abspath(path)
with S.lock:
folders = list(S.folders)
for folder in folders:
try:
f = os.path.abspath(folder)
if os.path.commonpath([f, abs_path]) == f:
return f
except ValueError:
continue
return folders[0] if folders else DEFAULT_FOLDER
def read_journal_deduped(): def read_journal_deduped():
"""Read the journal into {abs_path: latest_record}. journal_write only """Read the journal into {abs_path: latest_record}. journal_write only
ever appends (fast during a tagging run), so a photo retagged more than ever appends (fast during a tagging run), so a photo retagged more than
@@ -300,15 +338,17 @@ def trash_or_remove_file(path):
def cleanup_organized_symlinks(abs_paths): def cleanup_organized_symlinks(abs_paths):
"""Remove _organized alias symlinks pointing at any of the given files. """Remove _organized alias symlinks pointing at any of the given files.
One folder walk covers the whole batch instead of one walk per file.""" One folder walk per configured folder covers the whole batch, instead
of one walk per file."""
if not abs_paths: if not abs_paths:
return return
with S.lock: with S.lock:
folder = S.folder folders = list(S.folders)
targets = set(abs_paths)
for folder in folders:
org_root = os.path.join(folder, "_organized") org_root = os.path.join(folder, "_organized")
if not os.path.exists(org_root): if not os.path.exists(org_root):
return continue
targets = set(abs_paths)
for root, dirs, files in os.walk(org_root): for root, dirs, files in os.walk(org_root):
dirs[:] = [d for d in dirs if not d.startswith(".")] dirs[:] = [d for d in dirs if not d.startswith(".")]
for name in files: for name in files:
@@ -394,14 +434,8 @@ def delete_photo_file_and_record(path, skip_records=False):
if not path: if not path:
return False, "path is required" return False, "path is required"
abs_path = os.path.abspath(path) abs_path = os.path.abspath(path)
with S.lock: if not is_inside_any_folder(abs_path):
folder = os.path.abspath(S.folder) if S.folder else None return False, f"refusing to delete outside the configured photo folders: {abs_path}"
try:
inside_folder = bool(folder) and os.path.commonpath([folder, abs_path]) == folder
except ValueError:
inside_folder = False
if not inside_folder:
return False, f"refusing to delete outside the configured photo folder: {abs_path}"
deleted_disk = False deleted_disk = False
if os.path.exists(abs_path): if os.path.exists(abs_path):
deleted_disk = trash_or_remove_file(abs_path) deleted_disk = trash_or_remove_file(abs_path)
@@ -743,6 +777,35 @@ def scan_folder(folder):
videos.append(os.path.join(root, name)) videos.append(os.path.join(root, name))
return images, videos, sidecars return images, videos, sidecars
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)."""
with S.lock:
folders = list(S.folders)
all_images, all_videos, total_sidecars = [], [], 0
seen = set()
for folder in folders:
images, videos, sidecars = scan_folder(folder)
for p in images:
if p not in seen:
seen.add(p)
all_images.append(p)
for p in videos:
if p not in seen:
seen.add(p)
all_videos.append(p)
total_sidecars += sidecars
done = sum(1 for p in all_images if p in S.done_paths)
with S.lock:
S.files = all_images
S.total_images = len(all_images)
S.video_files = all_videos
S.skipped_videos = len(all_videos)
S.skipped_sidecars = total_sidecars
S.status = "idle"
return all_images, all_videos, total_sidecars, done
def organize_alias(folder, file_path, category): def organize_alias(folder, file_path, category):
folder = os.path.abspath(folder) folder = os.path.abspath(folder)
file_path = os.path.abspath(file_path) file_path = os.path.abspath(file_path)
@@ -1046,8 +1109,6 @@ def process_loop(settings):
ocr_model = settings.get("ocrModel") or "glm-ocr:latest" ocr_model = settings.get("ocrModel") or "glm-ocr:latest"
organize = bool(settings.get("organize", False)) organize = bool(settings.get("organize", False))
integrity = bool(settings.get("integrity", False)) integrity = bool(settings.get("integrity", False))
with S.lock:
organize_folder = S.folder or DEFAULT_FOLDER
photo_prompt = build_prompt(length_key, "photo") photo_prompt = build_prompt(length_key, "photo")
shot_prompt = build_prompt(length_key, "screenshot") shot_prompt = build_prompt(length_key, "screenshot")
@@ -1240,7 +1301,7 @@ def process_loop(settings):
else: else:
write_metadata(path, desc, category, keep_backup, preserve_date) write_metadata(path, desc, category, keep_backup, preserve_date)
if organize: if organize:
organize_alias(organize_folder, path, category) organize_alias(find_owning_folder(path), path, category)
dt = time.time() - t0 dt = time.time() - t0
with S.lock: with S.lock:
@@ -1363,7 +1424,9 @@ class Handler(BaseHTTPRequestHandler):
elif self.path == "/api/state": elif self.path == "/api/state":
with S.lock: with S.lock:
payload = stats_payload() payload = stats_payload()
payload.update({"folder": S.folder, "log": S.log_ring[-200:], payload.update({"folders": S.folders,
"folder": S.folders[0] if S.folders else "",
"log": S.log_ring[-200:],
"categories": CATEGORIES, "current": S.current, "categories": CATEGORIES, "current": S.current,
"settings": S.settings, "failuresCount": len(S.failed_paths)}) "settings": S.settings, "failuresCount": len(S.failed_paths)})
self._json(payload) self._json(payload)
@@ -1386,7 +1449,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_response(404); self.end_headers(); return self.send_response(404); self.end_headers(); return
abs_path = os.path.abspath(img_path) abs_path = os.path.abspath(img_path)
with S.lock: with S.lock:
folder_ok = abs_path.startswith(os.path.abspath(S.folder)) folder_ok = is_inside_any_folder(abs_path, S.folders)
journal_ok = abs_path in S.done_paths journal_ok = abs_path in S.done_paths
if not (folder_ok or journal_ok): if not (folder_ok or journal_ok):
self.send_response(403); self.end_headers(); return self.send_response(403); self.end_headers(); return
@@ -1423,7 +1486,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_response(404); self.end_headers(); return self.send_response(404); self.end_headers(); return
abs_path = os.path.abspath(img_path) abs_path = os.path.abspath(img_path)
with S.lock: with S.lock:
folder_ok = abs_path.startswith(os.path.abspath(S.folder)) folder_ok = is_inside_any_folder(abs_path, S.folders)
journal_ok = abs_path in S.done_paths journal_ok = abs_path in S.done_paths
if not (folder_ok or journal_ok): if not (folder_ok or journal_ok):
self.send_response(403); self.end_headers(); return self.send_response(403); self.end_headers(); return
@@ -1594,7 +1657,8 @@ class Handler(BaseHTTPRequestHandler):
re_read = params.get("re_read", ["0"])[0] == "1" re_read = params.get("re_read", ["0"])[0] == "1"
if re_read: if re_read:
with S.lock: with S.lock:
folder = S.folder folders = list(S.folders)
for folder in folders:
sync_metadata_from_folder(folder) sync_metadata_from_folder(folder)
push_stats() push_stats()
@@ -1829,55 +1893,90 @@ class Handler(BaseHTTPRequestHandler):
self._json({"error": "bad json"}, 400); return self._json({"error": "bad json"}, 400); return
if self.path == "/api/scan": if self.path == "/api/scan":
folder = body.get("folder") or DEFAULT_FOLDER # {"folders": [...]} replaces the whole list; {"folder": "..."}
if not os.path.isdir(folder): # (legacy single-folder form) replaces it with just that one.
self._json({"error": f"not a folder: {folder}"}, 400); return requested = body.get("folders")
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:
self._json({"error": f"no valid folder in: {requested}"}, 400); return
with S.lock: with S.lock:
if S.status == "running": if S.status == "running":
self._json({"error": "stop the run before rescanning"}, 400); return self._json({"error": "stop the run before rescanning"}, 400); return
S.status = "scanning"; S.folder = folder S.status = "scanning"; S.folders = valid
save_folder_config(folder) save_folders_config(valid)
broadcast("state", {"status": "scanning"}) broadcast("state", {"status": "scanning"})
log("info", f"scanning {folder} …") log("info", f"scanning {len(valid)} folder(s): {', '.join(valid)} …")
images, videos, sidecars = scan_folder(folder) images, videos, sidecars, done = rescan_configured_folders()
done = sum(1 for p in images if p in S.done_paths)
with S.lock:
S.files = images
S.total_images = len(images)
S.video_files = videos
S.skipped_videos = len(videos)
S.skipped_sidecars = sidecars
S.status = "idle"
log("ok", f"scan complete: {len(images)} images | {len(videos)} videos found | " log("ok", f"scan complete: {len(images)} images | {len(videos)} videos found | "
f"{sidecars} hidden/sidecar files ignored | {done} already tagged") f"{sidecars} hidden/sidecar files ignored | {done} already tagged")
broadcast("state", {"status": "idle"}) broadcast("state", {"status": "idle"})
push_stats() push_stats()
self._json({"images": len(images), "videos": len(videos), self._json({"folders": valid, "images": len(images), "videos": len(videos),
"sidecars": sidecars, "alreadyDone": done}) "sidecars": sidecars, "alreadyDone": done})
elif self.path == "/api/add_folder":
folder = body.get("folder")
if not folder or not os.path.isdir(folder):
self._json({"error": f"not a folder: {folder}"}, 400); return
with S.lock:
if S.status == "running":
self._json({"error": "stop the run before adding a folder"}, 400); return
folder = os.path.abspath(folder)
with S.lock:
already = any(os.path.abspath(f) == folder for f in S.folders)
if not already:
S.folders.append(folder)
folders = list(S.folders)
save_folders_config(folders)
log("info", f"{'already tracking' if already else 'added'} folder: {folder} — rescanning all …")
images, videos, sidecars, done = rescan_configured_folders()
sync_metadata_from_folder(folder)
load_journal()
push_stats()
self._json({"ok": True, "folders": folders, "images": len(images),
"videos": len(videos), "sidecars": sidecars, "alreadyDone": done})
elif self.path == "/api/remove_folder":
folder = body.get("folder")
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 removing a folder"}, 400); return
folder = os.path.abspath(folder)
with S.lock:
S.folders = [f for f in S.folders if os.path.abspath(f) != folder]
folders = list(S.folders)
save_folders_config(folders)
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),
"videos": len(videos), "sidecars": sidecars, "alreadyDone": done})
elif self.path == "/api/choose_folder": elif self.path == "/api/choose_folder":
try: 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 "Select Photo Folder to Organize:")'] 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) r = subprocess.run(cmd, capture_output=True, timeout=60)
if r.returncode == 0: if r.returncode == 0:
chosen = r.stdout.decode("utf-8").strip() chosen = r.stdout.decode("utf-8").strip()
if chosen and os.path.isdir(chosen): if chosen and os.path.isdir(chosen):
chosen = os.path.abspath(chosen)
with S.lock: with S.lock:
S.folder = chosen already = any(os.path.abspath(f) == chosen for f in S.folders)
save_folder_config(chosen) if not already:
images, videos, sidecars = scan_folder(chosen) S.folders.append(chosen)
done = sum(1 for p in images if p in S.done_paths) folders = list(S.folders)
with S.lock: save_folders_config(folders)
S.files = images images, videos, sidecars, done = rescan_configured_folders()
S.total_images = len(images)
S.video_files = videos
S.skipped_videos = len(videos)
S.skipped_sidecars = sidecars
S.status = "idle"
sync_metadata_from_folder(chosen) sync_metadata_from_folder(chosen)
load_journal() load_journal()
push_stats() push_stats()
self._json({"ok": True, "folder": chosen, "images": len(images), "videos": len(videos), "alreadyDone": done}) self._json({"ok": True, "folder": chosen, "folders": folders,
"images": len(images), "videos": len(videos), "alreadyDone": done})
return return
self._json({"error": "No folder selected or dialog cancelled"}, 400) self._json({"error": "No folder selected or dialog cancelled"}, 400)
except Exception as e: except Exception as e:
@@ -1930,8 +2029,9 @@ class Handler(BaseHTTPRequestHandler):
elif self.path == "/api/undo_all": elif self.path == "/api/undo_all":
with S.lock: with S.lock:
folder = S.folder folders = list(S.folders)
log("warn", f"UNDO ALL: Removing all PHOTON metadata from files in {folder} ...") log("warn", f"UNDO ALL: Removing all PHOTON metadata from files in {len(folders)} folder(s): {', '.join(folders)} ...")
for folder in folders:
args = ["exiftool", "-r", "-P", "-overwrite_original", "-if", args = ["exiftool", "-r", "-P", "-overwrite_original", "-if",
"$Subject =~ /photon-tagged/ or $Keywords =~ /photon-tagged/", "$Subject =~ /photon-tagged/ or $Keywords =~ /photon-tagged/",
"-EXIF:ImageDescription=", "-IPTC:Caption-Abstract=", "-XMP-dc:Description=", "-Description=", "-EXIF:ImageDescription=", "-IPTC:Caption-Abstract=", "-XMP-dc:Description=", "-Description=",
@@ -1941,14 +2041,14 @@ class Handler(BaseHTTPRequestHandler):
args.append(f"-Keywords-={cat}") args.append(f"-Keywords-={cat}")
args.append(folder) args.append(folder)
r = subprocess.run(args, capture_output=True, timeout=600) r = subprocess.run(args, capture_output=True, timeout=600)
log("info", f"Exiftool undo completed: {r.stdout.decode(errors='replace')[:200]}") log("info", f"Exiftool undo completed for {folder}: {r.stdout.decode(errors='replace')[:200]}")
org_root = os.path.join(folder, "_organized") org_root = os.path.join(folder, "_organized")
if os.path.exists(org_root): if os.path.exists(org_root):
try: try:
shutil.rmtree(org_root) shutil.rmtree(org_root)
log("info", "Deleted organized symlink directory.") log("info", f"Deleted organized symlink directory in {folder}.")
except Exception as e: except Exception as e:
log("error", f"Failed to delete _organized folder: {e}") log("error", f"Failed to delete _organized folder in {folder}: {e}")
if os.path.exists(JOURNAL): if os.path.exists(JOURNAL):
try: try:
os.remove(JOURNAL) os.remove(JOURNAL)
@@ -2006,10 +2106,9 @@ class Handler(BaseHTTPRequestHandler):
keep_backup = bool(S.settings.get("keepBackup", False)) keep_backup = bool(S.settings.get("keepBackup", False))
preserve_date = bool(S.settings.get("preserveDate", True)) preserve_date = bool(S.settings.get("preserveDate", True))
organize = bool(S.settings.get("organize", False)) organize = bool(S.settings.get("organize", False))
folder = S.folder
write_metadata(path, desc, category, keep_backup, preserve_date) write_metadata(path, desc, category, keep_backup, preserve_date)
if organize: if organize:
organize_alias(folder, path, category) organize_alias(find_owning_folder(path), path, category)
existing_recs = [] existing_recs = []
found = False found = False
if os.path.exists(JOURNAL): if os.path.exists(JOURNAL):
@@ -2116,9 +2215,7 @@ class Handler(BaseHTTPRequestHandler):
pass pass
write_metadata(path, desc, category, keep_backup, preserve_date) write_metadata(path, desc, category, keep_backup, preserve_date)
if organize: if organize:
with S.lock: organize_alias(find_owning_folder(path), path, category)
folder = S.folder
organize_alias(folder, path, category)
existing_recs = [] existing_recs = []
found = False found = False
if os.path.exists(JOURNAL): if os.path.exists(JOURNAL):
@@ -2270,7 +2367,7 @@ def main():
load_failures() load_failures()
load_phash_cache() load_phash_cache()
print(f"PHOTON console → http://localhost:{PORT}") print(f"PHOTON console → http://localhost:{PORT}")
print(f"photo folder → {DEFAULT_FOLDER}" 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 "")) + (" (auto-detected — change it anytime from the Console tab)" if len(sys.argv) <= 1 and not os.environ.get("PHOTON_FOLDER") else ""))
if n: if n:
print(f"journal loaded: {n} photos already tagged (will be skipped on resume)") print(f"journal loaded: {n} photos already tagged (will be skipped on resume)")