fix(core): don't change unicode characters

This commit is contained in:
Steppenstreuner
2026-08-28 18:52:06 +02:00
parent 6eaab5bc5d
commit 6b2c92e367
6 changed files with 101 additions and 29 deletions
+57 -13
View File
@@ -11,11 +11,27 @@ async function api(path, opts = {}) {
if (!resp.ok) {
let msg = resp.statusText;
try { msg = (await resp.json()).detail || msg; } catch {}
throw new Error(msg);
const err = new Error(msg);
err.status = resp.status;
throw err;
}
return resp.json();
}
// create a request; on a duplicate warning (409) ask the user before retrying
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?")) {
return api("/api/requests?allow_duplicate=true", {
method: "POST", body: JSON.stringify(body),
});
}
throw err;
}
}
function toast(msg, isError = false) {
const t = $("#toast");
t.textContent = msg;
@@ -216,15 +232,12 @@ $("#search-form").addEventListener("submit", async (e) => {
if (!libId) { toast("Erst eine Library für diesen Typ anlegen (Tab Libraries)", true); return; }
const volInput = box.querySelector(`[data-vol="${i}"]`);
try {
await api("/api/requests", {
method: "POST",
body: JSON.stringify({
await createRequest({
library_id: parseInt(libId),
title: r.title, authors: r.authors, narrator: r.narrator || "",
external_id: r.external_id,
year: r.year, series: r.series, cover_url: r.cover_url,
volume: volInput && volInput.value ? parseInt(volInput.value) : (r.volume ?? null),
}),
});
setLastLib(type, libId);
btn.textContent = "✓ Angefragt";
@@ -277,16 +290,13 @@ $("#manual-form").addEventListener("submit", async (e) => {
});
toast(`${created.length} Anfragen angelegt`);
} else {
await api("/api/requests", {
method: "POST",
body: JSON.stringify({
await createRequest({
library_id: parseInt(data.library_id),
title: data.title, authors: data.authors, series: data.series,
narrator: data.narrator || "",
external_id: data.external_id,
year: data.year ? parseInt(data.year) : null,
volume: data.volume ? parseInt(data.volume) : null,
}),
});
toast("Anfrage angelegt");
}
@@ -675,10 +685,47 @@ function openQuickDialog(i) {
$("#quick-library").innerHTML =
libOptions(item.media_type) || "<option value=''>— keine Library —</option>";
$("#quick-results").innerHTML = "";
renderQuickOpenRequests(item);
$("#quick-dialog").showModal();
runQuickSearch();
}
// offer linking to similar already-open requests instead of creating a duplicate
function renderQuickOpenRequests(item) {
const box = $("#quick-open-requests");
const tokens = cleanFileName(item.name).toLowerCase().split(" ").filter((t) => t.length > 2);
const matches = missingReqs
.filter((r) => r.media_type === item.media_type)
.map((r) => {
const hay = `${r.title} ${r.authors} ${r.series} ${r.narrator}`.toLowerCase();
return { r, hits: tokens.filter((t) => hay.includes(t)).length };
})
.filter((m) => m.hits >= 2)
.sort((a, b) => b.hits - a.hits)
.slice(0, 3);
if (!matches.length) { box.innerHTML = ""; return; }
box.innerHTML =
'<p class="muted">Passende offene Requests:</p>' +
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>
</div>`
)
.join("");
box.querySelectorAll("[data-link-req]").forEach((b) =>
b.addEventListener("click", () => {
const i = quickItemIndex;
scanItems[i].request_id = parseInt(b.dataset.linkReq);
scanItems[i].checked = true;
renderImportTable();
$("#quick-dialog").close();
toast("Mit bestehendem Request verbunden");
})
);
}
async function runQuickSearch() {
const item = scanItems[quickItemIndex];
const box = $("#quick-results");
@@ -726,14 +773,11 @@ async function pickQuickResult(r) {
if (!libId) { toast("Erst eine Library für diesen Typ anlegen", true); return; }
const i = quickItemIndex;
try {
const req = await api("/api/requests", {
method: "POST",
body: JSON.stringify({
const req = await createRequest({
library_id: parseInt(libId),
title: r.title, authors: r.authors, narrator: r.narrator || "",
external_id: r.external_id, volume: r.volume ?? null,
year: r.year, series: r.series, cover_url: r.cover_url,
}),
});
setLastLib(scanItems[i].media_type, libId);
missingReqs.push(req);
+1
View File
@@ -179,6 +179,7 @@
<label class="muted"
>Ziel-Library <select id="quick-library"></select
></label>
<div id="quick-open-requests"></div>
<div id="quick-results" class="cards"></div>
<div class="row">
<button type="button" id="quick-close" class="secondary">
+1 -1
View File
@@ -160,7 +160,7 @@ def test_audio_tags_written_on_import(client):
}]})
assert resp.json()["results"][0]["ok"]
dest = root / "Ulf Blanck" / "Die drei Kids Folge 085" # "?" is stripped from paths
dest = root / "Ulf Blanck" / "Die drei ??? Kids Folge 085" # "?" becomes fullwidth
files = sorted(dest.iterdir())
tags = ID3(files[0])
assert str(tags["TALB"]) == "Die drei ??? Kids Folge 085"
+4 -2
View File
@@ -9,8 +9,10 @@ def req(**kw):
return SimpleNamespace(**base)
def test_sanitize_removes_forbidden_chars():
assert sanitize('A<b>:c/d\\e|f?g*h"') == "Abcdefgh"
def test_sanitize_replaces_forbidden_chars():
assert sanitize("Die drei ???") == "Die drei ???"
assert sanitize("a/b") == "ab"
assert "\x01" not in sanitize("a\x01b")
def test_ebook_template():
+20 -1
View File
@@ -55,10 +55,29 @@ def list_requests(status: str | None = None, media_type: str | None = None,
@router.post("", response_model=RequestOut)
def create_request(data: RequestIn, session: Session = Depends(get_session)):
def create_request(data: RequestIn, allow_duplicate: bool = False,
session: Session = Depends(get_session)):
lib = session.get(Library, data.library_id)
if not lib:
raise HTTPException(404, "library not found")
if not allow_duplicate:
open_reqs = session.scalars(
select(BookRequest).where(
BookRequest.status == "missing",
BookRequest.media_type == lib.media_type,
)
).all()
for r in open_reqs:
same_id = data.external_id and r.external_id == data.external_id
same_title = (
r.title.strip().lower() == data.title.strip().lower()
and (r.narrator or "") == (data.narrator or "")
and r.volume == data.volume
)
if same_id or same_title:
raise HTTPException(
409, f"Bereits als missing vorhanden: „{r.title}“ (#{r.id})"
)
req = BookRequest(media_type=lib.media_type, **data.model_dump())
session.add(req)
session.commit()
+8 -2
View File
@@ -1,10 +1,16 @@
import re
_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
# characters not allowed in (Windows/SMB-safe) file names are replaced with
# fullwidth lookalikes so names like "Die drei ???" stay readable
_REPLACEMENTS = str.maketrans({
"<": "", ">": "", ":": "", '"': "", "/": "",
"\\": "", "|": "", "?": "", "*": "",
})
_CONTROL = re.compile(r"[\x00-\x1f]")
def sanitize(part: str, max_len: int = 120) -> str:
part = _FORBIDDEN.sub("", part).strip(" .")
part = _CONTROL.sub("", part.translate(_REPLACEMENTS)).strip(" .")
part = re.sub(r"\s+", " ", part)
return part[:max_len].strip(" .") or "Unknown"