docs: shorten the readme and trim code comments
This commit is contained in:
+32
-50
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user