Add support for multiple source folders

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

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

View File

@@ -80,6 +80,16 @@ button:disabled{opacity:.3;cursor:not-allowed}
font-family:var(--font-mono);font-size:12px;padding:9px 12px;border-radius:4px}
.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");