Files
rename-mvp/app/static/app.js
T
2026-03-08 07:41:24 +01:00

396 lines
15 KiB
JavaScript

(function () {
const STORAGE_KEY = "rename_mvp_session_id";
const sessionId = initSessionId();
const state = {
sessionId,
selectedSeries: null,
episodes: [],
roots: [],
folders: [],
discoveredFiles: [],
currentSubpath: "",
};
const el = {
sessionMeta: document.getElementById("sessionMeta"),
outputBox: document.getElementById("outputBox"),
searchInput: document.getElementById("searchInput"),
searchBtn: document.getElementById("searchBtn"),
searchResults: document.getElementById("searchResults"),
seriesInfo: document.getElementById("seriesInfo"),
refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"),
episodesList: document.getElementById("episodesList"),
refreshSelectedEpisodesBtn: document.getElementById("refreshSelectedEpisodesBtn"),
clearSelectedEpisodesBtn: document.getElementById("clearSelectedEpisodesBtn"),
selectedEpisodesList: document.getElementById("selectedEpisodesList"),
rootsSelect: document.getElementById("rootsSelect"),
foldersSelect: document.getElementById("foldersSelect"),
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"),
clearSelectedFilesBtn: document.getElementById("clearSelectedFilesBtn"),
selectedFilesList: document.getElementById("selectedFilesList"),
mappingPreviewBtn: document.getElementById("mappingPreviewBtn"),
filenamePreviewBtn: document.getElementById("filenamePreviewBtn"),
renameExecuteBtn: document.getElementById("renameExecuteBtn"),
};
function initSessionId() {
const existing = localStorage.getItem(STORAGE_KEY);
if (existing) return existing;
const created = "ui-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
localStorage.setItem(STORAGE_KEY, created);
return created;
}
function q(path) {
return path.includes("?") ? `${path}&session_id=${encodeURIComponent(state.sessionId)}` : `${path}?session_id=${encodeURIComponent(state.sessionId)}`;
}
async function api(path, options = {}) {
const resp = await fetch(path, options);
const text = await resp.text();
let data = {};
try {
data = text ? JSON.parse(text) : {};
} catch (_err) {
data = { raw: text };
}
if (!resp.ok) {
throw new Error(data.detail || data.raw || `HTTP ${resp.status}`);
}
return data;
}
function out(label, payload) {
el.outputBox.textContent = `${label}\n${JSON.stringify(payload, null, 2)}`;
}
function makeActionButton(label, handler, secondary) {
const btn = document.createElement("button");
btn.textContent = label;
if (secondary) btn.className = "secondary";
btn.addEventListener("click", handler);
return btn;
}
function setLoading(btn, loading) {
if (!btn) return;
btn.disabled = loading;
}
async function loadRoots() {
const data = await api("/api/files/roots");
state.roots = data.items || [];
el.rootsSelect.innerHTML = "";
for (const root of state.roots) {
const opt = document.createElement("option");
opt.value = root.id;
opt.textContent = `${root.id}: ${root.path}`;
el.rootsSelect.appendChild(opt);
}
if (state.roots.length > 0) {
state.currentSubpath = "";
el.subpathInput.value = "";
await loadFolders();
}
out("Roots loaded", data);
}
async function doSearch() {
const query = (el.searchInput.value || "").trim();
if (!query) return;
const data = await api(`/api/tvdb/search?q=${encodeURIComponent(query)}`);
el.searchResults.innerHTML = "";
(data.items || []).forEach((item) => {
const li = document.createElement("li");
const left = document.createElement("span");
left.textContent = item.display_name || item.name || "(no name)";
const right = document.createElement("div");
const btn = makeActionButton("Select", async () => {
state.selectedSeries = item;
el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`;
await loadEpisodes();
});
right.appendChild(btn);
li.appendChild(left);
li.appendChild(right);
el.searchResults.appendChild(li);
});
out("Search result", data);
}
async function loadEpisodes() {
if (!state.selectedSeries || !state.selectedSeries.id) {
throw new Error("Select a series first");
}
const data = await api(`/api/tvdb/series/${encodeURIComponent(state.selectedSeries.id)}/episodes?order_type=aired`);
state.episodes = data.items || [];
el.episodesList.innerHTML = "";
state.episodes.forEach((episode) => {
const li = document.createElement("li");
const left = document.createElement("span");
left.textContent = episode.label || `${episode.season_number}x${episode.episode_number} ${episode.title}`;
const right = document.createElement("div");
const btn = makeActionButton("Add", async () => {
const payload = {
id: episode.id,
series: state.selectedSeries.name,
year: state.selectedSeries.year,
season_number: episode.season_number,
episode_number: episode.episode_number,
title: episode.title,
aired: episode.aired,
label: episode.label,
};
const res = await api(q("/api/session/selected-episodes"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: [payload] }),
});
await loadSelectedEpisodes();
out("Episode added", res);
});
right.appendChild(btn);
li.appendChild(left);
li.appendChild(right);
el.episodesList.appendChild(li);
});
out("Episodes loaded", data);
}
async function loadSelectedEpisodes() {
const data = await api(q("/api/session/selected-episodes"));
el.selectedEpisodesList.innerHTML = "";
(data.items || []).forEach((item, idx) => {
const li = document.createElement("li");
const left = document.createElement("span");
left.textContent = item.episode.label || item.episode.title || `Episode #${idx + 1}`;
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;
}
async function loadFolders() {
const rootId = el.rootsSelect.value;
if (!rootId) throw new Error("No root selected");
const subpath = (el.subpathInput.value || "").trim();
state.currentSubpath = subpath;
const data = await api(
`/api/files/folders?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(subpath)}&limit=500`
);
state.folders = data.items || [];
el.foldersSelect.innerHTML = "";
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = state.folders.length ? "Choose folder..." : "(No folders)";
el.foldersSelect.appendChild(placeholder);
state.folders.forEach((folder) => {
const opt = document.createElement("option");
opt.value = folder.subpath;
opt.textContent = folder.subpath || folder.name;
el.foldersSelect.appendChild(opt);
});
out("Folders loaded", data);
}
async function discoverFiles() {
const rootId = el.rootsSelect.value;
if (!rootId) throw new Error("No root selected");
const selectedFolder = (el.foldersSelect.value || "").trim();
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) => {
const li = document.createElement("li");
const left = document.createElement("span");
left.textContent = file.relative_path || file.name;
const right = document.createElement("div");
right.appendChild(makeActionButton("Add", async () => {
const payload = { path: file.path, name: file.name };
const res = await api(q("/api/session/selected-files"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: [payload] }),
});
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));
li.appendChild(left);
li.appendChild(right);
el.selectedFilesList.appendChild(li);
});
return data;
}
async function callPreview(path, label) {
const data = await api(q(path));
out(label, data);
}
async function executeRename() {
const data = await api(q("/api/session/rename-execute") + "&confirm=true", {
method: "POST",
});
if (data.executed) {
out("Rename execute: success", data);
// Keep UI in sync with renamed destinations using existing selected-files endpoints.
const renamedFiles = (data.items || [])
.filter((item) => item.status === "renamed")
.map((item) => ({
path: item.destination_path,
name: item.proposed_filename,
}));
await api(q("/api/session/selected-files"), { method: "DELETE" });
if (renamedFiles.length > 0) {
await api(q("/api/session/selected-files"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: renamedFiles }),
});
}
await loadSelectedFiles();
return;
}
out("Rename execute: preflight failed", data);
}
async function withHandler(fn, btn) {
try {
setLoading(btn, true);
await fn();
} catch (err) {
out("Error", { detail: err.message || String(err) });
} finally {
setLoading(btn, false);
}
}
function bindEvents() {
el.searchBtn.addEventListener("click", () => withHandler(doSearch, el.searchBtn));
el.refreshEpisodesBtn.addEventListener("click", () => withHandler(loadEpisodes, el.refreshEpisodesBtn));
el.refreshSelectedEpisodesBtn.addEventListener("click", () => withHandler(loadSelectedEpisodes, el.refreshSelectedEpisodesBtn));
el.clearSelectedEpisodesBtn.addEventListener("click", () =>
withHandler(async () => {
const res = await api(q("/api/session/selected-episodes"), { method: "DELETE" });
await loadSelectedEpisodes();
out("Selected episodes cleared", res);
}, el.clearSelectedEpisodesBtn)
);
el.refreshRootsBtn.addEventListener("click", () => withHandler(loadRoots, el.refreshRootsBtn));
el.rootsSelect.addEventListener("change", () => withHandler(async () => {
state.currentSubpath = "";
el.subpathInput.value = "";
await loadFolders();
el.discoveredFilesList.innerHTML = "";
}, el.rootsSelect));
el.loadFoldersBtn.addEventListener("click", () => withHandler(loadFolders, el.loadFoldersBtn));
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.clearSelectedFilesBtn.addEventListener("click", () =>
withHandler(async () => {
const res = await api(q("/api/session/selected-files"), { method: "DELETE" });
await loadSelectedFiles();
out("Selected files cleared", res);
}, el.clearSelectedFilesBtn)
);
el.mappingPreviewBtn.addEventListener("click", () => withHandler(() => callPreview("/api/session/mapping-preview", "Mapping preview"), el.mappingPreviewBtn));
el.filenamePreviewBtn.addEventListener("click", () => withHandler(() => callPreview("/api/session/filename-preview", "Filename preview"), el.filenamePreviewBtn));
el.renameExecuteBtn.addEventListener("click", () => withHandler(executeRename, el.renameExecuteBtn));
}
async function init() {
el.sessionMeta.textContent = `session_id: ${state.sessionId}`;
el.recursiveInput.checked = true;
bindEvents();
await loadRoots();
await loadSelectedEpisodes();
await loadSelectedFiles();
}
init().catch((err) => out("Init error", { detail: err.message || String(err) }));
})();