Nichts hier.
";
+ box.innerHTML = `
${r.cover_url ? `
})
` : '
?
'}
-
${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}
+
${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}
${esc(r.authors)}
${r.narrator ? `
🎙 ${esc(r.narrator)}` : ""}
${esc(r.media_type)} → ${esc(r.library_name)}
@@ -522,7 +551,7 @@ function renderRequests(status) {
const updateBulkBtn = () => {
const n = box.querySelectorAll("[data-sel-req]:checked").length;
bulkBtn.hidden = n === 0;
- bulkBtn.textContent = `Ausgewählte entfernen (${n})`;
+ bulkBtn.textContent = t("common.removeSelectedN", { n });
};
box.querySelectorAll("[data-sel-req]").forEach((c) =>
c.addEventListener("change", updateBulkBtn)
@@ -534,15 +563,14 @@ async function bulkDeleteRequests(status) {
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
const ids = [...box.querySelectorAll("[data-sel-req]:checked")].map((c) => c.dataset.selReq);
if (!ids.length) return;
- const hint = status === "imported"
- ? `${ids.length} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.`
- : `${ids.length} Anfrage(n) entfernen?`;
+ const hint = t(status === "imported" ? "list.confirmDeleteImported" : "list.confirmDeleteRequests",
+ { n: ids.length });
if (!confirm(hint)) return;
for (const id of ids) {
try { await api("/api/requests/" + id, { method: "DELETE" }); }
catch (err) { toast(err.message, true); }
}
- toast(`${ids.length} entfernt`);
+ toast(t("list.deletedN", { n: ids.length }));
loadRequests(status);
}
@@ -586,7 +614,7 @@ function openDetail(id, status) {
.join("");
$("#detail-title").textContent = r.title;
$("#detail-status").textContent =
- `${r.media_type} · ${r.status === "missing" ? "fehlt noch" : "importiert"}`;
+ `${r.media_type} · ${t(r.status === "missing" ? "detail.statusMissing" : "detail.statusImported")}`;
$("#detail-path").textContent = r.imported_path || "";
const img = $("#detail-cover");
img.hidden = !r.cover_url;
@@ -619,17 +647,17 @@ $("#detail-relocate").addEventListener("click", async () => {
if (!detailRequest) return;
const btn = $("#detail-relocate");
btn.disabled = true;
- btn.innerHTML = '
Lege ab…';
+ btn.innerHTML = `
${t("detail.relocating")}`;
try {
await saveDetail($("#detail-form"));
const res = await api(`/api/requests/${detailRequest.id}/relocate`, { method: "POST" });
- toast(res.moved ? `Neu abgelegt: ${res.dest}` : "Liegt bereits richtig");
+ toast(res.moved ? t("detail.relocated", { dest: res.dest }) : t("detail.alreadyPlaced"));
$("#detail-dialog").close();
loadRequests(detailRequest.status);
} catch (err) { toast(err.message, true); }
finally {
btn.disabled = false;
- btn.textContent = "Speichern & neu ablegen";
+ btn.textContent = t("detail.relocate");
}
});
@@ -637,17 +665,17 @@ $("#detail-retag").addEventListener("click", async () => {
if (!detailRequest) return;
const btn = $("#detail-retag");
btn.disabled = true;
- btn.innerHTML = '
Tagge…';
+ btn.innerHTML = `
${t("detail.retagging")}`;
try {
await saveDetail($("#detail-form"));
const res = await api(`/api/requests/${detailRequest.id}/retag`, { method: "POST" });
- toast(`${res.files} Datei(en) neu getaggt`);
+ toast(t("detail.retagged", { n: res.files }));
$("#detail-dialog").close();
loadRequests(detailRequest.status);
} catch (err) { toast(err.message, true); }
finally {
btn.disabled = false;
- btn.textContent = "Speichern & neu taggen";
+ btn.textContent = t("detail.retag");
}
});
@@ -656,7 +684,7 @@ $("#detail-form").addEventListener("submit", async (e) => {
if (!detailRequest) return;
try {
await saveDetail(e.target);
- toast("Anfrage gespeichert");
+ toast(t("detail.saved"));
loadRequests(detailRequest.status);
} catch (err) { toast(err.message, true); }
});
@@ -674,7 +702,7 @@ const IMPORT_PAGE_SIZE = 25;
$("#scan-btn").addEventListener("click", async () => {
const scanBtn = $("#scan-btn");
scanBtn.disabled = true;
- $("#scan-info").innerHTML = '
Scanne…';
+ $("#scan-info").innerHTML = `
${t("import.scanning")}`;
try {
const split = $("#split-dirs").checked;
const [scan, reqs, done] = await Promise.all([
@@ -694,7 +722,8 @@ $("#scan-btn").addEventListener("click", async () => {
missingReqs = [...reqs, ...done.filter((r) => orphanedIds.has(r.id))];
importedReqs = done.filter((r) => !orphanedIds.has(r.id));
importPage = 0;
- $("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`;
+ $("#scan-info").textContent =
+ t("import.candidates", { n: scan.items.length, dir: scan.download_dir });
$("#import-toolbar").hidden = scanItems.length === 0;
formatOptions();
renderImportTable();
@@ -737,7 +766,7 @@ function humanSize(bytes) {
const units = ["B", "KB", "MB", "GB"];
let n = bytes, u = 0;
while (n >= 1024 && u < units.length - 1) { n /= 1024; u++; }
- return `${n.toFixed(n < 10 && u > 0 ? 1 : 0).replace(".", ",")} ${units[u]}`;
+ return `${n.toFixed(n < 10 && u > 0 ? 1 : 0).replace(".", decimalMark())} ${units[u]}`;
}
// a Calibre export ships one book in a dozen formats, so the row has to say
@@ -753,7 +782,7 @@ function formatOptions() {
const all = [...new Set(scanItems.flatMap((it) => it.formats || []))].sort();
const current = $("#import-filter-format").value;
$("#import-filter-format").innerHTML =
- '
' +
+ `
` +
all.map((f) => `
`).join("");
}
@@ -802,25 +831,25 @@ function renderImportTable() {
${esc(item.name)}${item.is_dir ? " 📁" : ""}
${formatTag(item)}
- ${item.parts ? `${item.parts.length} Teile · ${item.files.length} Dateien` : ""}
+ ${item.parts ? `${t("import.parts", { parts: item.parts.length, files: item.files.length })}` : ""}
${!item.parts && item.maybe_separate
- ? `${item.files.length} Titel?`
+ ? `${t("import.maybeSeparate", { n: item.files.length })}`
: ""}
${item.rel_dir ? ` ${esc(item.rel_dir)}/ ` : ""}
|
${esc(item.media_type)} |
- |
${libraryCell(item, i)} |
${item.importState ? importStateCell(item)
- : item.conflict ? '
⚠ Konflikt | '
+ : item.conflict ? `
${t("import.conflict")} | `
: scoreCell(item)}
`;
})
@@ -828,7 +857,9 @@ function renderImportTable() {
table.hidden = false;
$("#import-btn").hidden = false;
const showsState = scanItems.some((it) => it.importState);
- table.querySelector("thead th:last-child").textContent = showsState ? "Status" : "Score";
+ const head = $("#import-score-head");
+ head.dataset.i18n = showsState ? "common.status" : "common.score";
+ head.textContent = t(head.dataset.i18n);
tbody.querySelectorAll("[data-quick]").forEach((b) =>
b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick)))
@@ -850,7 +881,7 @@ function renderImportTable() {
}),
});
Object.assign(req, saved);
- toast(`„${req.title}" → ${saved.library_name}`);
+ toast(t("import.movedToLibrary", { title: req.title, library: saved.library_name }));
} catch (err) {
req.library_id = previous;
sel.value = String(previous);
@@ -893,7 +924,7 @@ function libraryCell(item, i) {
.filter((l) => l.media_type === req.media_type)
.map((l) => `
`)
.join("");
- return `
${opts}`;
+ return `
${opts}`;
}
function requestById(id) {
@@ -903,18 +934,19 @@ function requestById(id) {
function importStateCell(item) {
if (item.importState === "running") {
- return '
läuft… | ';
+ return `
${t("import.running")} | `;
}
- if (item.importState === "done") return '
✓ importiert | ';
- return `
✗ Fehler | `;
+ if (item.importState === "done") return `
${t("import.done")} | `;
+ return `
${t("import.failed")} | `;
}
function updateImportPageInfo(pages) {
pages = pages ?? Math.max(1, Math.ceil(viewIdx.length / IMPORT_PAGE_SIZE));
const selected = scanItems.filter((it) => it.checked && it.request_id).length;
- const filtered = viewIdx.length !== scanItems.length ? ` (gefiltert: ${viewIdx.length})` : "";
- $("#import-page-info").textContent =
- `Seite ${importPage + 1}/${pages} · ${scanItems.length} Einträge${filtered} · ${selected} ausgewählt`;
+ const filtered = viewIdx.length !== scanItems.length ? t("import.filtered", { n: viewIdx.length }) : "";
+ $("#import-page-info").textContent = t("import.pageInfo", {
+ page: importPage + 1, pages, total: scanItems.length, filtered, selected,
+ });
}
$("#import-prev").addEventListener("click", () => { importPage--; renderImportTable(); });
@@ -940,15 +972,15 @@ $("#import-select-page").addEventListener("click", () => {
applyImportView();
const start = importPage * IMPORT_PAGE_SIZE;
const pageIdx = viewIdx.slice(start, start + IMPORT_PAGE_SIZE);
- selectIndices(pageIdx, "dieser Seite");
+ selectIndices(pageIdx, t("import.thisPage"));
});
$("#import-select-all").addEventListener("click", () => {
applyImportView();
- selectIndices(viewIdx, "insgesamt");
+ selectIndices(viewIdx, t("import.inTotal"));
});
-// entries without a suggestion are included, "Anfragen aus Ordnernamen" needs them
+// entries without a suggestion are included, "requests from folder names" needs them
function selectIndices(indices, what) {
scanItems.forEach((it) => (it.checked = false)); // "take what I see", not "add"
indices.forEach((i) => {
@@ -960,8 +992,10 @@ function selectIndices(indices, what) {
renderImportTable();
const withReq = indices.filter((i) => scanItems[i].request_id).length;
toast(
- `${indices.length} Einträge ${what} ausgewählt` +
- (withReq < indices.length ? `, davon ${indices.length - withReq} ohne Zuordnung` : "")
+ t("import.selectedN", { n: indices.length, what }) +
+ (withReq < indices.length
+ ? t("import.selectedWithout", { n: indices.length - withReq })
+ : "")
);
}
@@ -983,17 +1017,15 @@ function nameToRequest(item) {
$("#import-from-names").addEventListener("click", () => {
namesTargets = scanItems.filter((it) => it.checked && !it.request_id);
if (!namesTargets.length) {
- toast("Keine ausgewählten Einträge ohne Zuordnung", true);
+ toast(t("names.noneWithout"), true);
return;
}
const type = namesTargets[0].media_type;
namesTargets = namesTargets.filter((it) => it.media_type === type);
- $("#names-intro").textContent =
- `${namesTargets.length} Einträge ohne Zuordnung (${type}). Titel und Folgennummer ` +
- "kommen aus dem Ordnernamen; Serie und Autor gelten für alle.";
+ $("#names-intro").textContent = t("names.intro", { n: namesTargets.length, type });
$("#names-series").value = lastSeries;
$("#names-authors").value = "";
- $("#names-library").innerHTML = libOptions(type) || "
";
+ $("#names-library").innerHTML = libOptions(type) || noLibraryOption();
renderNamesPreview();
$("#names-dialog").showModal();
});
@@ -1008,7 +1040,7 @@ function renderNamesPreview() {
})
.join("") +
(namesTargets.length > shown.length
- ? `
… und ${namesTargets.length - shown.length} weitere
`
+ ? `
${esc(t("names.more", { n: namesTargets.length - shown.length }))}
`
: "");
}
@@ -1017,12 +1049,12 @@ $("#names-cancel").addEventListener("click", () => $("#names-dialog").close());
$("#names-submit").addEventListener("click", async () => {
const libId = $("#names-library").value;
- if (!libId) { toast("Erst eine Library für diesen Typ anlegen", true); return; }
+ if (!libId) { toast(t("common.needLibraryFirst"), true); return; }
const series = (lastSeries = $("#names-series").value.trim());
const authors = $("#names-authors").value.trim();
const btn = $("#names-submit");
btn.disabled = true;
- btn.innerHTML = '
Lege an…';
+ btn.innerHTML = `
${t("common.creating")}`;
try {
const items = namesTargets.map((it) => ({ ...nameToRequest(it), series, authors }));
const res = await api("/api/requests/bulk-items", {
@@ -1046,16 +1078,16 @@ $("#names-submit").addEventListener("click", async () => {
$("#names-dialog").close();
renderImportTable();
refreshMissingBadge();
- toast(
- `${res.created.length} Anfragen angelegt` +
- (res.skipped ? `, ${res.skipped} gab es schon` : "") +
- `, ${linked} Einträge verbunden`
- );
+ toast(t("names.result", {
+ created: res.created.length,
+ skipped: res.skipped ? t("names.existed", { n: res.skipped }) : "",
+ linked,
+ }));
} catch (err) {
toast(err.message, true);
} finally {
btn.disabled = false;
- btn.textContent = "Anlegen & verbinden";
+ btn.textContent = t("common.createAndLink");
}
});
@@ -1075,16 +1107,16 @@ function commonPrefix(names) {
$("#import-merge").addEventListener("click", () => {
const chosen = scanItems.filter((it) => it.checked);
if (chosen.length < 2) {
- toast("Mindestens zwei Einträge auswählen", true);
+ toast(t("import.needTwo"), true);
return;
}
if (new Set(chosen.map((it) => it.media_type)).size > 1) {
- toast("Nur Einträge desselben Typs lassen sich zusammenfassen", true);
+ toast(t("import.sameTypeOnly"), true);
return;
}
const parents = new Set(chosen.map((it) => it.rel_dir));
if (parents.size > 1) {
- toast("Die Einträge liegen in verschiedenen Ordnern", true);
+ toast(t("import.differentFolders"), true);
return;
}
// "A - Sphinx", "B - Volk" share no prefix, then the folder carries the title
@@ -1110,7 +1142,7 @@ $("#import-merge").addEventListener("click", () => {
const first = scanItems.indexOf(chosen[0]);
scanItems = scanItems.filter((it) => !chosen.includes(it));
scanItems.splice(first, 0, merged);
- toast(`${chosen.length} Einträge als „${name}" zusammengefasst (${merged.files.length} Dateien)`);
+ toast(t("import.mergedN", { n: chosen.length, name, files: merged.files.length }));
renderImportTable();
});
@@ -1134,7 +1166,7 @@ function splitItem(i) {
score: null,
}));
scanItems.splice(i, 1, ...parts);
- toast(`„${item.name}" in ${parts.length} Einzeltitel aufgeteilt`);
+ toast(t("import.splitN", { name: item.name, n: parts.length }));
} else {
return;
}
@@ -1183,7 +1215,7 @@ function openQuickDialog(i) {
const { number, rest } = splitEpisodeNumber(item.name);
quickEpisode = number;
$("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name;
- $("#quick-episode").textContent = number != null ? `Folge ${number} — nicht mitgesucht` : "";
+ $("#quick-episode").textContent = number != null ? t("quick.episodeNote", { n: number }) : "";
$("#quick-q").value = cleanFileName(rest);
quickPicked = null;
$("#quick-req-filter").value = "";
@@ -1192,10 +1224,9 @@ function openQuickDialog(i) {
$("#quick-m-volume").value = number ?? "";
$("#quick-m-series").value = lastSeries;
$("#quick-based-on").hidden = true;
- $("#quick-manual-summary").textContent = "Nicht bei Audible? Manuell anlegen";
+ $("#quick-manual-summary").textContent = t("quick.manualSummary");
$("#quick-manual").open = false;
- $("#quick-library").innerHTML =
- libOptions(item.media_type) || "
";
+ $("#quick-library").innerHTML = libOptions(item.media_type) || noLibraryOption();
$("#quick-results").innerHTML = "";
renderQuickOpenRequests(item);
$("#quick-dialog").showModal();
@@ -1219,8 +1250,9 @@ function renderQuickOpenRequests(item) {
const terms = query.split(/\s+/);
matches = open.filter((r) => terms.every((t) => hay(r).includes(t)));
heading = matches.length
- ? `${Math.min(matches.length, QUICK_OPEN_LIMIT)} von ${matches.length} Treffern (${open.length} offen)`
- : `Kein Treffer unter ${open.length} offenen Anfragen`;
+ ? t("quick.hits", { shown: Math.min(matches.length, QUICK_OPEN_LIMIT),
+ total: matches.length, open: open.length })
+ : t("quick.noHits", { open: open.length });
matches = matches.slice(0, QUICK_OPEN_LIMIT);
} else {
const tokens = cleanFileName(item.name).toLowerCase().split(" ").filter((t) => t.length > 2);
@@ -1230,7 +1262,7 @@ function renderQuickOpenRequests(item) {
.sort((a, b) => b.hits - a.hits)
.slice(0, 3)
.map((m) => m.r);
- heading = matches.length ? "Passende offene Requests:" : "";
+ heading = matches.length ? t("quick.matchingOpen") : "";
}
box.innerHTML =
@@ -1238,8 +1270,8 @@ function renderQuickOpenRequests(item) {
matches
.map(
(r) => `
- ${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""} — ${esc(r.authors)} (${esc(r.library_name)})
-
+ ${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""} — ${esc(r.authors)} (${esc(r.library_name)})
+
`
)
.join("");
@@ -1251,7 +1283,7 @@ function renderQuickOpenRequests(item) {
scanItems[i].checked = true;
renderImportTable();
$("#quick-dialog").close();
- toast("Mit bestehendem Request verbunden");
+ toast(t("quick.linked"));
})
);
}
@@ -1259,7 +1291,7 @@ function renderQuickOpenRequests(item) {
async function runQuickSearch() {
const item = scanItems[quickItemIndex];
const box = $("#quick-results");
- box.innerHTML = "
Suche läuft…
";
+ box.innerHTML = `
${t("quick.searching")}
`;
try {
// an "english" library should not offer the German edition
const lib = libraries.find((l) => String(l.id) === $("#quick-library").value);
@@ -1269,7 +1301,7 @@ async function runQuickSearch() {
);
quickAutoSearch = false;
if (!results.length) {
- box.innerHTML = `
${EMPTY_HINTS[item.media_type]}
`;
+ box.innerHTML = `
${esc(t(EMPTY_HINTS[item.media_type]))}
`;
return;
}
// one compact row per hit
@@ -1280,7 +1312,7 @@ async function runQuickSearch() {
r.narrator ? "🎙 " + r.narrator : "",
r.series ? `📚 ${r.series}${r.volume != null ? " #" + r.volume : ""}` : "",
r.year ?? "",
- LANGUAGE_NAMES[r.language] || r.language,
+ languageName(r.language),
r.source && r.source !== "audible" ? SOURCE_NAMES[r.source] || r.source : "",
].filter(Boolean).map(esc).join(" · ");
return `
@@ -1289,8 +1321,8 @@ async function runQuickSearch() {
${esc(r.title)}
${meta}
-
-
+
+
`;
})
.join("");
@@ -1319,7 +1351,7 @@ $("#quick-close").addEventListener("click", () => $("#quick-dialog").close());
// 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; }
+ if (!libId) { toast(t("common.needLibraryFirst"), true); return; }
const i = quickItemIndex;
try {
const req = await createRequest({ library_id: parseInt(libId), ...body });
@@ -1330,7 +1362,7 @@ async function requestAndConnect(body) {
renderImportTable();
refreshMissingBadge();
$("#quick-dialog").close();
- toast(`„${req.title}" angefragt und verbunden`);
+ toast(t("quick.requestedAndLinked", { title: req.title }));
} catch (err) { toast(err.message, true); }
}
@@ -1350,17 +1382,16 @@ function editBeforeRequest(r) {
$("#quick-m-authors").value = r.authors || "";
$("#quick-m-series").value = r.series || "";
$("#quick-m-volume").value = r.volume ?? quickEpisode ?? "";
- $("#quick-based-on").textContent =
- `Übernommen von Audible: „${r.title}" — Sprecher, Cover, Jahr und ASIN bleiben erhalten.`;
+ $("#quick-based-on").textContent = t("quick.basedOn", { title: r.title });
$("#quick-based-on").hidden = false;
- $("#quick-manual-summary").textContent = "Felder anpassen";
+ $("#quick-manual-summary").textContent = t("quick.editSummary");
$("#quick-manual").open = true;
$("#quick-m-title").focus();
}
$("#quick-m-submit").addEventListener("click", () => {
const title = $("#quick-m-title").value.trim();
- if (!title) { toast("Titel angeben", true); return; }
+ if (!title) { toast(t("quick.needTitle"), true); return; }
const volume = $("#quick-m-volume").value;
const base = quickPicked
? { narrator: quickPicked.narrator || "", external_id: quickPicked.external_id,
@@ -1408,10 +1439,7 @@ function buildImportGroups(groups) {
// either a multi-part title (merge) or a mismatch (skip), decided once
function askAboutSharedRequests(shared) {
const dlg = $("#conflict-dialog");
- $("#conflict-intro").textContent =
- `${shared.length} Anfrage(n) sind mehreren Einträgen zugeordnet. Gehören die Einträge ` +
- "zusammen (Mehrteiler), lassen sie sich als ein Hörbuch importieren — sonst " +
- "überspringst du sie, korrigierst die Zuordnung und importierst sie später.";
+ $("#conflict-intro").textContent = t("conflict.intro", { n: shared.length });
$("#conflict-list").innerHTML = shared
.map((group) => {
const req = missingReqs.find((r) => r.id === group[0].request_id);
@@ -1469,13 +1497,11 @@ function sharedParent(group) {
$("#import-btn").addEventListener("click", async () => {
const chosen = scanItems.filter((item) => item.checked && item.request_id);
- if (!chosen.length) { toast("Nichts ausgewählt", true); return; }
+ if (!chosen.length) { toast(t("import.nothingSelected"), true); return; }
const groups = await collapseSharedRequests(chosen);
if (!groups) return;
if (!groups.length) {
- $("#import-summary").innerHTML =
- `
Nichts importiert: alle ausgewählten Einträge waren mehreren ` +
- `Anfragen zugeordnet — Filter „Nur Konflikte" zeigt sie.
`;
+ $("#import-summary").innerHTML = `
${esc(t("import.nothingImported"))}
`;
renderImportTable();
return;
}
@@ -1520,12 +1546,11 @@ $("#import-btn").addEventListener("click", async () => {
const failed = allResults.filter((r) => !r.ok);
$("#import-summary").innerHTML =
`
` +
- `${allResults.length - failed.length} importiert` +
- (failed.length ? `, ${failed.length} Fehler` : "") +
+ esc(t("import.summary", { n: allResults.length - failed.length })) +
+ (failed.length ? esc(t("import.summaryFailed", { n: failed.length })) : "") +
`
` +
(skippedConflicts
- ? `
${skippedConflicts} Anfrage(n) waren mehreren Einträgen ` +
- `zugeordnet und wurden übersprungen — Filter „Nur Konflikte" zeigt sie.
`
+ ? `
${esc(t("import.summarySkipped", { n: skippedConflicts }))}
`
: "");
$("#import-results").innerHTML = [...failed, ...allResults.filter((r) => r.ok)]
.map((r) =>
@@ -1534,7 +1559,8 @@ $("#import-btn").addEventListener("click", async () => {
: `
✗ ${esc(r.path)}: ${esc(r.error)}
`
)
.join("");
- toast(failed.length ? `Import fertig, ${failed.length} Fehler` : "Import fertig", failed.length > 0);
+ toast(failed.length ? t("import.finishedFailed", { n: failed.length }) : t("import.finished"),
+ failed.length > 0);
refreshMissingBadge();
clearTimeout(cleanupTimer);
cleanupTimer = setTimeout(() => {
@@ -1545,14 +1571,13 @@ $("#import-btn").addEventListener("click", async () => {
importPage = 0;
setImportProgress(0, 0);
renderImportTable();
- $("#scan-info").textContent =
- `${scanItems.length} Kandidat(en) übrig — für den aktuellen Stand erneut scannen`;
+ $("#scan-info").textContent = t("import.remaining", { n: scanItems.length });
}
}, IMPORT_DONE_CLEANUP_MS);
// the list stays: rescanning would wipe the feedback
} finally {
btn.disabled = false;
- btn.textContent = "Ausgewählte importieren";
+ btn.textContent = t("import.run");
}
});
@@ -1563,7 +1588,7 @@ function setImportProgress(done, total) {
box.hidden = done < total;
if (done >= total) {
box.querySelector("progress").value = total;
- box.querySelector("span").textContent = `${total}/${total} · fertig`;
+ box.querySelector("span").textContent = t("import.progressDone", { total });
}
return;
}
@@ -1575,6 +1600,28 @@ function setImportProgress(done, total) {
`${done}/${total} · ${Math.round((done / total) * 100)} %`;
}
+// ---- language ----
+function updateLangButton() {
+ $("#lang-toggle").textContent = lang === "de" ? "EN" : "DE";
+}
+
+$("#lang-toggle").addEventListener("click", () => setLang(lang === "de" ? "en" : "de"));
+
+// the static labels are handled by applyI18n, everything rendered from data is not
+function onLangChange() {
+ updateLangButton();
+ renderSearchResults();
+ renderRequests("missing");
+ renderRequests("imported");
+ if (libraries.length) loadLibraries();
+ const editId = $("#lib-form").dataset.editId;
+ editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
+ if (scanItems.length) renderImportTable();
+}
+
// ---- init ----
+applyI18n();
+updateLangButton();
+endLibraryEdit();
loadLibraries();
refreshMissingBadge();
diff --git a/static/i18n.js b/static/i18n.js
new file mode 100644
index 0000000..9fee7f6
--- /dev/null
+++ b/static/i18n.js
@@ -0,0 +1,486 @@
+// UI strings for both languages. Keys are grouped by the view they belong to;
+// {placeholders} are filled by t().
+const STRINGS = {
+ de: {
+ "common.cancel": "Abbrechen",
+ "common.save": "Speichern",
+ "common.create": "Anlegen",
+ "common.request": "Anfragen",
+ "common.search": "Suchen",
+ "common.all": "Alle",
+ "common.none": "Keine",
+ "common.close": "Schließen",
+ "common.edit": "Bearbeiten",
+ "common.delete": "Löschen",
+ "common.link": "Verbinden",
+ "common.createAndLink": "Anlegen & verbinden",
+ "common.selectAll": "Alle auswählen",
+ "common.removeSelected": "Ausgewählte entfernen",
+ "common.removeSelectedN": "Ausgewählte entfernen ({n})",
+ "common.library": "Library",
+ "common.type": "Typ",
+ "common.title": "Titel",
+ "common.authors": "Autor(en)",
+ "common.author": "Autor",
+ "common.narrator": "Sprecher",
+ "common.series": "Serie",
+ "common.volume": "Band",
+ "common.year": "Jahr",
+ "common.name": "Name",
+ "common.path": "Pfad",
+ "common.language": "Sprache",
+ "common.score": "Score",
+ "common.status": "Status",
+ "common.noLibrary": "— keine Library —",
+ "common.nothingHere": "Nichts hier.",
+ "common.optionalForAll": "optional, für alle",
+ "common.optional": "optional",
+ "common.needLibraryFirst": "Erst eine Library für diesen Typ anlegen (Tab Libraries)",
+ "common.creating": "Lege an…",
+ "lang.german": "Deutsch",
+ "lang.english": "Englisch",
+
+ "nav.search": "Suche",
+ "nav.missing": "Missing",
+ "nav.imported": "Importiert",
+ "nav.import": "Import",
+ "nav.settings": "Libraries",
+ "nav.langToggle": "Switch to English",
+
+ "search.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche",
+ "search.allLanguages": "Alle Sprachen",
+ "search.placeholder": "Titel, Autor oder ISBN…",
+ "search.manual": "Manuell anlegen",
+ "search.running": "Suche…",
+ "search.wholeSeries": "Ganze Serie…",
+ "search.requested": "✓ Angefragt",
+ "search.emptyEbook": "Keine Treffer. Tipp: ISBN ohne Bindestriche oder Titel + Autor versuchen — und ggf. „Alle Sprachen“ wählen.",
+ "search.emptyAudiobook": "Keine Treffer. Tipp: EAN/ISBN funktioniert bei Audible nicht — nach Titel/Autor suchen.",
+ "search.emptyComic": "Keine Treffer bei AniList (Manga). Westliche Comics per „Manuell anlegen“ erfassen.",
+
+ "manual.heading": "Manuell anfragen",
+ "manual.volumeTo": "bis Band (Bulk, optional)",
+ "manual.bulkHint": "Legt pro Band/Folge eine Anfrage an, z.B. 1 bis 300",
+ "manual.externalId": "ISBN/ID",
+ "manual.noLibraryChosen": "Keine Library gewählt",
+ "manual.needStartVolume": "Für Bulk bitte Start-Band angeben",
+ "manual.created": "Anfrage angelegt",
+ "manual.createdN": "{n} Anfragen angelegt",
+
+ "series.fallbackTitle": "Serie",
+ "series.from": "von Nr.",
+ "series.to": "bis Nr.",
+ "series.loading": "{unit} werden gesucht…",
+ "series.unitVolumes": "Bände",
+ "series.unitEpisodes": "Folgen",
+ "series.found": "{n} {unit} gefunden",
+ "series.range": " (Nr. {from}–{to})",
+ "series.noteEbook": ". Bände ohne Ausgabe in der gewählten Sprache stehen mit ihrem Originaltitel da.",
+ "series.noteAudiobook": ". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen.",
+ "series.gapVolumes": "… {n} Bände nicht bei Hardcover gefunden ({from}–{to})",
+ "series.gapVolume": "… {n} Band nicht bei Hardcover gefunden ({from}–{to})",
+ "series.gapEpisodes": "… {n} Folgen nicht bei Audible gefunden ({from}–{to})",
+ "series.gapEpisode": "… {n} Folge nicht bei Audible gefunden ({from}–{to})",
+ "series.requestN": "{n} anfragen",
+ "series.needLibrary": "Erst eine {type}-Library anlegen (Tab Libraries)",
+ "series.createdN": "{n} Anfragen angelegt",
+ "series.skippedN": ", {n} bereits vorhanden",
+
+ "list.allTypes": "Alle Typen",
+ "list.allLibraries": "Alle Libraries",
+ "list.filterPlaceholder": "Filtern (Titel/Autor/Serie)…",
+ "list.view": "Ansicht",
+ "list.viewList": "Liste",
+ "list.viewSmall": "Kleine Symbole",
+ "list.viewLarge": "Große Symbole",
+ "list.confirmDeleteRequests": "{n} Anfrage(n) entfernen?",
+ "list.confirmDeleteImported": "{n} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.",
+ "list.deletedN": "{n} entfernt",
+
+ "detail.statusMissing": "fehlt noch",
+ "detail.statusImported": "importiert",
+ "detail.externalId": "ISBN/ASIN",
+ "detail.retag": "Speichern & neu taggen",
+ "detail.relocate": "Speichern & neu ablegen",
+ "detail.relocateTitle": "Ordner und Dateinamen nach dem Schema der Library neu anlegen",
+ "detail.relocating": "Lege ab…",
+ "detail.relocated": "Neu abgelegt: {dest}",
+ "detail.alreadyPlaced": "Liegt bereits richtig",
+ "detail.retagging": "Tagge…",
+ "detail.retagged": "{n} Datei(en) neu getaggt",
+ "detail.saved": "Anfrage gespeichert",
+ "detail.duplicateConfirm": "Trotzdem anlegen?",
+
+ "import.scan": "Download-Ordner scannen",
+ "import.splitDirs": "Ordner als Einzeldateien behandeln (z.B. Folgen-Sammlungen)",
+ "import.scanning": "Scanne…",
+ "import.candidates": "{n} Kandidat(en) in {dir}",
+ "import.remaining": "{n} Kandidat(en) übrig — für den aktuellen Stand erneut scannen",
+ "import.filterPlaceholder": "Filtern (Name/Pfad)…",
+ "import.modeSuggested": "Nur mit Vorschlag",
+ "import.modeUnassigned": "Nur ohne Zuordnung",
+ "import.modeConflict": "Nur Konflikte",
+ "import.formatTitle": "Nur Einträge mit diesem Dateiformat zeigen",
+ "import.allFormats": "Alle Formate",
+ "import.sortScan": "Reihenfolge: Scan",
+ "import.sortScore": "Score absteigend",
+ "import.sortName": "Name A–Z",
+ "import.selectSuggested": "Alle mit Vorschlag auswählen",
+ "import.selectAllTitle": "Alle Einträge der aktuellen Ansicht auswählen, auch ohne Zuordnung",
+ "import.selectPage": "Diese Seite auswählen",
+ "import.selectPageTitle": "Nur die Einträge dieser Seite auswählen, alle anderen abwählen",
+ "import.deselectAll": "Alle abwählen",
+ "import.merge": "Ausgewählte zusammenfassen",
+ "import.mergeTitle": "Mehrteiler: ausgewählte Einträge als ein Hörbuch importieren",
+ "import.fromNames": "Anfragen aus Ordnernamen",
+ "import.fromNamesTitle": "Für ausgewählte Einträge ohne Zuordnung Anfragen aus den Ordnernamen anlegen",
+ "import.colFile": "Datei/Ordner",
+ "import.colAssignment": "Zuordnung",
+ "import.prev": "← Zurück",
+ "import.next": "Weiter →",
+ "import.run": "Ausgewählte importieren",
+ "import.skip": "— überspringen —",
+ "import.appendGroup": "Bereits importiert — Teile anhängen",
+ "import.quickTitle": "Metadaten suchen und Anfrage direkt verbinden",
+ "import.unmerge": "Zusammenfassung wieder auflösen",
+ "import.splitDir": "Ordner in einzelne Titel aufteilen",
+ "import.parts": "{parts} Teile · {files} Dateien",
+ "import.maybeSeparate": "{n} Titel?",
+ "import.maybeSeparateTitle": "Die {n} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen",
+ "import.libraryCellTitle": "Ziel-Library dieser Anfrage ändern",
+ "import.conflict": "⚠ Konflikt",
+ "import.conflictTitle": "Mehrere Einträge zeigen auf dieselbe Anfrage",
+ "import.running": "läuft…",
+ "import.done": "✓ importiert",
+ "import.failed": "✗ Fehler",
+ "import.pageInfo": "Seite {page}/{pages} · {total} Einträge{filtered} · {selected} ausgewählt",
+ "import.filtered": " (gefiltert: {n})",
+ "import.movedToLibrary": "„{title}\" → {library}",
+ "import.selectedN": "{n} Einträge {what} ausgewählt",
+ "import.selectedWithout": ", davon {n} ohne Zuordnung",
+ "import.thisPage": "dieser Seite",
+ "import.inTotal": "insgesamt",
+ "import.nothingSelected": "Nichts ausgewählt",
+ "import.needTwo": "Mindestens zwei Einträge auswählen",
+ "import.sameTypeOnly": "Nur Einträge desselben Typs lassen sich zusammenfassen",
+ "import.differentFolders": "Die Einträge liegen in verschiedenen Ordnern",
+ "import.mergedN": "{n} Einträge als „{name}\" zusammengefasst ({files} Dateien)",
+ "import.splitN": "„{name}\" in {n} Einzeltitel aufgeteilt",
+ "import.nothingImported": "Nichts importiert: alle ausgewählten Einträge waren mehreren Anfragen zugeordnet — Filter „Nur Konflikte\" zeigt sie.",
+ "import.summary": "{n} importiert",
+ "import.summaryFailed": ", {n} Fehler",
+ "import.summarySkipped": "{n} Anfrage(n) waren mehreren Einträgen zugeordnet und wurden übersprungen — Filter „Nur Konflikte\" zeigt sie.",
+ "import.finished": "Import fertig",
+ "import.finishedFailed": "Import fertig, {n} Fehler",
+ "import.progressDone": "{total}/{total} · fertig",
+
+ "names.heading": "Anfragen aus Ordnernamen",
+ "names.intro": "{n} Einträge ohne Zuordnung ({type}). Titel und Folgennummer kommen aus dem Ordnernamen; Serie und Autor gelten für alle.",
+ "names.more": "… und {n} weitere",
+ "names.noneWithout": "Keine ausgewählten Einträge ohne Zuordnung",
+ "names.result": "{created} Anfragen angelegt{skipped}, {linked} Einträge verbunden",
+ "names.existed": ", {n} gab es schon",
+
+ "conflict.heading": "Mehrfach zugeordnete Anfragen",
+ "conflict.intro": "{n} Anfrage(n) sind mehreren Einträgen zugeordnet. Gehören die Einträge zusammen (Mehrteiler), lassen sie sich als ein Hörbuch importieren — sonst überspringst du sie, korrigierst die Zuordnung und importierst sie später.",
+ "conflict.merge": "Als je ein Hörbuch zusammenfassen",
+ "conflict.skip": "Konflikte überspringen, Rest importieren",
+
+ "quick.heading": "Suchen & Anfragen",
+ "quick.placeholder": "Titel/Serie suchen…",
+ "quick.targetLibrary": "Ziel-Library",
+ "quick.openRequests": "Offene Anfragen",
+ "quick.reqFilter": "nach Titel, Autor, Serie filtern…",
+ "quick.manualSummary": "Nicht bei Audible? Manuell anlegen",
+ "quick.editSummary": "Felder anpassen",
+ "quick.episodeVolume": "Folge/Band",
+ "quick.searching": "Suche läuft…",
+ "quick.episodeNote": "Folge {n} — nicht mitgesucht",
+ "quick.matchingOpen": "Passende offene Requests:",
+ "quick.hits": "{shown} von {total} Treffern ({open} offen)",
+ "quick.noHits": "Kein Treffer unter {open} offenen Anfragen",
+ "quick.linked": "Mit bestehendem Request verbunden",
+ "quick.editTitle": "Felder vor dem Anlegen anpassen (z.B. Serie)",
+ "quick.pickTitle": "Anfrage anlegen und mit diesem Eintrag verbinden",
+ "quick.basedOn": "Übernommen von Audible: „{title}\" — Sprecher, Cover, Jahr und ASIN bleiben erhalten.",
+ "quick.requestedAndLinked": "„{title}\" angefragt und verbunden",
+ "quick.needTitle": "Titel angeben",
+
+ "lib.heading": "Libraries",
+ "lib.folderTemplate": "Ordner-Schema",
+ "lib.fileTemplate": "Datei-Schema",
+ "lib.new": "Neue Library",
+ "lib.edit": "Library „{name}“ bearbeiten",
+ "lib.namePlaceholder": "Name (z.B. Kids)",
+ "lib.folderPlaceholder": "Ordner-Schema (optional)",
+ "lib.filePlaceholder": "Datei-Schema (optional)",
+ "lib.languageTitle": "Sprache der Ausgaben in dieser Library — filtert die Metadaten-Suche",
+ "lib.anyLanguage": "Sprache: egal",
+ "lib.placeholders": "Platzhalter: {Author} {Authors} {Narrator} {Narrators} {Title} {Year} {Series} {Volume} — {Author}/{Narrator} nennen den ersten Namen, {Authors}/{Narrators} alle. Leere Werte fallen samt Trenner weg.",
+ "lib.confirmDelete": "Library löschen?",
+ "lib.saved": "Library gespeichert — gilt für künftige Importe",
+ "lib.created": "Library angelegt",
+ },
+
+ en: {
+ "common.cancel": "Cancel",
+ "common.save": "Save",
+ "common.create": "Create",
+ "common.request": "Request",
+ "common.search": "Search",
+ "common.all": "All",
+ "common.none": "None",
+ "common.close": "Close",
+ "common.edit": "Edit",
+ "common.delete": "Delete",
+ "common.link": "Link",
+ "common.createAndLink": "Create & link",
+ "common.selectAll": "Select all",
+ "common.removeSelected": "Remove selected",
+ "common.removeSelectedN": "Remove selected ({n})",
+ "common.library": "Library",
+ "common.type": "Type",
+ "common.title": "Title",
+ "common.authors": "Author(s)",
+ "common.author": "Author",
+ "common.narrator": "Narrator",
+ "common.series": "Series",
+ "common.volume": "Volume",
+ "common.year": "Year",
+ "common.name": "Name",
+ "common.path": "Path",
+ "common.language": "Language",
+ "common.score": "Score",
+ "common.status": "Status",
+ "common.noLibrary": "— no library —",
+ "common.nothingHere": "Nothing here.",
+ "common.optionalForAll": "optional, for all",
+ "common.optional": "optional",
+ "common.needLibraryFirst": "Create a library for this type first (Libraries tab)",
+ "common.creating": "Creating…",
+ "lang.german": "German",
+ "lang.english": "English",
+
+ "nav.search": "Search",
+ "nav.missing": "Missing",
+ "nav.imported": "Imported",
+ "nav.import": "Import",
+ "nav.settings": "Libraries",
+ "nav.langToggle": "Auf Deutsch umschalten",
+
+ "search.languageTitle": "Edition language — filters the ebook and audiobook search",
+ "search.allLanguages": "All languages",
+ "search.placeholder": "Title, author or ISBN…",
+ "search.manual": "Add manually",
+ "search.running": "Searching…",
+ "search.wholeSeries": "Whole series…",
+ "search.requested": "✓ Requested",
+ "search.emptyEbook": "No hits. Try the ISBN without dashes, or title + author — and maybe pick “All languages”.",
+ "search.emptyAudiobook": "No hits. EAN/ISBN does not work on Audible — search by title/author.",
+ "search.emptyComic": "No hits on AniList (manga). Add western comics via “Add manually”.",
+
+ "manual.heading": "Request manually",
+ "manual.volumeTo": "to volume (bulk, optional)",
+ "manual.bulkHint": "Creates one request per volume/episode, e.g. 1 to 300",
+ "manual.externalId": "ISBN/ID",
+ "manual.noLibraryChosen": "No library chosen",
+ "manual.needStartVolume": "Bulk needs a starting volume",
+ "manual.created": "Request created",
+ "manual.createdN": "{n} requests created",
+
+ "series.fallbackTitle": "Series",
+ "series.from": "from no.",
+ "series.to": "to no.",
+ "series.loading": "Looking for {unit}…",
+ "series.unitVolumes": "volumes",
+ "series.unitEpisodes": "episodes",
+ "series.found": "{n} {unit} found",
+ "series.range": " (no. {from}–{to})",
+ "series.noteEbook": ". Volumes without an edition in the chosen language keep their original title.",
+ "series.noteAudiobook": ". Audible has no series query — single episodes can be missing.",
+ "series.gapVolumes": "… {n} volumes not found on Hardcover ({from}–{to})",
+ "series.gapVolume": "… {n} volume not found on Hardcover ({from}–{to})",
+ "series.gapEpisodes": "… {n} episodes not found on Audible ({from}–{to})",
+ "series.gapEpisode": "… {n} episode not found on Audible ({from}–{to})",
+ "series.requestN": "Request {n}",
+ "series.needLibrary": "Create an {type} library first (Libraries tab)",
+ "series.createdN": "{n} requests created",
+ "series.skippedN": ", {n} already there",
+
+ "list.allTypes": "All types",
+ "list.allLibraries": "All libraries",
+ "list.filterPlaceholder": "Filter (title/author/series)…",
+ "list.view": "View",
+ "list.viewList": "List",
+ "list.viewSmall": "Small icons",
+ "list.viewLarge": "Large icons",
+ "list.confirmDeleteRequests": "Remove {n} request(s)?",
+ "list.confirmDeleteImported": "Remove {n} entr(y/ies)? The imported files stay in the library.",
+ "list.deletedN": "{n} removed",
+
+ "detail.statusMissing": "still missing",
+ "detail.statusImported": "imported",
+ "detail.externalId": "ISBN/ASIN",
+ "detail.retag": "Save & re-tag",
+ "detail.relocate": "Save & re-file",
+ "detail.relocateTitle": "Rebuild folder and file names from the library's scheme",
+ "detail.relocating": "Filing…",
+ "detail.relocated": "Re-filed: {dest}",
+ "detail.alreadyPlaced": "Already in the right place",
+ "detail.retagging": "Tagging…",
+ "detail.retagged": "{n} file(s) re-tagged",
+ "detail.saved": "Request saved",
+ "detail.duplicateConfirm": "Create anyway?",
+
+ "import.scan": "Scan download folder",
+ "import.splitDirs": "Treat folders as single files (e.g. episode collections)",
+ "import.scanning": "Scanning…",
+ "import.candidates": "{n} candidate(s) in {dir}",
+ "import.remaining": "{n} candidate(s) left — scan again for the current state",
+ "import.filterPlaceholder": "Filter (name/path)…",
+ "import.modeSuggested": "With suggestion only",
+ "import.modeUnassigned": "Unassigned only",
+ "import.modeConflict": "Conflicts only",
+ "import.formatTitle": "Show only entries with this file format",
+ "import.allFormats": "All formats",
+ "import.sortScan": "Order: scan",
+ "import.sortScore": "Score descending",
+ "import.sortName": "Name A–Z",
+ "import.selectSuggested": "Select all with a suggestion",
+ "import.selectAllTitle": "Select every entry of the current view, unassigned ones too",
+ "import.selectPage": "Select this page",
+ "import.selectPageTitle": "Select only this page's entries, deselect all others",
+ "import.deselectAll": "Deselect all",
+ "import.merge": "Merge selected",
+ "import.mergeTitle": "Multi-part title: import the selected entries as one audiobook",
+ "import.fromNames": "Requests from folder names",
+ "import.fromNamesTitle": "Create requests from the folder names of the selected unassigned entries",
+ "import.colFile": "File/folder",
+ "import.colAssignment": "Assignment",
+ "import.prev": "← Back",
+ "import.next": "Next →",
+ "import.run": "Import selected",
+ "import.skip": "— skip —",
+ "import.appendGroup": "Already imported — append parts",
+ "import.quickTitle": "Search metadata and link the request right away",
+ "import.unmerge": "Undo the merge",
+ "import.splitDir": "Split the folder into single titles",
+ "import.parts": "{parts} parts · {files} files",
+ "import.maybeSeparate": "{n} titles?",
+ "import.maybeSeparateTitle": "The {n} files carry different titles and are each big enough for a whole book — split with ✂️",
+ "import.libraryCellTitle": "Change this request's target library",
+ "import.conflict": "⚠ Conflict",
+ "import.conflictTitle": "Several entries point at the same request",
+ "import.running": "running…",
+ "import.done": "✓ imported",
+ "import.failed": "✗ Error",
+ "import.pageInfo": "Page {page}/{pages} · {total} entries{filtered} · {selected} selected",
+ "import.filtered": " (filtered: {n})",
+ "import.movedToLibrary": "“{title}” → {library}",
+ "import.selectedN": "{n} entries {what} selected",
+ "import.selectedWithout": ", {n} of them unassigned",
+ "import.thisPage": "on this page",
+ "import.inTotal": "in total",
+ "import.nothingSelected": "Nothing selected",
+ "import.needTwo": "Select at least two entries",
+ "import.sameTypeOnly": "Only entries of the same type can be merged",
+ "import.differentFolders": "The entries live in different folders",
+ "import.mergedN": "{n} entries merged as “{name}” ({files} files)",
+ "import.splitN": "“{name}” split into {n} single titles",
+ "import.nothingImported": "Nothing imported: every selected entry was assigned to several requests — the “Conflicts only” filter shows them.",
+ "import.summary": "{n} imported",
+ "import.summaryFailed": ", {n} failed",
+ "import.summarySkipped": "{n} request(s) were assigned to several entries and were skipped — the “Conflicts only” filter shows them.",
+ "import.finished": "Import finished",
+ "import.finishedFailed": "Import finished, {n} failed",
+ "import.progressDone": "{total}/{total} · done",
+
+ "names.heading": "Requests from folder names",
+ "names.intro": "{n} unassigned entries ({type}). Title and episode number come from the folder name; series and author apply to all of them.",
+ "names.more": "… and {n} more",
+ "names.noneWithout": "No selected entries without an assignment",
+ "names.result": "{created} requests created{skipped}, {linked} entries linked",
+ "names.existed": ", {n} already existed",
+
+ "conflict.heading": "Requests assigned more than once",
+ "conflict.intro": "{n} request(s) are assigned to several entries. If those entries belong together (a multi-part title), they can be imported as one audiobook — otherwise skip them, fix the assignment and import them later.",
+ "conflict.merge": "Merge each into one audiobook",
+ "conflict.skip": "Skip conflicts, import the rest",
+
+ "quick.heading": "Search & request",
+ "quick.placeholder": "Search title/series…",
+ "quick.targetLibrary": "Target library",
+ "quick.openRequests": "Open requests",
+ "quick.reqFilter": "filter by title, author, series…",
+ "quick.manualSummary": "Not on Audible? Add manually",
+ "quick.editSummary": "Adjust the fields",
+ "quick.episodeVolume": "Episode/volume",
+ "quick.searching": "Searching…",
+ "quick.episodeNote": "Episode {n} — not part of the search",
+ "quick.matchingOpen": "Matching open requests:",
+ "quick.hits": "{shown} of {total} hits ({open} open)",
+ "quick.noHits": "No hit among {open} open requests",
+ "quick.linked": "Linked to the existing request",
+ "quick.editTitle": "Adjust the fields before creating (e.g. the series)",
+ "quick.pickTitle": "Create the request and link it to this entry",
+ "quick.basedOn": "Taken from Audible: “{title}” — narrator, cover, year and ASIN are kept.",
+ "quick.requestedAndLinked": "“{title}” requested and linked",
+ "quick.needTitle": "Enter a title",
+
+ "lib.heading": "Libraries",
+ "lib.folderTemplate": "Folder scheme",
+ "lib.fileTemplate": "File scheme",
+ "lib.new": "New library",
+ "lib.edit": "Edit library “{name}”",
+ "lib.namePlaceholder": "Name (e.g. Kids)",
+ "lib.folderPlaceholder": "Folder scheme (optional)",
+ "lib.filePlaceholder": "File scheme (optional)",
+ "lib.languageTitle": "Language of this library's editions — filters the metadata search",
+ "lib.anyLanguage": "Language: any",
+ "lib.placeholders": "Placeholders: {Author} {Authors} {Narrator} {Narrators} {Title} {Year} {Series} {Volume} — {Author}/{Narrator} name the first one, {Authors}/{Narrators} all of them. Empty values drop out with their separator.",
+ "lib.confirmDelete": "Delete library?",
+ "lib.saved": "Library saved — applies to future imports",
+ "lib.created": "Library created",
+ },
+};
+
+const LANG_KEY = "wordarr.lang";
+
+function initialLang() {
+ try {
+ const saved = localStorage.getItem(LANG_KEY);
+ if (saved && STRINGS[saved]) return saved;
+ } catch {}
+ return (navigator.language || "en").startsWith("de") ? "de" : "en";
+}
+
+let lang = initialLang();
+
+// {Placeholders} of the naming scheme must survive, so only known vars are replaced
+function t(key, vars) {
+ let s = STRINGS[lang][key] ?? STRINGS.en[key] ?? key;
+ if (vars) {
+ for (const [name, value] of Object.entries(vars)) {
+ s = s.split("{" + name + "}").join(String(value));
+ }
+ }
+ return s;
+}
+
+const I18N_ATTRS = { "i18nPlaceholder": "placeholder", "i18nTitle": "title", "i18nAria": "aria-label" };
+
+function applyI18n(root = document) {
+ root.querySelectorAll("[data-i18n]").forEach((el) => (el.textContent = t(el.dataset.i18n)));
+ for (const [data, attr] of Object.entries(I18N_ATTRS)) {
+ root.querySelectorAll(`[data-${data.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())}]`)
+ .forEach((el) => el.setAttribute(attr, t(el.dataset[data])));
+ }
+ document.documentElement.lang = lang;
+}
+
+function setLang(next) {
+ lang = next;
+ try { localStorage.setItem(LANG_KEY, next); } catch {}
+ applyI18n();
+ if (typeof onLangChange === "function") onLangChange();
+}
diff --git a/static/index.html b/static/index.html
index 9eef9ca..5e18991 100644
--- a/static/index.html
+++ b/static/index.html
@@ -13,13 +13,15 @@
📚 wordarr
@@ -30,71 +32,67 @@
-
-
-
-
+
+
+
+
-
-
-
+
+
+
@@ -102,23 +100,21 @@
-
+
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
@@ -126,15 +122,13 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
@@ -142,73 +136,61 @@