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}
|
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
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")
|
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)")
|
||||||
|
|||||||
Reference in New Issue
Block a user