fix(core): don't change unicode characters
This commit is contained in:
+57
-13
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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") == "a⧸b"
|
||||
assert "\x01" not in sanitize("a\x01b")
|
||||
|
||||
|
||||
def test_ebook_template():
|
||||
|
||||
+20
-1
@@ -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
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user