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