Compare commits
4
Commits
v0.1.0
...
6493d15c52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6493d15c52 | ||
|
|
8e7be708b2 | ||
|
|
d9b4c95ca6 | ||
|
|
169e84901c |
@@ -0,0 +1,37 @@
|
||||
name: Build image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
# GITEA_TOKEN reicht, wenn der Runner Packages schreiben darf;
|
||||
# sonst ein PAT mit package:write als Secret REGISTRY_TOKEN hinterlegen.
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
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 "${{ gitea.actor }}" --password-stdin
|
||||
# der sha landet als Build-Stempel neben der Version im UI
|
||||
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)"
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -25,6 +25,24 @@ 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`. Rolling the new image out stays a manual step (dockhand).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -34,7 +52,8 @@ the same mount, otherwise moving turns into copy + delete.
|
||||
2. **Search** – look up a title (ebooks: Hardcover, Google Books and Open
|
||||
Library, audiobooks: Audible plus MusicBrainz, manga: AniList) and request
|
||||
it, or add it by hand. The language filter narrows ebook and audiobook hits. For a series,
|
||||
*Ganze Serie…* requests every episode at once.
|
||||
*Ganze Serie…* requests every part at once — audiobooks from Audible's keyword
|
||||
crawl, ebooks from Hardcover's series listing (needs the token).
|
||||
3. **Import** – scan the download folder. wordarr suggests file→request matches,
|
||||
which you confirm or correct; entries can be merged, split, or turned into
|
||||
requests from their folder names. Every row names its format and size, and
|
||||
|
||||
+5
-1
@@ -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"
|
||||
|
||||
+227
-158
@@ -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,26 +256,28 @@ $("#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>`
|
||||
)
|
||||
.join("");
|
||||
box.querySelectorAll("[data-series]").forEach((btn) =>
|
||||
btn.addEventListener("click", () => openSeriesDialog(results[btn.dataset.series]))
|
||||
btn.addEventListener("click", () =>
|
||||
openSeriesDialog(results[btn.dataset.series], type, language)
|
||||
)
|
||||
);
|
||||
box.querySelectorAll("[data-req]").forEach((btn) =>
|
||||
btn.addEventListener("click", async () => {
|
||||
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({
|
||||
@@ -257,23 +288,18 @@ $("#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 = [];
|
||||
let seriesType = "audiobook";
|
||||
|
||||
function renderSeriesList() {
|
||||
const from = parseInt($("#series-from").value);
|
||||
@@ -288,7 +314,10 @@ function renderSeriesList() {
|
||||
let gap = "";
|
||||
if (e.volume != null && previous != null && e.volume > previous + 1) {
|
||||
const missing = e.volume - previous - 1;
|
||||
gap = `<div class="gap">… ${missing} Folge${missing > 1 ? "n" : ""} nicht 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>
|
||||
@@ -310,24 +339,28 @@ 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) {
|
||||
async function openSeriesDialog(result, type = "audiobook", language = "") {
|
||||
const dlg = $("#series-dialog");
|
||||
$("#series-title").textContent = result.series || "Serie";
|
||||
$("#series-info").innerHTML = '<span class="spinner"></span> Folgen werden gesucht…';
|
||||
seriesType = type;
|
||||
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("audiobook") || "<option value=''>— keine Library —</option>";
|
||||
$("#series-library").innerHTML = libOptions(type) || noLibraryOption();
|
||||
$("#series-submit").disabled = true;
|
||||
dlg.showModal();
|
||||
try {
|
||||
seriesEpisodes = await api(
|
||||
`/api/search/series?series_id=${encodeURIComponent(result.series_id)}` +
|
||||
`&title=${encodeURIComponent(result.series || result.title)}`
|
||||
`&title=${encodeURIComponent(result.series || result.title)}` +
|
||||
`&media_type=${type}&language=${encodeURIComponent(language)}`
|
||||
);
|
||||
} catch (err) {
|
||||
$("#series-info").textContent = err.message;
|
||||
@@ -335,9 +368,12 @@ async function openSeriesDialog(result) {
|
||||
}
|
||||
const numbered = seriesEpisodes.filter((e) => e.volume != null);
|
||||
$("#series-info").textContent =
|
||||
`${seriesEpisodes.length} Folgen gefunden` +
|
||||
(numbered.length ? ` (Nr. ${numbered[0].volume}–${numbered[numbered.length - 1].volume})` : "") +
|
||||
". 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();
|
||||
}
|
||||
|
||||
@@ -356,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 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,
|
||||
@@ -364,16 +403,16 @@ $("#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",
|
||||
body: JSON.stringify({ library_id: parseInt(libId), items }),
|
||||
});
|
||||
setLastLib("audiobook", libId);
|
||||
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();
|
||||
@@ -390,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();
|
||||
@@ -400,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", {
|
||||
@@ -418,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),
|
||||
@@ -428,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();
|
||||
@@ -439,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"; }
|
||||
}
|
||||
@@ -484,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;
|
||||
}
|
||||
@@ -493,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>
|
||||
@@ -512,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)
|
||||
@@ -524,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);
|
||||
}
|
||||
|
||||
@@ -576,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;
|
||||
@@ -609,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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -627,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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -646,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); }
|
||||
});
|
||||
@@ -664,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([
|
||||
@@ -684,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();
|
||||
@@ -727,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
|
||||
@@ -743,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("");
|
||||
}
|
||||
|
||||
@@ -792,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>`;
|
||||
})
|
||||
@@ -818,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)))
|
||||
@@ -840,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);
|
||||
@@ -883,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) {
|
||||
@@ -893,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(); });
|
||||
@@ -930,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) => {
|
||||
@@ -950,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 })
|
||||
: "")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -973,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();
|
||||
});
|
||||
@@ -998,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>`
|
||||
: "");
|
||||
}
|
||||
|
||||
@@ -1007,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", {
|
||||
@@ -1036,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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1065,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
|
||||
@@ -1100,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();
|
||||
});
|
||||
|
||||
@@ -1124,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;
|
||||
}
|
||||
@@ -1173,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 = "";
|
||||
@@ -1182,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();
|
||||
@@ -1209,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);
|
||||
@@ -1220,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 =
|
||||
@@ -1228,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("");
|
||||
@@ -1241,7 +1283,7 @@ function renderQuickOpenRequests(item) {
|
||||
scanItems[i].checked = true;
|
||||
renderImportTable();
|
||||
$("#quick-dialog").close();
|
||||
toast("Mit bestehendem Request verbunden");
|
||||
toast(t("quick.linked"));
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1249,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);
|
||||
@@ -1259,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
|
||||
@@ -1270,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">
|
||||
@@ -1279,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("");
|
||||
@@ -1309,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 });
|
||||
@@ -1320,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); }
|
||||
}
|
||||
|
||||
@@ -1340,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,
|
||||
@@ -1398,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);
|
||||
@@ -1459,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;
|
||||
}
|
||||
@@ -1510,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) =>
|
||||
@@ -1524,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(() => {
|
||||
@@ -1535,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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1553,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;
|
||||
}
|
||||
@@ -1565,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
@@ -0,0 +1,486 @@
|
||||
// UI strings for both languages. Keys are grouped by the view they belong to;
|
||||
// {placeholders} are filled by t().
|
||||
const STRINGS = {
|
||||
de: {
|
||||
"common.cancel": "Abbrechen",
|
||||
"common.save": "Speichern",
|
||||
"common.create": "Anlegen",
|
||||
"common.request": "Anfragen",
|
||||
"common.search": "Suchen",
|
||||
"common.all": "Alle",
|
||||
"common.none": "Keine",
|
||||
"common.close": "Schließen",
|
||||
"common.edit": "Bearbeiten",
|
||||
"common.delete": "Löschen",
|
||||
"common.link": "Verbinden",
|
||||
"common.createAndLink": "Anlegen & verbinden",
|
||||
"common.selectAll": "Alle auswählen",
|
||||
"common.removeSelected": "Ausgewählte entfernen",
|
||||
"common.removeSelectedN": "Ausgewählte entfernen ({n})",
|
||||
"common.library": "Library",
|
||||
"common.type": "Typ",
|
||||
"common.title": "Titel",
|
||||
"common.authors": "Autor(en)",
|
||||
"common.author": "Autor",
|
||||
"common.narrator": "Sprecher",
|
||||
"common.series": "Serie",
|
||||
"common.volume": "Band",
|
||||
"common.year": "Jahr",
|
||||
"common.name": "Name",
|
||||
"common.path": "Pfad",
|
||||
"common.language": "Sprache",
|
||||
"common.score": "Score",
|
||||
"common.status": "Status",
|
||||
"common.noLibrary": "— keine Library —",
|
||||
"common.nothingHere": "Nichts hier.",
|
||||
"common.optionalForAll": "optional, für alle",
|
||||
"common.optional": "optional",
|
||||
"common.needLibraryFirst": "Erst eine Library für diesen Typ anlegen (Tab Libraries)",
|
||||
"common.creating": "Lege an…",
|
||||
"lang.german": "Deutsch",
|
||||
"lang.english": "Englisch",
|
||||
|
||||
"nav.search": "Suche",
|
||||
"nav.missing": "Missing",
|
||||
"nav.imported": "Importiert",
|
||||
"nav.import": "Import",
|
||||
"nav.settings": "Libraries",
|
||||
"nav.langToggle": "Switch to English",
|
||||
|
||||
"search.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche",
|
||||
"search.allLanguages": "Alle Sprachen",
|
||||
"search.placeholder": "Titel, Autor oder ISBN…",
|
||||
"search.manual": "Manuell anlegen",
|
||||
"search.running": "Suche…",
|
||||
"search.wholeSeries": "Ganze Serie…",
|
||||
"search.requested": "✓ Angefragt",
|
||||
"search.emptyEbook": "Keine Treffer. Tipp: ISBN ohne Bindestriche oder Titel + Autor versuchen — und ggf. „Alle Sprachen“ wählen.",
|
||||
"search.emptyAudiobook": "Keine Treffer. Tipp: EAN/ISBN funktioniert bei Audible nicht — nach Titel/Autor suchen.",
|
||||
"search.emptyComic": "Keine Treffer bei AniList (Manga). Westliche Comics per „Manuell anlegen“ erfassen.",
|
||||
|
||||
"manual.heading": "Manuell anfragen",
|
||||
"manual.volumeTo": "bis Band (Bulk, optional)",
|
||||
"manual.bulkHint": "Legt pro Band/Folge eine Anfrage an, z.B. 1 bis 300",
|
||||
"manual.externalId": "ISBN/ID",
|
||||
"manual.noLibraryChosen": "Keine Library gewählt",
|
||||
"manual.needStartVolume": "Für Bulk bitte Start-Band angeben",
|
||||
"manual.created": "Anfrage angelegt",
|
||||
"manual.createdN": "{n} Anfragen angelegt",
|
||||
|
||||
"series.fallbackTitle": "Serie",
|
||||
"series.from": "von Nr.",
|
||||
"series.to": "bis Nr.",
|
||||
"series.loading": "{unit} werden gesucht…",
|
||||
"series.unitVolumes": "Bände",
|
||||
"series.unitEpisodes": "Folgen",
|
||||
"series.found": "{n} {unit} gefunden",
|
||||
"series.range": " (Nr. {from}–{to})",
|
||||
"series.noteEbook": ". Bände ohne Ausgabe in der gewählten Sprache stehen mit ihrem Originaltitel da.",
|
||||
"series.noteAudiobook": ". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen.",
|
||||
"series.gapVolumes": "… {n} Bände nicht bei Hardcover gefunden ({from}–{to})",
|
||||
"series.gapVolume": "… {n} Band nicht bei Hardcover gefunden ({from}–{to})",
|
||||
"series.gapEpisodes": "… {n} Folgen nicht bei Audible gefunden ({from}–{to})",
|
||||
"series.gapEpisode": "… {n} Folge nicht bei Audible gefunden ({from}–{to})",
|
||||
"series.requestN": "{n} anfragen",
|
||||
"series.needLibrary": "Erst eine {type}-Library anlegen (Tab Libraries)",
|
||||
"series.createdN": "{n} Anfragen angelegt",
|
||||
"series.skippedN": ", {n} bereits vorhanden",
|
||||
|
||||
"list.allTypes": "Alle Typen",
|
||||
"list.allLibraries": "Alle Libraries",
|
||||
"list.filterPlaceholder": "Filtern (Titel/Autor/Serie)…",
|
||||
"list.view": "Ansicht",
|
||||
"list.viewList": "Liste",
|
||||
"list.viewSmall": "Kleine Symbole",
|
||||
"list.viewLarge": "Große Symbole",
|
||||
"list.confirmDeleteRequests": "{n} Anfrage(n) entfernen?",
|
||||
"list.confirmDeleteImported": "{n} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.",
|
||||
"list.deletedN": "{n} entfernt",
|
||||
|
||||
"detail.statusMissing": "fehlt noch",
|
||||
"detail.statusImported": "importiert",
|
||||
"detail.externalId": "ISBN/ASIN",
|
||||
"detail.retag": "Speichern & neu taggen",
|
||||
"detail.relocate": "Speichern & neu ablegen",
|
||||
"detail.relocateTitle": "Ordner und Dateinamen nach dem Schema der Library neu anlegen",
|
||||
"detail.relocating": "Lege ab…",
|
||||
"detail.relocated": "Neu abgelegt: {dest}",
|
||||
"detail.alreadyPlaced": "Liegt bereits richtig",
|
||||
"detail.retagging": "Tagge…",
|
||||
"detail.retagged": "{n} Datei(en) neu getaggt",
|
||||
"detail.saved": "Anfrage gespeichert",
|
||||
"detail.duplicateConfirm": "Trotzdem anlegen?",
|
||||
|
||||
"import.scan": "Download-Ordner scannen",
|
||||
"import.splitDirs": "Ordner als Einzeldateien behandeln (z.B. Folgen-Sammlungen)",
|
||||
"import.scanning": "Scanne…",
|
||||
"import.candidates": "{n} Kandidat(en) in {dir}",
|
||||
"import.remaining": "{n} Kandidat(en) übrig — für den aktuellen Stand erneut scannen",
|
||||
"import.filterPlaceholder": "Filtern (Name/Pfad)…",
|
||||
"import.modeSuggested": "Nur mit Vorschlag",
|
||||
"import.modeUnassigned": "Nur ohne Zuordnung",
|
||||
"import.modeConflict": "Nur Konflikte",
|
||||
"import.formatTitle": "Nur Einträge mit diesem Dateiformat zeigen",
|
||||
"import.allFormats": "Alle Formate",
|
||||
"import.sortScan": "Reihenfolge: Scan",
|
||||
"import.sortScore": "Score absteigend",
|
||||
"import.sortName": "Name A–Z",
|
||||
"import.selectSuggested": "Alle mit Vorschlag auswählen",
|
||||
"import.selectAllTitle": "Alle Einträge der aktuellen Ansicht auswählen, auch ohne Zuordnung",
|
||||
"import.selectPage": "Diese Seite auswählen",
|
||||
"import.selectPageTitle": "Nur die Einträge dieser Seite auswählen, alle anderen abwählen",
|
||||
"import.deselectAll": "Alle abwählen",
|
||||
"import.merge": "Ausgewählte zusammenfassen",
|
||||
"import.mergeTitle": "Mehrteiler: ausgewählte Einträge als ein Hörbuch importieren",
|
||||
"import.fromNames": "Anfragen aus Ordnernamen",
|
||||
"import.fromNamesTitle": "Für ausgewählte Einträge ohne Zuordnung Anfragen aus den Ordnernamen anlegen",
|
||||
"import.colFile": "Datei/Ordner",
|
||||
"import.colAssignment": "Zuordnung",
|
||||
"import.prev": "← Zurück",
|
||||
"import.next": "Weiter →",
|
||||
"import.run": "Ausgewählte importieren",
|
||||
"import.skip": "— überspringen —",
|
||||
"import.appendGroup": "Bereits importiert — Teile anhängen",
|
||||
"import.quickTitle": "Metadaten suchen und Anfrage direkt verbinden",
|
||||
"import.unmerge": "Zusammenfassung wieder auflösen",
|
||||
"import.splitDir": "Ordner in einzelne Titel aufteilen",
|
||||
"import.parts": "{parts} Teile · {files} Dateien",
|
||||
"import.maybeSeparate": "{n} Titel?",
|
||||
"import.maybeSeparateTitle": "Die {n} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen",
|
||||
"import.libraryCellTitle": "Ziel-Library dieser Anfrage ändern",
|
||||
"import.conflict": "⚠ Konflikt",
|
||||
"import.conflictTitle": "Mehrere Einträge zeigen auf dieselbe Anfrage",
|
||||
"import.running": "läuft…",
|
||||
"import.done": "✓ importiert",
|
||||
"import.failed": "✗ Fehler",
|
||||
"import.pageInfo": "Seite {page}/{pages} · {total} Einträge{filtered} · {selected} ausgewählt",
|
||||
"import.filtered": " (gefiltert: {n})",
|
||||
"import.movedToLibrary": "„{title}\" → {library}",
|
||||
"import.selectedN": "{n} Einträge {what} ausgewählt",
|
||||
"import.selectedWithout": ", davon {n} ohne Zuordnung",
|
||||
"import.thisPage": "dieser Seite",
|
||||
"import.inTotal": "insgesamt",
|
||||
"import.nothingSelected": "Nichts ausgewählt",
|
||||
"import.needTwo": "Mindestens zwei Einträge auswählen",
|
||||
"import.sameTypeOnly": "Nur Einträge desselben Typs lassen sich zusammenfassen",
|
||||
"import.differentFolders": "Die Einträge liegen in verschiedenen Ordnern",
|
||||
"import.mergedN": "{n} Einträge als „{name}\" zusammengefasst ({files} Dateien)",
|
||||
"import.splitN": "„{name}\" in {n} Einzeltitel aufgeteilt",
|
||||
"import.nothingImported": "Nichts importiert: alle ausgewählten Einträge waren mehreren Anfragen zugeordnet — Filter „Nur Konflikte\" zeigt sie.",
|
||||
"import.summary": "{n} importiert",
|
||||
"import.summaryFailed": ", {n} Fehler",
|
||||
"import.summarySkipped": "{n} Anfrage(n) waren mehreren Einträgen zugeordnet und wurden übersprungen — Filter „Nur Konflikte\" zeigt sie.",
|
||||
"import.finished": "Import fertig",
|
||||
"import.finishedFailed": "Import fertig, {n} Fehler",
|
||||
"import.progressDone": "{total}/{total} · fertig",
|
||||
|
||||
"names.heading": "Anfragen aus Ordnernamen",
|
||||
"names.intro": "{n} Einträge ohne Zuordnung ({type}). Titel und Folgennummer kommen aus dem Ordnernamen; Serie und Autor gelten für alle.",
|
||||
"names.more": "… und {n} weitere",
|
||||
"names.noneWithout": "Keine ausgewählten Einträge ohne Zuordnung",
|
||||
"names.result": "{created} Anfragen angelegt{skipped}, {linked} Einträge verbunden",
|
||||
"names.existed": ", {n} gab es schon",
|
||||
|
||||
"conflict.heading": "Mehrfach zugeordnete Anfragen",
|
||||
"conflict.intro": "{n} Anfrage(n) sind mehreren Einträgen zugeordnet. Gehören die Einträge zusammen (Mehrteiler), lassen sie sich als ein Hörbuch importieren — sonst überspringst du sie, korrigierst die Zuordnung und importierst sie später.",
|
||||
"conflict.merge": "Als je ein Hörbuch zusammenfassen",
|
||||
"conflict.skip": "Konflikte überspringen, Rest importieren",
|
||||
|
||||
"quick.heading": "Suchen & Anfragen",
|
||||
"quick.placeholder": "Titel/Serie suchen…",
|
||||
"quick.targetLibrary": "Ziel-Library",
|
||||
"quick.openRequests": "Offene Anfragen",
|
||||
"quick.reqFilter": "nach Titel, Autor, Serie filtern…",
|
||||
"quick.manualSummary": "Nicht bei Audible? Manuell anlegen",
|
||||
"quick.editSummary": "Felder anpassen",
|
||||
"quick.episodeVolume": "Folge/Band",
|
||||
"quick.searching": "Suche läuft…",
|
||||
"quick.episodeNote": "Folge {n} — nicht mitgesucht",
|
||||
"quick.matchingOpen": "Passende offene Requests:",
|
||||
"quick.hits": "{shown} von {total} Treffern ({open} offen)",
|
||||
"quick.noHits": "Kein Treffer unter {open} offenen Anfragen",
|
||||
"quick.linked": "Mit bestehendem Request verbunden",
|
||||
"quick.editTitle": "Felder vor dem Anlegen anpassen (z.B. Serie)",
|
||||
"quick.pickTitle": "Anfrage anlegen und mit diesem Eintrag verbinden",
|
||||
"quick.basedOn": "Übernommen von Audible: „{title}\" — Sprecher, Cover, Jahr und ASIN bleiben erhalten.",
|
||||
"quick.requestedAndLinked": "„{title}\" angefragt und verbunden",
|
||||
"quick.needTitle": "Titel angeben",
|
||||
|
||||
"lib.heading": "Libraries",
|
||||
"lib.folderTemplate": "Ordner-Schema",
|
||||
"lib.fileTemplate": "Datei-Schema",
|
||||
"lib.new": "Neue Library",
|
||||
"lib.edit": "Library „{name}“ bearbeiten",
|
||||
"lib.namePlaceholder": "Name (z.B. Kids)",
|
||||
"lib.folderPlaceholder": "Ordner-Schema (optional)",
|
||||
"lib.filePlaceholder": "Datei-Schema (optional)",
|
||||
"lib.languageTitle": "Sprache der Ausgaben in dieser Library — filtert die Metadaten-Suche",
|
||||
"lib.anyLanguage": "Sprache: egal",
|
||||
"lib.placeholders": "Platzhalter: {Author} {Authors} {Narrator} {Narrators} {Title} {Year} {Series} {Volume} — {Author}/{Narrator} nennen den ersten Namen, {Authors}/{Narrators} alle. Leere Werte fallen samt Trenner weg.",
|
||||
"lib.confirmDelete": "Library löschen?",
|
||||
"lib.saved": "Library gespeichert — gilt für künftige Importe",
|
||||
"lib.created": "Library angelegt",
|
||||
},
|
||||
|
||||
en: {
|
||||
"common.cancel": "Cancel",
|
||||
"common.save": "Save",
|
||||
"common.create": "Create",
|
||||
"common.request": "Request",
|
||||
"common.search": "Search",
|
||||
"common.all": "All",
|
||||
"common.none": "None",
|
||||
"common.close": "Close",
|
||||
"common.edit": "Edit",
|
||||
"common.delete": "Delete",
|
||||
"common.link": "Link",
|
||||
"common.createAndLink": "Create & link",
|
||||
"common.selectAll": "Select all",
|
||||
"common.removeSelected": "Remove selected",
|
||||
"common.removeSelectedN": "Remove selected ({n})",
|
||||
"common.library": "Library",
|
||||
"common.type": "Type",
|
||||
"common.title": "Title",
|
||||
"common.authors": "Author(s)",
|
||||
"common.author": "Author",
|
||||
"common.narrator": "Narrator",
|
||||
"common.series": "Series",
|
||||
"common.volume": "Volume",
|
||||
"common.year": "Year",
|
||||
"common.name": "Name",
|
||||
"common.path": "Path",
|
||||
"common.language": "Language",
|
||||
"common.score": "Score",
|
||||
"common.status": "Status",
|
||||
"common.noLibrary": "— no library —",
|
||||
"common.nothingHere": "Nothing here.",
|
||||
"common.optionalForAll": "optional, for all",
|
||||
"common.optional": "optional",
|
||||
"common.needLibraryFirst": "Create a library for this type first (Libraries tab)",
|
||||
"common.creating": "Creating…",
|
||||
"lang.german": "German",
|
||||
"lang.english": "English",
|
||||
|
||||
"nav.search": "Search",
|
||||
"nav.missing": "Missing",
|
||||
"nav.imported": "Imported",
|
||||
"nav.import": "Import",
|
||||
"nav.settings": "Libraries",
|
||||
"nav.langToggle": "Auf Deutsch umschalten",
|
||||
|
||||
"search.languageTitle": "Edition language — filters the ebook and audiobook search",
|
||||
"search.allLanguages": "All languages",
|
||||
"search.placeholder": "Title, author or ISBN…",
|
||||
"search.manual": "Add manually",
|
||||
"search.running": "Searching…",
|
||||
"search.wholeSeries": "Whole series…",
|
||||
"search.requested": "✓ Requested",
|
||||
"search.emptyEbook": "No hits. Try the ISBN without dashes, or title + author — and maybe pick “All languages”.",
|
||||
"search.emptyAudiobook": "No hits. EAN/ISBN does not work on Audible — search by title/author.",
|
||||
"search.emptyComic": "No hits on AniList (manga). Add western comics via “Add manually”.",
|
||||
|
||||
"manual.heading": "Request manually",
|
||||
"manual.volumeTo": "to volume (bulk, optional)",
|
||||
"manual.bulkHint": "Creates one request per volume/episode, e.g. 1 to 300",
|
||||
"manual.externalId": "ISBN/ID",
|
||||
"manual.noLibraryChosen": "No library chosen",
|
||||
"manual.needStartVolume": "Bulk needs a starting volume",
|
||||
"manual.created": "Request created",
|
||||
"manual.createdN": "{n} requests created",
|
||||
|
||||
"series.fallbackTitle": "Series",
|
||||
"series.from": "from no.",
|
||||
"series.to": "to no.",
|
||||
"series.loading": "Looking for {unit}…",
|
||||
"series.unitVolumes": "volumes",
|
||||
"series.unitEpisodes": "episodes",
|
||||
"series.found": "{n} {unit} found",
|
||||
"series.range": " (no. {from}–{to})",
|
||||
"series.noteEbook": ". Volumes without an edition in the chosen language keep their original title.",
|
||||
"series.noteAudiobook": ". Audible has no series query — single episodes can be missing.",
|
||||
"series.gapVolumes": "… {n} volumes not found on Hardcover ({from}–{to})",
|
||||
"series.gapVolume": "… {n} volume not found on Hardcover ({from}–{to})",
|
||||
"series.gapEpisodes": "… {n} episodes not found on Audible ({from}–{to})",
|
||||
"series.gapEpisode": "… {n} episode not found on Audible ({from}–{to})",
|
||||
"series.requestN": "Request {n}",
|
||||
"series.needLibrary": "Create an {type} library first (Libraries tab)",
|
||||
"series.createdN": "{n} requests created",
|
||||
"series.skippedN": ", {n} already there",
|
||||
|
||||
"list.allTypes": "All types",
|
||||
"list.allLibraries": "All libraries",
|
||||
"list.filterPlaceholder": "Filter (title/author/series)…",
|
||||
"list.view": "View",
|
||||
"list.viewList": "List",
|
||||
"list.viewSmall": "Small icons",
|
||||
"list.viewLarge": "Large icons",
|
||||
"list.confirmDeleteRequests": "Remove {n} request(s)?",
|
||||
"list.confirmDeleteImported": "Remove {n} entr(y/ies)? The imported files stay in the library.",
|
||||
"list.deletedN": "{n} removed",
|
||||
|
||||
"detail.statusMissing": "still missing",
|
||||
"detail.statusImported": "imported",
|
||||
"detail.externalId": "ISBN/ASIN",
|
||||
"detail.retag": "Save & re-tag",
|
||||
"detail.relocate": "Save & re-file",
|
||||
"detail.relocateTitle": "Rebuild folder and file names from the library's scheme",
|
||||
"detail.relocating": "Filing…",
|
||||
"detail.relocated": "Re-filed: {dest}",
|
||||
"detail.alreadyPlaced": "Already in the right place",
|
||||
"detail.retagging": "Tagging…",
|
||||
"detail.retagged": "{n} file(s) re-tagged",
|
||||
"detail.saved": "Request saved",
|
||||
"detail.duplicateConfirm": "Create anyway?",
|
||||
|
||||
"import.scan": "Scan download folder",
|
||||
"import.splitDirs": "Treat folders as single files (e.g. episode collections)",
|
||||
"import.scanning": "Scanning…",
|
||||
"import.candidates": "{n} candidate(s) in {dir}",
|
||||
"import.remaining": "{n} candidate(s) left — scan again for the current state",
|
||||
"import.filterPlaceholder": "Filter (name/path)…",
|
||||
"import.modeSuggested": "With suggestion only",
|
||||
"import.modeUnassigned": "Unassigned only",
|
||||
"import.modeConflict": "Conflicts only",
|
||||
"import.formatTitle": "Show only entries with this file format",
|
||||
"import.allFormats": "All formats",
|
||||
"import.sortScan": "Order: scan",
|
||||
"import.sortScore": "Score descending",
|
||||
"import.sortName": "Name A–Z",
|
||||
"import.selectSuggested": "Select all with a suggestion",
|
||||
"import.selectAllTitle": "Select every entry of the current view, unassigned ones too",
|
||||
"import.selectPage": "Select this page",
|
||||
"import.selectPageTitle": "Select only this page's entries, deselect all others",
|
||||
"import.deselectAll": "Deselect all",
|
||||
"import.merge": "Merge selected",
|
||||
"import.mergeTitle": "Multi-part title: import the selected entries as one audiobook",
|
||||
"import.fromNames": "Requests from folder names",
|
||||
"import.fromNamesTitle": "Create requests from the folder names of the selected unassigned entries",
|
||||
"import.colFile": "File/folder",
|
||||
"import.colAssignment": "Assignment",
|
||||
"import.prev": "← Back",
|
||||
"import.next": "Next →",
|
||||
"import.run": "Import selected",
|
||||
"import.skip": "— skip —",
|
||||
"import.appendGroup": "Already imported — append parts",
|
||||
"import.quickTitle": "Search metadata and link the request right away",
|
||||
"import.unmerge": "Undo the merge",
|
||||
"import.splitDir": "Split the folder into single titles",
|
||||
"import.parts": "{parts} parts · {files} files",
|
||||
"import.maybeSeparate": "{n} titles?",
|
||||
"import.maybeSeparateTitle": "The {n} files carry different titles and are each big enough for a whole book — split with ✂️",
|
||||
"import.libraryCellTitle": "Change this request's target library",
|
||||
"import.conflict": "⚠ Conflict",
|
||||
"import.conflictTitle": "Several entries point at the same request",
|
||||
"import.running": "running…",
|
||||
"import.done": "✓ imported",
|
||||
"import.failed": "✗ Error",
|
||||
"import.pageInfo": "Page {page}/{pages} · {total} entries{filtered} · {selected} selected",
|
||||
"import.filtered": " (filtered: {n})",
|
||||
"import.movedToLibrary": "“{title}” → {library}",
|
||||
"import.selectedN": "{n} entries {what} selected",
|
||||
"import.selectedWithout": ", {n} of them unassigned",
|
||||
"import.thisPage": "on this page",
|
||||
"import.inTotal": "in total",
|
||||
"import.nothingSelected": "Nothing selected",
|
||||
"import.needTwo": "Select at least two entries",
|
||||
"import.sameTypeOnly": "Only entries of the same type can be merged",
|
||||
"import.differentFolders": "The entries live in different folders",
|
||||
"import.mergedN": "{n} entries merged as “{name}” ({files} files)",
|
||||
"import.splitN": "“{name}” split into {n} single titles",
|
||||
"import.nothingImported": "Nothing imported: every selected entry was assigned to several requests — the “Conflicts only” filter shows them.",
|
||||
"import.summary": "{n} imported",
|
||||
"import.summaryFailed": ", {n} failed",
|
||||
"import.summarySkipped": "{n} request(s) were assigned to several entries and were skipped — the “Conflicts only” filter shows them.",
|
||||
"import.finished": "Import finished",
|
||||
"import.finishedFailed": "Import finished, {n} failed",
|
||||
"import.progressDone": "{total}/{total} · done",
|
||||
|
||||
"names.heading": "Requests from folder names",
|
||||
"names.intro": "{n} unassigned entries ({type}). Title and episode number come from the folder name; series and author apply to all of them.",
|
||||
"names.more": "… and {n} more",
|
||||
"names.noneWithout": "No selected entries without an assignment",
|
||||
"names.result": "{created} requests created{skipped}, {linked} entries linked",
|
||||
"names.existed": ", {n} already existed",
|
||||
|
||||
"conflict.heading": "Requests assigned more than once",
|
||||
"conflict.intro": "{n} request(s) are assigned to several entries. If those entries belong together (a multi-part title), they can be imported as one audiobook — otherwise skip them, fix the assignment and import them later.",
|
||||
"conflict.merge": "Merge each into one audiobook",
|
||||
"conflict.skip": "Skip conflicts, import the rest",
|
||||
|
||||
"quick.heading": "Search & request",
|
||||
"quick.placeholder": "Search title/series…",
|
||||
"quick.targetLibrary": "Target library",
|
||||
"quick.openRequests": "Open requests",
|
||||
"quick.reqFilter": "filter by title, author, series…",
|
||||
"quick.manualSummary": "Not on Audible? Add manually",
|
||||
"quick.editSummary": "Adjust the fields",
|
||||
"quick.episodeVolume": "Episode/volume",
|
||||
"quick.searching": "Searching…",
|
||||
"quick.episodeNote": "Episode {n} — not part of the search",
|
||||
"quick.matchingOpen": "Matching open requests:",
|
||||
"quick.hits": "{shown} of {total} hits ({open} open)",
|
||||
"quick.noHits": "No hit among {open} open requests",
|
||||
"quick.linked": "Linked to the existing request",
|
||||
"quick.editTitle": "Adjust the fields before creating (e.g. the series)",
|
||||
"quick.pickTitle": "Create the request and link it to this entry",
|
||||
"quick.basedOn": "Taken from Audible: “{title}” — narrator, cover, year and ASIN are kept.",
|
||||
"quick.requestedAndLinked": "“{title}” requested and linked",
|
||||
"quick.needTitle": "Enter a title",
|
||||
|
||||
"lib.heading": "Libraries",
|
||||
"lib.folderTemplate": "Folder scheme",
|
||||
"lib.fileTemplate": "File scheme",
|
||||
"lib.new": "New library",
|
||||
"lib.edit": "Edit library “{name}”",
|
||||
"lib.namePlaceholder": "Name (e.g. Kids)",
|
||||
"lib.folderPlaceholder": "Folder scheme (optional)",
|
||||
"lib.filePlaceholder": "File scheme (optional)",
|
||||
"lib.languageTitle": "Language of this library's editions — filters the metadata search",
|
||||
"lib.anyLanguage": "Language: any",
|
||||
"lib.placeholders": "Placeholders: {Author} {Authors} {Narrator} {Narrators} {Title} {Year} {Series} {Volume} — {Author}/{Narrator} name the first one, {Authors}/{Narrators} all of them. Empty values drop out with their separator.",
|
||||
"lib.confirmDelete": "Delete library?",
|
||||
"lib.saved": "Library saved — applies to future imports",
|
||||
"lib.created": "Library created",
|
||||
},
|
||||
};
|
||||
|
||||
const LANG_KEY = "wordarr.lang";
|
||||
|
||||
function initialLang() {
|
||||
try {
|
||||
const saved = localStorage.getItem(LANG_KEY);
|
||||
if (saved && STRINGS[saved]) return saved;
|
||||
} catch {}
|
||||
return (navigator.language || "en").startsWith("de") ? "de" : "en";
|
||||
}
|
||||
|
||||
let lang = initialLang();
|
||||
|
||||
// {Placeholders} of the naming scheme must survive, so only known vars are replaced
|
||||
function t(key, vars) {
|
||||
let s = STRINGS[lang][key] ?? STRINGS.en[key] ?? key;
|
||||
if (vars) {
|
||||
for (const [name, value] of Object.entries(vars)) {
|
||||
s = s.split("{" + name + "}").join(String(value));
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const I18N_ATTRS = { "i18nPlaceholder": "placeholder", "i18nTitle": "title", "i18nAria": "aria-label" };
|
||||
|
||||
function applyI18n(root = document) {
|
||||
root.querySelectorAll("[data-i18n]").forEach((el) => (el.textContent = t(el.dataset.i18n)));
|
||||
for (const [data, attr] of Object.entries(I18N_ATTRS)) {
|
||||
root.querySelectorAll(`[data-${data.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())}]`)
|
||||
.forEach((el) => el.setAttribute(attr, t(el.dataset[data])));
|
||||
}
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
|
||||
function setLang(next) {
|
||||
lang = next;
|
||||
try { localStorage.setItem(LANG_KEY, next); } catch {}
|
||||
applyI18n();
|
||||
if (typeof onLangChange === "function") onLangChange();
|
||||
}
|
||||
+141
-176
@@ -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 A–Z</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 & 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 & 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 & 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 & 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 & 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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -83,6 +83,80 @@ def test_hardcover_drops_books_without_an_edition_in_that_language(monkeypatch):
|
||||
assert asyncio.run(hardcover.search("hobbit", "german")) == []
|
||||
|
||||
|
||||
SERIES_ROWS = {
|
||||
"name": "Die drei Fragezeichen",
|
||||
"book_series": [
|
||||
{"position": 1, "book": {
|
||||
"id": 1, "title": "Super-Papagei", "release_year": 1968,
|
||||
"contributions": [{"author": {"name": "Robert Arthur"}}],
|
||||
"cached_image": {"url": "https://example.invalid/1.jpg"}}},
|
||||
{"position": 2, "book": {"id": 2, "title": "Misteri Danau"}},
|
||||
{"position": 2, "book": {"id": 3, "title": "Der Phantomsee"}},
|
||||
{"position": 2, "book": {"id": 2, "title": "duplicate row"}},
|
||||
{"position": 0, "book": {"id": 4, "title": "Sonderband"}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def series_client(monkeypatch, editions):
|
||||
seen = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json
|
||||
body = json.loads(request.content)
|
||||
seen.append(body["variables"])
|
||||
if "editions" in body["query"]:
|
||||
return httpx.Response(200, json={"data": {"editions": editions}})
|
||||
return httpx.Response(200, json={"data": {"series": [SERIES_ROWS]}})
|
||||
|
||||
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
|
||||
mock_client(monkeypatch, hardcover, handler)
|
||||
return seen
|
||||
|
||||
|
||||
def test_hardcover_series_sorts_by_volume_and_keeps_the_first_row(monkeypatch):
|
||||
series_client(monkeypatch, [])
|
||||
books = asyncio.run(hardcover.series_books("10146"))
|
||||
assert [(b.volume, b.title) for b in books] == [
|
||||
(1, "Super-Papagei"), (2, "Misteri Danau"), (None, "Sonderband"),
|
||||
]
|
||||
first = books[0]
|
||||
assert (first.series, first.series_id, first.authors, first.media_type) == (
|
||||
"Die drei Fragezeichen", "10146", "Robert Arthur", "ebook")
|
||||
assert first.cover_url == "https://example.invalid/1.jpg"
|
||||
|
||||
|
||||
def test_hardcover_series_prefers_the_row_with_an_edition_in_the_language(monkeypatch):
|
||||
calls = series_client(monkeypatch, [
|
||||
{"book_id": 3, "title": "Der Phantomsee", "isbn_13": "9783440032251",
|
||||
"release_date": "1969-01-01", "cached_image": None},
|
||||
])
|
||||
books = asyncio.run(hardcover.series_books("10146", "german"))
|
||||
assert calls[1] == {"ids": [1, 2, 3, 4], "lang": "de"}
|
||||
second = next(b for b in books if b.volume == 2)
|
||||
assert (second.title, second.language, second.external_id, second.year) == (
|
||||
"Der Phantomsee", "german", "9783440032251", 1969)
|
||||
# a volume without a German edition keeps its original row instead of vanishing
|
||||
assert [b.title for b in books] == ["Super-Papagei", "Der Phantomsee", "Sonderband"]
|
||||
|
||||
|
||||
def test_hardcover_series_survives_a_broken_edition_lookup(monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json
|
||||
if "editions" in json.loads(request.content)["query"]:
|
||||
return httpx.Response(500)
|
||||
return httpx.Response(200, json={"data": {"series": [SERIES_ROWS]}})
|
||||
|
||||
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
|
||||
mock_client(monkeypatch, hardcover, handler)
|
||||
assert len(asyncio.run(hardcover.series_books("10146", "german"))) == 3
|
||||
|
||||
|
||||
def test_hardcover_series_needs_a_numeric_id(monkeypatch):
|
||||
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
|
||||
assert asyncio.run(hardcover.series_books("")) == []
|
||||
|
||||
|
||||
def test_google_books_restricts_the_language(monkeypatch):
|
||||
seen = []
|
||||
|
||||
|
||||
@@ -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] == "-"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..metadata import PROVIDERS, audible
|
||||
from ..metadata import PROVIDERS, SERIES_PROVIDERS
|
||||
from ..metadata.base import MetadataResult
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
@@ -18,13 +18,16 @@ async def search(media_type: str, q: str, language: str = "", fallback: bool = T
|
||||
|
||||
|
||||
@router.get("/series", response_model=list[MetadataResult])
|
||||
async def series(series_id: str, title: str):
|
||||
"""All episodes of an Audible series, sorted by episode number. Audible has
|
||||
no series endpoint, so this is a paged keyword crawl and may miss a few
|
||||
episodes - those can still be added by hand."""
|
||||
async def series(series_id: str, title: str = "", media_type: str = "audiobook",
|
||||
language: str = ""):
|
||||
"""All parts of a series, by number. Audible has no series endpoint, so
|
||||
audiobooks are a keyword crawl that may miss episodes; Hardcover is exact."""
|
||||
lookup = SERIES_PROVIDERS.get(media_type)
|
||||
if not lookup:
|
||||
raise HTTPException(400, f"no series lookup for media_type: {media_type}")
|
||||
if not series_id:
|
||||
raise HTTPException(400, "series_id required")
|
||||
try:
|
||||
return await audible.series_episodes(series_id, title)
|
||||
return await lookup(series_id, title, language)
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"metadata provider error: {exc}")
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
@@ -85,8 +85,22 @@ async def audiobook_search(query: str, language: str = "",
|
||||
return hits + [r for r in extra if (r.title.strip().lower(), r.volume) not in seen]
|
||||
|
||||
|
||||
async def _audiobook_series(series_id: str, title: str, language: str = ""):
|
||||
return await audible.series_episodes(series_id, title)
|
||||
|
||||
|
||||
async def _ebook_series(series_id: str, title: str = "", language: str = ""):
|
||||
return await hardcover.series_books(series_id, language)
|
||||
|
||||
|
||||
PROVIDERS = {
|
||||
"ebook": ebook_search,
|
||||
"audiobook": audiobook_search,
|
||||
"comic": anilist.search,
|
||||
}
|
||||
|
||||
# a media type without an entry gets no "whole series" button
|
||||
SERIES_PROVIDERS = {
|
||||
"audiobook": _audiobook_series,
|
||||
"ebook": _ebook_series,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ The GraphQL search is Typesense backed and returns its hits as untyped JSON, so
|
||||
every field is read defensively. A book row describes the work, not one edition,
|
||||
so when a language is requested the matching editions are fetched in a second
|
||||
query and their title/ISBN/cover replace the ones of the original edition.
|
||||
Series are one query - unlike Audible, Hardcover knows them.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -21,6 +22,7 @@ ENDPOINT = "https://api.hardcover.app/v1/graphql"
|
||||
TOKEN = os.environ.get("WORDARR_HARDCOVER_TOKEN", "").strip()
|
||||
LANGUAGES = {"german": "de", "english": "en"}
|
||||
RESULT_LIMIT = 10
|
||||
SERIES_LIMIT = 500 # books per series
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,6 +51,25 @@ query Editions($ids: [Int!]!, $lang: String!) {
|
||||
"""
|
||||
|
||||
|
||||
_SERIES = """
|
||||
query Series($id: Int!, $limit: Int!) {
|
||||
series(where: {id: {_eq: $id}}) {
|
||||
name
|
||||
book_series(order_by: {position: asc_nulls_last}, limit: $limit) {
|
||||
position
|
||||
book {
|
||||
id
|
||||
title
|
||||
release_year
|
||||
cached_image
|
||||
contributions(limit: 3) { author { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
async def _post(client: httpx.AsyncClient, query: str, variables: dict) -> dict:
|
||||
resp = await client.post(
|
||||
ENDPOINT,
|
||||
@@ -88,8 +109,27 @@ def _isbn(*candidates) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _series_of(doc: dict) -> tuple[str, str]:
|
||||
"""Name and id of the featured series; series_names[0] can be another one."""
|
||||
featured = ((doc.get("featured_series") or {}).get("series")) or {}
|
||||
if featured.get("name"):
|
||||
return featured["name"], str(featured.get("id") or "")
|
||||
names = doc.get("series_names") or []
|
||||
ids = doc.get("series_ids") or []
|
||||
return (names[0] if names else ""), (str(ids[0]) if ids else "")
|
||||
|
||||
|
||||
def _volume(position) -> int | None:
|
||||
# position 0 means "somewhere in this series", not "episode zero"
|
||||
try:
|
||||
number = int(float(position))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number or None
|
||||
|
||||
|
||||
def _to_result(doc: dict, language: str) -> MetadataResult:
|
||||
series = doc.get("series_names") or []
|
||||
series, series_id = _series_of(doc)
|
||||
year = doc.get("release_year")
|
||||
return MetadataResult(
|
||||
media_type="ebook",
|
||||
@@ -97,16 +137,18 @@ def _to_result(doc: dict, language: str) -> MetadataResult:
|
||||
title=doc.get("title", ""),
|
||||
authors=_authors(doc),
|
||||
external_id=_isbn(doc.get("isbns")),
|
||||
year=int(year) if str(year).isdigit() else None,
|
||||
year=int(str(year)) if str(year).isdigit() else None,
|
||||
language=language,
|
||||
series=series[0] if series else "",
|
||||
series=series,
|
||||
series_id=series_id,
|
||||
volume=_volume(doc.get("featured_series_position")),
|
||||
cover_url=_image_url(doc.get("image")),
|
||||
)
|
||||
|
||||
|
||||
async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult],
|
||||
language: str) -> list[MetadataResult]:
|
||||
"""Replace each hit with its edition in the wanted language, drop the rest."""
|
||||
async def _localized(client: httpx.AsyncClient, results: dict[int, MetadataResult],
|
||||
language: str) -> dict[int, MetadataResult]:
|
||||
"""The hits that have an edition in the wanted language, swapped in."""
|
||||
data = await _post(client, _EDITIONS,
|
||||
{"ids": list(results), "lang": LANGUAGES[language]})
|
||||
localized: dict[int, MetadataResult] = {}
|
||||
@@ -118,11 +160,19 @@ async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult
|
||||
release = str(ed.get("release_date") or "")
|
||||
localized[book_id] = base.model_copy(update={
|
||||
"title": ed.get("title") or base.title,
|
||||
"external_id": _isbn(ed.get("isbn_13"), ed.get("isbn_10")) or base.external_id,
|
||||
"external_id": (_isbn(ed.get("isbn_13"), ed.get("isbn_10"))
|
||||
or base.external_id),
|
||||
"year": int(release[:4]) if release[:4].isdigit() else base.year,
|
||||
"language": language,
|
||||
"cover_url": _image_url(ed.get("cached_image")) or base.cover_url,
|
||||
})
|
||||
return localized
|
||||
|
||||
|
||||
async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult],
|
||||
language: str) -> list[MetadataResult]:
|
||||
"""Replace each hit with its edition in the wanted language, drop the rest."""
|
||||
localized = await _localized(client, results, language)
|
||||
return [localized[book_id] for book_id in results if book_id in localized]
|
||||
|
||||
|
||||
@@ -139,7 +189,7 @@ async def search(query: str, language: str = "",
|
||||
doc = hit.get("document") or {}
|
||||
book_id = doc.get("id")
|
||||
try:
|
||||
book_id = int(book_id)
|
||||
book_id = int(str(book_id))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if doc.get("title"):
|
||||
@@ -152,3 +202,58 @@ async def search(query: str, language: str = "",
|
||||
# without the edition lookup the hits are still worth showing
|
||||
log.warning("hardcover edition lookup failed for %r: %s", query, exc)
|
||||
return list(results.values())
|
||||
|
||||
|
||||
async def series_books(series_id: str, language: str = "") -> list[MetadataResult]:
|
||||
"""Every volume of a series, by position. A position often holds several book
|
||||
rows (the same story as another work); the one with an edition in the wanted
|
||||
language wins the slot, else the first. Unnumbered books go last."""
|
||||
if not TOKEN:
|
||||
return []
|
||||
try:
|
||||
numeric_id = int(str(series_id))
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
data = await _post(client, _SERIES, {"id": numeric_id, "limit": SERIES_LIMIT})
|
||||
row = (data.get("series") or [{}])[0]
|
||||
name = row.get("name", "")
|
||||
|
||||
slots: dict[int, list[int]] = {} # volume -> book rows filed under it
|
||||
loose: list[int] = []
|
||||
books: dict[int, MetadataResult] = {}
|
||||
for entry in row.get("book_series") or []:
|
||||
book = entry.get("book") or {}
|
||||
try:
|
||||
book_id = int(str(book.get("id")))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not book.get("title") or book_id in books:
|
||||
continue
|
||||
volume = _volume(entry.get("position"))
|
||||
books[book_id] = _to_result(
|
||||
{**book, "image": book.get("cached_image"),
|
||||
"series_names": [name], "series_ids": [numeric_id],
|
||||
"featured_series_position": volume},
|
||||
"",
|
||||
)
|
||||
if volume is None:
|
||||
loose.append(book_id)
|
||||
else:
|
||||
slots.setdefault(volume, []).append(book_id)
|
||||
|
||||
localized: dict[int, MetadataResult] = {}
|
||||
if language in LANGUAGES and books:
|
||||
try:
|
||||
localized = await _localized(client, books, language)
|
||||
except (httpx.HTTPError, RuntimeError) as exc:
|
||||
log.warning("hardcover series editions failed for %s: %s",
|
||||
series_id, exc)
|
||||
|
||||
def pick(candidates: list[int]) -> MetadataResult:
|
||||
best = next((i for i in candidates if i in localized), candidates[0])
|
||||
return localized.get(best) or books[best]
|
||||
|
||||
return ([pick(slots[volume]) for volume in sorted(slots)]
|
||||
+ [localized.get(i) or books[i] for i in loose])
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user