aantal mappen en layout aangepast

This commit is contained in:
kodi
2026-03-08 08:56:09 +01:00
parent 06c144d2fc
commit e10082e1bc
7 changed files with 480 additions and 226 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ def discover_files(
def discover_folders( def discover_folders(
root_id: str = Query(..., min_length=1), root_id: str = Query(..., min_length=1),
subpath: str = Query(""), subpath: str = Query(""),
limit: int = Query(500, ge=1, le=2000), limit: int = Query(5000, ge=1, le=20000),
): ):
service = FileDiscoveryService() service = FileDiscoveryService()
try: try:
+3 -1
View File
@@ -87,7 +87,7 @@ class FileDiscoveryService:
self, self,
root_id: str, root_id: str,
subpath: str = "", subpath: str = "",
limit: int = 500, limit: int = 5000,
) -> dict: ) -> dict:
root = self._get_root_by_id(root_id) root = self._get_root_by_id(root_id)
target = self._resolve_target(root["path"], subpath) target = self._resolve_target(root["path"], subpath)
@@ -109,10 +109,12 @@ class FileDiscoveryService:
break break
if not entry.is_dir(): if not entry.is_dir():
continue continue
try: try:
relative_to_root = entry.resolve().relative_to(root["path"]) relative_to_root = entry.resolve().relative_to(root["path"])
except ValueError: except ValueError:
continue continue
folders.append( folders.append(
{ {
"name": entry.name, "name": entry.name,
+299 -183
View File
@@ -1,14 +1,18 @@
(function () { (function () {
const STORAGE_KEY = "rename_mvp_session_id"; const STORAGE_KEY = "rename_mvp_session_id";
const sessionId = initSessionId();
const state = { const state = {
sessionId, sessionId: initSessionId(),
selectedSeries: null, selectedSeries: null,
episodes: [], episodes: [],
selectedEpisodes: [],
selectedFiles: [],
selectedPairIndex: null,
roots: [], roots: [],
folders: [], modalFolders: [],
discoveredFiles: [], modalFiles: [],
currentSubpath: "", modalSelectedFilePaths: new Set(),
syncScrolling: false,
}; };
const el = { const el = {
@@ -20,23 +24,35 @@
seriesInfo: document.getElementById("seriesInfo"), seriesInfo: document.getElementById("seriesInfo"),
refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"), refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"),
episodesList: document.getElementById("episodesList"), episodesList: document.getElementById("episodesList"),
episodeMeta: document.getElementById("episodeMeta"),
refreshSelectedEpisodesBtn: document.getElementById("refreshSelectedEpisodesBtn"), refreshSelectedEpisodesBtn: document.getElementById("refreshSelectedEpisodesBtn"),
clearSelectedEpisodesBtn: document.getElementById("clearSelectedEpisodesBtn"), clearSelectedEpisodesBtn: document.getElementById("clearSelectedEpisodesBtn"),
episodeUpBtn: document.getElementById("episodeUpBtn"),
episodeDownBtn: document.getElementById("episodeDownBtn"),
episodeRemoveBtn: document.getElementById("episodeRemoveBtn"),
selectedEpisodesList: document.getElementById("selectedEpisodesList"), selectedEpisodesList: document.getElementById("selectedEpisodesList"),
rootsSelect: document.getElementById("rootsSelect"), fileMeta: document.getElementById("fileMeta"),
foldersSelect: document.getElementById("foldersSelect"), selectFilesBtn: document.getElementById("selectFilesBtn"),
refreshRootsBtn: document.getElementById("refreshRootsBtn"),
subpathInput: document.getElementById("subpathInput"),
loadFoldersBtn: document.getElementById("loadFoldersBtn"),
loadFilesBtn: document.getElementById("loadFilesBtn"),
recursiveInput: document.getElementById("recursiveInput"),
discoveredFilesList: document.getElementById("discoveredFilesList"),
refreshSelectedFilesBtn: document.getElementById("refreshSelectedFilesBtn"), refreshSelectedFilesBtn: document.getElementById("refreshSelectedFilesBtn"),
clearSelectedFilesBtn: document.getElementById("clearSelectedFilesBtn"), clearSelectedFilesBtn: document.getElementById("clearSelectedFilesBtn"),
fileUpBtn: document.getElementById("fileUpBtn"),
fileDownBtn: document.getElementById("fileDownBtn"),
fileRemoveBtn: document.getElementById("fileRemoveBtn"),
selectedFilesList: document.getElementById("selectedFilesList"), selectedFilesList: document.getElementById("selectedFilesList"),
mappingPreviewBtn: document.getElementById("mappingPreviewBtn"),
filenamePreviewBtn: document.getElementById("filenamePreviewBtn"), filenamePreviewBtn: document.getElementById("filenamePreviewBtn"),
renameExecuteBtn: document.getElementById("renameExecuteBtn"), renameExecuteBtn: document.getElementById("renameExecuteBtn"),
fileModal: document.getElementById("fileModal"),
fileModalTitle: document.getElementById("fileModalTitle"),
closeFileModalBtn: document.getElementById("closeFileModalBtn"),
modalRootSelect: document.getElementById("modalRootSelect"),
modalFolderSelect: document.getElementById("modalFolderSelect"),
modalSubpathInput: document.getElementById("modalSubpathInput"),
modalRecursiveInput: document.getElementById("modalRecursiveInput"),
modalLoadFoldersBtn: document.getElementById("modalLoadFoldersBtn"),
modalLoadFilesBtn: document.getElementById("modalLoadFilesBtn"),
modalFoldersList: document.getElementById("modalFoldersList"),
modalFilesList: document.getElementById("modalFilesList"),
modalAddSelectedFilesBtn: document.getElementById("modalAddSelectedFilesBtn"),
}; };
function initSessionId() { function initSessionId() {
@@ -48,7 +64,9 @@
} }
function q(path) { function q(path) {
return path.includes("?") ? `${path}&session_id=${encodeURIComponent(state.sessionId)}` : `${path}?session_id=${encodeURIComponent(state.sessionId)}`; return path.includes("?")
? `${path}&session_id=${encodeURIComponent(state.sessionId)}`
: `${path}?session_id=${encodeURIComponent(state.sessionId)}`;
} }
async function api(path, options = {}) { async function api(path, options = {}) {
@@ -70,7 +88,7 @@
el.outputBox.textContent = `${label}\n${JSON.stringify(payload, null, 2)}`; el.outputBox.textContent = `${label}\n${JSON.stringify(payload, null, 2)}`;
} }
function makeActionButton(label, handler, secondary) { function makeBtn(label, handler, secondary) {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.textContent = label; btn.textContent = label;
if (secondary) btn.className = "secondary"; if (secondary) btn.className = "secondary";
@@ -78,27 +96,97 @@
return btn; return btn;
} }
function setLoading(btn, loading) { function setBusy(btn, busy) {
if (!btn) return; if (btn) btn.disabled = busy;
btn.disabled = loading; }
function updateMeta() {
const epCount = state.selectedEpisodes.length;
const fileCount = state.selectedFiles.length;
const mismatch = epCount !== fileCount;
const mismatchText = mismatch ? " mismatch" : " aligned";
el.episodeMeta.innerHTML = `Rows: <b>${epCount}</b>`;
el.fileMeta.innerHTML = `Rows: <b>${fileCount}</b> <span class="${mismatch ? "mismatch" : ""}">${mismatchText}</span>`;
}
function selectPair(index) {
state.selectedPairIndex = index;
renderSelectedEpisodes();
renderSelectedFiles();
}
function syncScroll(source, target) {
if (state.syncScrolling) return;
state.syncScrolling = true;
target.scrollTop = source.scrollTop;
requestAnimationFrame(() => {
state.syncScrolling = false;
});
}
function renderSelectedEpisodes() {
el.selectedEpisodesList.innerHTML = "";
state.selectedEpisodes.forEach((item, idx) => {
const li = document.createElement("li");
if (state.selectedPairIndex === idx) li.classList.add("selected");
const left = document.createElement("span");
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = `#${idx + 1}`;
left.appendChild(badge);
left.appendChild(document.createTextNode(item.episode.label || item.episode.title || "(episode)"));
const right = document.createElement("div");
right.appendChild(makeBtn("Select", () => selectPair(idx), true));
li.appendChild(left);
li.appendChild(right);
li.addEventListener("click", () => selectPair(idx));
el.selectedEpisodesList.appendChild(li);
});
updateMeta();
}
function renderSelectedFiles() {
el.selectedFilesList.innerHTML = "";
state.selectedFiles.forEach((item, idx) => {
const li = document.createElement("li");
if (state.selectedPairIndex === idx) li.classList.add("selected");
const left = document.createElement("span");
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = `#${idx + 1}`;
left.appendChild(badge);
left.appendChild(document.createTextNode(item.file.path || item.file.name || "(file)"));
const right = document.createElement("div");
right.appendChild(makeBtn("Select", () => selectPair(idx), true));
li.appendChild(left);
li.appendChild(right);
li.addEventListener("click", () => selectPair(idx));
el.selectedFilesList.appendChild(li);
});
updateMeta();
} }
async function loadRoots() { async function loadRoots() {
const data = await api("/api/files/roots"); const data = await api("/api/files/roots");
state.roots = data.items || []; state.roots = data.items || [];
el.rootsSelect.innerHTML = ""; el.modalRootSelect.innerHTML = "";
for (const root of state.roots) { state.roots.forEach((root) => {
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = root.id; opt.value = root.id;
opt.textContent = `${root.id}: ${root.path}`; opt.textContent = `${root.id}: ${root.path}`;
el.rootsSelect.appendChild(opt); el.modalRootSelect.appendChild(opt);
});
if (state.roots.length) {
el.modalSubpathInput.value = "";
await loadModalFolders();
} }
if (state.roots.length > 0) {
state.currentSubpath = "";
el.subpathInput.value = "";
await loadFolders();
}
out("Roots loaded", data);
} }
async function doSearch() { async function doSearch() {
@@ -109,14 +197,13 @@
(data.items || []).forEach((item) => { (data.items || []).forEach((item) => {
const li = document.createElement("li"); const li = document.createElement("li");
const left = document.createElement("span"); const left = document.createElement("span");
left.textContent = item.display_name || item.name || "(no name)"; left.textContent = item.display_name || item.name || "(series)";
const right = document.createElement("div"); const right = document.createElement("div");
const btn = makeActionButton("Select", async () => { right.appendChild(makeBtn("Select", async () => {
state.selectedSeries = item; state.selectedSeries = item;
el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`; el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`;
await loadEpisodes(); await loadEpisodes();
}); }));
right.appendChild(btn);
li.appendChild(left); li.appendChild(left);
li.appendChild(right); li.appendChild(right);
el.searchResults.appendChild(li); el.searchResults.appendChild(li);
@@ -131,13 +218,12 @@
const data = await api(`/api/tvdb/series/${encodeURIComponent(state.selectedSeries.id)}/episodes?order_type=aired`); const data = await api(`/api/tvdb/series/${encodeURIComponent(state.selectedSeries.id)}/episodes?order_type=aired`);
state.episodes = data.items || []; state.episodes = data.items || [];
el.episodesList.innerHTML = ""; el.episodesList.innerHTML = "";
state.episodes.forEach((episode) => { state.episodes.forEach((episode) => {
const li = document.createElement("li"); const li = document.createElement("li");
const left = document.createElement("span"); const left = document.createElement("span");
left.textContent = episode.label || `${episode.season_number}x${episode.episode_number} ${episode.title}`; left.textContent = episode.label || `${episode.season_number}x${episode.episode_number} ${episode.title}`;
const right = document.createElement("div"); const right = document.createElement("div");
const btn = makeActionButton("Add", async () => { right.appendChild(makeBtn("Add", async () => {
const payload = { const payload = {
id: episode.id, id: episode.id,
series: state.selectedSeries.name, series: state.selectedSeries.name,
@@ -148,15 +234,13 @@
aired: episode.aired, aired: episode.aired,
label: episode.label, label: episode.label,
}; };
const res = await api(q("/api/session/selected-episodes"), { await api(q("/api/session/selected-episodes"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: [payload] }), body: JSON.stringify({ items: [payload] }),
}); });
await loadSelectedEpisodes(); await loadSelectedEpisodes();
out("Episode added", res); }));
});
right.appendChild(btn);
li.appendChild(left); li.appendChild(left);
li.appendChild(right); li.appendChild(right);
el.episodesList.appendChild(li); el.episodesList.appendChild(li);
@@ -166,159 +250,179 @@
async function loadSelectedEpisodes() { async function loadSelectedEpisodes() {
const data = await api(q("/api/session/selected-episodes")); const data = await api(q("/api/session/selected-episodes"));
el.selectedEpisodesList.innerHTML = ""; state.selectedEpisodes = data.items || [];
(data.items || []).forEach((item, idx) => { if (state.selectedPairIndex != null && state.selectedPairIndex >= state.selectedEpisodes.length) {
const li = document.createElement("li"); state.selectedPairIndex = state.selectedEpisodes.length - 1;
const left = document.createElement("span"); }
left.textContent = item.episode.label || item.episode.title || `Episode #${idx + 1}`; renderSelectedEpisodes();
const right = document.createElement("div");
right.appendChild(makeActionButton("Up", async () => {
if (idx === 0) return;
await api(q("/api/session/selected-episodes/reorder"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from_index: idx, to_index: idx - 1 }),
});
await loadSelectedEpisodes();
}, true));
right.appendChild(makeActionButton("Down", async () => {
if (idx >= data.items.length - 1) return;
await api(q("/api/session/selected-episodes/reorder"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from_index: idx, to_index: idx + 1 }),
});
await loadSelectedEpisodes();
}, true));
right.appendChild(makeActionButton("Remove", async () => {
await api(q(`/api/session/selected-episodes/${item.selection_id}`), { method: "DELETE" });
await loadSelectedEpisodes();
}, true));
li.appendChild(left);
li.appendChild(right);
el.selectedEpisodesList.appendChild(li);
});
return data; return data;
} }
async function loadFolders() { async function loadSelectedFiles() {
const rootId = el.rootsSelect.value; const data = await api(q("/api/session/selected-files"));
if (!rootId) throw new Error("No root selected"); state.selectedFiles = data.items || [];
const subpath = (el.subpathInput.value || "").trim(); if (state.selectedPairIndex != null && state.selectedPairIndex >= state.selectedFiles.length) {
state.currentSubpath = subpath; state.selectedPairIndex = state.selectedFiles.length - 1;
const data = await api( }
`/api/files/folders?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(subpath)}&limit=500` renderSelectedFiles();
); return data;
state.folders = data.items || []; }
el.foldersSelect.innerHTML = "";
function openFileModal() {
state.modalSelectedFilePaths = new Set();
el.fileModalTitle.textContent = "File Discovery";
el.modalAddSelectedFilesBtn.style.display = "";
el.modalLoadFilesBtn.style.display = "";
el.fileModal.classList.remove("hidden");
el.fileModal.setAttribute("aria-hidden", "false");
}
function closeFileModal() {
el.fileModal.classList.add("hidden");
el.fileModal.setAttribute("aria-hidden", "true");
}
async function loadModalFolders() {
const rootId = el.modalRootSelect.value;
if (!rootId) throw new Error("No root selected");
const subpath = (el.modalSubpathInput.value || "").trim();
const data = await api(
`/api/files/folders?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(subpath)}&limit=5000`
);
state.modalFolders = data.items || [];
el.modalFolderSelect.innerHTML = "";
const placeholder = document.createElement("option"); const placeholder = document.createElement("option");
placeholder.value = ""; placeholder.value = "";
placeholder.textContent = state.folders.length ? "Choose folder..." : "(No folders)"; placeholder.textContent = state.modalFolders.length ? "Choose folder..." : "(No folders)";
el.foldersSelect.appendChild(placeholder); el.modalFolderSelect.appendChild(placeholder);
state.folders.forEach((folder) => { el.modalFoldersList.innerHTML = "";
state.modalFolders.forEach((folder) => {
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = folder.subpath; opt.value = folder.subpath;
opt.textContent = folder.subpath || folder.name; opt.textContent = folder.subpath || folder.name;
el.foldersSelect.appendChild(opt); el.modalFolderSelect.appendChild(opt);
const li = document.createElement("li");
const left = document.createElement("span");
left.textContent = folder.subpath || folder.name;
const right = document.createElement("div");
right.appendChild(makeBtn("Select", () => {
el.modalFolderSelect.value = folder.subpath;
el.modalSubpathInput.value = folder.subpath;
}, true));
li.appendChild(left);
li.appendChild(right);
el.modalFoldersList.appendChild(li);
}); });
out("Folders loaded", data); out("Folders loaded", data);
} }
async function discoverFiles() { async function loadModalFiles() {
const rootId = el.rootsSelect.value; const rootId = el.modalRootSelect.value;
const subpath = (el.modalFolderSelect.value || "").trim();
if (!rootId) throw new Error("No root selected"); if (!rootId) throw new Error("No root selected");
const selectedFolder = (el.foldersSelect.value || "").trim(); if (!subpath) throw new Error("Choose a folder first");
if (!selectedFolder) throw new Error("Choose a folder first");
el.subpathInput.value = selectedFolder;
state.currentSubpath = selectedFolder;
const subpath = encodeURIComponent(selectedFolder);
const recursive = el.recursiveInput.checked ? "true" : "false";
const data = await api(`/api/files/discover?root_id=${encodeURIComponent(rootId)}&subpath=${subpath}&recursive=${recursive}&limit=200`);
state.discoveredFiles = data.items || [];
el.discoveredFilesList.innerHTML = "";
state.discoveredFiles.forEach((file) => { el.modalSubpathInput.value = subpath;
const recursive = el.modalRecursiveInput.checked ? "true" : "false";
const data = await api(
`/api/files/discover?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(subpath)}&recursive=${recursive}&limit=200`
);
state.modalFiles = data.items || [];
el.modalFilesList.innerHTML = "";
state.modalSelectedFilePaths = new Set();
state.modalFiles.forEach((file) => {
const li = document.createElement("li"); const li = document.createElement("li");
const left = document.createElement("span"); const left = document.createElement("span");
left.textContent = file.relative_path || file.name; left.textContent = file.relative_path || file.name;
const right = document.createElement("div"); const right = document.createElement("div");
right.appendChild(makeActionButton("Add", async () => { right.appendChild(makeBtn("Toggle", () => {
const payload = { path: file.path, name: file.name }; if (state.modalSelectedFilePaths.has(file.path)) {
const res = await api(q("/api/session/selected-files"), { state.modalSelectedFilePaths.delete(file.path);
method: "POST", li.classList.remove("selected");
headers: { "Content-Type": "application/json" }, } else {
body: JSON.stringify({ items: [payload] }), state.modalSelectedFilePaths.add(file.path);
}); li.classList.add("selected");
await loadSelectedFiles(); }
out("File added", res);
}));
li.appendChild(left);
li.appendChild(right);
el.discoveredFilesList.appendChild(li);
});
out("Discover result", data);
}
async function loadSelectedFiles() {
const data = await api(q("/api/session/selected-files"));
el.selectedFilesList.innerHTML = "";
(data.items || []).forEach((item, idx) => {
const li = document.createElement("li");
const left = document.createElement("span");
left.textContent = item.file.path || item.file.name || `File #${idx + 1}`;
const right = document.createElement("div");
right.appendChild(makeActionButton("Up", async () => {
if (idx === 0) return;
await api(q("/api/session/selected-files/reorder"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from_index: idx, to_index: idx - 1 }),
});
await loadSelectedFiles();
}, true));
right.appendChild(makeActionButton("Down", async () => {
if (idx >= data.items.length - 1) return;
await api(q("/api/session/selected-files/reorder"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from_index: idx, to_index: idx + 1 }),
});
await loadSelectedFiles();
}, true));
right.appendChild(makeActionButton("Remove", async () => {
await api(q(`/api/session/selected-files/${item.selection_id}`), { method: "DELETE" });
await loadSelectedFiles();
}, true)); }, true));
li.appendChild(left); li.appendChild(left);
li.appendChild(right); li.appendChild(right);
el.selectedFilesList.appendChild(li); el.modalFilesList.appendChild(li);
}); });
return data;
out("Discovered files", data);
} }
async function callPreview(path, label) { async function addModalSelectedFiles() {
const data = await api(q(path)); const selected = state.modalFiles.filter((f) => state.modalSelectedFilePaths.has(f.path));
out(label, data); if (!selected.length) throw new Error("Select at least one file");
const payload = selected.map((f) => ({ path: f.path, name: f.name }));
await api(q("/api/session/selected-files"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: payload }),
});
await loadSelectedFiles();
closeFileModal();
}
async function reorderSelectedEpisodes(delta) {
if (state.selectedPairIndex == null) throw new Error("Select an episode row first");
const from = state.selectedPairIndex;
const to = from + delta;
if (to < 0 || to >= state.selectedEpisodes.length) return;
await api(q("/api/session/selected-episodes/reorder"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from_index: from, to_index: to }),
});
state.selectedPairIndex = to;
await loadSelectedEpisodes();
}
async function reorderSelectedFiles(delta) {
if (state.selectedPairIndex == null) throw new Error("Select a file row first");
const from = state.selectedPairIndex;
const to = from + delta;
if (to < 0 || to >= state.selectedFiles.length) return;
await api(q("/api/session/selected-files/reorder"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from_index: from, to_index: to }),
});
state.selectedPairIndex = to;
await loadSelectedFiles();
}
async function removeSelectedEpisode() {
if (state.selectedPairIndex == null) throw new Error("Select an episode row first");
const item = state.selectedEpisodes[state.selectedPairIndex];
if (!item) return;
await api(q(`/api/session/selected-episodes/${item.selection_id}`), { method: "DELETE" });
await loadSelectedEpisodes();
}
async function removeSelectedFile() {
if (state.selectedPairIndex == null) throw new Error("Select a file row first");
const item = state.selectedFiles[state.selectedPairIndex];
if (!item) return;
await api(q(`/api/session/selected-files/${item.selection_id}`), { method: "DELETE" });
await loadSelectedFiles();
} }
async function executeRename() { async function executeRename() {
const data = await api(q("/api/session/rename-execute") + "&confirm=true", { const data = await api(q("/api/session/rename-execute") + "&confirm=true", { method: "POST" });
method: "POST",
});
if (data.executed) { if (data.executed) {
out("Rename execute: success", data); out("Rename execute: success", data);
// Keep UI in sync with renamed destinations using existing selected-files endpoints.
const renamedFiles = (data.items || []) const renamedFiles = (data.items || [])
.filter((item) => item.status === "renamed") .filter((i) => i.status === "renamed")
.map((item) => ({ .map((i) => ({ path: i.destination_path, name: i.proposed_filename }));
path: item.destination_path,
name: item.proposed_filename,
}));
await api(q("/api/session/selected-files"), { method: "DELETE" }); await api(q("/api/session/selected-files"), { method: "DELETE" });
if (renamedFiles.length > 0) { if (renamedFiles.length) {
await api(q("/api/session/selected-files"), { await api(q("/api/session/selected-files"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -328,67 +432,79 @@
await loadSelectedFiles(); await loadSelectedFiles();
return; return;
} }
out("Rename execute: preflight failed", data); out("Rename execute: preflight failed", data);
} }
async function withHandler(fn, btn) { async function withHandler(fn, btn) {
try { try {
setLoading(btn, true); setBusy(btn, true);
await fn(); await fn();
} catch (err) { } catch (err) {
out("Error", { detail: err.message || String(err) }); out("Error", { detail: err.message || String(err) });
} finally { } finally {
setLoading(btn, false); setBusy(btn, false);
} }
} }
function bindEvents() { function bindEvents() {
el.searchBtn.addEventListener("click", () => withHandler(doSearch, el.searchBtn)); el.searchBtn.addEventListener("click", () => withHandler(doSearch, el.searchBtn));
el.refreshEpisodesBtn.addEventListener("click", () => withHandler(loadEpisodes, el.refreshEpisodesBtn)); el.refreshEpisodesBtn.addEventListener("click", () => withHandler(loadEpisodes, el.refreshEpisodesBtn));
el.refreshSelectedEpisodesBtn.addEventListener("click", () => withHandler(loadSelectedEpisodes, el.refreshSelectedEpisodesBtn)); el.refreshSelectedEpisodesBtn.addEventListener("click", () => withHandler(loadSelectedEpisodes, el.refreshSelectedEpisodesBtn));
el.clearSelectedEpisodesBtn.addEventListener("click", () => el.clearSelectedEpisodesBtn.addEventListener("click", () =>
withHandler(async () => { withHandler(async () => {
const res = await api(q("/api/session/selected-episodes"), { method: "DELETE" }); await api(q("/api/session/selected-episodes"), { method: "DELETE" });
state.selectedPairIndex = null;
await loadSelectedEpisodes(); await loadSelectedEpisodes();
out("Selected episodes cleared", res);
}, el.clearSelectedEpisodesBtn) }, el.clearSelectedEpisodesBtn)
); );
el.refreshRootsBtn.addEventListener("click", () => withHandler(loadRoots, el.refreshRootsBtn)); el.episodeUpBtn.addEventListener("click", () => withHandler(() => reorderSelectedEpisodes(-1), el.episodeUpBtn));
el.rootsSelect.addEventListener("change", () => withHandler(async () => { el.episodeDownBtn.addEventListener("click", () => withHandler(() => reorderSelectedEpisodes(1), el.episodeDownBtn));
state.currentSubpath = ""; el.episodeRemoveBtn.addEventListener("click", () => withHandler(removeSelectedEpisode, el.episodeRemoveBtn));
el.subpathInput.value = "";
await loadFolders(); el.selectFilesBtn.addEventListener("click", () => withHandler(async () => {
el.discoveredFilesList.innerHTML = ""; openFileModal();
}, el.rootsSelect)); await loadRoots();
el.loadFoldersBtn.addEventListener("click", () => withHandler(loadFolders, el.loadFoldersBtn)); }, el.selectFilesBtn));
el.loadFilesBtn.addEventListener("click", () => withHandler(discoverFiles, el.loadFilesBtn));
el.foldersSelect.addEventListener("change", () => {
const chosen = (el.foldersSelect.value || "").trim();
if (chosen) {
el.subpathInput.value = chosen;
}
});
el.refreshSelectedFilesBtn.addEventListener("click", () => withHandler(loadSelectedFiles, el.refreshSelectedFilesBtn)); el.refreshSelectedFilesBtn.addEventListener("click", () => withHandler(loadSelectedFiles, el.refreshSelectedFilesBtn));
el.clearSelectedFilesBtn.addEventListener("click", () => el.clearSelectedFilesBtn.addEventListener("click", () =>
withHandler(async () => { withHandler(async () => {
const res = await api(q("/api/session/selected-files"), { method: "DELETE" }); await api(q("/api/session/selected-files"), { method: "DELETE" });
state.selectedPairIndex = null;
await loadSelectedFiles(); await loadSelectedFiles();
out("Selected files cleared", res);
}, el.clearSelectedFilesBtn) }, el.clearSelectedFilesBtn)
); );
el.mappingPreviewBtn.addEventListener("click", () => withHandler(() => callPreview("/api/session/mapping-preview", "Mapping preview"), el.mappingPreviewBtn)); el.fileUpBtn.addEventListener("click", () => withHandler(() => reorderSelectedFiles(-1), el.fileUpBtn));
el.filenamePreviewBtn.addEventListener("click", () => withHandler(() => callPreview("/api/session/filename-preview", "Filename preview"), el.filenamePreviewBtn)); el.fileDownBtn.addEventListener("click", () => withHandler(() => reorderSelectedFiles(1), el.fileDownBtn));
el.fileRemoveBtn.addEventListener("click", () => withHandler(removeSelectedFile, el.fileRemoveBtn));
el.filenamePreviewBtn.addEventListener("click", () => withHandler(async () => out("Filename preview", await api(q("/api/session/filename-preview"))), el.filenamePreviewBtn));
el.renameExecuteBtn.addEventListener("click", () => withHandler(executeRename, el.renameExecuteBtn)); el.renameExecuteBtn.addEventListener("click", () => withHandler(executeRename, el.renameExecuteBtn));
el.closeFileModalBtn.addEventListener("click", closeFileModal);
el.fileModal.addEventListener("click", (e) => {
if (e.target === el.fileModal) closeFileModal();
});
el.modalRootSelect.addEventListener("change", () => withHandler(loadModalFolders, el.modalRootSelect));
el.modalLoadFoldersBtn.addEventListener("click", () => withHandler(loadModalFolders, el.modalLoadFoldersBtn));
el.modalLoadFilesBtn.addEventListener("click", () => withHandler(loadModalFiles, el.modalLoadFilesBtn));
el.modalFolderSelect.addEventListener("change", () => {
const sub = (el.modalFolderSelect.value || "").trim();
if (sub) el.modalSubpathInput.value = sub;
});
el.modalAddSelectedFilesBtn.addEventListener("click", () => withHandler(addModalSelectedFiles, el.modalAddSelectedFilesBtn));
el.selectedEpisodesList.addEventListener("scroll", () => syncScroll(el.selectedEpisodesList, el.selectedFilesList));
el.selectedFilesList.addEventListener("scroll", () => syncScroll(el.selectedFilesList, el.selectedEpisodesList));
} }
async function init() { async function init() {
el.sessionMeta.textContent = `session_id: ${state.sessionId}`; el.sessionMeta.textContent = `session_id: ${state.sessionId}`;
el.recursiveInput.checked = true; el.modalRecursiveInput.checked = true;
bindEvents(); bindEvents();
await loadRoots();
await loadSelectedEpisodes(); await loadSelectedEpisodes();
await loadSelectedFiles(); await loadSelectedFiles();
await loadRoots();
} }
init().catch((err) => out("Init error", { detail: err.message || String(err) })); init().catch((err) => out("Init error", { detail: err.message || String(err) }));
+117 -4
View File
@@ -41,6 +41,9 @@ body {
border: 1px solid #d7dee9; border: 1px solid #d7dee9;
border-radius: 8px; border-radius: 8px;
padding: 10px; padding: 10px;
min-height: 420px;
display: flex;
flex-direction: column;
} }
.panel h2 { .panel h2 {
@@ -98,6 +101,101 @@ button.secondary {
border-radius: 6px; border-radius: 6px;
} }
.linked-list-wrap {
flex: 1;
min-height: 240px;
max-height: 440px;
}
.linked-list {
height: 100%;
}
.badge {
display: inline-block;
font-size: 11px;
font-weight: 700;
color: #0f172a;
background: #dbeafe;
border: 1px solid #bfdbfe;
border-radius: 999px;
padding: 1px 6px;
margin-right: 6px;
}
.list li.selected {
background: #e0f2fe;
}
.panel-footer {
position: sticky;
bottom: 0;
background: #ffffff;
border-top: 1px solid #e4eaf2;
padding-top: 8px;
margin-top: 8px;
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
}
.advanced-row {
margin-top: 8px;
justify-content: flex-end;
}
.mismatch {
color: #b91c1c;
font-weight: 700;
}
.modal {
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.hidden {
display: none;
}
.modal-card {
width: min(1100px, 96vw);
background: #ffffff;
border: 1px solid #d7dee9;
border-radius: 10px;
padding: 12px;
}
.modal-head {
justify-content: space-between;
}
.modal-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 8px;
}
.modal-grid .list {
max-height: 280px;
}
#panelSelectedEpisodes .panel-footer button:first-child,
#panelSelectedFiles .panel-footer button:last-child,
#panelSelectedFiles .advanced-row button:last-child {
border-color: #0b3a6e;
background: #0b3a6e;
color: #ffffff;
}
.list li { .list li {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -117,10 +215,6 @@ button.secondary {
margin-bottom: 8px; margin-bottom: 8px;
} }
.output {
margin: 0 12px 12px;
}
#outputBox { #outputBox {
margin: 0; margin: 0;
background: #0b1220; background: #0b1220;
@@ -132,6 +226,21 @@ button.secondary {
font-size: 12px; font-size: 12px;
} }
.debug-box {
margin: 0 12px 12px;
background: #ffffff;
border: 1px solid #d7dee9;
border-radius: 8px;
padding: 8px;
}
.debug-box > summary {
cursor: pointer;
user-select: none;
color: #334155;
font-size: 13px;
}
@media (max-width: 1600px) { @media (max-width: 1600px) {
.grid { .grid {
grid-template-columns: repeat(2, minmax(280px, 1fr)); grid-template-columns: repeat(2, minmax(280px, 1fr));
@@ -142,4 +251,8 @@ button.secondary {
.grid { .grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.modal-grid {
grid-template-columns: 1fr;
}
} }
+59 -35
View File
@@ -33,57 +33,81 @@
<section class="panel" id="panelSelectedEpisodes"> <section class="panel" id="panelSelectedEpisodes">
<h2>3. Selected Episodes</h2> <h2>3. Selected Episodes</h2>
<div class="row"> <div id="episodeMeta" class="muted"></div>
<div class="linked-list-wrap">
<ul id="selectedEpisodesList" class="list linked-list"></ul>
</div>
<div class="panel-footer">
<button id="refreshSelectedEpisodesBtn">Refresh</button> <button id="refreshSelectedEpisodesBtn">Refresh</button>
<button id="episodeUpBtn" class="secondary">Up</button>
<button id="episodeDownBtn" class="secondary">Down</button>
<button id="episodeRemoveBtn" class="secondary">Remove</button>
<button id="clearSelectedEpisodesBtn">Clear</button> <button id="clearSelectedEpisodesBtn">Clear</button>
</div> </div>
<ul id="selectedEpisodesList" class="list"></ul>
</section> </section>
<section class="panel" id="panelSelectedFiles"> <section class="panel" id="panelSelectedFiles">
<h2>4. Selected Files</h2> <h2>4. Selected Files</h2>
<div class="stack"> <div class="stack">
<div class="row"> <div id="fileMeta" class="muted"></div>
<label for="rootsSelect">Root</label> <div class="linked-list-wrap">
<select id="rootsSelect"></select> <ul id="selectedFilesList" class="list linked-list"></ul>
<label for="foldersSelect">Folder</label>
<select id="foldersSelect"></select>
<button id="refreshRootsBtn">Refresh Roots</button>
</div> </div>
<div class="row"> <div class="panel-footer">
<input id="subpathInput" type="text" placeholder="Current subpath (relative)" /> <button id="selectFilesBtn">Select Files</button>
<button id="loadFoldersBtn">Load Folders</button> <button id="refreshSelectedFilesBtn" class="secondary">Refresh</button>
<button id="loadFilesBtn">Load Files</button> <button id="fileUpBtn" class="secondary">Up</button>
<label> <button id="fileDownBtn" class="secondary">Down</button>
<input id="recursiveInput" type="checkbox" checked /> <button id="fileRemoveBtn" class="secondary">Remove</button>
recursive <button id="clearSelectedFilesBtn">Clear</button>
</label>
</div>
<div>
<h3>Discovered Files</h3>
<ul id="discoveredFilesList" class="list"></ul>
</div>
<div class="row">
<button id="refreshSelectedFilesBtn">Refresh Selected Files</button>
<button id="clearSelectedFilesBtn">Clear Selected Files</button>
</div>
<div>
<h3>Selected Files</h3>
<ul id="selectedFilesList" class="list"></ul>
</div>
<div class="row">
<button id="mappingPreviewBtn">Mapping Preview</button>
<button id="filenamePreviewBtn">Filename Preview</button>
<button id="renameExecuteBtn">Rename Execute (confirm=true)</button> <button id="renameExecuteBtn">Rename Execute (confirm=true)</button>
<button id="filenamePreviewBtn" class="secondary">Filename Preview (Debug)</button>
</div> </div>
</div> </div>
</section> </section>
</main> </main>
<section class="panel output"> <details class="debug-box">
<h2>Output</h2> <summary>Debug Output</summary>
<pre id="outputBox"></pre> <pre id="outputBox"></pre>
</section> </details>
<div id="fileModal" class="modal hidden" aria-hidden="true">
<div class="modal-card">
<div class="row modal-head">
<h3 id="fileModalTitle">File Discovery</h3>
<button id="closeFileModalBtn" class="secondary">Close</button>
</div>
<div class="row">
<label for="modalRootSelect">Root</label>
<select id="modalRootSelect"></select>
<label for="modalFolderSelect">Folder</label>
<select id="modalFolderSelect"></select>
</div>
<div class="row">
<input id="modalSubpathInput" type="text" placeholder="Current subpath (relative)" />
<button id="modalLoadFoldersBtn" class="secondary">Load Folders</button>
<label>
<input id="modalRecursiveInput" type="checkbox" checked />
recursive
</label>
<button id="modalLoadFilesBtn">Load Files</button>
</div>
<div class="modal-grid">
<div>
<h4>Folders</h4>
<ul id="modalFoldersList" class="list"></ul>
</div>
<div>
<h4>Files</h4>
<ul id="modalFilesList" class="list"></ul>
</div>
</div>
<div class="row">
<button id="modalAddSelectedFilesBtn">Add Selected Files</button>
</div>
</div>
</div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
Binary file not shown.
+1 -2
View File
@@ -30,7 +30,7 @@ required = [
"2. Episodes", "2. Episodes",
"3. Selected Episodes", "3. Selected Episodes",
"4. Selected Files", "4. Selected Files",
"Mapping Preview", "Debug Output",
"Filename Preview", "Filename Preview",
"Rename Execute (confirm=true)", "Rename Execute (confirm=true)",
] ]
@@ -77,7 +77,6 @@ required_endpoints = [
"/api/files/roots", "/api/files/roots",
"/api/files/folders", "/api/files/folders",
"/api/files/discover", "/api/files/discover",
"/api/session/mapping-preview",
"/api/session/filename-preview", "/api/session/filename-preview",
"/api/session/rename-execute", "/api/session/rename-execute",
] ]