Compare commits

...
5 Commits
Author SHA1 Message Date
Steppenstreuner dc83071a0a fix(ci): drop the socket mount the runnter already provides
Build image / image (push) Successful in 49s
2026-09-01 18:18:08 +02:00
Steppenstreuner e22eded2bf add(ci): pull and recreate the wordarr container after a successful push
Build image / image (push) Failing after 0s
2026-09-01 18:12:22 +02:00
Steppenstreuner 6493d15c52 add(ci): build and push the image on every push to main
Build image / image (push) Successful in 50s
2026-09-01 17:59:33 +02:00
Steppenstreuner 8e7be708b2 add(ui/ux): show the running version and build stamp in the header 2026-09-01 17:50:50 +02:00
Steppenstreuner d9b4c95ca6 add(ui/ux): german and english ui with a toggle in the navbar 2026-09-01 17:50:26 +02:00
12 changed files with 1009 additions and 339 deletions
+61
View File
@@ -0,0 +1,61 @@
name: Build image
on:
push:
branches:
- main
jobs:
image:
runs-on: ubuntu-latest
container:
image: gitea/runner-images:ubuntu-latest
# den Docker-Socket haengt der Runner selbst ein, ein zweiter Mount
# waere ein "Duplicate mount point"
options: -v /home/jazz/stacks:/deploy-target
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Tests
run: |
python3 -m venv /tmp/venv
/tmp/venv/bin/pip install --quiet -e .[dev]
/tmp/venv/bin/python -m pytest -q # the browser tests skip without playwright
- name: Image bauen und pushen
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER }}
ACTOR: ${{ gitea.actor }}
run: |
command -v docker >/dev/null || { apt-get update -qq && apt-get install -y -qq docker.io; }
sha=$(git rev-parse --short HEAD)
image=gitea.steppencloud.de/admin/wordarr
token=${REGISTRY_TOKEN:-$GITEA_TOKEN}
echo "$token" | docker login gitea.steppencloud.de -u "${REGISTRY_USER:-$ACTOR}" --password-stdin
docker build --build-arg WORDARR_BUILD="$sha" -t "$image:$sha" -t "$image:latest" .
docker push "$image:$sha"
docker push "$image:latest"
echo "Gepusht: $image:latest ($sha)"
- name: wordarr im arr-Stack neu ziehen
run: |
mountpoint -q /deploy-target || {
echo "::error::/deploy-target ist nicht gemountet - valid_volumes im act_runner pruefen"
exit 1
}
if docker compose version >/dev/null 2>&1; then
compose="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
compose="docker-compose"
else
apt-get update -qq && apt-get install -y -qq docker-compose-v2
compose="docker compose"
fi
cd /deploy-target/arr
$compose pull wordarr # ohne pull bleibt :latest das alte Image
$compose up -d wordarr
echo "Laeuft:"
docker inspect -f '{{.Config.Image}} seit {{.State.StartedAt}}' wordarr
+3 -1
View File
@@ -6,8 +6,10 @@ COPY wordarr ./wordarr
COPY static ./static
RUN pip install --no-cache-dir .
ARG WORDARR_BUILD=""
ENV WORDARR_CONFIG_DIR=/config \
WORDARR_DOWNLOAD_DIR=/mnt/downloads
WORDARR_DOWNLOAD_DIR=/mnt/downloads \
WORDARR_BUILD=$WORDARR_BUILD
VOLUME /config
EXPOSE 8787
+20
View File
@@ -25,6 +25,26 @@ the same mount, otherwise moving turns into copy + delete.
| `WORDARR_EXTRA_EBOOK_EXTENSIONS` | | extra ebook formats the scan should offer, e.g. `.kepub,.lit,.rtf` |
| `WORDARR_HARDCOVER_TOKEN` | | Hardcover API token, the best ebook source; without it Hardcover is skipped |
| `WORDARR_GOOGLE_BOOKS_KEY` | | optional, only needed when the keyless Google Books quota runs dry |
| `WORDARR_BUILD` | | shown next to the version, e.g. the git sha; also a build arg of the image |
The header shows the running version and, behind it, the build stamp (the newest
source file, or `WORDARR_BUILD` when the image was built with it) - the quickest
way to see whether a redeploy actually arrived. `GET /api/version` returns the
same three values. Build with the git sha via
`WORDARR_BUILD=$(git rev-parse --short HEAD) docker compose up -d --build`.
The UI speaks German and English; the button next to the navigation switches
between them and remembers the choice. Without one, the browser language decides.
## Build
`.gitea/workflows/build.yml` runs the tests on every push to `main` and then
builds and pushes `gitea.steppencloud.de/admin/wordarr` as `:latest` and
`:<short sha>`, passing that sha as `WORDARR_BUILD`. It logs in with
`REGISTRY_TOKEN` if the secret exists, otherwise with the runner's own
`GITEA_TOKEN`. The last step pulls the fresh image on the host and recreates
just the wordarr container of the arr stack, so the version in the header is the
commit that was pushed.
## Usage
+5 -1
View File
@@ -1,6 +1,10 @@
services:
wordarr:
build: .
build:
context: .
args:
# e.g. WORDARR_BUILD=$(git rev-parse --short HEAD) docker compose up -d --build
WORDARR_BUILD: ${WORDARR_BUILD:-}
container_name: wordarr
ports:
- "8787:8787"
+218 -159
View File
@@ -2,7 +2,7 @@ const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
let libraries = [];
const LANGUAGE_NAMES = { german: "Deutsch", english: "Englisch" };
const languageName = (code) => (code ? t("lang." + code) : "");
const SOURCE_NAMES = {
musicbrainz: "MusicBrainz",
hardcover: "Hardcover",
@@ -30,7 +30,7 @@ async function createRequest(body) {
try {
return await api("/api/requests", { method: "POST", body: JSON.stringify(body) });
} catch (err) {
if (err.status === 409 && confirm(err.message + "\nTrotzdem anlegen?")) {
if (err.status === 409 && confirm(err.message + "\n" + t("detail.duplicateConfirm"))) {
return api("/api/requests?allow_duplicate=true", {
method: "POST", body: JSON.stringify(body),
});
@@ -61,6 +61,14 @@ function getLastLib(type) {
function setLastLib(type, id) {
try { localStorage.setItem("wordarr.lastLib." + type, String(id)); } catch {}
}
function noLibraryOption() {
return `<option value="">${t("common.noLibrary")}</option>`;
}
function decimalMark() {
return lang === "de" ? "," : ".";
}
function libOptions(mediaType) {
const last = getLastLib(mediaType);
return libraries
@@ -80,9 +88,9 @@ async function refreshMissingBadge() {
}
// ---- navigation ----
$$("nav button").forEach((btn) =>
$$("nav button[data-view]").forEach((btn) =>
btn.addEventListener("click", () => {
$$("nav button").forEach((b) => b.classList.remove("active"));
$$("nav button[data-view]").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
$$("main > section").forEach((s) => (s.hidden = true));
$("#view-" + btn.dataset.view).hidden = false;
@@ -101,10 +109,10 @@ async function loadLibraries() {
(l) => `<tr>
<td>${esc(l.name)}</td><td>${esc(l.media_type)}</td><td>${esc(l.root_path)}</td>
<td class="mono">${esc(l.folder_template)}</td><td class="mono">${esc(l.file_template)}</td>
<td>${l.language ? esc(LANGUAGE_NAMES[l.language] || l.language) : "—"}</td>
<td>${l.language ? esc(languageName(l.language)) : "—"}</td>
<td class="row">
<button class="secondary" data-edit-lib="${l.id}">Bearbeiten</button>
<button class="danger" data-del-lib="${l.id}">Löschen</button>
<button class="secondary" data-edit-lib="${l.id}">${t("common.edit")}</button>
<button class="danger" data-del-lib="${l.id}">${t("common.delete")}</button>
</td>
</tr>`
)
@@ -114,7 +122,7 @@ async function loadLibraries() {
);
tbody.querySelectorAll("[data-del-lib]").forEach((b) =>
b.addEventListener("click", async () => {
if (!confirm("Library löschen?")) return;
if (!confirm(t("lib.confirmDelete"))) return;
try {
await api("/api/libraries/" + b.dataset.delLib, { method: "DELETE" });
loadLibraries();
@@ -123,7 +131,7 @@ async function loadLibraries() {
);
const filter = $("#missing-filter-library");
filter.innerHTML =
'<option value="">Alle Libraries</option>' +
`<option value="">${t("list.allLibraries")}</option>` +
libraries.map((l) => `<option value="${l.id}">${esc(l.name)}</option>`).join("");
}
@@ -138,8 +146,8 @@ function startLibraryEdit(id) {
form.file_template.value = lib.file_template;
form.language.value = lib.language || "";
form.dataset.editId = lib.id;
$("#lib-form-title").textContent = `Library „${lib.name}“ bearbeiten`;
$("#lib-form-submit").textContent = "Speichern";
$("#lib-form-title").textContent = t("lib.edit", { name: lib.name });
$("#lib-form-submit").textContent = t("common.save");
$("#lib-form-cancel").hidden = false;
form.scrollIntoView({ behavior: "smooth" });
}
@@ -148,8 +156,8 @@ function endLibraryEdit() {
const form = $("#lib-form");
form.reset();
delete form.dataset.editId;
$("#lib-form-title").textContent = "Neue Library";
$("#lib-form-submit").textContent = "Anlegen";
$("#lib-form-title").textContent = t("lib.new");
$("#lib-form-submit").textContent = t("common.create");
$("#lib-form-cancel").hidden = true;
}
$("#lib-form-cancel").addEventListener("click", endLibraryEdit);
@@ -163,10 +171,10 @@ $("#lib-form").addEventListener("submit", async (e) => {
try {
if (editId) {
await api("/api/libraries/" + editId, { method: "PUT", body: JSON.stringify(data) });
toast("Library gespeichert — gilt für künftige Importe");
toast(t("lib.saved"));
} else {
await api("/api/libraries", { method: "POST", body: JSON.stringify(data) });
toast("Library angelegt");
toast(t("lib.created"));
}
endLibraryEdit();
loadLibraries();
@@ -193,30 +201,51 @@ $("#lib-form").addEventListener("submit", async (e) => {
// ---- search & request ----
const EMPTY_HINTS = {
ebook: "Keine Treffer. Tipp: ISBN ohne Bindestriche oder Titel + Autor versuchen — und ggf. „Alle Sprachen“ wählen.",
audiobook: "Keine Treffer. Tipp: EAN/ISBN funktioniert bei Audible nicht — nach Titel/Autor suchen.",
comic: "Keine Treffer bei AniList (Manga). Westliche Comics per „Manuell anlegen“ erfassen.",
ebook: "search.emptyEbook",
audiobook: "search.emptyAudiobook",
comic: "search.emptyComic",
};
$("#search-type").addEventListener("change", () => ($("#search-results").innerHTML = ""));
let lastSearch = null; // kept so a language switch can redraw the hits
$("#search-type").addEventListener("change", () => {
lastSearch = null;
$("#search-results").innerHTML = "";
});
$("#search-form").addEventListener("submit", async (e) => {
e.preventDefault();
const type = $("#search-type").value;
const q = $("#search-q").value;
const box = $("#search-results");
const searchBtn = $("#search-btn");
searchBtn.disabled = true;
searchBtn.innerHTML = '<span class="spinner"></span> Suche…';
searchBtn.innerHTML = `<span class="spinner"></span> ${t("search.running")}`;
try {
const language = $("#search-language").value;
const results = await api(
`/api/search?media_type=${type}&q=${encodeURIComponent(q)}&language=${language}`
);
if (!results.length) {
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[type]}</p>`;
return;
}
lastSearch = { results, type, language };
renderSearchResults();
} catch (err) {
lastSearch = null;
$("#search-results").innerHTML = "";
toast(err.message, true);
} finally {
searchBtn.disabled = false;
searchBtn.textContent = t("common.search");
}
});
function renderSearchResults() {
if (!lastSearch) return;
const { results, type, language } = lastSearch;
const box = $("#search-results");
if (!results.length) {
box.innerHTML = `<p class='muted'>${esc(t(EMPTY_HINTS[type]))}</p>`;
return;
}
{
const opts = libOptions(type);
box.innerHTML = results
.map(
@@ -227,12 +256,12 @@ $("#search-form").addEventListener("submit", async (e) => {
<span>${esc(r.authors)}</span>
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
${r.series && r.media_type ? `<span class="muted">📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}</span>` : ""}
<span class="muted">${r.year ?? ""} ${r.language ? "· " + esc(LANGUAGE_NAMES[r.language] || r.language) : ""} ${r.source && r.source !== "audible" ? "· " + esc(SOURCE_NAMES[r.source] || r.source) : ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""}
<span class="muted">${r.year ?? ""} ${r.language ? "· " + esc(languageName(r.language)) : ""} ${r.source && r.source !== "audible" ? "· " + esc(SOURCE_NAMES[r.source] || r.source) : ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
${type === "comic" ? `<input type="number" min="0" placeholder="${t("common.volume")}" class="volume" data-vol="${i}">` : ""}
<div class="row">
<select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select>
<button data-req="${i}">Anfragen</button>
${r.series_id ? `<button class="secondary" data-series="${i}">Ganze Serie…</button>` : ""}
<select data-lib="${i}">${opts || noLibraryOption()}</select>
<button data-req="${i}">${t("common.request")}</button>
${r.series_id ? `<button class="secondary" data-series="${i}">${t("search.wholeSeries")}</button>` : ""}
</div>
</div>
</div>`
@@ -248,7 +277,7 @@ $("#search-form").addEventListener("submit", async (e) => {
const i = btn.dataset.req;
const r = results[i];
const libId = box.querySelector(`[data-lib="${i}"]`).value;
if (!libId) { toast("Erst eine Library für diesen Typ anlegen (Tab Libraries)", true); return; }
if (!libId) { toast(t("common.needLibraryFirst"), true); return; }
const volInput = box.querySelector(`[data-vol="${i}"]`);
try {
await createRequest({
@@ -259,20 +288,14 @@ $("#search-form").addEventListener("submit", async (e) => {
volume: volInput && volInput.value ? parseInt(volInput.value) : (r.volume ?? null),
});
setLastLib(type, libId);
btn.textContent = "✓ Angefragt";
btn.textContent = t("search.requested");
btn.disabled = true;
refreshMissingBadge();
} catch (err) { toast(err.message, true); }
})
);
} catch (err) {
box.innerHTML = "";
toast(err.message, true);
} finally {
searchBtn.disabled = false;
searchBtn.textContent = "Suchen";
}
});
}
// ---- request a whole series ----
let seriesEpisodes = [];
@@ -291,9 +314,10 @@ function renderSeriesList() {
let gap = "";
if (e.volume != null && previous != null && e.volume > previous + 1) {
const missing = e.volume - previous - 1;
const ebook = seriesType === "ebook";
const unit = ebook ? (missing > 1 ? "Bände" : "Band") : `Folge${missing > 1 ? "n" : ""}`;
gap = `<div class="gap">… ${missing} ${unit} nicht ${ebook ? "bei Hardcover" : "bei Audible"} gefunden (${previous + 1}${e.volume - 1})</div>`;
const key = seriesType === "ebook"
? (missing > 1 ? "series.gapVolumes" : "series.gapVolume")
: (missing > 1 ? "series.gapEpisodes" : "series.gapEpisode");
gap = `<div class="gap">${esc(t(key, { n: missing, from: previous + 1, to: e.volume - 1 }))}</div>`;
}
if (e.volume != null) previous = e.volume;
return `${gap}<label>
@@ -315,20 +339,21 @@ function seriesChecked() {
function updateSeriesCount() {
const n = seriesChecked().length;
const btn = $("#series-submit");
btn.textContent = n ? `${n} anfragen` : "Anfragen";
btn.textContent = n ? t("series.requestN", { n }) : t("common.request");
btn.disabled = n === 0;
}
async function openSeriesDialog(result, type = "audiobook", language = "") {
const dlg = $("#series-dialog");
seriesType = type;
const unit = type === "ebook" ? "Bände" : "Folgen";
$("#series-title").textContent = result.series || "Serie";
$("#series-info").innerHTML = `<span class="spinner"></span> ${unit} werden gesucht…`;
const unit = t(type === "ebook" ? "series.unitVolumes" : "series.unitEpisodes");
$("#series-title").textContent = result.series || t("series.fallbackTitle");
$("#series-info").innerHTML =
`<span class="spinner"></span> ${esc(t("series.loading", { unit }))}`;
$("#series-list").innerHTML = "";
$("#series-from").value = "";
$("#series-to").value = "";
$("#series-library").innerHTML = libOptions(type) || "<option value=''>— keine Library —</option>";
$("#series-library").innerHTML = libOptions(type) || noLibraryOption();
$("#series-submit").disabled = true;
dlg.showModal();
try {
@@ -343,11 +368,12 @@ async function openSeriesDialog(result, type = "audiobook", language = "") {
}
const numbered = seriesEpisodes.filter((e) => e.volume != null);
$("#series-info").textContent =
`${seriesEpisodes.length} ${unit} gefunden` +
(numbered.length ? ` (Nr. ${numbered[0].volume}${numbered[numbered.length - 1].volume})` : "") +
(type === "ebook"
? ". Bände ohne Ausgabe in der gewählten Sprache stehen mit ihrem Originaltitel da."
: ". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen.");
t("series.found", { n: seriesEpisodes.length, unit }) +
(numbered.length
? t("series.range", { from: numbered[0].volume,
to: numbered[numbered.length - 1].volume })
: "") +
t(type === "ebook" ? "series.noteEbook" : "series.noteAudiobook");
renderSeriesList();
}
@@ -366,7 +392,10 @@ $("#series-cancel").addEventListener("click", () => $("#series-dialog").close())
$("#series-submit").addEventListener("click", async () => {
const libId = $("#series-library").value;
if (!libId) { toast(`Erst eine ${seriesType === "ebook" ? "Ebook" : "Audiobook"}-Library anlegen (Tab Libraries)`, true); return; }
if (!libId) {
toast(t("series.needLibrary", { type: seriesType === "ebook" ? "Ebook" : "Audiobook" }), true);
return;
}
const items = seriesChecked().map((e) => ({
title: e.title, authors: e.authors, narrator: e.narrator || "",
external_id: e.external_id, year: e.year, series: e.series,
@@ -374,7 +403,7 @@ $("#series-submit").addEventListener("click", async () => {
}));
const btn = $("#series-submit");
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Lege an…';
btn.innerHTML = `<span class="spinner"></span> ${t("common.creating")}`;
try {
const res = await api("/api/requests/bulk-items", {
method: "POST",
@@ -382,8 +411,8 @@ $("#series-submit").addEventListener("click", async () => {
});
setLastLib(seriesType, libId);
toast(
`${res.created.length} Anfragen angelegt` +
(res.skipped ? `, ${res.skipped} bereits vorhanden` : "")
t("series.createdN", { n: res.created.length }) +
(res.skipped ? t("series.skippedN", { n: res.skipped }) : "")
);
$("#series-dialog").close();
refreshMissingBadge();
@@ -400,7 +429,7 @@ $("#manual-btn").addEventListener("click", () => {
const dlg = $("#manual-dialog");
const typeSel = dlg.querySelector("[name=media_type]");
const updateLibs = () => {
$("#manual-library").innerHTML = libOptions(typeSel.value) || "<option value=''>— keine Library —</option>";
$("#manual-library").innerHTML = libOptions(typeSel.value) || noLibraryOption();
};
typeSel.onchange = updateLibs;
updateLibs();
@@ -410,11 +439,11 @@ $("#manual-btn").addEventListener("click", () => {
$("#manual-form").addEventListener("submit", async (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
const data = Object.fromEntries(new FormData(e.target));
if (!data.library_id) { toast("Keine Library gewählt", true); return; }
if (!data.library_id) { toast(t("manual.noLibraryChosen"), true); return; }
try {
if (data.volume_to) {
if (!data.volume) {
toast("Für Bulk bitte Start-Band angeben", true);
toast(t("manual.needStartVolume"), true);
return;
}
const created = await api("/api/requests/bulk", {
@@ -428,7 +457,7 @@ $("#manual-form").addEventListener("submit", async (e) => {
year: data.year ? parseInt(data.year) : null,
}),
});
toast(`${created.length} Anfragen angelegt`);
toast(t("manual.createdN", { n: created.length }));
} else {
await createRequest({
library_id: parseInt(data.library_id),
@@ -438,7 +467,7 @@ $("#manual-form").addEventListener("submit", async (e) => {
year: data.year ? parseInt(data.year) : null,
volume: data.volume ? parseInt(data.volume) : null,
});
toast("Anfrage angelegt");
toast(t("manual.created"));
}
setLastLib(data.media_type, data.library_id);
e.target.reset();
@@ -449,7 +478,7 @@ $("#manual-form").addEventListener("submit", async (e) => {
// ---- missing / imported lists ----
let currentRequests = { missing: [], imported: [] };
// view mode (Liste / kleine / große Symbole), shared by both lists
// view mode (list / small / large icons), shared by both lists
function getViewMode() {
try { return localStorage.getItem("wordarr.viewMode") || "small"; } catch { return "small"; }
}
@@ -494,7 +523,7 @@ function renderRequests(status) {
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete");
if (!reqs.length) {
box.innerHTML = "<p class='muted'>Nichts hier.</p>";
box.innerHTML = `<p class='muted'>${t("common.nothingHere")}</p>`;
bulkBtn.hidden = true;
return;
}
@@ -503,7 +532,7 @@ function renderRequests(status) {
(r) => `<div class="card clickable" data-detail="${r.id}">
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
<div class="card-body">
<strong>${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}</strong>
<strong>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}</strong>
<span>${esc(r.authors)}</span>
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
<span class="muted">${esc(r.media_type)}${esc(r.library_name)}</span>
@@ -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 = '<span class="spinner"></span> Lege ab…';
btn.innerHTML = `<span class="spinner"></span> ${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 = '<span class="spinner"></span> Tagge…';
btn.innerHTML = `<span class="spinner"></span> ${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 = '<span class="spinner"></span> Scanne…';
$("#scan-info").innerHTML = `<span class="spinner"></span> ${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 =
'<option value="">Alle Formate</option>' +
`<option value="">${t("import.allFormats")}</option>` +
all.map((f) => `<option value="${esc(f)}" ${f === current ? "selected" : ""}>.${esc(f)}</option>`).join("");
}
@@ -802,25 +831,25 @@ function renderImportTable() {
<td class="mono">
${esc(item.name)}${item.is_dir ? " 📁" : ""}
${formatTag(item)}
${item.parts ? `<span class="tag">${item.parts.length} Teile · ${item.files.length} Dateien</span>` : ""}
${item.parts ? `<span class="tag">${t("import.parts", { parts: item.parts.length, files: item.files.length })}</span>` : ""}
${!item.parts && item.maybe_separate
? `<span class="tag warn" title="Die ${item.files.length} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen">${item.files.length} Titel?</span>`
? `<span class="tag warn" title="${esc(t("import.maybeSeparateTitle", { n: item.files.length }))}">${t("import.maybeSeparate", { n: item.files.length })}</span>`
: ""}
${item.rel_dir ? `<div class="subpath">${esc(item.rel_dir)}/</div>` : ""}
</td>
<td>${esc(item.media_type)}</td>
<td class="row">
<select data-select="${i}"><option value="">— überspringen —</option>${opts}
${appendOpts ? `<optgroup label="Bereits importiert — Teile anhängen">${appendOpts}</optgroup>` : ""}
<select data-select="${i}"><option value="">${t("import.skip")}</option>${opts}
${appendOpts ? `<optgroup label="${esc(t("import.appendGroup"))}">${appendOpts}</optgroup>` : ""}
</select>
<button class="secondary" data-quick="${i}" title="Metadaten suchen und Anfrage direkt verbinden">🔍</button>
<button class="secondary" data-quick="${i}" title="${esc(t("import.quickTitle"))}">🔍</button>
${item.parts || item.maybe_separate
? `<button class="secondary" data-split="${i}" title="${item.parts ? "Zusammenfassung wieder auflösen" : "Ordner in einzelne Titel aufteilen"}">✂️</button>`
? `<button class="secondary" data-split="${i}" title="${esc(t(item.parts ? "import.unmerge" : "import.splitDir"))}">✂️</button>`
: ""}
</td>
<td>${libraryCell(item, i)}</td>
${item.importState ? importStateCell(item)
: item.conflict ? '<td class="state conflict" title="Mehrere Einträge zeigen auf dieselbe Anfrage">⚠ Konflikt</td>'
: item.conflict ? `<td class="state conflict" title="${esc(t("import.conflictTitle"))}">${t("import.conflict")}</td>`
: scoreCell(item)}
</tr>`;
})
@@ -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) => `<option value="${l.id}" ${l.id === req.library_id ? "selected" : ""}>${esc(l.name)}</option>`)
.join("");
return `<select data-lib-for="${i}" title="Ziel-Library dieser Anfrage ändern">${opts}</select>`;
return `<select data-lib-for="${i}" title="${esc(t("import.libraryCellTitle"))}">${opts}</select>`;
}
function requestById(id) {
@@ -903,18 +934,19 @@ function requestById(id) {
function importStateCell(item) {
if (item.importState === "running") {
return '<td class="state running"><span class="spinner"></span> läuft…</td>';
return `<td class="state running"><span class="spinner"></span> ${t("import.running")}</td>`;
}
if (item.importState === "done") return '<td class="state done">✓ importiert</td>';
return `<td class="state failed" title="${esc(item.importError)}">✗ Fehler</td>`;
if (item.importState === "done") return `<td class="state done">${t("import.done")}</td>`;
return `<td class="state failed" title="${esc(item.importError)}">${t("import.failed")}</td>`;
}
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) || "<option value=''>— keine Library —</option>";
$("#names-library").innerHTML = libOptions(type) || noLibraryOption();
renderNamesPreview();
$("#names-dialog").showModal();
});
@@ -1008,7 +1040,7 @@ function renderNamesPreview() {
})
.join("") +
(namesTargets.length > shown.length
? `<div class="gap">… und ${namesTargets.length - shown.length} weitere</div>`
? `<div class="gap">${esc(t("names.more", { n: namesTargets.length - shown.length }))}</div>`
: "");
}
@@ -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 = '<span class="spinner"></span> Lege an…';
btn.innerHTML = `<span class="spinner"></span> ${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) || "<option value=''>— keine Library —</option>";
$("#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) => `<div class="row open-req">
<span>${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}${esc(r.authors)} (${esc(r.library_name)})</span>
<button class="secondary" data-link-req="${r.id}">Verbinden</button>
<span>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}${esc(r.authors)} (${esc(r.library_name)})</span>
<button class="secondary" data-link-req="${r.id}">${t("common.link")}</button>
</div>`
)
.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 = "<p class='muted'>Suche läuft…</p>";
box.innerHTML = `<p class='muted'>${t("quick.searching")}</p>`;
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 = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
box.innerHTML = `<p class='muted'>${esc(t(EMPTY_HINTS[item.media_type]))}</p>`;
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 `<div class="card">
@@ -1289,8 +1321,8 @@ async function runQuickSearch() {
<strong>${esc(r.title)}</strong>
<span class="muted">${meta}</span>
</div>
<button data-edit="${j}" class="secondary" title="Felder vor dem Anlegen anpassen (z.B. Serie)">✎</button>
<button data-pick="${j}" title="Anfrage anlegen und mit diesem Eintrag verbinden">Verbinden</button>
<button data-edit="${j}" class="secondary" title="${esc(t("quick.editTitle"))}">✎</button>
<button data-pick="${j}" title="${esc(t("quick.pickTitle"))}">${t("common.link")}</button>
</div>`;
})
.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 =
`<p class="error">Nichts importiert: alle ausgewählten Einträge waren mehreren ` +
`Anfragen zugeordnet — Filter „Nur Konflikte" zeigt sie.</p>`;
$("#import-summary").innerHTML = `<p class="error">${esc(t("import.nothingImported"))}</p>`;
renderImportTable();
return;
}
@@ -1520,12 +1546,11 @@ $("#import-btn").addEventListener("click", async () => {
const failed = allResults.filter((r) => !r.ok);
$("#import-summary").innerHTML =
`<p class="${failed.length ? "error" : "ok"}"><strong>` +
`${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 })) : "") +
`</strong></p>` +
(skippedConflicts
? `<p class="error">${skippedConflicts} Anfrage(n) waren mehreren Einträgen ` +
`zugeordnet und wurden übersprungen — Filter „Nur Konflikte" zeigt sie.</p>`
? `<p class="error">${esc(t("import.summarySkipped", { n: skippedConflicts }))}</p>`
: "");
$("#import-results").innerHTML = [...failed, ...allResults.filter((r) => r.ok)]
.map((r) =>
@@ -1534,7 +1559,8 @@ $("#import-btn").addEventListener("click", async () => {
: `<p class="error">✗ ${esc(r.path)}: ${esc(r.error)}</p>`
)
.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,40 @@ 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();
}
// ---- version ----
// the build stamp is the quickest answer to "did the redeploy arrive?"
async function showVersion() {
try {
const v = await api("/api/version");
// the version alone rarely moves, so the stamp is what tells deploys apart
$("#app-version").textContent = `v${v.version} · ${v.build || v.built_at}`;
$("#app-version").title = `${v.built_at} UTC${v.build ? " · " + v.build : ""}`;
} catch {}
}
// ---- init ----
applyI18n();
updateLangButton();
endLibraryEdit();
loadLibraries();
refreshMissingBadge();
showVersion();
+486
View File
@@ -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 AZ",
"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 AZ",
"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();
}
+141 -176
View File
@@ -11,15 +11,17 @@
</head>
<body>
<header>
<h1>📚 wordarr</h1>
<h1>📚 wordarr <span id="app-version"></span></h1>
<nav>
<button data-view="search" class="active">Suche</button>
<button data-view="search" class="active" data-i18n="nav.search"></button>
<button data-view="missing">
Missing <span id="missing-badge" class="badge" hidden></span>
<span data-i18n="nav.missing"></span>
<span id="missing-badge" class="badge" hidden></span>
</button>
<button data-view="imported">Importiert</button>
<button data-view="import">Import</button>
<button data-view="settings">Libraries</button>
<button data-view="imported" data-i18n="nav.imported"></button>
<button data-view="import" data-i18n="nav.import"></button>
<button data-view="settings" data-i18n="nav.settings"></button>
<button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
</nav>
</header>
<main>
@@ -30,71 +32,67 @@
<option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option>
</select>
<select id="search-language" title="Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche">
<option value="">Alle Sprachen</option>
<option value="german">Deutsch</option>
<option value="english">Englisch</option>
<select id="search-language" data-i18n-title="search.languageTitle">
<option value="" data-i18n="search.allLanguages"></option>
<option value="german" data-i18n="lang.german"></option>
<option value="english" data-i18n="lang.english"></option>
</select>
<input id="search-q" placeholder="Titel, Autor oder ISBN…" required />
<button type="submit" id="search-btn">Suchen</button>
<button type="button" id="manual-btn" class="secondary">
Manuell anlegen
</button>
<input id="search-q" data-i18n-placeholder="search.placeholder" required />
<button type="submit" id="search-btn" data-i18n="common.search"></button>
<button type="button" id="manual-btn" class="secondary" data-i18n="search.manual"></button>
</form>
<div id="search-results" class="cards"></div>
<dialog id="manual-dialog">
<form id="manual-form" method="dialog">
<h3>Manuell anfragen</h3>
<h3 data-i18n="manual.heading"></h3>
<label
>Typ
><span data-i18n="common.type"></span>
<select name="media_type">
<option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option>
</select>
</label>
<label>Titel <input name="title" required /></label>
<label>Autor(en) <input name="authors" /></label>
<label>Sprecher <input name="narrator" /></label>
<label>Serie <input name="series" /></label>
<label>Band <input name="volume" type="number" min="0" /></label>
<label><span data-i18n="common.title"></span> <input name="title" required /></label>
<label><span data-i18n="common.authors"></span> <input name="authors" /></label>
<label><span data-i18n="common.narrator"></span> <input name="narrator" /></label>
<label><span data-i18n="common.series"></span> <input name="series" /></label>
<label><span data-i18n="common.volume"></span> <input name="volume" type="number" min="0" /></label>
<label
>bis Band (Bulk, optional)
><span data-i18n="manual.volumeTo"></span>
<input name="volume_to" type="number" min="0" />
<span class="muted"
>Legt pro Band/Folge eine Anfrage an, z.B. 1 bis 300</span
>
<span class="muted" data-i18n="manual.bulkHint"></span>
</label>
<label>Jahr <input name="year" type="number" /></label>
<label>ISBN/ID <input name="external_id" /></label>
<label><span data-i18n="common.year"></span> <input name="year" type="number" /></label>
<label><span data-i18n="manual.externalId"></span> <input name="external_id" /></label>
<label
>Library
><span data-i18n="common.library"></span>
<select name="library_id" id="manual-library"></select
></label>
<div class="row">
<button value="cancel" class="secondary">Abbrechen</button>
<button value="ok" id="manual-submit">Anfragen</button>
<button value="cancel" class="secondary" data-i18n="common.cancel"></button>
<button value="ok" id="manual-submit" data-i18n="common.request"></button>
</div>
</form>
</dialog>
<dialog id="series-dialog">
<h3 id="series-title">Serie anfragen</h3>
<h3 id="series-title"></h3>
<p class="muted" id="series-info"></p>
<div class="bar wrap">
<label>von Folge <input id="series-from" type="number" min="0" /></label>
<label>bis Folge <input id="series-to" type="number" min="0" /></label>
<button type="button" id="series-all" class="secondary">Alle</button>
<button type="button" id="series-none" class="secondary">Keine</button>
<label><span data-i18n="series.from"></span> <input id="series-from" type="number" min="0" /></label>
<label><span data-i18n="series.to"></span> <input id="series-to" type="number" min="0" /></label>
<button type="button" id="series-all" class="secondary" data-i18n="common.all"></button>
<button type="button" id="series-none" class="secondary" data-i18n="common.none"></button>
</div>
<div id="series-list" class="series-list"></div>
<div class="row">
<label>Library <select id="series-library"></select></label>
<label><span data-i18n="common.library"></span> <select id="series-library"></select></label>
</div>
<div class="row">
<button type="button" id="series-cancel" class="secondary">Abbrechen</button>
<button type="button" id="series-submit">Anfragen</button>
<button type="button" id="series-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="series-submit" data-i18n="common.request"></button>
</div>
</dialog>
</section>
@@ -102,23 +100,21 @@
<section id="view-missing" hidden>
<div class="bar wrap">
<select id="missing-filter-type">
<option value="">Alle Typen</option>
<option value="" data-i18n="list.allTypes"></option>
<option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option>
</select>
<select id="missing-filter-library">
<option value="">Alle Libraries</option>
<option value="" data-i18n="list.allLibraries"></option>
</select>
<input id="missing-search" placeholder="Filtern (Titel/Autor/Serie)…" />
<button id="missing-select-all" class="secondary">Alle auswählen</button>
<button id="missing-bulk-delete" class="danger" hidden>
Ausgewählte entfernen
</button>
<div class="view-toggle" role="group" aria-label="Ansicht">
<button data-viewmode="list" title="Liste"></button>
<button data-viewmode="small" title="Kleine Symbole"></button>
<button data-viewmode="large" title="Große Symbole"></button>
<input id="missing-search" data-i18n-placeholder="list.filterPlaceholder" />
<button id="missing-select-all" class="secondary" data-i18n="common.selectAll"></button>
<button id="missing-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
<div class="view-toggle" role="group" data-i18n-aria="list.view">
<button data-viewmode="list" data-i18n-title="list.viewList"></button>
<button data-viewmode="small" data-i18n-title="list.viewSmall"></button>
<button data-viewmode="large" data-i18n-title="list.viewLarge"></button>
</div>
</div>
<div id="missing-list" class="cards"></div>
@@ -126,15 +122,13 @@
<section id="view-imported" hidden>
<div class="bar wrap">
<input id="imported-search" placeholder="Filtern (Titel/Autor/Serie)…" />
<button id="imported-select-all" class="secondary">Alle auswählen</button>
<button id="imported-bulk-delete" class="danger" hidden>
Ausgewählte entfernen
</button>
<div class="view-toggle" role="group" aria-label="Ansicht">
<button data-viewmode="list" title="Liste"></button>
<button data-viewmode="small" title="Kleine Symbole"></button>
<button data-viewmode="large" title="Große Symbole"></button>
<input id="imported-search" data-i18n-placeholder="list.filterPlaceholder" />
<button id="imported-select-all" class="secondary" data-i18n="common.selectAll"></button>
<button id="imported-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
<div class="view-toggle" role="group" data-i18n-aria="list.view">
<button data-viewmode="list" data-i18n-title="list.viewList"></button>
<button data-viewmode="small" data-i18n-title="list.viewSmall"></button>
<button data-viewmode="large" data-i18n-title="list.viewLarge"></button>
</div>
</div>
<div id="imported-list" class="cards"></div>
@@ -142,73 +136,61 @@
<section id="view-import" hidden>
<div class="bar wrap">
<button id="scan-btn">Download-Ordner scannen</button>
<button id="scan-btn" data-i18n="import.scan"></button>
<label class="muted"
><input type="checkbox" id="split-dirs" /> Ordner als Einzeldateien
behandeln (z.B. Folgen-Sammlungen)</label
>
><input type="checkbox" id="split-dirs" /> <span data-i18n="import.splitDirs"></span
></label>
<span id="scan-info" class="muted"></span>
</div>
<div class="bar wrap" id="import-toolbar" hidden>
<input id="import-filter-text" placeholder="Filtern (Name/Pfad)…" />
<input id="import-filter-text" data-i18n-placeholder="import.filterPlaceholder" />
<select id="import-filter-mode">
<option value="all">Alle</option>
<option value="suggested">Nur mit Vorschlag</option>
<option value="unassigned">Nur ohne Zuordnung</option>
<option value="conflict">Nur Konflikte</option>
<option value="all" data-i18n="common.all"></option>
<option value="suggested" data-i18n="import.modeSuggested"></option>
<option value="unassigned" data-i18n="import.modeUnassigned"></option>
<option value="conflict" data-i18n="import.modeConflict"></option>
</select>
<select id="import-filter-format" title="Nur Einträge mit diesem Dateiformat zeigen">
<option value="">Alle Formate</option>
<select id="import-filter-format" data-i18n-title="import.formatTitle">
<option value="" data-i18n="import.allFormats"></option>
</select>
<select id="import-sort">
<option value="none">Reihenfolge: Scan</option>
<option value="score">Score absteigend</option>
<option value="name">Name AZ</option>
<option value="none" data-i18n="import.sortScan"></option>
<option value="score" data-i18n="import.sortScore"></option>
<option value="name" data-i18n="import.sortName"></option>
</select>
<button id="import-select-suggested" class="secondary">
Alle mit Vorschlag auswählen
</button>
<button id="import-select-suggested" class="secondary" data-i18n="import.selectSuggested"></button>
<button id="import-select-all" class="secondary"
title="Alle Einträge der aktuellen Ansicht auswählen, auch ohne Zuordnung">
Alle auswählen
</button>
data-i18n-title="import.selectAllTitle" data-i18n="common.selectAll"></button>
<button id="import-select-page" class="secondary"
title="Nur die Einträge dieser Seite auswählen, alle anderen abwählen">
Diese Seite auswählen
</button>
<button id="import-deselect-all" class="secondary">
Alle abwählen
</button>
<button id="import-merge" class="secondary" title="Mehrteiler: ausgewählte Einträge als ein Hörbuch importieren">
Ausgewählte zusammenfassen
</button>
data-i18n-title="import.selectPageTitle" data-i18n="import.selectPage"></button>
<button id="import-deselect-all" class="secondary" data-i18n="import.deselectAll"></button>
<button id="import-merge" class="secondary"
data-i18n-title="import.mergeTitle" data-i18n="import.merge"></button>
<button id="import-from-names" class="secondary"
title="Für ausgewählte Einträge ohne Zuordnung Anfragen aus den Ordnernamen anlegen">
Anfragen aus Ordnernamen
</button>
data-i18n-title="import.fromNamesTitle" data-i18n="import.fromNames"></button>
</div>
<div class="table-scroll">
<table id="import-table" hidden>
<thead>
<tr>
<th></th>
<th>Datei/Ordner</th>
<th>Typ</th>
<th>Zuordnung</th>
<th>Library</th>
<th>Score</th>
<th data-i18n="import.colFile"></th>
<th data-i18n="common.type"></th>
<th data-i18n="import.colAssignment"></th>
<th data-i18n="common.library"></th>
<th id="import-score-head" data-i18n="common.score"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="bar" id="import-pager" hidden>
<button id="import-prev" class="secondary">← Zurück</button>
<button id="import-prev" class="secondary" data-i18n="import.prev"></button>
<span id="import-page-info" class="muted"></span>
<button id="import-next" class="secondary">Weiter →</button>
<button id="import-next" class="secondary" data-i18n="import.next"></button>
</div>
<div class="bar">
<button id="import-btn" hidden>Ausgewählte importieren</button>
<button id="import-btn" hidden data-i18n="import.run"></button>
<div id="import-progress" hidden>
<progress max="1" value="0"></progress>
<span class="muted"></span>
@@ -218,49 +200,47 @@
<div id="import-results"></div>
<dialog id="names-dialog">
<h3>Anfragen aus Ordnernamen</h3>
<h3 data-i18n="names.heading"></h3>
<p class="muted" id="names-intro"></p>
<div class="bar wrap">
<label>Serie <input id="names-series" placeholder="optional, für alle" /></label>
<label>Autor <input id="names-authors" placeholder="optional, für alle" /></label>
<label>Library <select id="names-library"></select></label>
<label><span data-i18n="common.series"></span>
<input id="names-series" data-i18n-placeholder="common.optionalForAll" /></label>
<label><span data-i18n="common.author"></span>
<input id="names-authors" data-i18n-placeholder="common.optionalForAll" /></label>
<label><span data-i18n="common.library"></span> <select id="names-library"></select></label>
</div>
<div id="names-preview" class="series-list"></div>
<div class="row">
<button type="button" id="names-cancel" class="secondary">Abbrechen</button>
<button type="button" id="names-submit">Anlegen &amp; verbinden</button>
<button type="button" id="names-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="names-submit" data-i18n="common.createAndLink"></button>
</div>
</dialog>
<dialog id="conflict-dialog">
<h3>Mehrfach zugeordnete Anfragen</h3>
<h3 data-i18n="conflict.heading"></h3>
<p class="muted" id="conflict-intro"></p>
<div id="conflict-list" class="series-list"></div>
<div class="row">
<button type="button" id="conflict-cancel" class="secondary">Abbrechen</button>
<button type="button" id="conflict-merge" class="secondary">
Als je ein Hörbuch zusammenfassen
</button>
<button type="button" id="conflict-skip">
Konflikte überspringen, Rest importieren
</button>
<button type="button" id="conflict-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="conflict-merge" class="secondary" data-i18n="conflict.merge"></button>
<button type="button" id="conflict-skip" data-i18n="conflict.skip"></button>
</div>
</dialog>
<dialog id="quick-dialog">
<h3>Suchen &amp; Anfragen</h3>
<h3 data-i18n="quick.heading"></h3>
<p class="muted mono" id="quick-file"></p>
<p class="muted" id="quick-episode"></p>
<form id="quick-search-form" class="bar">
<input id="quick-q" placeholder="Titel/Serie suchen…" required />
<button type="submit">Suchen</button>
<input id="quick-q" data-i18n-placeholder="quick.placeholder" required />
<button type="submit" data-i18n="common.search"></button>
</form>
<label class="muted"
>Ziel-Library <select id="quick-library"></select
><span data-i18n="quick.targetLibrary"></span> <select id="quick-library"></select
></label>
<label class="muted"
>Offene Anfragen
<input id="quick-req-filter" placeholder="nach Titel, Autor, Serie filtern…" />
><span data-i18n="quick.openRequests"></span>
<input id="quick-req-filter" data-i18n-placeholder="quick.reqFilter" />
</label>
<div id="quick-scroll">
<div id="quick-open-requests"></div>
@@ -268,49 +248,48 @@
</div>
<div id="quick-foot">
<details id="quick-manual">
<summary id="quick-manual-summary">
Nicht bei Audible? Manuell anlegen
</summary>
<summary id="quick-manual-summary"></summary>
<p class="muted" id="quick-based-on" hidden></p>
<div class="quick-manual-fields">
<label>Titel <input id="quick-m-title" /></label>
<label>Autor <input id="quick-m-authors" placeholder="optional" /></label>
<label>Serie <input id="quick-m-series" placeholder="optional" /></label>
<label><span data-i18n="common.title"></span> <input id="quick-m-title" /></label>
<label><span data-i18n="common.author"></span>
<input id="quick-m-authors" data-i18n-placeholder="common.optional" /></label>
<label><span data-i18n="common.series"></span>
<input id="quick-m-series" data-i18n-placeholder="common.optional" /></label>
<label class="narrow"
>Folge/Band <input id="quick-m-volume" type="number" min="0" /></label>
><span data-i18n="quick.episodeVolume"></span>
<input id="quick-m-volume" type="number" min="0" /></label>
</div>
<button type="button" id="quick-m-submit">Anlegen &amp; verbinden</button>
<button type="button" id="quick-m-submit" data-i18n="common.createAndLink"></button>
</details>
<div class="row">
<button type="button" id="quick-close" class="secondary">
Schließen
</button>
<button type="button" id="quick-close" class="secondary" data-i18n="common.close"></button>
</div>
</div>
</dialog>
</section>
<section id="view-settings" hidden>
<h3>Libraries</h3>
<h3 data-i18n="lib.heading"></h3>
<div class="table-scroll">
<table id="lib-table">
<thead>
<tr>
<th>Name</th>
<th>Typ</th>
<th>Pfad</th>
<th>Ordner-Schema</th>
<th>Datei-Schema</th>
<th>Sprache</th>
<th data-i18n="common.name"></th>
<th data-i18n="common.type"></th>
<th data-i18n="common.path"></th>
<th data-i18n="lib.folderTemplate"></th>
<th data-i18n="lib.fileTemplate"></th>
<th data-i18n="common.language"></th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<h4 id="lib-form-title">Neue Library</h4>
<h4 id="lib-form-title"></h4>
<form id="lib-form" class="bar wrap">
<input name="name" placeholder="Name (z.B. Kids)" required />
<input name="name" data-i18n-placeholder="lib.namePlaceholder" required />
<select name="media_type">
<option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option>
@@ -324,29 +303,17 @@
list="path-suggestions"
autocomplete="off" />
<datalist id="path-suggestions"></datalist>
<input
name="folder_template"
placeholder="Ordner-Schema (optional)"
class="wide" />
<input
name="file_template"
placeholder="Datei-Schema (optional)"
class="wide" />
<select name="language" title="Sprache der Ausgaben in dieser Library — filtert die Metadaten-Suche">
<option value="">Sprache: egal</option>
<option value="german">Deutsch</option>
<option value="english">Englisch</option>
<input name="folder_template" data-i18n-placeholder="lib.folderPlaceholder" class="wide" />
<input name="file_template" data-i18n-placeholder="lib.filePlaceholder" class="wide" />
<select name="language" data-i18n-title="lib.languageTitle">
<option value="" data-i18n="lib.anyLanguage"></option>
<option value="german" data-i18n="lang.german"></option>
<option value="english" data-i18n="lang.english"></option>
</select>
<button type="submit" id="lib-form-submit">Anlegen</button>
<button type="button" id="lib-form-cancel" class="secondary" hidden>
Abbrechen
</button>
<button type="submit" id="lib-form-submit" data-i18n="common.create"></button>
<button type="button" id="lib-form-cancel" class="secondary" hidden data-i18n="common.cancel"></button>
</form>
<p class="muted">
Platzhalter: {Author} {Authors} {Narrator} {Narrators} {Title} {Year}
{Series} {Volume} — {Author}/{Narrator} nennen den ersten Namen,
{Authors}/{Narrators} alle. Leere Werte fallen samt Trenner weg.
</p>
<p class="muted" data-i18n="lib.placeholders"></p>
</section>
</main>
@@ -360,32 +327,30 @@
<p class="muted" id="detail-status"></p>
</div>
</div>
<label>Titel <input name="title" required /></label>
<label>Autor(en) <input name="authors" /></label>
<label>Sprecher <input name="narrator" /></label>
<label>Serie <input name="series" /></label>
<label><span data-i18n="common.title"></span> <input name="title" required /></label>
<label><span data-i18n="common.authors"></span> <input name="authors" /></label>
<label><span data-i18n="common.narrator"></span> <input name="narrator" /></label>
<label><span data-i18n="common.series"></span> <input name="series" /></label>
<div class="row">
<label>Band <input name="volume" type="number" min="0" /></label>
<label>Jahr <input name="year" type="number" /></label>
<label><span data-i18n="common.volume"></span> <input name="volume" type="number" min="0" /></label>
<label><span data-i18n="common.year"></span> <input name="year" type="number" /></label>
</div>
<label>ISBN/ASIN <input name="external_id" /></label>
<label>Library <select name="library_id" id="detail-library"></select></label>
<label><span data-i18n="detail.externalId"></span> <input name="external_id" /></label>
<label><span data-i18n="common.library"></span>
<select name="library_id" id="detail-library"></select></label>
<p class="muted mono" id="detail-path"></p>
<div class="row">
<button value="cancel" class="secondary">Abbrechen</button>
<button type="button" id="detail-retag" class="secondary" hidden>
Speichern &amp; neu taggen
</button>
<button value="cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="detail-retag" class="secondary" hidden data-i18n="detail.retag"></button>
<button type="button" id="detail-relocate" class="secondary" hidden
title="Ordner und Dateinamen nach dem Schema der Library neu anlegen">
Speichern &amp; neu ablegen
</button>
<button value="ok" id="detail-save">Speichern</button>
data-i18n-title="detail.relocateTitle" data-i18n="detail.relocate"></button>
<button value="ok" id="detail-save" data-i18n="common.save"></button>
</div>
</form>
</dialog>
<div id="toast" hidden></div>
<script src="/i18n.js"></script>
<script src="/app.js"></script>
</body>
</html>
+1
View File
@@ -10,6 +10,7 @@ header {
padding: 0.8rem; background: var(--panel); border-bottom: 1px solid var(--border);
}
h1 { font-size: 1.2rem; }
#app-version { color: var(--muted); font-size: 0.72rem; font-weight: 400; font-variant-numeric: tabular-nums; }
nav { display: flex; gap: 0.4rem; flex-wrap: wrap; }
nav button { background: none; border: none; color: var(--muted); padding: 0.5rem 0.9rem; cursor: pointer; border-radius: 4px; font-size: 0.95rem; }
nav button.active, nav button:hover { color: var(--text); background: var(--bg); }
+7
View File
@@ -1135,3 +1135,10 @@ def test_extra_ebook_extensions_are_configurable(client, monkeypatch):
finally:
monkeypatch.delenv("WORDARR_EXTRA_EBOOK_EXTENSIONS")
importlib.reload(config_module)
def test_version_endpoint_reports_a_build_stamp(client):
data = client.get("/api/version").json()
assert data["version"]
# "2026-09-01 15:43" - the newest source file, so a stale deploy stands out
assert len(data["built_at"]) == 16 and data["built_at"][4] == "-"
+36 -1
View File
@@ -57,12 +57,14 @@ def server(tmp_path_factory):
@pytest.fixture
def page(server):
"""A German browser, so the UI language is the same everywhere."""
with sync_api.sync_playwright() as pw:
try:
browser = pw.chromium.launch()
except Exception as exc: # browsers not downloaded
pytest.skip(f"no chromium: {exc}")
page = browser.new_page(viewport={"width": 1200, "height": 900})
page = browser.new_page(viewport={"width": 1200, "height": 900},
locale="de-DE")
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(server, wait_until="networkidle")
@@ -124,3 +126,36 @@ def test_format_tags_and_the_format_filter(page):
rows = page.locator("#import-table tbody tr")
assert rows.count() == 1
assert "epub" in rows.first.locator(".tag.fmt").text_content()
def test_the_browser_language_decides_and_the_toggle_switches(page, server):
assert page.locator("nav button[data-view=search]").text_content() == "Suche"
assert page.locator("#lang-toggle").text_content() == "EN"
page.click("#lang-toggle")
assert page.locator("nav button[data-view=search]").text_content() == "Search"
assert page.locator("#lang-toggle").text_content() == "DE"
assert page.get_attribute("html", "lang") == "en"
assert page.get_attribute("#search-q", "placeholder") == "Title, author or ISBN…"
page.reload(wait_until="networkidle") # the choice outlives the reload
assert page.locator("nav button[data-view=search]").text_content() == "Search"
def test_english_reaches_the_rendered_rows(page):
page.click("#lang-toggle")
page.click("nav button[data-view=import]")
page.click("#scan-btn")
page.wait_for_selector("[data-quick]")
assert "— skip —" in page.locator("#import-table tbody select").first.text_content()
assert "2.0 KB" in " ".join(page.locator("#import-table .tag.fmt").all_text_contents())
# switching back re-renders the table that is already on screen
page.click("#lang-toggle")
assert "— überspringen —" in page.locator("#import-table tbody select").first.text_content()
assert "2,0 KB" in " ".join(page.locator("#import-table .tag.fmt").all_text_contents())
def test_the_header_shows_the_running_version(page):
stamp = page.locator("#app-version").text_content()
assert stamp.startswith("v")
assert len(stamp) > 3 # version plus the build stamp behind it
+7 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from . import db
from . import db, version
from .api import fs, imports, libraries, requests, search
@@ -23,6 +23,12 @@ app.include_router(imports.router)
app.include_router(fs.router)
@app.get("/api/version")
def read_version():
return {"version": version.VERSION, "build": version.BUILD,
"built_at": version.BUILT_AT}
class RevalidatingStatics(StaticFiles):
"""Without a Cache-Control header the browser caches heuristically and does
not ask again, so a redeployed app.js kept rendering the old UI. no-cache
+24
View File
@@ -0,0 +1,24 @@
"""Version and build stamp, so the UI can say what is actually deployed."""
import os
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as package_version
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def _built_at() -> str:
"""Newest source file - a redeploy that did not take keeps the old stamp."""
files = [*(ROOT / "wordarr").rglob("*.py"), *(ROOT / "static").glob("*")]
newest = max((f.stat().st_mtime for f in files), default=0.0)
return datetime.fromtimestamp(newest, timezone.utc).strftime("%Y-%m-%d %H:%M")
try:
VERSION = package_version("wordarr")
except PackageNotFoundError: # running from a checkout without an install
VERSION = "0.0.0+src"
BUILD = os.environ.get("WORDARR_BUILD", "").strip() # git sha, set by the image build
BUILT_AT = _built_at()