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:
78
index.html
78
index.html
@@ -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}
|
||||
.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-three{display:grid;grid-template-columns:330px 1fr 320px;gap:14px}
|
||||
@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 ==================== -->
|
||||
<div id="tab-console" class="tab-content active">
|
||||
<div id="folderChips" class="folderChips"></div>
|
||||
<div class="controls">
|
||||
<div class="folderRow">
|
||||
<input id="folder" spellcheck="false" placeholder="/path/to/your/photos">
|
||||
<button id="btnBrowseFolder" title="Open native macOS folder chooser">📁 Browse</button>
|
||||
<input id="folder" spellcheck="false" placeholder="/path/to/your/photos — add as many source folders as you like">
|
||||
<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">
|
||||
<button id="btnScan">◈ Scan Directory</button>
|
||||
<button id="btnScan" title="Re-scan every configured source folder now">⟳ Rescan All</button>
|
||||
</div>
|
||||
<button id="btnStart" class="btn-primary" disabled>▶ Start Pipeline</button>
|
||||
<button id="btnPause" disabled>❚❚ Pause</button>
|
||||
@@ -1008,6 +1020,51 @@ async function api(path, body){
|
||||
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")){
|
||||
$("btnBrowseFolder").onclick = async ()=>{
|
||||
$("btnBrowseFolder").disabled = true;
|
||||
@@ -1015,10 +1072,8 @@ if($("btnBrowseFolder")){
|
||||
const j = await api("/api/choose_folder", {});
|
||||
$("btnBrowseFolder").disabled = false;
|
||||
if(j.ok && j.folder){
|
||||
$("folder").value = j.folder;
|
||||
showToast("Selected folder: " + j.folder, "ok");
|
||||
scanned = true;
|
||||
setBadge("idle");
|
||||
showToast("Added folder: " + j.folder, "ok");
|
||||
applyFolderList(j.folders);
|
||||
} else if($("fallbackFolderInput")){
|
||||
$("fallbackFolderInput").click();
|
||||
}
|
||||
@@ -1026,8 +1081,8 @@ if($("btnBrowseFolder")){
|
||||
}
|
||||
|
||||
$("btnScan").onclick = async ()=>{
|
||||
const j = await api("/api/scan",{folder:$("folder").value});
|
||||
if(j.images!=null){ scanned=true; setBadge("idle"); }
|
||||
const j = await api("/api/scan", currentFolders.length ? {folders: currentFolders} : {});
|
||||
if(j.images!=null){ applyFolderList(j.folders); }
|
||||
};
|
||||
|
||||
$("btnStart").onclick = ()=>{
|
||||
@@ -1061,7 +1116,7 @@ $("btnRetryFailed").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;
|
||||
const j = await api("/api/undo_all", {});
|
||||
$("btnUndoAll").disabled = false;
|
||||
@@ -2052,7 +2107,8 @@ function connect(){
|
||||
CATS = st.categories||[];
|
||||
renderCats(st.catCounts||{});
|
||||
(st.log||[]).forEach(addLog);
|
||||
if(st.folder) $("folder").value = st.folder;
|
||||
currentFolders = st.folders || [];
|
||||
renderFolderChips();
|
||||
applyStats(st);
|
||||
setBadge(st.status||"idle");
|
||||
if(st.total>0) scanned=true, setBadge(st.status||"idle");
|
||||
|
||||
253
server.py
253
server.py
@@ -32,47 +32,55 @@ PORT = int(os.environ.get("PHOTON_PORT", 8765))
|
||||
|
||||
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):
|
||||
try:
|
||||
with open(FOLDER_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
folder = json.load(f).get("folder")
|
||||
if folder and os.path.isdir(folder):
|
||||
return folder
|
||||
data = json.load(f)
|
||||
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
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
return []
|
||||
|
||||
def save_folder_config(folder):
|
||||
def save_folders_config(folders):
|
||||
try:
|
||||
with open(FOLDER_CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"folder": folder}, f)
|
||||
json.dump({"folders": folders}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def resolve_default_folder():
|
||||
def resolve_default_folders():
|
||||
"""Zero-config folder detection, in priority order:
|
||||
1. `python3 server.py /path/to/photos` — explicit CLI argument
|
||||
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
|
||||
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 field + Scan button.
|
||||
Always overridable from the Console tab's folder list.
|
||||
"""
|
||||
if len(sys.argv) > 1:
|
||||
arg = os.path.abspath(sys.argv[1])
|
||||
if os.path.isdir(arg):
|
||||
return arg
|
||||
return [arg]
|
||||
env = os.environ.get("PHOTON_FOLDER")
|
||||
if env and os.path.isdir(env):
|
||||
return os.path.abspath(env)
|
||||
saved = load_saved_folder()
|
||||
return [os.path.abspath(env)]
|
||||
saved = load_saved_folders()
|
||||
if 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")
|
||||
CATEGORIES_FILE = os.path.join(APP_DIR, "photon_categories.json")
|
||||
|
||||
@@ -166,7 +174,7 @@ class State:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
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.video_files = [] # video paths found on last scan
|
||||
self.total_images = 0
|
||||
@@ -220,13 +228,43 @@ def resolve_portable_path(path):
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
with S.lock:
|
||||
curr_folder = S.folder
|
||||
if curr_folder and os.path.isdir(curr_folder):
|
||||
cand = os.path.join(curr_folder, os.path.basename(path))
|
||||
folders = list(S.folders)
|
||||
for folder in folders:
|
||||
if folder and os.path.isdir(folder):
|
||||
cand = os.path.join(folder, os.path.basename(path))
|
||||
if os.path.exists(cand):
|
||||
return cand
|
||||
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():
|
||||
"""Read the journal into {abs_path: latest_record}. journal_write only
|
||||
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):
|
||||
"""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:
|
||||
return
|
||||
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")
|
||||
if not os.path.exists(org_root):
|
||||
return
|
||||
targets = set(abs_paths)
|
||||
continue
|
||||
for root, dirs, files in os.walk(org_root):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for name in files:
|
||||
@@ -394,14 +434,8 @@ def delete_photo_file_and_record(path, skip_records=False):
|
||||
if not path:
|
||||
return False, "path is required"
|
||||
abs_path = os.path.abspath(path)
|
||||
with S.lock:
|
||||
folder = os.path.abspath(S.folder) if S.folder else None
|
||||
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}"
|
||||
if not is_inside_any_folder(abs_path):
|
||||
return False, f"refusing to delete outside the configured photo folders: {abs_path}"
|
||||
deleted_disk = False
|
||||
if os.path.exists(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))
|
||||
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):
|
||||
folder = os.path.abspath(folder)
|
||||
file_path = os.path.abspath(file_path)
|
||||
@@ -1046,8 +1109,6 @@ def process_loop(settings):
|
||||
ocr_model = settings.get("ocrModel") or "glm-ocr:latest"
|
||||
organize = bool(settings.get("organize", False))
|
||||
integrity = bool(settings.get("integrity", False))
|
||||
with S.lock:
|
||||
organize_folder = S.folder or DEFAULT_FOLDER
|
||||
|
||||
photo_prompt = build_prompt(length_key, "photo")
|
||||
shot_prompt = build_prompt(length_key, "screenshot")
|
||||
@@ -1240,7 +1301,7 @@ def process_loop(settings):
|
||||
else:
|
||||
write_metadata(path, desc, category, keep_backup, preserve_date)
|
||||
if organize:
|
||||
organize_alias(organize_folder, path, category)
|
||||
organize_alias(find_owning_folder(path), path, category)
|
||||
|
||||
dt = time.time() - t0
|
||||
with S.lock:
|
||||
@@ -1363,7 +1424,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif self.path == "/api/state":
|
||||
with S.lock:
|
||||
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,
|
||||
"settings": S.settings, "failuresCount": len(S.failed_paths)})
|
||||
self._json(payload)
|
||||
@@ -1386,7 +1449,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_response(404); self.end_headers(); return
|
||||
abs_path = os.path.abspath(img_path)
|
||||
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
|
||||
if not (folder_ok or journal_ok):
|
||||
self.send_response(403); self.end_headers(); return
|
||||
@@ -1423,7 +1486,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_response(404); self.end_headers(); return
|
||||
abs_path = os.path.abspath(img_path)
|
||||
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
|
||||
if not (folder_ok or journal_ok):
|
||||
self.send_response(403); self.end_headers(); return
|
||||
@@ -1594,7 +1657,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
re_read = params.get("re_read", ["0"])[0] == "1"
|
||||
if re_read:
|
||||
with S.lock:
|
||||
folder = S.folder
|
||||
folders = list(S.folders)
|
||||
for folder in folders:
|
||||
sync_metadata_from_folder(folder)
|
||||
push_stats()
|
||||
|
||||
@@ -1829,55 +1893,90 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._json({"error": "bad json"}, 400); return
|
||||
|
||||
if self.path == "/api/scan":
|
||||
folder = body.get("folder") or DEFAULT_FOLDER
|
||||
if not os.path.isdir(folder):
|
||||
self._json({"error": f"not a folder: {folder}"}, 400); return
|
||||
# {"folders": [...]} replaces the whole list; {"folder": "..."}
|
||||
# (legacy single-folder form) replaces it with just that one.
|
||||
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:
|
||||
if S.status == "running":
|
||||
self._json({"error": "stop the run before rescanning"}, 400); return
|
||||
S.status = "scanning"; S.folder = folder
|
||||
save_folder_config(folder)
|
||||
S.status = "scanning"; S.folders = valid
|
||||
save_folders_config(valid)
|
||||
broadcast("state", {"status": "scanning"})
|
||||
log("info", f"scanning {folder} …")
|
||||
images, videos, sidecars = scan_folder(folder)
|
||||
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("info", f"scanning {len(valid)} folder(s): {', '.join(valid)} …")
|
||||
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({"images": len(images), "videos": len(videos),
|
||||
self._json({"folders": valid, "images": len(images), "videos": len(videos),
|
||||
"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":
|
||||
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)
|
||||
if r.returncode == 0:
|
||||
chosen = r.stdout.decode("utf-8").strip()
|
||||
if chosen and os.path.isdir(chosen):
|
||||
chosen = os.path.abspath(chosen)
|
||||
with S.lock:
|
||||
S.folder = chosen
|
||||
save_folder_config(chosen)
|
||||
images, videos, sidecars = scan_folder(chosen)
|
||||
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"
|
||||
already = any(os.path.abspath(f) == chosen for f in S.folders)
|
||||
if not already:
|
||||
S.folders.append(chosen)
|
||||
folders = list(S.folders)
|
||||
save_folders_config(folders)
|
||||
images, videos, sidecars, done = rescan_configured_folders()
|
||||
sync_metadata_from_folder(chosen)
|
||||
load_journal()
|
||||
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
|
||||
self._json({"error": "No folder selected or dialog cancelled"}, 400)
|
||||
except Exception as e:
|
||||
@@ -1930,8 +2029,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
elif self.path == "/api/undo_all":
|
||||
with S.lock:
|
||||
folder = S.folder
|
||||
log("warn", f"UNDO ALL: Removing all PHOTON metadata from files in {folder} ...")
|
||||
folders = list(S.folders)
|
||||
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",
|
||||
"$Subject =~ /photon-tagged/ or $Keywords =~ /photon-tagged/",
|
||||
"-EXIF:ImageDescription=", "-IPTC:Caption-Abstract=", "-XMP-dc:Description=", "-Description=",
|
||||
@@ -1941,14 +2041,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
args.append(f"-Keywords-={cat}")
|
||||
args.append(folder)
|
||||
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")
|
||||
if os.path.exists(org_root):
|
||||
try:
|
||||
shutil.rmtree(org_root)
|
||||
log("info", "Deleted organized symlink directory.")
|
||||
log("info", f"Deleted organized symlink directory in {folder}.")
|
||||
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):
|
||||
try:
|
||||
os.remove(JOURNAL)
|
||||
@@ -2006,10 +2106,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
keep_backup = bool(S.settings.get("keepBackup", False))
|
||||
preserve_date = bool(S.settings.get("preserveDate", True))
|
||||
organize = bool(S.settings.get("organize", False))
|
||||
folder = S.folder
|
||||
write_metadata(path, desc, category, keep_backup, preserve_date)
|
||||
if organize:
|
||||
organize_alias(folder, path, category)
|
||||
organize_alias(find_owning_folder(path), path, category)
|
||||
existing_recs = []
|
||||
found = False
|
||||
if os.path.exists(JOURNAL):
|
||||
@@ -2116,9 +2215,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
write_metadata(path, desc, category, keep_backup, preserve_date)
|
||||
if organize:
|
||||
with S.lock:
|
||||
folder = S.folder
|
||||
organize_alias(folder, path, category)
|
||||
organize_alias(find_owning_folder(path), path, category)
|
||||
existing_recs = []
|
||||
found = False
|
||||
if os.path.exists(JOURNAL):
|
||||
@@ -2270,7 +2367,7 @@ def main():
|
||||
load_failures()
|
||||
load_phash_cache()
|
||||
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 ""))
|
||||
if n:
|
||||
print(f"journal loaded: {n} photos already tagged (will be skipped on resume)")
|
||||
|
||||
Reference in New Issue
Block a user