Add self-healing path relocation for multi-disk portability, macOS native folder picker, and double-clickable launch.command
This commit is contained in:
15
index.html
15
index.html
@@ -341,6 +341,7 @@ 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">
|
<input id="folder" spellcheck="false" placeholder="/path/to/your/photos">
|
||||||
|
<button id="btnBrowseFolder" title="Open native macOS folder chooser">📁 Browse</button>
|
||||||
<button id="btnScan">◈ Scan Directory</button>
|
<button id="btnScan">◈ Scan Directory</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="btnStart" class="btn-primary" disabled>▶ Start Pipeline</button>
|
<button id="btnStart" class="btn-primary" disabled>▶ Start Pipeline</button>
|
||||||
@@ -991,6 +992,20 @@ async function api(path, body){
|
|||||||
return j;
|
return j;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if($("btnBrowseFolder")){
|
||||||
|
$("btnBrowseFolder").onclick = async ()=>{
|
||||||
|
$("btnBrowseFolder").disabled = true;
|
||||||
|
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");
|
||||||
|
const scanRes = await api("/api/scan",{folder: j.folder});
|
||||||
|
if(scanRes.images!=null){ scanned=true; setBadge("idle"); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
$("btnScan").onclick = async ()=>{
|
$("btnScan").onclick = async ()=>{
|
||||||
const j = await api("/api/scan",{folder:$("folder").value});
|
const j = await api("/api/scan",{folder:$("folder").value});
|
||||||
if(j.images!=null){ scanned=true; setBadge("idle"); }
|
if(j.images!=null){ scanned=true; setBadge("idle"); }
|
||||||
|
|||||||
20
launch.command
Executable file
20
launch.command
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# PHOTON — Double-click macOS launcher
|
||||||
|
# Automatically starts the server and opens the console in your default web browser.
|
||||||
|
|
||||||
|
CD_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
cd "$CD_DIR"
|
||||||
|
|
||||||
|
echo "======================================================="
|
||||||
|
echo " PHOTON — Local Photo Intelligence Console Launch "
|
||||||
|
echo "======================================================="
|
||||||
|
echo ""
|
||||||
|
echo "Starting local HTTP server..."
|
||||||
|
python3 server.py &
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
sleep 1.5
|
||||||
|
echo "Opening browser at http://localhost:8765 ..."
|
||||||
|
open "http://localhost:8765"
|
||||||
|
|
||||||
|
wait $SERVER_PID
|
||||||
40
server.py
40
server.py
@@ -214,6 +214,19 @@ def write_failures():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def resolve_portable_path(path):
|
||||||
|
if not path:
|
||||||
|
return 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))
|
||||||
|
if os.path.exists(cand):
|
||||||
|
return cand
|
||||||
|
return path
|
||||||
|
|
||||||
def load_journal():
|
def load_journal():
|
||||||
n = 0
|
n = 0
|
||||||
with S.lock:
|
with S.lock:
|
||||||
@@ -227,8 +240,9 @@ def load_journal():
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
rec = json.loads(line)
|
rec = json.loads(line)
|
||||||
|
path = resolve_portable_path(rec.get("path", ""))
|
||||||
with S.lock:
|
with S.lock:
|
||||||
S.done_paths.add(rec["path"])
|
S.done_paths.add(path)
|
||||||
cat = rec.get("category")
|
cat = rec.get("category")
|
||||||
if cat not in S.cat_counts:
|
if cat not in S.cat_counts:
|
||||||
S.cat_counts[cat] = 0
|
S.cat_counts[cat] = 0
|
||||||
@@ -246,7 +260,6 @@ def trash_or_remove_file(path):
|
|||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
# Try macOS Trash via AppleScript
|
|
||||||
cmd = ["osascript", "-e", f'tell application "Finder" to delete POSIX file "{os.path.abspath(path)}"']
|
cmd = ["osascript", "-e", f'tell application "Finder" to delete POSIX file "{os.path.abspath(path)}"']
|
||||||
r = subprocess.run(cmd, capture_output=True, timeout=10)
|
r = subprocess.run(cmd, capture_output=True, timeout=10)
|
||||||
if r.returncode == 0:
|
if r.returncode == 0:
|
||||||
@@ -262,7 +275,6 @@ def trash_or_remove_file(path):
|
|||||||
|
|
||||||
def remove_photo_records(path):
|
def remove_photo_records(path):
|
||||||
abs_path = os.path.abspath(path)
|
abs_path = os.path.abspath(path)
|
||||||
# 1. Remove symlink in _organized
|
|
||||||
with S.lock:
|
with S.lock:
|
||||||
folder = S.folder
|
folder = S.folder
|
||||||
org_root = os.path.join(folder, "_organized")
|
org_root = os.path.join(folder, "_organized")
|
||||||
@@ -278,7 +290,6 @@ def remove_photo_records(path):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 2. Clean thumbnail and view caches
|
|
||||||
try:
|
try:
|
||||||
h = hashlib.md5(abs_path.encode()).hexdigest()
|
h = hashlib.md5(abs_path.encode()).hexdigest()
|
||||||
t1 = os.path.join(tempfile.gettempdir(), "photon_thumbs", f"{h}.jpg")
|
t1 = os.path.join(tempfile.gettempdir(), "photon_thumbs", f"{h}.jpg")
|
||||||
@@ -290,7 +301,6 @@ def remove_photo_records(path):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 3. Rewrite journal without this path
|
|
||||||
if os.path.exists(JOURNAL):
|
if os.path.exists(JOURNAL):
|
||||||
lines_to_keep = []
|
lines_to_keep = []
|
||||||
with open(JOURNAL, "r", encoding="utf-8") as f:
|
with open(JOURNAL, "r", encoding="utf-8") as f:
|
||||||
@@ -308,7 +318,6 @@ def remove_photo_records(path):
|
|||||||
for l in lines_to_keep:
|
for l in lines_to_keep:
|
||||||
f.write(l + "\n")
|
f.write(l + "\n")
|
||||||
|
|
||||||
# 4. Remove from State
|
|
||||||
with S.lock:
|
with S.lock:
|
||||||
if abs_path in S.done_paths:
|
if abs_path in S.done_paths:
|
||||||
S.done_paths.remove(abs_path)
|
S.done_paths.remove(abs_path)
|
||||||
@@ -356,7 +365,8 @@ def load_search_index():
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
rec = json.loads(line)
|
rec = json.loads(line)
|
||||||
path = rec.get("path", "")
|
path = resolve_portable_path(rec.get("path", ""))
|
||||||
|
rec["path"] = path
|
||||||
name = os.path.basename(path)
|
name = os.path.basename(path)
|
||||||
rec["name"] = name
|
rec["name"] = name
|
||||||
rec["_ext"] = os.path.splitext(name)[1].lower()
|
rec["_ext"] = os.path.splitext(name)[1].lower()
|
||||||
@@ -1678,6 +1688,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._json({"images": len(images), "videos": len(videos),
|
self._json({"images": len(images), "videos": len(videos),
|
||||||
"sidecars": sidecars, "alreadyDone": done})
|
"sidecars": sidecars, "alreadyDone": done})
|
||||||
|
|
||||||
|
elif self.path == "/api/choose_folder":
|
||||||
|
try:
|
||||||
|
cmd = ["osascript", "-e", 'POSIX path of (choose folder with prompt "Select Photo Folder to Organize:")']
|
||||||
|
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):
|
||||||
|
with S.lock:
|
||||||
|
S.folder = chosen
|
||||||
|
save_folder_config(chosen)
|
||||||
|
self._json({"ok": True, "folder": chosen})
|
||||||
|
return
|
||||||
|
self._json({"error": "No folder selected"}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
self._json({"error": str(e)}, 500)
|
||||||
|
|
||||||
elif self.path == "/api/start":
|
elif self.path == "/api/start":
|
||||||
with S.lock:
|
with S.lock:
|
||||||
if S.status == "running":
|
if S.status == "running":
|
||||||
|
|||||||
Reference in New Issue
Block a user