docs: shorten the readme and trim code comments

This commit is contained in:
Steppenstreuner
2026-08-29 16:18:37 +02:00
parent 8a05d2d3c3
commit 551352e838
16 changed files with 132 additions and 229 deletions
+32 -50
View File
@@ -20,7 +20,7 @@ async function api(path, opts = {}) {
return resp.json();
}
// create a request; on a duplicate warning (409) ask the user before retrying
// on a duplicate warning (409) ask before retrying
async function createRequest(body) {
try {
return await api("/api/requests", { method: "POST", body: JSON.stringify(body) });
@@ -48,7 +48,7 @@ function esc(s) {
return d.innerHTML;
}
// last used library per media type (per-browser convenience)
// last used library per media type
function getLastLib(type) {
try { return localStorage.getItem("wordarr.lastLib." + type); } catch { return null; }
}
@@ -278,7 +278,7 @@ function renderSeriesList() {
let previous = null;
$("#series-list").innerHTML = seriesEpisodes
.map((e, i) => {
// make holes in the numbering visible - the crawl cannot reach every episode
// the crawl cannot reach every episode, show the holes
let gap = "";
if (e.volume != null && previous != null && e.volume > previous + 1) {
const missing = e.volume - previous - 1;
@@ -598,8 +598,7 @@ async function saveDetail(form) {
});
}
// after correcting series/title/author the files are still filed under the old
// name - this re-applies the library's scheme to what is already on disk
// re-applies the library's scheme to files already on disk
$("#detail-relocate").addEventListener("click", async () => {
if (!detailRequest) return;
const btn = $("#detail-relocate");
@@ -669,13 +668,13 @@ $("#scan-btn").addEventListener("click", async () => {
]);
clearTimeout(cleanupTimer);
scanItems = scan.items;
// selection state lives here, not in the DOM, so it survives paging/filtering
// selection lives here, not in the DOM, so it survives paging
scanItems.forEach((item) => {
item.request_id = item.suggested_request_id;
item.checked = !!item.suggested_request_id;
});
orphanedIds = new Set(scan.orphaned_request_ids || []);
// an imported title whose files vanished is selectable like an open one
// an imported title whose files vanished counts as open again
missingReqs = [...reqs, ...done.filter((r) => orphanedIds.has(r.id))];
importedReqs = done.filter((r) => !orphanedIds.has(r.id));
importPage = 0;
@@ -743,8 +742,7 @@ function renderImportTable() {
.filter((r) => r.media_type === item.media_type)
.map((r) => option(r, orphanedIds.has(r.id) ? " ↺" : ""))
.join("");
// already imported audiobooks can take further parts (a late CD, or one
// that failed while its siblings went through)
// imported audiobooks can take further parts (a late CD)
const appendOpts = importedReqs
.filter((r) => r.media_type === item.media_type && r.imported_path)
.map((r) => option(r, " ↩︎"))
@@ -836,8 +834,7 @@ function renderImportTable() {
updateImportPageInfo(pages);
}
// which library the assigned request writes to - visible in the row, because the
// select inside the search dialog is easy to miss
// target library of the assigned request, easy to miss inside the dialog
function libraryCell(item, i) {
const req = requestById(item.request_id);
if (!req) return '<span class="muted">—</span>';
@@ -899,8 +896,7 @@ $("#import-select-all").addEventListener("click", () => {
selectIndices(viewIdx, "insgesamt");
});
// Entries without a suggestion are selected too - they are exactly the ones
// "Anfragen aus Ordnernamen" works on.
// entries without a suggestion are included, "Anfragen aus Ordnernamen" needs them
function selectIndices(indices, what) {
scanItems.forEach((it) => (it.checked = false)); // "take what I see", not "add"
indices.forEach((i) => {
@@ -924,8 +920,7 @@ $("#import-deselect-all").addEventListener("click", () => {
});
// ---- create requests straight from folder names ----
// For series that Audible only knows in part (Lady Bedfort: 34 of 117) the
// folder names are the better source - they carry number and title already.
// for series Audible only knows in part, the folder names carry number and title
let namesTargets = [];
function nameToRequest(item) {
@@ -982,8 +977,7 @@ $("#names-submit").addEventListener("click", async () => {
method: "POST",
body: JSON.stringify({ library_id: parseInt(libId), items }),
});
// reload so that entries skipped as duplicates can be linked to the request
// that already existed
// reload so duplicates can be linked to the existing request
missingReqs = await api("/api/requests?status=missing");
const key = (t, v) => `${(t || "").trim().toLowerCase()}|${v ?? ""}`;
const byKey = new Map(missingReqs.map((r) => [key(r.title, r.volume), r]));
@@ -1014,7 +1008,7 @@ $("#names-submit").addEventListener("click", async () => {
});
// ---- multi-part episodes: import several entries as one audiobook ----
// e.g. "100 - Toteninsel Teil 1/2/3", which Audible lists as a single title
// e.g. "100 - Toteninsel Teil 1/2/3", one title for Audible
function commonPrefix(names) {
let prefix = names[0];
for (const n of names.slice(1)) {
@@ -1022,7 +1016,7 @@ function commonPrefix(names) {
while (i < prefix.length && i < n.length && prefix[i] === n[i]) i++;
prefix = prefix.slice(0, i);
}
// drop a dangling part marker: "100 - Toteninsel Teil " -> "100 - Toteninsel"
// drop a dangling part marker
return prefix.replace(/[\s._\-–—:]*(?:teil|part|cd|disc|folge)?[\s._\-–—:]*$/i, "").trim();
}
@@ -1041,8 +1035,7 @@ $("#import-merge").addEventListener("click", () => {
toast("Die Einträge liegen in verschiedenen Ordnern", true);
return;
}
// parts may share no prefix at all ("A - Sphinx", "B - Volk"): then the
// folder holding them carries the real title ("100 - Toteninsel")
// "A - Sphinx", "B - Volk" share no prefix, then the folder carries the title
const parentName = (chosen[0].rel_dir || "").split("/").filter(Boolean).pop();
const name =
commonPrefix(chosen.map((it) => it.name)) || parentName || chosen[0].name;
@@ -1071,8 +1064,7 @@ function splitItem(i) {
if (item.parts) { // undo a merge
scanItems.splice(i, 1, ...item.parts);
} else if (item.is_dir && item.files.length > 1) {
// a folder holding one book per file (a series bought as single titles):
// turn every file into its own entry so each can get its own request
// one book per file: every file becomes its own entry
const parts = item.files.map((f) => ({
path: f,
name: f.split("/").pop().replace(/\.[^.]+$/, ""),
@@ -1101,8 +1093,7 @@ let quickAutoSearch = false; // the search fired on open, not by the user
let lastSeries = "";
// "017 - Titel", "Folge 17: Titel", "[003] Titel" -> episode number + rest.
// A bare number without a separator ("100 Stunden") or without a leading zero is
// left alone: there it is part of the title, not a numbering.
// A bare number without separator or leading zero stays part of the title.
const EPISODE_PATTERNS = [
/^\s*(?:folge|teil|band|nr\.?)\s*(\d{1,4})\s*(?:[-–—._:]+\s*|\s+)/i, // "Folge 124: Titel"
/^\s*[\[(](\d{1,4})[\])]\s*(?:[-–—._:]+\s*|\s+)/, // "[003] Titel"
@@ -1132,8 +1123,7 @@ function cleanFileName(name) {
function openQuickDialog(i) {
quickItemIndex = i;
const item = scanItems[i];
// the episode number wrecks the Audible keyword search, so it is kept out of
// the query and used as the volume instead
// the number wrecks the Audible search, so it becomes the volume instead
const { number, rest } = splitEpisodeNumber(item.name);
quickEpisode = number;
$("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name;
@@ -1157,9 +1147,8 @@ function openQuickDialog(i) {
runQuickSearch();
}
// offer linking to an already-open request instead of creating a duplicate.
// Without a filter these are the best guesses from the file name; typing turns
// the box into a search over every open request, because the guess can be wrong.
// link to an open request instead of creating a duplicate: guesses from the
// file name, or a search over all open requests once something is typed
const QUICK_OPEN_LIMIT = 20;
function renderQuickOpenRequests(item) {
@@ -1216,8 +1205,7 @@ async function runQuickSearch() {
const box = $("#quick-results");
box.innerHTML = "<p class='muted'>Suche läuft…</p>";
try {
// the target library decides the language: an "english" library should not
// offer the German edition of the same book
// an "english" library should not offer the German edition
const lib = libraries.find((l) => String(l.id) === $("#quick-library").value);
const results = await api(
`/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}` +
@@ -1228,7 +1216,7 @@ async function runQuickSearch() {
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
return;
}
// one compact row per hit: cover, title + a single meta line, action right
// one compact row per hit
box.innerHTML = results
.map((r, j) => {
const meta = [
@@ -1272,7 +1260,7 @@ $("#quick-search-form").addEventListener("submit", (e) => {
});
$("#quick-close").addEventListener("click", () => $("#quick-dialog").close());
// create the request and wire it to the scanned entry the dialog was opened for
// create the request and wire it to the scanned entry
async function requestAndConnect(body) {
const libId = $("#quick-library").value;
if (!libId) { toast("Erst eine Library für diesen Typ anlegen", true); return; }
@@ -1298,9 +1286,8 @@ function pickQuickResult(r) {
});
}
// take a hit into the fields so title/author/series can be corrected before the
// request is created - Audible files Harry Potter under "Wizarding World", and
// the series ends up in the folder name
// take a hit into the fields so it can be corrected first - Audible files
// Harry Potter under "Wizarding World", which would end up in the folder name
function editBeforeRequest(r) {
quickPicked = r;
$("#quick-m-title").value = r.title || "";
@@ -1334,13 +1321,11 @@ $("#quick-m-submit").addEventListener("click", () => {
// ---- import execution (batched, with progress) ----
const IMPORT_BATCH_SIZE = 20;
// finished rows disappear after a while: the result list below keeps the record,
// and a shrinking table makes the remaining work obvious
// finished rows disappear after a while, the result list keeps the record
const IMPORT_DONE_CLEANUP_MS = 15000;
let cleanupTimer = null;
// several entries on one request means one audiobook split across folders:
// importing them one by one only imports the first and fails the rest
// several entries on one request: one audiobook split across folders
function groupByRequest(chosen) {
const byRequest = new Map();
for (const item of chosen) {
@@ -1364,8 +1349,7 @@ function buildImportGroups(groups) {
}));
}
// Several entries on one request is either a multi-part title (merge them) or a
// mismatch (skip them). The user decides once, then keeps the rest importable.
// either a multi-part title (merge) or a mismatch (skip), decided once
function askAboutSharedRequests(shared) {
const dlg = $("#conflict-dialog");
$("#conflict-intro").textContent =
@@ -1402,7 +1386,7 @@ async function collapseSharedRequests(chosen) {
if (answer === "cancel") return null;
if (answer === "merge") return buildImportGroups(groups);
// skip: mark the clashing entries, deselect them, import everything else
// mark and deselect the clashing entries, import everything else
const skipped = shared.flat();
skipped.forEach((it) => {
it.conflict = true;
@@ -1443,11 +1427,10 @@ $("#import-btn").addEventListener("click", async () => {
setImportProgress(0, total);
try {
// one request per entry: the row can only turn green once its own move came
// back, and a batch would only report after all of its items finished
// one request per entry, otherwise a row cannot turn green on its own
for (const { item, parts } of groups) {
parts.forEach((p) => (p.importState = "running"));
// follow the running entry across pages, otherwise only the bar moves
// follow the running entry across pages
applyImportView();
const pos = viewIdx.indexOf(scanItems.indexOf(parts[0]));
if (pos >= 0) importPage = Math.floor(pos / IMPORT_PAGE_SIZE);
@@ -1493,7 +1476,7 @@ $("#import-btn").addEventListener("click", async () => {
refreshMissingBadge();
clearTimeout(cleanupTimer);
cleanupTimer = setTimeout(() => {
// only the successful ones go; failures and conflicts stay to be dealt with
// failures and conflicts stay
const before = scanItems.length;
scanItems = scanItems.filter((it) => it.importState !== "done");
if (scanItems.length !== before) {
@@ -1504,8 +1487,7 @@ $("#import-btn").addEventListener("click", async () => {
`${scanItems.length} Kandidat(en) übrig — für den aktuellen Stand erneut scannen`;
}
}, IMPORT_DONE_CLEANUP_MS);
// the list stays as it is, with its green and red rows - rescanning would
// wipe exactly the feedback the user just waited for
// the list stays: rescanning would wipe the feedback
} finally {
btn.disabled = false;
btn.textContent = "Ausgewählte importieren";
+4 -7
View File
@@ -57,8 +57,7 @@ button:disabled { opacity: 0.6; cursor: default; }
.tag.warn { background: #8a6d1f; }
/* per-entry import feedback */
/* :not([hidden]) matters: a plain "display: flex" would beat the hidden
attribute and leave an empty bar sitting there (same trap as a <dialog>) */
/* :not([hidden]): a plain display rule would beat the hidden attribute */
#import-progress:not([hidden]) { display: flex; align-items: center; gap: 0.6rem; flex: 1; }
#import-progress progress { flex: 1; max-width: 26rem; height: 0.7rem; }
tr.row-running { background: rgba(90, 140, 220, 0.12); }
@@ -122,10 +121,8 @@ dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5re
.series-list input[type=checkbox] { flex: none; }
.series-list .num { flex: none; min-width: 3.2em; color: var(--muted); font-variant-numeric: tabular-nums; }
.series-list .gap { padding: 0.35rem 0.6rem; color: var(--muted); font-size: 0.85rem; }
/* the dialog keeps head and foot in place; only the results scroll, so the
"manuell anlegen" panel stays reachable no matter how many hits came back */
/* [open] matters: a bare "#quick-dialog { display: flex }" would override the
browser's display:none for a closed dialog and leave it stuck on screen */
/* head and foot stay in place, only the results scroll */
/* [open]: a bare display rule would leave the closed dialog on screen */
#quick-dialog[open] { display: flex; flex-direction: column; }
#quick-dialog { max-height: 85vh; width: min(680px, 92vw); }
#quick-scroll { flex: 1 1 auto; min-height: 4rem; overflow-y: auto; margin-top: 0.6rem; }
@@ -140,7 +137,7 @@ dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5re
#quick-search-form { gap: 0.5rem; }
#quick-q { flex: 1 1 auto; min-width: 0; }
#quick-file { word-break: break-word; }
/* one row per hit: cover | title + meta line | action */
/* one row per hit */
#quick-results .card { align-items: center; gap: 0.7rem; padding: 0.45rem 0.6rem; }
#quick-results .card-body { flex: 1 1 auto; gap: 0.1rem; }
#quick-results .card-body strong { line-height: 1.25; }