From b1c01976521ec83c7c79f6123ad44c2e0f72ad7f Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Thu, 24 Sep 2026 07:44:16 +0200 Subject: [PATCH] Release v1.53.2 --- CHANGELOG.md | 20 ++ TODO.md | 21 ++ VERSION | 2 +- architecture/magazine-ingestion.md | 7 + core/magazine.py | 270 +++++++++++++++++-- core/pronunciations.json | 81 ++++++ core/pronunciations.py | 134 ++++++++++ requirements.txt | 1 + routes/magazine.py | 113 +++++++- static/dist/main.min.js | 6 +- static/index.html | 16 +- static/js/magazine.js | 399 +++++++++++++++++++++++++---- static/js/voice-library.js | 4 +- static/js/voice-picker.js | 10 +- static/sections/s-magazine.html | 48 +++- static/style.css | 52 +++- tests/magazine.spec.js | 82 +++++- tests/test_magazine.py | 99 ++++++- tests/test_magazine_routes.py | 20 ++ tests/test_pronunciations.py | 40 +++ 20 files changed, 1312 insertions(+), 113 deletions(-) create mode 100644 core/pronunciations.json create mode 100644 core/pronunciations.py create mode 100644 tests/test_pronunciations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fa494cf..712f8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.53.2] — 2026-09-24 + +### Added +- **Magazine: exported MP3s can use a second voice for printed interviews.** When exactly two recurring speaker labels are detected, the narrator and optional interview voice alternate turns without reading the labels aloud; ordinary prose containing colons is left alone. +- **Magazine: generated-audio playlists now include a synchronized reading companion.** The playlist shows page, duration and played/partly-played/unplayed status, follows the current sentence and word using estimated timing, displays the article's figures alongside the transcript, remembers playback progress, and offers previous/play/next controls plus a progress display in driving mode. +- **Magazine: title and issue metadata can be edited after import.** Exported filenames use a parsed issue year/month when available, and a new “Regenerate all MP3s” action rebuilds approved audio after metadata, text, pronunciation or voice changes. +- **Magazine review: a page-level checkbox selects or clears every text and image region**, including an indeterminate state for partially selected pages. + +### Changed +- **Magazine narration now performs conservative OCR and pronunciation cleanup without changing displayed source text.** Printed line-break hyphens are removed, known German/English abbreviations are expanded for speech, and dictionary-backed missing German umlauts are restored only when unambiguous. +- **Magazine page cards now show every article sharing the page with direct Review and Play actions**, and the article list is once again the default contents view. +- **Voice searches now include display names and tags** in both the voice library and shared voice pickers. + +### Fixed +- **Magazine: paragraphs flowing from one column into the next no longer create a giant union box over both columns.** The semantic stage keeps one physical box per column while still merging fragments within each individual column. +- **Magazine: oversized drop caps no longer make vision OCR omit the first line of a paragraph.** Suspicious lowercase starts receive a guarded local-OCR second opinion that is accepted only when it substantially matches the existing transcription. +- **Magazine: internal headings on continuation pages no longer split a multi-page article into separate playlist items.** + +--- + ## [1.53.0] — 2026-09-23 ### Added diff --git a/TODO.md b/TODO.md index 2e66f67..09d8b50 100644 --- a/TODO.md +++ b/TODO.md @@ -39,6 +39,27 @@ User-set priority for picking up feature work, in this order: Deployment and the deferred multi-user initiative below are explicitly parked, not part of this order. +### Magazine MP3 quality pass — set 2026-09-23 + +Work these in order; each item needs regression coverage against German and +English magazine text before it is marked complete. + +1. [x] **Remove printed line-break hyphens before narration.** Fuse words split + only because they crossed a printed line, while preserving real compounds + such as `KI-Modell`. +2. [x] **Expand German and English acronyms for TTS.** Keep a maintainable + pronunciation database and expand whole tokens such as `KI`, `AI`, `LLM` + and `API` in spoken text without changing the displayed article. +3. [x] **Conservatively restore OCR-missed German umlauts.** Use a spellchecker + only where an umlaut variant is known and unambiguous; never rewrite names + or unknown technical vocabulary on a guess. +4. [x] **Keep multi-page articles as one playlist item.** Prefer contents-page + anchors and explicit continuation markers, and prevent running heads or + internal headings from starting a new article. +5. [x] **Support two-voice printed interviews.** Detect repeated speaker labels, + use the selected narrator and optional second interview voice, and render + the turns as one article. + ### P0 — do first - [x] **S — Stop provider API keys from reaching frontend JavaScript or logs.** Provider keys are masked in settings diff --git a/VERSION b/VERSION index 3f48301..95e26be 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.53.0 +1.53.2 diff --git a/architecture/magazine-ingestion.md b/architecture/magazine-ingestion.md index ab3c5db..f239c1b 100644 --- a/architecture/magazine-ingestion.md +++ b/architecture/magazine-ingestion.md @@ -50,6 +50,13 @@ PDF → human review Stage D, in the UI — nothing is narrated until approved ``` +The semantic grouping stage keeps a logical paragraph split into separate +physical regions when it flows from one text column into the next. Joining the +text is semantically correct, but joining the rectangles creates a large union +box over unrelated copy between the two pieces (seen repeatedly on pages 111 +and 114 of the measured c't issue). Fragments within one column are still +merged; only a genuine column transition remains as two boxes. + ### Why server-side, not in the browser Text extraction for plain PDFs runs client-side (PDF.js), and that stays. Layout diff --git a/core/magazine.py b/core/magazine.py index b024ebb..e9387d7 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -38,6 +38,7 @@ import unicodedata import requests from core.retry import request_with_retries +from core.pronunciations import normalize_ocr_text, prepare_text_for_tts from dataclasses import dataclass, field, asdict from pathlib import Path from typing import Any, Iterable, Sequence @@ -731,8 +732,22 @@ def group_articles(blocks: Sequence[Block], if not any(b.type in NARRATABLE and b.type != BLOCK_TITLE for b in by_page[page]): continue - titles = [b for b in by_page[page] if b.type == BLOCK_TITLE + page_blocks = by_page[page] + titles = [b for b in page_blocks if b.type == BLOCK_TITLE and looks_like_real_title(b.text)] + # A large subheading, chart title or pull-out on a continuation page + # is frequently classified as a title. It is not a new article when + # body copy already precedes it in reading order. Genuine page-level + # article leads either come first, or have a nearby byline/deck. + if titles: + title = titles[0] + before = [b for b in page_blocks + if b.reading_order < title.reading_order and b.type == BLOCK_BODY] + following = [b for b in page_blocks + if title.reading_order < b.reading_order <= title.reading_order + 3] + has_lead_context = any(b.type in (BLOCK_BYLINE, BLOCK_DECK) for b in following) + if before and not has_lead_context: + titles = [] if titles: starts[page] = { "title": _normalise(titles[0].text), @@ -1262,6 +1277,102 @@ def vision_ocr_page_chain(png_path: Path, return None +_LOWERCASE_REGION_START = re.compile(r"^[a-zäöüß]") +_DETACHED_DROP_CAP = re.compile(r"^([A-ZÄÖÜ])\s+([a-zäöüß]{3,})\b") + + +def prefer_drop_cap_repair(original: str, candidate: str) -> str: + """Use a local OCR reading when it demonstrably restores a missing first line. + + Layout OCR models commonly treat an oversized initial as a separate visual + object, then begin the paragraph transcription on line two. The tell is a + body region beginning with a lower-case word fragment (``tert, dass`` in + ``eingetrichtert, dass``). A crop read by a second OCR engine is accepted + only when most of the existing transcription still appears in it and it + adds an upper-case beginning; this prevents an ordinary lower-case page + continuation from being replaced by an unrelated, poorer reading. + """ + old = " ".join((original or "").split()) + # Tesseract often emits the ordinary first-line letters *before* the + # tall initial because their tops are higher: ``ahrelang …\nJ tert …``. + # Put that isolated capital back at the front before comparing readings. + lines = [" ".join(line.split()) for line in (candidate or "").splitlines() if line.strip()] + if len(lines) >= 2 and _LOWERCASE_REGION_START.match(lines[0]): + cap_line = re.match(r"^([A-ZÄÖÜ])\s+(.+)$", lines[1]) + if cap_line: + lines = [cap_line.group(1) + lines[0], cap_line.group(2), *lines[2:]] + new = " ".join(lines).lstrip("|¦[]{}() ") + new = _DETACHED_DROP_CAP.sub(r"\1\2", new, count=1) + if not old or not new or not _LOWERCASE_REGION_START.match(old) or not new[:1].isupper(): + return old + if len(new) < max(40, int(len(old) * 0.85)) or ocr_quality_score(new) < 0.55: + return old + matcher = difflib.SequenceMatcher(None, old.lower(), new.lower(), autojunk=False) + matches = matcher.get_matching_blocks() + coverage = sum(m.size for m in matches) / max(1, len(old)) + first_substantial = next((m for m in matches if m.size >= 8 and m.a <= 20), None) + if coverage < 0.65 or first_substantial is None or not (1 <= first_substantial.b <= 250): + return old + # Keep the trusted existing body verbatim and take only the missing + # prefix from the local reading. Besides avoiding a wholesale downgrade, + # this also prevents a crop touching the next paragraph by a few pixels + # from appending that paragraph a second time. + prefix = new[:first_substantial.b].rstrip() + if prefix.endswith("-"): + prefix = prefix[:-1] # printed line-break hyphen: eingetrich- + tert + return prefix + old + joins_word = prefix[-1:].isalnum() and old[:1].isalnum() + return prefix + ("" if joins_word or old[:1] in ",.;:!?" else " ") + old + + +def repair_drop_cap_omissions(png_path: Path, regions: list[dict[str, Any]], + lang: str = "deu+eng") -> int: + """Re-read only suspicious paragraph crops with Tesseract. + + This is intentionally a narrow second opinion, not a full second OCR pass: + normal regions cost nothing, and a replacement is made only through + :func:`prefer_drop_cap_repair`'s overlap guard. + """ + suspicious = [r for r in regions + if r.get("label") in {"text", "paragraph", "body"} + and len((r.get("text") or "").strip()) >= 80 + and _LOWERCASE_REGION_START.match((r.get("text") or "").strip())] + if not suspicious: + return 0 + try: + from PIL import Image + image = Image.open(png_path) + except Exception: + return 0 + repaired = 0 + try: + for region in suspicious: + x, y, w, h = (float(v) for v in region["bbox"]) + pad = 6 + crop = image.crop((max(0, int(x) - pad), max(0, int(y) - pad), + min(image.width, int(math.ceil(x + w)) + pad), + min(image.height, int(math.ceil(y + h)) + pad))) + with tempfile.NamedTemporaryFile(prefix="drop-cap-", suffix=".png", delete=False) as tmp: + crop_path = Path(tmp.name) + try: + crop.save(crop_path) + proc = subprocess.run( + ["tesseract", str(crop_path), "stdout", "-l", lang, "--psm", "6"], + capture_output=True, text=True, timeout=60, check=True, + ) + replacement = prefer_drop_cap_repair(region["text"], proc.stdout) + if replacement != " ".join(region["text"].split()): + region["text"] = replacement + repaired += 1 + except (subprocess.SubprocessError, FileNotFoundError, OSError): + continue + finally: + crop_path.unlink(missing_ok=True) + finally: + image.close() + return repaired + + # One line per visual type -- a chart needs "what trend", a table needs # "what's listed", not the generic "what's shown" that suits a photo. Every # variant still asks for one or two plain sentences: this is read aloud @@ -2102,6 +2213,7 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110, regions = vision_ocr_page_chain(png, vision_engines, timeout=vision_ocr_timeout) if regions: + repair_drop_cap_omissions(png, regions, ocr_lang) scale_x = rect.width / max(1.0, _png_width(png, fitz)) vision_blocks = build_vision_blocks( regions, page_no, scale_x, id_prefix="b") @@ -2369,6 +2481,7 @@ def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int, regions = vision_ocr_page_chain(png, engines, timeout=timeout) if not regions: return None + repair_drop_cap_omissions(png, regions) scale_x = page_meta["width"] / max(1.0, _png_width(png, fitz)) new_blocks = build_vision_blocks(regions, page_no, scale_x, id_prefix="r") @@ -2431,11 +2544,81 @@ def region_looks_like_a_picture(bbox: Sequence[float], page_ink) -> bool: def apply_semantic_regions(blocks: list[dict], regions: list[dict], page_meta: dict | None = None, page_ink=None) -> list[dict]: - """Group existing OCR fragments without allowing invented IDs or text.""" + """Group existing OCR fragments without allowing invented IDs or text. + + A paragraph may continue from the bottom of one column at the top of the + next. The semantic model correctly recognises that as one logical + paragraph, but collapsing both OCR fragments into one rectangular bbox + paints the union over all of the unrelated text between them. Keep one + output region per physical column in that case; narration can still read + the adjacent regions continuously. + """ known = {b["id"]: b for b in blocks} used = set() out = [] allowed = set(NARRATABLE) | set(VISUAL_TYPES) | {BLOCK_AD, BLOCK_SIDEBAR, BLOCK_CAPTION, BLOCK_FURNITURE, BLOCK_PULLQUOTE} + + # Infer the grid from the ungrouped OCR boxes, while their real column + # geometry is still available. Doing this after forming a union is too + # late: the union itself bridges the gutter and hides the two columns. + semantic_spans: list[tuple[float, float]] = [] + semantic_col_w = 0.0 + if page_meta and page_meta.get("width"): + geometry_blocks = [ + Block(id=b["id"], page=b["page"], bbox=list(b["bbox"]), + type=b["type"], text=b.get("text", "")) + for b in blocks + ] + anchors, semantic_col_w = column_anchors(geometry_blocks, float(page_meta["width"])) + semantic_spans = [ + (anchor, min(anchor + semantic_col_w, anchors[i + 1]) + if i + 1 < len(anchors) else anchor + semantic_col_w) + for i, anchor in enumerate(anchors) + ] + + def physical_groups(members: list[dict], kind: str) -> list[list[dict]]: + """Partition a logical body paragraph at genuine column boundaries.""" + if kind != BLOCK_BODY or len(members) < 2 or len(semantic_spans) < 2: + return [members] + + def overlap(member: dict, span: tuple[float, float]) -> float: + x, _, width, _ = member["bbox"] + return max(0.0, min(x + width, span[1]) - max(x, span[0])) + + assignments = [] + for member in members: + column = max(range(len(semantic_spans)), + key=lambda i: (overlap(member, semantic_spans[i]), -i)) + # A wide standfirst or a tiny detached glyph is not evidence that + # a paragraph crosses columns. Each split member must occupy a + # useful fraction of its assigned text column. + if overlap(member, semantic_spans[column]) < 0.45 * min( + semantic_col_w, member["bbox"][2]): + return [members] + assignments.append(column) + if len(set(assignments)) < 2: + return [members] + + grouped: dict[int, list[dict]] = {} + for member, column in zip(members, assignments): + grouped.setdefault(column, []).append(member) + return [sorted(grouped[column], key=lambda b: (b["bbox"][1], b["bbox"][0])) + for column in sorted(grouped)] + + def append_group(members: list[dict], kind: str, label: str, + visual_box: list[float] | None, continues: bool) -> None: + x = min(b["bbox"][0] for b in members) + y = min(b["bbox"][1] for b in members) + right = max(b["bbox"][0] + b["bbox"][2] for b in members) + bottom = max(b["bbox"][1] + b["bbox"][3] for b in members) + separator = "\n" if kind in (BLOCK_CODE, BLOCK_TABLE) else " " + text = separator.join(b["text"] for b in members) + if kind in NARRATABLE: + text = re.sub(r"(?<=\w)-\s+(?=[a-zäöüß])", "", text) + out.append(dict(members[0], bbox=visual_box or [x, y, right - x, bottom - y], text=text, + type=kind, region_label=label, source="llm_layout", + reading_order=len(out), continues_from_previous_page=continues)) + for region in regions: if not isinstance(region, dict): raise ValueError("Invalid semantic region") @@ -2478,18 +2661,10 @@ def apply_semantic_regions(blocks: list[dict], regions: list[dict], page_meta: d raise ValueError("Cannot merge advertising and editorial text") kind = BLOCK_AD used.update(ids) - x = min(b["bbox"][0] for b in members) - y = min(b["bbox"][1] for b in members) - right = max(b["bbox"][0] + b["bbox"][2] for b in members) - bottom = max(b["bbox"][1] + b["bbox"][3] for b in members) - separator = "\n" if kind in (BLOCK_CODE, BLOCK_TABLE) else " " - text = separator.join(b["text"] for b in members) - if kind in NARRATABLE: - text = re.sub(r"(?<=\w)-\s+(?=[a-zäöüß])", "", text) - out.append(dict(members[0], bbox=visual_box or [x, y, right - x, bottom - y], text=text, - type=kind, region_label=label, source="llm_layout", - reading_order=len(out), - continues_from_previous_page=region.get("continues_from_previous_page") is True)) + groups = physical_groups(members, kind) + for i, group in enumerate(groups): + append_group(group, kind, label, visual_box, + i == 0 and region.get("continues_from_previous_page") is True) # Incomplete model output must not silently remove document content. return out + [b for b in blocks if b["id"] not in used] @@ -3302,7 +3477,7 @@ def article_text(layout: dict[str, Any], article: dict[str, Any], wanted = set(article.get("blocks") or []) types = set(NARRATABLE) | ({BLOCK_CAPTION} if include_captions else set()) blocks = [ - dict(b, text=strip_continuation_markers(b["text"])) + dict(b, text=normalize_ocr_text(strip_continuation_markers(b["text"]))) for b in layout.get("blocks", []) if b["id"] in wanted and b["type"] in types ] @@ -3334,8 +3509,50 @@ def split_sentences_for_tts(text: str) -> list[str]: return [p.strip() for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()] +_INTERVIEW_LABEL_RE = re.compile( + r"^\s*([A-ZÄÖÜ][A-Za-zÄÖÜäöüß.'’\-]+(?:\s+[A-ZÄÖÜ][A-Za-zÄÖÜäöüß.'’\-]+){0,3})\s*:\s+(.+)$", + re.S, +) + + +def interview_tts_segments(blocks: Sequence[dict[str, Any]]) -> list[dict[str, str | None]]: + """Return article text segments with speaker labels for a two-person interview. + + Detection is intentionally strict: exactly two recurring labels must begin + paragraph blocks. Colons in ordinary prose therefore do not suddenly + switch voices. Speaker labels themselves are omitted from speech because + the voice change already conveys who is talking. + """ + parsed: list[tuple[str | None, str]] = [] + counts: dict[str, int] = {} + labels: dict[str, str] = {} + for block in blocks: + text = (block.get("text") or "").strip() + if not text: + continue + match = _INTERVIEW_LABEL_RE.match(text) + if not match: + parsed.append((None, text)) + continue + label, body = match.group(1).strip(), match.group(2).strip() + key = label.casefold() + labels.setdefault(key, label) + counts[key] = counts.get(key, 0) + 1 + parsed.append((key, body)) + + recurring = [key for key, count in counts.items() if count >= 2] + if len(recurring) != 2: + return [{"speaker": None, "text": text} for _, text in parsed] + allowed = set(recurring) + return [ + {"speaker": labels[speaker] if speaker in allowed else None, "text": text} + for speaker, text in parsed + ] + + def synthesize_article_audio(doc_dir: Path, article: dict[str, Any], layout: dict[str, Any], voice: str, settings: dict[str, Any], *, + interview_voice: str = "", album: str = "", cover_bytes: bytes | None = None, cover_mime: str = "image/png", progress_cb=None) -> Path: """Render one article to a single MP3, tagged with title/artist/album, @@ -3351,12 +3568,27 @@ def synthesize_article_audio(doc_dir: Path, article: dict[str, Any], layout: dic full_text = " ".join(b["text"] for b in blocks if (b.get("text") or "").strip()).strip() if not full_text: raise ValueError("Article has no narratable text -- approve it with at least one block ticked") - sentences = split_sentences_for_tts(full_text) - total = len(sentences) + segments = interview_tts_segments(blocks) if interview_voice else [ + {"speaker": None, "text": full_text} + ] + speakers = [] + for segment in segments: + speaker = segment.get("speaker") + if speaker and speaker not in speakers: + speakers.append(speaker) + voice_for = {speaker: (voice if i == 0 else interview_voice) + for i, speaker in enumerate(speakers[:2])} + utterances: list[tuple[str, str]] = [] + for segment in segments: + spoken = prepare_text_for_tts(str(segment["text"])) + segment_voice = voice_for.get(segment.get("speaker"), voice) + utterances.extend((sentence, segment_voice) + for sentence in split_sentences_for_tts(spoken)) + total = len(utterances) combined = AudioSegment.empty() - for i, sentence in enumerate(sentences): - audio_bytes, media_type = _tts_request_audio(sentence, voice, settings) + for i, (sentence, sentence_voice) in enumerate(utterances): + audio_bytes, media_type = _tts_request_audio(sentence, sentence_voice, settings) fmt = "wav" if audio_bytes[:4] == b"RIFF" or "wav" in media_type.lower() else None combined += AudioSegment.from_file(_io.BytesIO(audio_bytes), format=fmt) if progress_cb: diff --git a/core/pronunciations.json b/core/pronunciations.json new file mode 100644 index 0000000..81bd29b --- /dev/null +++ b/core/pronunciations.json @@ -0,0 +1,81 @@ +{ + "de": { + "KI": "Künstliche Intelligenz", + "AI": "Künstliche Intelligenz", + "ML": "maschinelles Lernen", + "LLM": "großes Sprachmodell", + "LLMs": "große Sprachmodelle", + "API": "Programmierschnittstelle", + "APIs": "Programmierschnittstellen", + "CPU": "Hauptprozessor", + "GPU": "Grafikprozessor", + "EU": "Europäische Union", + "USA": "Vereinigte Staaten", + "z. B.": "zum Beispiel", + "d. h.": "das heißt", + "u. a.": "unter anderem", + "bzw.": "beziehungsweise", + "ca.": "circa", + "Mio.": "Millionen", + "Mrd.": "Milliarden" + }, + "en": { + "AI": "artificial intelligence", + "ML": "machine learning", + "LLM": "large language model", + "LLMs": "large language models", + "API": "application programming interface", + "APIs": "application programming interfaces", + "CPU": "central processing unit", + "GPU": "graphics processing unit", + "EU": "European Union", + "USA": "United States", + "e.g.": "for example", + "i.e.": "that is" + }, + "de_umlaut_repairs": { + "ahnlich": "ähnlich", + "allmahlich": "allmählich", + "anderte": "änderte", + "anderung": "Änderung", + "arztlich": "ärztlich", + "außerst": "äußerst", + "bevolkerung": "Bevölkerung", + "buro": "Büro", + "burojob": "Bürojob", + "dafur": "dafür", + "durchschnittlich": "durchschnittlich", + "erklart": "erklärt", + "ermoglicht": "ermöglicht", + "fruher": "früher", + "funf": "fünf", + "fur": "für", + "großer": "größer", + "großte": "größte", + "großten": "größten", + "jahrlich": "jährlich", + "jungere": "jüngere", + "konnen": "können", + "konnte": "könnte", + "kunstlich": "künstlich", + "kunstliche": "künstliche", + "kunstlichen": "künstlichen", + "moglich": "möglich", + "mussen": "müssen", + "naturlich": "natürlich", + "offentlich": "öffentlich", + "personlich": "persönlich", + "prufen": "prüfen", + "schuler": "Schüler", + "uber": "über", + "ubrig": "übrig", + "verfugbar": "verfügbar", + "veroffentlicht": "veröffentlicht", + "vollig": "völlig", + "wahlt": "wählt", + "wahrend": "während", + "wurde": "würde", + "wurden": "würden", + "zukunftig": "zukünftig" + } +} diff --git a/core/pronunciations.py b/core/pronunciations.py new file mode 100644 index 0000000..57c35b2 --- /dev/null +++ b/core/pronunciations.py @@ -0,0 +1,134 @@ +"""Conservative OCR cleanup and spoken-form expansion for magazine TTS.""" +from __future__ import annotations + +import itertools +import json +import re +from functools import lru_cache +from pathlib import Path + +_DB_PATH = Path(__file__).with_name("pronunciations.json") +_WORD_RE = re.compile(r"\b[A-Za-zÄÖÜäöüß]{3,}\b") +_LINEBREAK_HYPHEN_RE = re.compile( + r"(?<=[A-Za-zÄÖÜäöüß])-(?P\s+)(?P[a-zäöüß][A-Za-zÄÖÜäöüß]*)" +) +_SUSPENDED_COMPOUND_WORDS = {"und", "oder", "beziehungsweise", "sowie"} +_GERMAN_HINTS = { + "aber", "auch", "auf", "aus", "bei", "das", "dass", "der", "die", "ein", + "eine", "einer", "einen", "für", "hat", "ist", "mit", "nicht", "oder", "sich", + "sie", "und", "von", "werden", "wie", "zu", +} + + +@lru_cache(maxsize=1) +def pronunciation_db() -> dict: + try: + data = json.loads(_DB_PATH.read_text("utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def remove_linebreak_hyphens(text: str) -> str: + """Fuse a word split at a printed line end, preserving real compounds.""" + text = (text or "").replace("\u00ad", "") + + def fuse(match: re.Match) -> str: + # ``Hardware- und Softwareentwicklung`` is a real suspended compound, + # not a printed line break. OCR historically flattened newlines to a + # space, so keep this small grammatical exception for saved layouts. + if match.group("next").casefold() in _SUSPENDED_COMPOUND_WORDS: + return match.group(0) + return match.group("next") + + return _LINEBREAK_HYPHEN_RE.sub(fuse, text) + + +def detect_text_language(text: str) -> str: + words = re.findall(r"[A-Za-zÄÖÜäöüß]+", (text or "").lower()) + if any(any(c in w for c in "äöüß") for w in words): + return "de" + hits = sum(1 for word in words if word in _GERMAN_HINTS) + return "de" if hits >= max(2, len(words) // 40) else "en" + + +@lru_cache(maxsize=1) +def _german_spellchecker(): + try: + from spellchecker import SpellChecker + return SpellChecker(language="de", distance=1) + except Exception: + return None + + +def _match_case(source: str, replacement: str) -> str: + if source.isupper() and len(source) > 1: + return replacement.upper() + if source[:1].isupper(): + return replacement[:1].upper() + replacement[1:] + return replacement + + +def _umlaut_variants(word: str) -> set[str]: + positions = [(i, {"a": "ä", "o": "ö", "u": "ü"}[c]) + for i, c in enumerate(word) if c in "aou"] + if not positions or len(positions) > 5: + return set() + variants = set() + for count in range(1, len(positions) + 1): + for chosen in itertools.combinations(positions, count): + chars = list(word) + for i, replacement in chosen: + chars[i] = replacement + variants.add("".join(chars)) + return variants + + +def restore_german_umlauts(text: str) -> str: + """Restore only dictionary-backed, unambiguous missing umlauts.""" + explicit = {str(k).lower(): str(v) for k, v in + (pronunciation_db().get("de_umlaut_repairs") or {}).items()} + checker = _german_spellchecker() + + def replace(match: re.Match) -> str: + token = match.group(0) + lower = token.lower() + if any(c in lower for c in "äöüß") or token.isupper(): + return token + if lower in explicit: + return _match_case(token, explicit[lower]) + # An unknown title-cased token is more likely to be a person's name, + # product or place than a common noun. Explicit reviewed repairs above + # may still correct a sentence-opening word such as ``Jahrlich``. + if token != lower: + return token + if checker is None or lower in checker: + return token + known = [candidate for candidate in _umlaut_variants(lower) if candidate in checker] + if len(known) != 1: + return token + return _match_case(token, known[0]) + + return _WORD_RE.sub(replace, text or "") + + +def normalize_ocr_text(text: str, language: str = "auto") -> str: + text = remove_linebreak_hyphens(text) + lang = detect_text_language(text) if language == "auto" else language + return restore_german_umlauts(text) if lang == "de" else text + + +def expand_abbreviations(text: str, language: str = "auto") -> str: + lang = detect_text_language(text) if language == "auto" else language + mapping = pronunciation_db().get(lang) or {} + result = text or "" + # Longest first so ``LLMs`` wins over ``LLM`` and dotted abbreviations + # are consumed as one unit. Match tokens only, never substrings in names. + for short, spoken in sorted(mapping.items(), key=lambda item: len(item[0]), reverse=True): + pattern = r"(? str: + return expand_abbreviations(normalize_ocr_text(text, language), language) diff --git a/requirements.txt b/requirements.txt index b4a9006..442b7a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ Pillow>=10.3.0 yt-dlp>=2024.3.10 pymupdf>=1.24.0 mutagen>=1.47.0 +pyspellchecker>=0.8.1 diff --git a/routes/magazine.py b/routes/magazine.py index 81d7629..acbefdf 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -113,6 +113,27 @@ async def magazine_create(request: Request): return {"id": doc_id} +@router.patch("/api/magazine/{doc_id}") +async def magazine_update_meta(doc_id: str, request: Request): + """Edit title/issue/publisher after creation -- there was previously no + way to do this at all once the magazine had been created, which mattered + for real use: the exported MP3 filename includes the issue's year and + month, parsed from this field, and a magazine created without one (the + common case, since it's an optional field at upload time) could never + get that fixed short of re-uploading the whole thing.""" + d, meta = _require_magazine(doc_id) + body = await request.json() + if "title" in body: + meta["title"] = str(body["title"]).strip()[:300] or "Untitled magazine" + if "issue" in body: + meta["issue"] = str(body["issue"]).strip()[:120] + if "publisher" in body: + meta["publisher"] = str(body["publisher"]).strip()[:120] + meta["updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + _write_meta(d, meta) + return meta + + # ── Stage A–C: ingestion ───────────────────────────────────────────────────── # Character budget for one page's live ingest preview (see @@ -494,7 +515,18 @@ async def magazine_articles(doc_id: str): # generated, instead of a 404 on every article open. for a in articles: a["has_podcast"] = (d / "podcast" / f"{a['id']}.json").is_file() - a["has_audio"] = (d / "audio" / f"{a['id']}.mp3").is_file() + audio_path = d / "audio" / f"{a['id']}.mp3" + a["has_audio"] = audio_path.is_file() + if a["has_audio"]: + try: + from mutagen import File as MutagenFile + parsed = MutagenFile(audio_path) + a["audio_seconds"] = round(float(parsed.info.length), 2) if parsed and parsed.info else 0 + except Exception: + # Duration is presentational; a damaged/mid-write tag must not + # make the whole contents endpoint fail. The browser will read + # the duration from the audio element once playback starts. + a["audio_seconds"] = 0 return { "articles": articles, "progress": meta.get("progress") or {}, @@ -593,6 +625,12 @@ async def magazine_progress(doc_id: str, request: Request): entry["sentence"] = max(0, int(body["sentence"])) if "seconds" in body: entry["seconds"] = max(0, float(body["seconds"])) + if "duration" in body: + entry["duration"] = max(0, float(body["duration"])) + if "completed" in body: + entry["completed"] = bool(body["completed"]) + if entry["completed"]: + entry["seconds"] = entry.get("duration", entry.get("seconds", 0)) progress[article_id] = entry meta["progress"] = progress meta["updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) @@ -1680,6 +1718,7 @@ def _article_cover(d: Path, article: dict[str, Any]) -> tuple[bytes | None, str] def _synthesize_article_audio_sync(doc_id: str, job_id: str, article_id: str, voice: str, + interview_voice: str = "", total_hint: int | None = None, index: int | None = None, count: int | None = None) -> Path: d = _LIB / doc_id @@ -1703,14 +1742,16 @@ def _synthesize_article_audio_sync(doc_id: str, job_id: str, article_id: str, vo return mag.synthesize_article_audio( d, article, layout, voice, settings, + interview_voice=interview_voice, album=meta.get("title") or "", cover_bytes=cover_bytes, cover_mime=cover_mime, progress_cb=progress, ) -def _audio_generate_one_sync(doc_id: str, job_id: str, article_id: str, voice: str) -> None: +def _audio_generate_one_sync(doc_id: str, job_id: str, article_id: str, voice: str, + interview_voice: str = "") -> None: try: - path = _synthesize_article_audio_sync(doc_id, job_id, article_id, voice) + path = _synthesize_article_audio_sync(doc_id, job_id, article_id, voice, interview_voice) background_job_update(job_id, stage="done", done=True, detail="Audio ready", result={"article": article_id, "file": path.name}) except Exception as exc: # noqa: BLE001 - surfaced to UI @@ -1725,11 +1766,13 @@ async def magazine_generate_article_audio(doc_id: str, article_id: str, request: article_id = _article_id(article_id) body = await request.json() if await request.body() else {} voice = str(body.get("voice") or meta.get("default_voice") or "").strip() + interview_voice = str(body.get("interview_voice") or "").strip() if not voice: raise HTTPException(400, "No narrator voice given") job_id = uuid.uuid4().hex background_job_create(job_id, "magazine-article-audio", fingerprint=f"{doc_id}:{article_id}") - asyncio.create_task(asyncio.to_thread(_audio_generate_one_sync, doc_id, job_id, article_id, voice)) + asyncio.create_task(asyncio.to_thread( + _audio_generate_one_sync, doc_id, job_id, article_id, voice, interview_voice)) return {"job_id": job_id} @@ -1741,12 +1784,59 @@ def _sanitize_filename_part(text: str) -> str: return re.sub(r"\s+", " ", text) +_MONTH_NAMES_EN = ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] +_MONTH_LOOKUP = { + "jan": "January", "januar": "January", "january": "January", + "feb": "February", "februar": "February", "february": "February", + "mar": "March", "märz": "March", "maerz": "March", "march": "March", + "apr": "April", "april": "April", + "mai": "May", "may": "May", + "jun": "June", "juni": "June", "june": "June", + "jul": "July", "juli": "July", "july": "July", + "aug": "August", "august": "August", + "sep": "September", "sept": "September", "september": "September", + "oct": "October", "okt": "October", "oktober": "October", "october": "October", + "nov": "November", "november": "November", + "dec": "December", "dez": "December", "dezember": "December", "december": "December", +} +_ISSUE_YEAR_RE = re.compile(r"\b(19|20)\d{2}\b") +_ISSUE_NUMERIC_MONTH_RE = re.compile(r"\b(0?[1-9]|1[0-2])[/.-](19|20)\d{2}\b|\b(19|20)\d{2}[-/](0?[1-9]|1[0-2])\b") + + +def _parse_issue_year_month(issue: str) -> tuple[str, str]: + """Best-effort year/month out of the free-text "Issue" field (e.g. + "September 2026", "09/2026", "2026-09") -- there's no structured date + anywhere else for a magazine, since "Issue" is optional at upload and + was, until now, impossible to add or fix afterward.""" + issue = issue or "" + year_m = _ISSUE_YEAR_RE.search(issue) + year = year_m.group(0) if year_m else "" + month = "" + for word in re.findall(r"[A-Za-zÄÖÜäöü]+", issue): + canon = _MONTH_LOOKUP.get(word.lower()) + if canon: + month = canon + break + if not month: + m = _ISSUE_NUMERIC_MONTH_RE.search(issue) + if m: + month_num = int(m.group(1) or m.group(4)) + month = _MONTH_NAMES_EN[month_num - 1] + return year, month + + def _article_download_filename(meta: dict, article: dict) -> str: """A human-readable name for an exported MP3 -- the on-disk file stays named by article id (stable, filesystem-safe, never collides); this is only what a download or a zip entry is called, since that's what a - person actually sees in their music/podcast player or file browser.""" - parts = [meta.get("title") or "Magazine"] + person actually sees in their music/podcast player or file browser. + Year and month (parsed from the magazine's own "Issue" field) lead, so + a folder of exports from several issues sorts chronologically by + filename; either is silently omitted if "Issue" isn't set or isn't + parseable, rather than leaving a blank segment.""" + year, month = _parse_issue_year_month(meta.get("issue") or "") + parts = [year, month, meta.get("title") or "Magazine"] page = (article.get("pages") or [None])[0] if page is not None: parts.append(f"p{page}") @@ -1769,13 +1859,16 @@ async def magazine_article_audio_file(doc_id: str, article_id: str): return FileResponse(path, media_type="audio/mpeg", filename=_article_download_filename(meta, article)) -def _audio_generate_batch_sync(doc_id: str, job_id: str, article_ids: list[str], voice: str) -> None: +def _audio_generate_batch_sync(doc_id: str, job_id: str, article_ids: list[str], voice: str, + interview_voice: str = "") -> None: ok, failed = [], [] total = len(article_ids) for i, article_id in enumerate(article_ids, start=1): background_job_update(job_id, detail=f"Article {i}/{total}…") try: - _synthesize_article_audio_sync(doc_id, job_id, article_id, voice, index=i, count=total) + _synthesize_article_audio_sync( + doc_id, job_id, article_id, voice, interview_voice, + index=i, count=total) ok.append(article_id) except Exception as exc: # noqa: BLE001 - one bad article should not stop the rest failed.append({"article": article_id, "error": str(exc)[:300]}) @@ -1794,6 +1887,7 @@ async def magazine_generate_batch_audio(doc_id: str, request: Request): d, meta = _require_magazine(doc_id) body = await request.json() if await request.body() else {} voice = str(body.get("voice") or meta.get("default_voice") or "").strip() + interview_voice = str(body.get("interview_voice") or "").strip() if not voice: raise HTTPException(400, "No narrator voice given") requested = body.get("article_ids") @@ -1807,7 +1901,8 @@ async def magazine_generate_batch_audio(doc_id: str, request: Request): raise HTTPException(400, "No approved articles to render") job_id = uuid.uuid4().hex background_job_create(job_id, "magazine-audio-batch", fingerprint=doc_id) - asyncio.create_task(asyncio.to_thread(_audio_generate_batch_sync, doc_id, job_id, article_ids, voice)) + asyncio.create_task(asyncio.to_thread( + _audio_generate_batch_sync, doc_id, job_id, article_ids, voice, interview_voice)) return {"job_id": job_id, "count": len(article_ids)} diff --git a/static/dist/main.min.js b/static/dist/main.min.js index 685e47c..5cc95a1 100644 --- a/static/dist/main.min.js +++ b/static/dist/main.min.js @@ -1,8 +1,8 @@ -(function(){"use strict";const _pickers={};function _voiceData(id){return(window._voices||[]).find(v=>v.id===id)||null}function _voiceLang(v,id){const raw=String((v==null?void 0:v.lang)||(v==null?void 0:v.language)||"").trim(),fromMeta=raw&&raw.length<=5?raw:"",fromId=String(id||"").split("_")[0]||"";return(fromMeta||fromId).toUpperCase()}function _voiceGender(v,id){const raw=String((v==null?void 0:v.gender)||(v==null?void 0:v.sex)||"").trim(),first=raw?raw.charAt(0).toUpperCase():"";if(["F","M","N"].includes(first))return first;const fromId=(String(id||"").split("_")[1]||"").charAt(0).toUpperCase();return["F","M","N"].includes(fromId)?fromId:""}function _voiceMetaLabel(id){const v=_voiceData(id);return[_voiceGender(v,id),_voiceLang(v,id)].filter(Boolean).join(" ")}function _voiceOptionLabel(id){const meta=_voiceMetaLabel(id);return meta?`${id} ${meta}`:id}const VOICE_AVATAR_ICONS={male:"mdi-face-man",female:"mdi-face-woman",neutral:"mdi-account",robot:"mdi-robot-outline",animal:"mdi-paw"},VOICE_AVATAR_COLORS={male:"#3b82f6",female:"#ec4899",neutral:"#6b7280",robot:"#0ea5e9",animal:"#f59e0b"};window.VOICE_AVATAR_ICONS=VOICE_AVATAR_ICONS,window.voiceAvatarIcon=function(avatarKey,size){const icon=VOICE_AVATAR_ICONS[avatarKey];if(!icon)return null;const s=size+"px",r=Math.round(size/2)+"px",bg=VOICE_AVATAR_COLORS[avatarKey]||"#6b7280";return``};function _avatarHtml(id,size){const v=_voiceData(id),s=size+"px",r=Math.round(size/2)+"px";if(v!=null&&v.has_picture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(v==null?void 0:v.avatar,size):null;if(icon)return icon;const lang=(v==null?void 0:v.lang)||"",color=_langColor(lang,id),init=(id||"?")[0].toUpperCase();return`${init}`}function _langColor(lang,id){const str=(lang||id||"").toLowerCase();if(str.startsWith("de"))return"#3b82f6";if(str.startsWith("en"))return"#10b981";if(str.startsWith("fr"))return"#8b5cf6";if(str.startsWith("es"))return"#f59e0b";if(str.startsWith("it"))return"#ef4444";if(str.startsWith("zh"))return"#ec4899";if(str.startsWith("ja"))return"#f97316";const palette=["#3b82f6","#10b981","#8b5cf6","#f59e0b","#ef4444","#ec4899","#06b6d4","#84cc16"];let h=0;for(let i=0;i>>0;return palette[h%palette.length]}function _flagSpan(v){return v&&v.flag?`${v.flag}`:""}function _buildItem(id,label){const v=_voiceData(id),meta=_voiceMetaLabel(id),name=id||label||"";return`
+(function(){"use strict";const _pickers={};function _voiceData(id){return(window._voices||[]).find(v=>v.id===id)||null}function _voiceLang(v,id){const raw=String((v==null?void 0:v.lang)||(v==null?void 0:v.language)||"").trim(),fromMeta=raw&&raw.length<=5?raw:"",fromId=String(id||"").split("_")[0]||"";return(fromMeta||fromId).toUpperCase()}function _voiceGender(v,id){const raw=String((v==null?void 0:v.gender)||(v==null?void 0:v.sex)||"").trim(),first=raw?raw.charAt(0).toUpperCase():"";if(["F","M","N"].includes(first))return first;const fromId=(String(id||"").split("_")[1]||"").charAt(0).toUpperCase();return["F","M","N"].includes(fromId)?fromId:""}function _voiceMetaLabel(id){const v=_voiceData(id);return[_voiceGender(v,id),_voiceLang(v,id)].filter(Boolean).join(" ")}function _voiceOptionLabel(id){const meta=_voiceMetaLabel(id);return meta?`${id} ${meta}`:id}function _voiceSearchText(id,label){const v=_voiceData(id)||{},tags=Array.isArray(v.tags)?v.tags.join(" "):v.tags||v.tag||"";return[id,label,v.name,tags].map(x=>String(x||"").toLowerCase()).join(" ")}const VOICE_AVATAR_ICONS={male:"mdi-face-man",female:"mdi-face-woman",neutral:"mdi-account",robot:"mdi-robot-outline",animal:"mdi-paw"},VOICE_AVATAR_COLORS={male:"#3b82f6",female:"#ec4899",neutral:"#6b7280",robot:"#0ea5e9",animal:"#f59e0b"};window.VOICE_AVATAR_ICONS=VOICE_AVATAR_ICONS,window.voiceAvatarIcon=function(avatarKey,size){const icon=VOICE_AVATAR_ICONS[avatarKey];if(!icon)return null;const s=size+"px",r=Math.round(size/2)+"px",bg=VOICE_AVATAR_COLORS[avatarKey]||"#6b7280";return``};function _avatarHtml(id,size){const v=_voiceData(id),s=size+"px",r=Math.round(size/2)+"px";if(v!=null&&v.has_picture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(v==null?void 0:v.avatar,size):null;if(icon)return icon;const lang=(v==null?void 0:v.lang)||"",color=_langColor(lang,id),init=(id||"?")[0].toUpperCase();return`${init}`}function _langColor(lang,id){const str=(lang||id||"").toLowerCase();if(str.startsWith("de"))return"#3b82f6";if(str.startsWith("en"))return"#10b981";if(str.startsWith("fr"))return"#8b5cf6";if(str.startsWith("es"))return"#f59e0b";if(str.startsWith("it"))return"#ef4444";if(str.startsWith("zh"))return"#ec4899";if(str.startsWith("ja"))return"#f97316";const palette=["#3b82f6","#10b981","#8b5cf6","#f59e0b","#ef4444","#ec4899","#06b6d4","#84cc16"];let h=0;for(let i=0;i>>0;return palette[h%palette.length]}function _flagSpan(v){return v&&v.flag?`${v.flag}`:""}function _buildItem(id,label){const v=_voiceData(id),meta=_voiceMetaLabel(id),name=id||label||"";return`
${_avatarHtml(id,26)} ${_esc(name)} ${meta?`${_esc(meta)}`:_flagSpan(v)} -
`}function _esc(s){return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function upgrade(selectId){const sel=document.getElementById(selectId);if(!sel||sel.dataset.vpUpgraded)return;sel.dataset.vpUpgraded="1",sel.style.display="none";const root=document.createElement("div");root.className="vp-root",root.dataset.vpFor=selectId,sel.parentNode.insertBefore(root,sel);const trigger=document.createElement("div");trigger.className="vp-trigger",trigger.tabIndex=0,trigger.innerHTML='\u2014 select after fetch \u2014',root.appendChild(trigger);const drop=document.createElement("div");drop.className="vp-drop",drop.hidden=!0,drop.innerHTML='
',root.appendChild(drop);const searchInp=drop.querySelector(".vp-search"),list=drop.querySelector(".vp-list");function _renderList(filter){const opts=Array.from(sel.options).filter(o=>o.value),q=(filter||"").toLowerCase(),filtered=q?opts.filter(o=>o.value.toLowerCase().includes(q)||o.textContent.toLowerCase().includes(q)):opts;if(!filtered.length){list.innerHTML='
No voices found
';return}list.innerHTML=filtered.map(o=>_buildItem(o.value,o.textContent)).join(""),list.querySelectorAll(".vp-item").forEach(el=>{el.classList.toggle("selected",el.dataset.value===sel.value)})}function _open(){drop.hidden=!1,drop.style.display="",trigger.classList.add("open"),searchInp.value="",_renderList(""),searchInp.focus(),requestAnimationFrame(()=>{const sel2=list.querySelector(".vp-item.selected");sel2&&sel2.scrollIntoView({block:"nearest"})})}function _close(){drop.hidden=!0,drop.style.display="none",trigger.classList.remove("open"),searchInp.blur()}function _select(value){sel.value=value,sel.dispatchEvent(new Event("change",{bubbles:!0})),_syncTrigger(),_close(),trigger.focus()}function _syncTrigger(){const v=sel.value,content=trigger.querySelector(".vp-trigger-content");if(!v)content.innerHTML='\u2014 select after fetch \u2014';else{const label=v,meta=_voiceMetaLabel(v);content.innerHTML=`${_avatarHtml(v,24)}${_esc(label)}${meta?`${_esc(meta)}`:""}`}}trigger.addEventListener("click",()=>drop.hidden?_open():_close()),trigger.addEventListener("keydown",e=>{(e.key==="Enter"||e.key===" ")&&(e.preventDefault(),_open())}),searchInp.addEventListener("input",()=>_renderList(searchInp.value)),searchInp.addEventListener("keydown",e=>{var _a;e.key==="Escape"&&(e.stopPropagation(),_close(),trigger.focus()),e.key==="ArrowDown"&&(e.preventDefault(),(_a=list.querySelector(".vp-item"))==null||_a.focus())}),list.addEventListener("pointerdown",e=>{const item=e.target.closest(".vp-item");item&&(e.preventDefault(),_select(item.dataset.value))}),list.addEventListener("click",e=>{const item=e.target.closest(".vp-item");item&&_select(item.dataset.value)}),list.addEventListener("keydown",e=>{var _a;const item=e.target.closest(".vp-item");item&&((e.key==="Enter"||e.key===" ")&&(e.preventDefault(),_select(item.dataset.value)),e.key==="ArrowDown"&&(e.preventDefault(),(item.nextElementSibling||item).focus()),e.key==="ArrowUp"&&(e.preventDefault(),(_a=item.previousElementSibling||item)!=null&&_a.focus()||searchInp.focus()),e.key==="Escape"&&(_close(),trigger.focus()))}),document.addEventListener("click",e=>{root.contains(e.target)||_close()},!0),sel.addEventListener("change",()=>{_syncTrigger(),_close()}),new MutationObserver(()=>{_syncTrigger()}).observe(sel,{childList:!0,attributes:!0,subtree:!0}),_pickers[selectId]={root,trigger,drop,searchInp,list,sel,renderList:_renderList,syncTrigger:_syncTrigger}}function populate(selectId,voices){const p=_pickers[selectId];if(!p)return;const{sel,renderList,syncTrigger}=p,prev=sel.value;sel.innerHTML='',voices.forEach(id=>{const opt=document.createElement("option");opt.value=id,opt.textContent=_voiceOptionLabel(id),sel.appendChild(opt)}),prev&&voices.includes(prev)&&(sel.value=prev),renderList(""),syncTrigger()}function getValue(selectId){var _a;return((_a=document.getElementById(selectId))==null?void 0:_a.value)||""}function setValue(selectId,value){var _a;const sel=document.getElementById(selectId);sel&&(sel.value=value,(_a=_pickers[selectId])==null||_a.syncTrigger())}function attachTextPicker(input){if(!input||input.dataset.vpTextAttached)return;input.dataset.vpTextAttached="1";const btn=document.createElement("button");btn.type="button",btn.className="vp-text-browse",btn.title="Browse voices",btn.innerHTML='',input.insertAdjacentElement("afterend",btn);const drop=document.createElement("div");drop.className="vp-drop vp-drop-floating",drop.hidden=!0,drop.innerHTML='
',document.body.appendChild(drop);const searchInp=drop.querySelector(".vp-search"),list=drop.querySelector(".vp-list");function _voices2(){return(window._voices||[]).filter(v=>v.enabled!==!1)}function _renderList(filter){const q=(filter||"").toLowerCase(),filtered=q?_voices2().filter(v=>v.id.toLowerCase().includes(q)):_voices2();if(!filtered.length){list.innerHTML='
No voices found
';return}list.innerHTML=filtered.map(v=>_buildItem(v.id,v.id)).join(""),list.querySelectorAll(".vp-item").forEach(el=>el.classList.toggle("selected",el.dataset.value===input.value))}function _position(){const r=input.getBoundingClientRect();drop.style.left=r.left+"px",drop.style.top=r.bottom+4+"px",drop.style.width=Math.max(240,r.width+32)+"px"}function _open(){_position(),drop.hidden=!1,searchInp.value="",_renderList(""),searchInp.focus()}function _close(){drop.hidden=!0}function _select(value){input.value=value,input.dispatchEvent(new Event("input",{bubbles:!0})),input.dispatchEvent(new Event("change",{bubbles:!0})),_close()}btn.addEventListener("click",e=>{e.stopPropagation(),drop.hidden?_open():_close()}),searchInp.addEventListener("input",()=>_renderList(searchInp.value)),searchInp.addEventListener("keydown",e=>{e.key==="Escape"&&(e.stopPropagation(),_close())}),list.addEventListener("click",e=>{const item=e.target.closest(".vp-item");item&&_select(item.dataset.value)}),document.addEventListener("click",e=>{!drop.contains(e.target)&&e.target!==btn&&_close()},!0),window.addEventListener("resize",()=>{drop.hidden||_position()})}window.VoicePicker={upgrade,populate,getValue,setValue,attachTextPicker}})(),function(){"use strict";const pickers={};function esc(value){return String(value==null?"":value).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function norm(value){if(typeof value=="string")return{id:value,label:value,meta:null,raw:value};const id=(value==null?void 0:value.id)||(value==null?void 0:value.voice)||(value==null?void 0:value.name)||(value==null?void 0:value.voice_id)||"",hasExplicitMeta=Object.prototype.hasOwnProperty.call(value||{},"meta"),looksLikeVoice=!!(value!=null&&value.display_name||value!=null&&value.duration||value!=null&&value.benchmark||value!=null&&value.has_picture||value!=null&&value.transcript);return{id,label:(value==null?void 0:value.label)||(value==null?void 0:value.display_name)||(value==null?void 0:value.name)||(value==null?void 0:value.voice)||id,meta:hasExplicitMeta?value.meta:looksLikeVoice?value:null,raw:value}}function libraryVoice(id){return(window._voices||[]).find(v=>v&&v.id===id)||null}function metaFor(item){return(item==null?void 0:item.meta)||libraryVoice(item==null?void 0:item.id)||(item==null?void 0:item.raw)||null}function langColor(lang,id){const str=String(lang||id||"").toLowerCase();if(str.startsWith("de"))return"#3b82f6";if(str.startsWith("en"))return"#10b981";if(str.startsWith("fr"))return"#8b5cf6";if(str.startsWith("es"))return"#f59e0b";if(str.startsWith("it"))return"#ef4444";if(str.startsWith("zh"))return"#ec4899";if(str.startsWith("ja"))return"#f97316";const palette=["#3b82f6","#10b981","#8b5cf6","#f59e0b","#ef4444","#ec4899","#06b6d4","#84cc16"];let h=0;for(let i=0;i>>0;return palette[h%palette.length]}function avatarHtml(item,size){var _a;const id=(item==null?void 0:item.id)||"",meta=metaFor(item),s=`${size}px`,r=`${Math.round(size/2)}px`;if(meta!=null&&meta.has_picture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(meta==null?void 0:meta.avatar,size):null;if(icon)return icon.replace(/vp-avatar/g,"bvp-avatar");const color=langColor((meta==null?void 0:meta.lang)||(meta==null?void 0:meta.language),id),init=((_a=((item==null?void 0:item.label)||id||"?").trim()[0])==null?void 0:_a.toUpperCase())||"?";return`${esc(init)}`}function numeric(value){const n=Number(value);return Number.isFinite(n)?n:null}function statsFor(item){var _a,_b,_c,_d,_e,_f;const meta=metaFor(item)||{},b=meta.benchmark||(item==null?void 0:item.benchmark)||{},parts=[],duration=numeric((_a=meta.duration)!=null?_a:item==null?void 0:item.duration),speed=numeric((_c=(_b=b.speed)!=null?_b:b.avg_speed)!=null?_c:meta.speed),rtf=numeric((_e=(_d=b.rtf)!=null?_d:b.avg_rtf)!=null?_e:meta.rtf),elapsed=numeric((_f=b.elapsed_sec)!=null?_f:b.avg_elapsed_sec);return duration!=null&&duration>0&&parts.push(`${duration.toFixed(1)}s`),speed!=null&&speed>0?parts.push(`${speed.toFixed(2)}x`):rtf!=null&&rtf>0&&parts.push(`RTF ${rtf.toFixed(2)}`),!parts.length&&elapsed!=null&&elapsed>0&&parts.push(`${elapsed.toFixed(1)}s TTS`),!parts.length&&b.ok===!1&&parts.push("benchmark failed"),parts.join(" \xB7 ")}function searchText(item){const meta=metaFor(item)||{};return[item==null?void 0:item.id,item==null?void 0:item.label,meta.display_name,meta.name,meta.lang,meta.language,Array.isArray(meta.tags)?meta.tags.join(" "):meta.tags,statsFor(item)].filter(Boolean).join(" ").toLowerCase()}function optionLabel(item){const stats=statsFor(item);return`${item.label||item.id}${stats?` (${stats})`:""}`}function renderTrigger(picker){const value=picker.select.value,item=picker.items.find(v=>v.id===value),content=picker.trigger.querySelector(".bvp-trigger-content");if(!item){content.innerHTML=`${esc(picker.placeholder)}`;return}const stats=statsFor(item);content.innerHTML=`${picker.plain?"":avatarHtml(item,24)}${esc(item.label||item.id)}${stats?`${esc(stats)}`:""}`}function renderList(picker,filter){const q=String(filter||"").trim().toLowerCase(),items=q?picker.items.filter(item=>searchText(item).includes(q)):picker.items;if(!items.length){picker.list.innerHTML=`
${esc(q?`No ${picker.noun} found`:picker.empty)}
`;return}picker.list.innerHTML=items.map(item=>{const meta=metaFor(item)||{},stats=statsFor(item),sub=[meta.lang||meta.language,meta.file_type].filter(Boolean).join(" \xB7 ");return`
`}function _esc(s){return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function upgrade(selectId){const sel=document.getElementById(selectId);if(!sel||sel.dataset.vpUpgraded)return;sel.dataset.vpUpgraded="1",sel.style.display="none";const root=document.createElement("div");root.className="vp-root",root.dataset.vpFor=selectId,sel.parentNode.insertBefore(root,sel);const trigger=document.createElement("div");trigger.className="vp-trigger",trigger.tabIndex=0,trigger.innerHTML='\u2014 select after fetch \u2014',root.appendChild(trigger);const drop=document.createElement("div");drop.className="vp-drop",drop.hidden=!0,drop.innerHTML='
',root.appendChild(drop);const searchInp=drop.querySelector(".vp-search"),list=drop.querySelector(".vp-list");function _renderList(filter){const opts=Array.from(sel.options).filter(o=>o.value),q=(filter||"").toLowerCase(),filtered=q?opts.filter(o=>_voiceSearchText(o.value,o.textContent).includes(q)):opts;if(!filtered.length){list.innerHTML='
No voices found
';return}list.innerHTML=filtered.map(o=>_buildItem(o.value,o.textContent)).join(""),list.querySelectorAll(".vp-item").forEach(el=>{el.classList.toggle("selected",el.dataset.value===sel.value)})}function _open(){drop.hidden=!1,drop.style.display="",trigger.classList.add("open"),searchInp.value="",_renderList(""),searchInp.focus(),requestAnimationFrame(()=>{const sel2=list.querySelector(".vp-item.selected");sel2&&sel2.scrollIntoView({block:"nearest"})})}function _close(){drop.hidden=!0,drop.style.display="none",trigger.classList.remove("open"),searchInp.blur()}function _select(value){sel.value=value,sel.dispatchEvent(new Event("change",{bubbles:!0})),_syncTrigger(),_close(),trigger.focus()}function _syncTrigger(){const v=sel.value,content=trigger.querySelector(".vp-trigger-content");if(!v)content.innerHTML='\u2014 select after fetch \u2014';else{const label=v,meta=_voiceMetaLabel(v);content.innerHTML=`${_avatarHtml(v,24)}${_esc(label)}${meta?`${_esc(meta)}`:""}`}}trigger.addEventListener("click",()=>drop.hidden?_open():_close()),trigger.addEventListener("keydown",e=>{(e.key==="Enter"||e.key===" ")&&(e.preventDefault(),_open())}),searchInp.addEventListener("input",()=>_renderList(searchInp.value)),searchInp.addEventListener("keydown",e=>{var _a;e.key==="Escape"&&(e.stopPropagation(),_close(),trigger.focus()),e.key==="ArrowDown"&&(e.preventDefault(),(_a=list.querySelector(".vp-item"))==null||_a.focus())}),list.addEventListener("pointerdown",e=>{const item=e.target.closest(".vp-item");item&&(e.preventDefault(),_select(item.dataset.value))}),list.addEventListener("click",e=>{const item=e.target.closest(".vp-item");item&&_select(item.dataset.value)}),list.addEventListener("keydown",e=>{var _a;const item=e.target.closest(".vp-item");item&&((e.key==="Enter"||e.key===" ")&&(e.preventDefault(),_select(item.dataset.value)),e.key==="ArrowDown"&&(e.preventDefault(),(item.nextElementSibling||item).focus()),e.key==="ArrowUp"&&(e.preventDefault(),(_a=item.previousElementSibling||item)!=null&&_a.focus()||searchInp.focus()),e.key==="Escape"&&(_close(),trigger.focus()))}),document.addEventListener("click",e=>{root.contains(e.target)||_close()},!0),sel.addEventListener("change",()=>{_syncTrigger(),_close()}),new MutationObserver(()=>{_syncTrigger()}).observe(sel,{childList:!0,attributes:!0,subtree:!0}),_pickers[selectId]={root,trigger,drop,searchInp,list,sel,renderList:_renderList,syncTrigger:_syncTrigger}}function populate(selectId,voices){const p=_pickers[selectId];if(!p)return;const{sel,renderList,syncTrigger}=p,prev=sel.value;sel.innerHTML='',voices.forEach(id=>{const opt=document.createElement("option");opt.value=id,opt.textContent=_voiceOptionLabel(id),sel.appendChild(opt)}),prev&&voices.includes(prev)&&(sel.value=prev),renderList(""),syncTrigger()}function getValue(selectId){var _a;return((_a=document.getElementById(selectId))==null?void 0:_a.value)||""}function setValue(selectId,value){var _a;const sel=document.getElementById(selectId);sel&&(sel.value=value,(_a=_pickers[selectId])==null||_a.syncTrigger())}function attachTextPicker(input){if(!input||input.dataset.vpTextAttached)return;input.dataset.vpTextAttached="1";const btn=document.createElement("button");btn.type="button",btn.className="vp-text-browse",btn.title="Browse voices",btn.innerHTML='',input.insertAdjacentElement("afterend",btn);const drop=document.createElement("div");drop.className="vp-drop vp-drop-floating",drop.hidden=!0,drop.innerHTML='
',document.body.appendChild(drop);const searchInp=drop.querySelector(".vp-search"),list=drop.querySelector(".vp-list");function _voices2(){return(window._voices||[]).filter(v=>v.enabled!==!1)}function _renderList(filter){const q=(filter||"").toLowerCase(),filtered=q?_voices2().filter(v=>_voiceSearchText(v.id,v.name||v.id).includes(q)):_voices2();if(!filtered.length){list.innerHTML='
No voices found
';return}list.innerHTML=filtered.map(v=>_buildItem(v.id,v.id)).join(""),list.querySelectorAll(".vp-item").forEach(el=>el.classList.toggle("selected",el.dataset.value===input.value))}function _position(){const r=input.getBoundingClientRect();drop.style.left=r.left+"px",drop.style.top=r.bottom+4+"px",drop.style.width=Math.max(240,r.width+32)+"px"}function _open(){_position(),drop.hidden=!1,searchInp.value="",_renderList(""),searchInp.focus()}function _close(){drop.hidden=!0}function _select(value){input.value=value,input.dispatchEvent(new Event("input",{bubbles:!0})),input.dispatchEvent(new Event("change",{bubbles:!0})),_close()}btn.addEventListener("click",e=>{e.stopPropagation(),drop.hidden?_open():_close()}),searchInp.addEventListener("input",()=>_renderList(searchInp.value)),searchInp.addEventListener("keydown",e=>{e.key==="Escape"&&(e.stopPropagation(),_close())}),list.addEventListener("click",e=>{const item=e.target.closest(".vp-item");item&&_select(item.dataset.value)}),document.addEventListener("click",e=>{!drop.contains(e.target)&&e.target!==btn&&_close()},!0),window.addEventListener("resize",()=>{drop.hidden||_position()})}window.VoicePicker={upgrade,populate,getValue,setValue,attachTextPicker}})(),function(){"use strict";const pickers={};function esc(value){return String(value==null?"":value).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function norm(value){if(typeof value=="string")return{id:value,label:value,meta:null,raw:value};const id=(value==null?void 0:value.id)||(value==null?void 0:value.voice)||(value==null?void 0:value.name)||(value==null?void 0:value.voice_id)||"",hasExplicitMeta=Object.prototype.hasOwnProperty.call(value||{},"meta"),looksLikeVoice=!!(value!=null&&value.display_name||value!=null&&value.duration||value!=null&&value.benchmark||value!=null&&value.has_picture||value!=null&&value.transcript);return{id,label:(value==null?void 0:value.label)||(value==null?void 0:value.display_name)||(value==null?void 0:value.name)||(value==null?void 0:value.voice)||id,meta:hasExplicitMeta?value.meta:looksLikeVoice?value:null,raw:value}}function libraryVoice(id){return(window._voices||[]).find(v=>v&&v.id===id)||null}function metaFor(item){return(item==null?void 0:item.meta)||libraryVoice(item==null?void 0:item.id)||(item==null?void 0:item.raw)||null}function langColor(lang,id){const str=String(lang||id||"").toLowerCase();if(str.startsWith("de"))return"#3b82f6";if(str.startsWith("en"))return"#10b981";if(str.startsWith("fr"))return"#8b5cf6";if(str.startsWith("es"))return"#f59e0b";if(str.startsWith("it"))return"#ef4444";if(str.startsWith("zh"))return"#ec4899";if(str.startsWith("ja"))return"#f97316";const palette=["#3b82f6","#10b981","#8b5cf6","#f59e0b","#ef4444","#ec4899","#06b6d4","#84cc16"];let h=0;for(let i=0;i>>0;return palette[h%palette.length]}function avatarHtml(item,size){var _a;const id=(item==null?void 0:item.id)||"",meta=metaFor(item),s=`${size}px`,r=`${Math.round(size/2)}px`;if(meta!=null&&meta.has_picture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(meta==null?void 0:meta.avatar,size):null;if(icon)return icon.replace(/vp-avatar/g,"bvp-avatar");const color=langColor((meta==null?void 0:meta.lang)||(meta==null?void 0:meta.language),id),init=((_a=((item==null?void 0:item.label)||id||"?").trim()[0])==null?void 0:_a.toUpperCase())||"?";return`${esc(init)}`}function numeric(value){const n=Number(value);return Number.isFinite(n)?n:null}function statsFor(item){var _a,_b,_c,_d,_e,_f;const meta=metaFor(item)||{},b=meta.benchmark||(item==null?void 0:item.benchmark)||{},parts=[],duration=numeric((_a=meta.duration)!=null?_a:item==null?void 0:item.duration),speed=numeric((_c=(_b=b.speed)!=null?_b:b.avg_speed)!=null?_c:meta.speed),rtf=numeric((_e=(_d=b.rtf)!=null?_d:b.avg_rtf)!=null?_e:meta.rtf),elapsed=numeric((_f=b.elapsed_sec)!=null?_f:b.avg_elapsed_sec);return duration!=null&&duration>0&&parts.push(`${duration.toFixed(1)}s`),speed!=null&&speed>0?parts.push(`${speed.toFixed(2)}x`):rtf!=null&&rtf>0&&parts.push(`RTF ${rtf.toFixed(2)}`),!parts.length&&elapsed!=null&&elapsed>0&&parts.push(`${elapsed.toFixed(1)}s TTS`),!parts.length&&b.ok===!1&&parts.push("benchmark failed"),parts.join(" \xB7 ")}function searchText(item){const meta=metaFor(item)||{};return[item==null?void 0:item.id,item==null?void 0:item.label,meta.display_name,meta.name,meta.lang,meta.language,Array.isArray(meta.tags)?meta.tags.join(" "):meta.tags,statsFor(item)].filter(Boolean).join(" ").toLowerCase()}function optionLabel(item){const stats=statsFor(item);return`${item.label||item.id}${stats?` (${stats})`:""}`}function renderTrigger(picker){const value=picker.select.value,item=picker.items.find(v=>v.id===value),content=picker.trigger.querySelector(".bvp-trigger-content");if(!item){content.innerHTML=`${esc(picker.placeholder)}`;return}const stats=statsFor(item);content.innerHTML=`${picker.plain?"":avatarHtml(item,24)}${esc(item.label||item.id)}${stats?`${esc(stats)}`:""}`}function renderList(picker,filter){const q=String(filter||"").trim().toLowerCase(),items=q?picker.items.filter(item=>searchText(item).includes(q)):picker.items;if(!items.length){picker.list.innerHTML=`
${esc(q?`No ${picker.noun} found`:picker.empty)}
`;return}picker.list.innerHTML=items.map(item=>{const meta=metaFor(item)||{},stats=statsFor(item),sub=[meta.lang||meta.language,meta.file_type].filter(Boolean).join(" \xB7 ");return``,document.body.appendChild(ov);let cancelled=!1;ov.querySelector("#vv-cancel").addEventListener("click",()=>{cancelled=!0});const fill=ov.querySelector("#vv-fill"),msg=ov.querySelector("#vv-msg"),results=await _verifyVoicesRoundtrip(ids,{onProgress:(done,total,id)=>{if(fill&&(fill.style.width=done/total*100+"%"),msg&&(msg.textContent=`${done} / ${total} \xB7 ${id}`),cancelled)throw new Error("cancelled")}}).catch(()=>[]);ov.remove(),btn&&(btn.disabled=!1);const failed=results.filter(r=>!r.ok),resOv=document.createElement("div");resOv.className="audiobook-overlay",resOv.innerHTML=`
diff --git a/static/index.html b/static/index.html index f007015..d8266e3 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,12 +27,12 @@ - - - - - - + + + + + + @@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/magazine.js b/static/js/magazine.js index 9c86fd8..a4a13b5 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -22,11 +22,17 @@ const state = { sentences: [], // [{ text, blockId, page, bbox }] images: [], // current article's figures, in reading order view: 'library', // library | contents | review | read - contentsView: 'pages', // list | pages — how the article list itself is shown + contentsView: 'list', // list | pages — how the article list itself is shown contentsSelectMode: false, // batch-select articles for bulk approve/reject contentsSelected: new Set(), pagesCols: 8, // "All pages" grid column count, user-adjustable 1-12 playlistCurrent: null, // article id of the MP3 playlist's current track + playlistSentences: [], // transcript aligned approximately to the exported MP3 + playlistImages: [], // figures/tables/charts shown alongside that transcript + playlistLoadToken: 0, // prevents a slow text fetch replacing a newer track + playlistLastSaved: 0, + playlistHighlightIndex: -1, + playlistSwitching: false, pane: 'reflow', // reflow | pdf playing: false, cancel: false, @@ -678,10 +684,9 @@ function renderContents() { const host = $('mag-contents'); if (!host) return; const title = $('mag-doc-title'); - if (title && state.doc) { - title.textContent = (state.doc.title || 'Magazine') + - (state.doc.issue ? ' — ' + state.doc.issue : ''); - } + if (title && state.doc && document.activeElement !== title) title.textContent = state.doc.title || 'Magazine'; + const issueInput = $('mag-doc-issue'); + if (issueInput && state.doc && document.activeElement !== issueInput) issueInput.value = state.doc.issue || ''; if (!state.articles.length) { host.innerHTML = '

No articles yet. Run ingestion to detect them.

'; return; @@ -820,12 +825,15 @@ function renderPagesView(host) { 'Page ' + p + '' + 'Page ' + p + '' + (arts.length - ? '' + esc(arts[0].title || 'Untitled') + - (arts.length > 1 ? ' +' + (arts.length - 1) + ' more' : '') + '' + - '
' + - arts.map((a) => '').join('') + - '
' + ? '
' + arts.map((a) => + '
' + + '' + esc(a.title || 'Untitled') + '' + + '' + + '' + + (a.has_audio ? '' : '') + + '' + + '
').join('') + '
' : 'no article' + '
') + '
'; @@ -843,6 +851,30 @@ function magSetPagesCols(n) { if (grid) grid.style.setProperty('--cols', state.pagesCols); } +// Editing the magazine's own title/issue was previously only possible at +// upload time -- "Issue" is optional there, so the common case left it +// permanently blank with no way to add it later. It matters beyond just +// display now: the exported MP3 filename's year/month come from parsing +// this field (see _parse_issue_year_month in routes/magazine.py). +async function magCommitDocField(el, field) { + const value = (field === 'title' ? el.textContent : el.value).replace(/\s+/g, ' ').trim(); + const previous = state.doc[field]; + if (value === (previous || '')) return; + if (field === 'title' && !value) { el.textContent = previous || 'Magazine'; return; } + state.doc[field] = value; + try { + await api('/api/magazine/' + state.doc.id, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [field]: value }), + }); + say(field === 'title' ? 'Title updated' : 'Issue updated', 'ok'); + } catch (e) { + state.doc[field] = previous; + if (field === 'title') el.textContent = previous || 'Magazine'; else el.value = previous || ''; + say('Could not save ' + field + ': ' + e.message, 'err'); + } +} + function magToggleContentsView() { const i = MAG_CONTENTS_VIEW_CYCLE.indexOf(state.contentsView); state.contentsView = MAG_CONTENTS_VIEW_CYCLE[(i + 1) % MAG_CONTENTS_VIEW_CYCLE.length]; @@ -941,41 +973,49 @@ function renderContentsBulkBar() { // ── Standalone MP3 export (title/author/cover/lyrics tags) ───────────────── // For listening away from this app entirely -- a phone's own music/podcast // player, a car head unit -- not the in-browser sentence-by-sentence player. -async function magGenerateAllAudio() { +async function magGenerateAllAudio(force = false) { const voice = ($('mag-audio-voice')?.value || '').trim(); + const interviewVoice = ($('mag-audio-interview-voice')?.value || '').trim(); if (!voice) return say('Pick a narrator voice for the export first', 'err'); // Skip whatever already has audio -- resuming after an aborted or // partial run (confirmed live: a magazine this size can genuinely take // a while, and killing the job partway through, deliberately or via a // container restart, is a real scenario, not an edge case) should not // mean re-synthesising articles that are already done. - const missingArticles = state.articles.filter((a) => a.status === 'approved' && !a.has_audio); + const missingArticles = state.articles.filter((a) => a.status === 'approved' && (force || !a.has_audio)); if (!missingArticles.length) return say('Every approved article already has audio', ''); const missing = missingArticles.map((a) => a.id); const pageCount = state.doc.pageCount || 0; const btn = $('mag-audio-export-btn'); + const rebuildBtn = $('mag-audio-rebuild-btn'); const status = $('mag-audio-export-status'); const progressWrap = $('mag-audio-export-progress-wrap'); const progressBar = $('mag-audio-export-progress'); const progressCount = $('mag-audio-export-count'); if (btn) btn.disabled = true; + if (rebuildBtn) rebuildBtn.disabled = true; if (progressWrap) progressWrap.hidden = false; let jobId; try { const r = await api('/api/magazine/' + state.doc.id + '/audio/batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ voice, article_ids: missing }), + body: JSON.stringify({ voice, interview_voice: interviewVoice, article_ids: missing }), }); jobId = r.job_id; } catch (e) { if (btn) btn.disabled = false; + if (rebuildBtn) rebuildBtn.disabled = false; if (progressWrap) progressWrap.hidden = true; return say('Could not start audio export: ' + e.message, 'err'); } for (;;) { let job; try { job = await api('/api/jobs/' + jobId); } - catch (e) { if (btn) btn.disabled = false; return say('Could not check audio export: ' + e.message, 'err'); } + catch (e) { + if (btn) btn.disabled = false; + if (rebuildBtn) rebuildBtn.disabled = false; + return say('Could not check audio export: ' + e.message, 'err'); + } if (status) status.textContent = job.detail || 'Working…'; // The job's own detail text always leads with "N/Total" -- either // "Article N/Total…" between articles, or "(N/Total) Title: sentence @@ -1001,6 +1041,7 @@ async function magGenerateAllAudio() { await new Promise((r) => setTimeout(r, 1500)); } if (btn) btn.disabled = false; + if (rebuildBtn) rebuildBtn.disabled = false; if (status) status.textContent = ''; if (progressWrap) progressWrap.hidden = true; await magRefreshArticles(); @@ -1255,8 +1296,21 @@ function magReviewPageColumn(page, wrapId, interactive) { pageHtml = '

No page to show.

'; } + const selectedCount = regions.filter((r) => r.kind === 'image' + ? ownImages.has(r.id) : included.has(r.id)).length; + const allSelected = regions.length > 0 && selectedCount === regions.length; let listHtml = '
' + + '
' + + 'Region' + + '' + + 'TypeContent' + + '
' + (interactive ? '

Keys: ↑/↓ move between regions · Space tick · X select · Enter edit text · R read again · M merge with the next region · Delete delete (Undo appears above)

' : '') + (interactive ? magReviewLegendHtml() : ''); regions.forEach((r, i) => { @@ -1462,6 +1516,10 @@ function renderReview() { 'Approve & Next' + '
'; host.innerHTML = html; + host.querySelectorAll('[data-mag-check-all]').forEach((input) => { + input.indeterminate = Number(input.dataset.selected) > 0 && + Number(input.dataset.selected) < Number(input.dataset.total); + }); if (state.orderMode) { $('mag-review-page-wrap')?.classList.add('is-ordering'); magOrderRefreshBar(); @@ -1612,6 +1670,24 @@ function magReviewClearCurrentPage() { say('All fields on this page cleared — select only the article content to keep.', 'ok'); } +// The header checkbox controls the complete region column for one page. It +// covers figures as well as text, matching the individual checkboxes below +// it; a partially selected page is rendered with the native indeterminate +// state so the header never implies that everything is included when it is +// not. +function magReviewToggleAllPage(input, page) { + if (!state.article || !state.layout) return; + if (!_magReviewChecked) _magReviewChecked = new Set(state.article.blocks || []); + const blockIds = (state.layout.blocks || []).filter((b) => b.page === page).map((b) => b.id); + const imageIds = (state.layout.images || []).filter((img) => img.page === page).map((img) => img.id); + if (input.checked) blockIds.forEach((id) => _magReviewChecked.add(id)); + else blockIds.forEach((id) => _magReviewChecked.delete(id)); + const images = new Set(state.article.images || []); + imageIds.forEach((id) => input.checked ? images.add(id) : images.delete(id)); + state.article.images = Array.from(images); + renderReview(); +} + // "Clear all fields" above only deselects a page's regions from narration -- // the boxes themselves, right or wrong, stay. When the detected layout on a // page is too tangled to fix by editing individual boxes, this deletes every @@ -2843,17 +2919,12 @@ async function magRead(articleId, opts) { // already been exported still went through the live reader from scratch. if (a.has_audio) { magTogglePlaylist(true); - magPlaylistPlay(articleId); - const saved = !(opts && opts.fromStart) && (state.doc.progress || {})[articleId]; - if (saved && saved.seconds) { - const audio = $('mag-playlist-audio'); - if (audio) { - const resume = () => { audio.currentTime = saved.seconds; audio.removeEventListener('loadedmetadata', resume); }; - audio.addEventListener('loadedmetadata', resume); - } - } + magPlaylistPlay(articleId, opts); return; } + const playlistAudio = $('mag-playlist-audio'); + if (playlistAudio) playlistAudio.pause(); + state.playlistCurrent = null; state.article = a; state.audioCache = new Map(); state.prepGen += 1; @@ -3228,27 +3299,189 @@ function renderPlaylistTracks() { } list.innerHTML = order.map((a, i) => { const active = a.id === state.playlistCurrent; - return '
  • ' + - '' + (active ? '' : i + 1) + '' + + const progress = (state.doc.progress || {})[a.id] || {}; + const completed = !!progress.completed; + const started = !completed && Number(progress.seconds) > 0; + const status = completed ? 'complete' : started ? 'in-progress' : 'unplayed'; + const statusLabel = completed ? 'Played' : started ? 'Partly played' : 'Not played'; + const duration = a.audio_seconds || progress.duration || a.estimated_seconds; + return '
  • ' + + '' + + '' + esc(fmtPages(a.pages)) + '' + '' + '' + esc(a.title || 'Untitled') + '' + (a.author ? 'by ' + esc(a.author) + '' : '') + '' + - '' + fmtPages(a.pages) + ' · ' + fmtDuration(a.estimated_seconds) + '' + + '' + fmtDuration(duration) + '' + + '' + statusLabel + '' + '
  • '; }).join(''); } -function magPlaylistPlay(articleId) { +function playlistDuration(a) { + const audio = $('mag-playlist-audio'); + if (audio && Number.isFinite(audio.duration) && audio.duration > 0) return audio.duration; + const progress = (state.doc?.progress || {})[a?.id] || {}; + return Number(a?.audio_seconds || progress.duration || a?.estimated_seconds || 0); +} + +function magFmtClock(seconds) { + const total = Math.max(0, Math.floor(Number(seconds) || 0)); + const h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60), s = total % 60; + return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(s).padStart(2, '0'); +} + +function savePlaylistProgress(completed, immediate) { + const a = state.articles.find((x) => x.id === state.playlistCurrent); + const audio = $('mag-playlist-audio'); + if (!a || !audio || !state.doc) return; + const now = Date.now(); + if (!immediate && now - state.playlistLastSaved < 5000) return; + state.playlistLastSaved = now; + const seconds = completed ? (Number.isFinite(audio.duration) ? audio.duration : audio.currentTime) : audio.currentTime; + const duration = Number.isFinite(audio.duration) ? audio.duration : playlistDuration(a); + const entry = Object.assign({}, (state.doc.progress || {})[a.id], { + seconds: Math.max(0, seconds || 0), duration: Math.max(0, duration || 0), completed: !!completed, + }); + state.doc.progress = state.doc.progress || {}; + state.doc.progress[a.id] = entry; + fetch('/api/magazine/' + state.doc.id + '/progress', { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, keepalive: !!immediate, + body: JSON.stringify({ article: a.id, seconds: entry.seconds, duration: entry.duration, completed: entry.completed }), + }).catch(() => {}); + renderPlaylistTracks(); +} + +function playlistTimeline(duration) { + const weights = state.playlistSentences.map((s) => Math.max(6, s.text.replace(/\s+/g, ' ').length)); + const total = weights.reduce((sum, n) => sum + n, 0) || 1; + let cursor = 0; + return weights.map((weight) => { + const start = cursor / total * duration; + cursor += weight; + return { start, end: cursor / total * duration }; + }); +} + +function magPlaylistSeekSentence(index) { + const audio = $('mag-playlist-audio'); + if (!audio || !state.playlistSentences[index]) return; + const timeline = playlistTimeline(playlistDuration(state.article)); + if (timeline[index]) audio.currentTime = timeline[index].start; + audio.play().catch(() => {}); +} + +function renderPlaylistCompanion() { + const companion = $('mag-playlist-companion'); + const transcript = $('mag-playlist-transcript'); + if (!companion || !transcript) return; + companion.hidden = !state.playlistSentences.length && !state.playlistImages.length; + state.playlistHighlightIndex = -1; + transcript.innerHTML = state.playlistSentences.map((s, i) => { + const tag = ['title', 'heading'].includes(s.type) ? 'h3' : 'p'; + const words = s.words.map((w, j) => '' + esc(w) + '').join(' '); + return '<' + tag + ' class="mag-playlist-line" data-playlist-sentence="' + i + '" ' + + 'onclick="magPlaylistSeekSentence(' + i + ')">' + words + ''; + }).join(''); + magPlaylistSync(); +} + +function renderPlaylistVisual(sentenceIndex) { + const host = $('mag-playlist-visual'); + if (!host) return; + if (!state.playlistImages.length) { host.hidden = true; return; } + const order = state.playlistSentences[sentenceIndex]?.readingOrder || 0; + let imageIndex = 0; + state.playlistImages.forEach((img, i) => { if ((img.reading_order || 0) <= order) imageIndex = i; }); + const img = state.playlistImages[imageIndex]; + if (host.dataset.imageId === img.id) { host.hidden = false; return; } + host.dataset.imageId = img.id; + const label = img.type === 'table' ? 'Table' : img.type === 'chart' ? 'Graph' : 'Image'; + host.innerHTML = '' + label + ' from page ' + img.page + '' + + '
    ' + label + ' · page ' + img.page + '' + + (img.description ? '' + esc(img.description) + '' : '') + '
    '; + host.hidden = false; +} + +function magPlaylistSync() { + const audio = $('mag-playlist-audio'); + const a = state.articles.find((x) => x.id === state.playlistCurrent); + if (!audio || !a) return; + const duration = playlistDuration(a); + const timeline = playlistTimeline(duration); + let index = timeline.findIndex((t) => audio.currentTime < t.end); + if (index < 0) index = Math.max(0, timeline.length - 1); + const follow = $('mag-playlist-karaoke')?.checked !== false; + document.querySelectorAll('#mag-playlist-transcript [data-playlist-sentence]').forEach((el) => { + el.classList.toggle('is-current', follow && Number(el.dataset.playlistSentence) === index); + el.querySelectorAll('.mag-w').forEach((w) => w.classList.remove('is-current')); + }); + const line = document.querySelector('#mag-playlist-transcript [data-playlist-sentence="' + index + '"]'); + if (follow && line && timeline[index]) { + const local = Math.max(0, Math.min(0.999, (audio.currentTime - timeline[index].start) / + Math.max(0.1, timeline[index].end - timeline[index].start))); + const words = line.querySelectorAll('.mag-w'); + if (words.length) words[Math.min(words.length - 1, Math.floor(local * words.length))].classList.add('is-current'); + if (state.playlistHighlightIndex !== index) { + if (!prefersReducedMotion()) line.scrollIntoView({ block: 'center', behavior: 'smooth' }); + else line.scrollIntoView({ block: 'center' }); + state.playlistHighlightIndex = index; + } + } + renderPlaylistVisual(index); + const pct = duration ? Math.min(100, audio.currentTime / duration * 100) : 0; + const drivingBar = $('mag-driving-progress-bar'); + const drivingTime = $('mag-driving-time'); + if (drivingBar) drivingBar.style.width = pct + '%'; + if (drivingTime) drivingTime.textContent = magFmtClock(audio.currentTime) + ' / ' + magFmtClock(duration); +} + +async function loadPlaylistCompanion(a) { + const token = ++state.playlistLoadToken; + state.playlistSentences = []; + state.playlistImages = []; + renderPlaylistCompanion(); + try { + await ensureLayout(); + const data = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/text?captions=1&descriptions=0'); + if (token !== state.playlistLoadToken || state.playlistCurrent !== a.id) return; + state.playlistSentences = splitSentences(data.blocks || []); + state.playlistImages = articleImages(a); + renderPlaylistCompanion(); + } catch (_) { + if (token === state.playlistLoadToken) renderPlaylistCompanion(); + } +} + +function magPlaylistPlay(articleId, options) { if (!articleId) return; const a = state.articles.find((x) => x.id === articleId); if (!a) return; + if (!options?.skipCurrentSave && state.playlistCurrent && state.playlistCurrent !== articleId) { + savePlaylistProgress(false, true); + } state.playlistCurrent = articleId; + state.article = a; + state.playlistLastSaved = 0; const audio = $('mag-playlist-audio'); if (audio) { + state.playlistSwitching = true; + audio.dataset.article = articleId; audio.src = '/api/magazine/' + state.doc.id + '/articles/' + articleId + '/audio'; - audio.play().catch(() => {}); + queueMicrotask(() => { state.playlistSwitching = false; }); + const saved = (state.doc.progress || {})[articleId] || {}; + const resumeAt = options?.fromStart || saved.completed ? 0 : Number(saved.seconds || 0); + const resume = () => { + if (resumeAt > 0 && resumeAt < audio.duration - 1) audio.currentTime = resumeAt; + audio.play().catch(() => {}); + }; + if (audio.readyState >= 1) resume(); + else audio.addEventListener('loadedmetadata', resume, { once: true }); } const titleEl = $('mag-playlist-title'); const subEl = $('mag-playlist-sub'); @@ -3261,12 +3494,23 @@ function magPlaylistPlay(articleId) { else cover.hidden = true; } renderPlaylistTracks(); + loadPlaylistCompanion(a); + if (state.driving) renderDriving(); } -function _magPlaylistNext() { +function _magPlaylistNext(eventOrCompleted) { + const completed = eventOrCompleted !== false; + savePlaylistProgress(completed, true); const order = _magPlaylistOrder(); const i = order.findIndex((a) => a.id === state.playlistCurrent); - if (i >= 0 && i < order.length - 1) magPlaylistPlay(order[i + 1].id); + if (i >= 0 && i < order.length - 1) magPlaylistPlay(order[i + 1].id, { skipCurrentSave: true }); + else syncDrivingPlayButton(); +} + +function magPlaylistPrevious() { + const order = _magPlaylistOrder(); + const i = order.findIndex((a) => a.id === state.playlistCurrent); + if (i > 0) magPlaylistPlay(order[i - 1].id); } function magStopQueue() { @@ -3340,7 +3584,7 @@ async function magLoadVoices() { if (root.VoicePicker && el.dataset.vpUpgraded) root.VoicePicker.setValue(id, value); else el.value = value; }; - const voiceLabels = { 'mag-voice': 'Narrator voice', 'mag-host-a': 'Host A voice', 'mag-host-b': 'Host B voice', 'mag-host-c': 'Host C voice', 'mag-audio-voice': 'Narrator for exported MP3s' }; + const voiceLabels = { 'mag-voice': 'Narrator voice', 'mag-host-a': 'Host A voice', 'mag-host-b': 'Host B voice', 'mag-host-c': 'Host C voice', 'mag-audio-voice': 'Narrator for exported MP3s', 'mag-audio-interview-voice': 'Interview voice 2 (optional)' }; Object.keys(voiceLabels).forEach((id) => { const el = $(id); if (!el) return; @@ -3503,6 +3747,17 @@ function magToggleDriving() { if (state.driving) renderDriving(); } +function magOpenDriving() { + const order = _magPlaylistOrder(); + if (!order.length) return say('Generate the magazine audio first', 'err'); + magTogglePlaylist(true); + if (!state.playlistCurrent) { + const next = order.find((a) => !((state.doc.progress || {})[a.id] || {}).completed) || order[0]; + magPlaylistPlay(next.id); + } + if (!state.driving) magToggleDriving(); +} + function renderDriving() { const a = state.article; if (!a) return; @@ -3512,16 +3767,68 @@ function renderDriving() { if (byEl) byEl.textContent = a.author ? 'By ' + a.author : ''; syncDrivingPlayButton(); const qEl = $('mag-driving-queue'); - if (qEl) qEl.hidden = !state.queue.length; + if (qEl) { + qEl.hidden = !state.queue.length && !state.playlistCurrent; + if (state.playlistCurrent) { + const order = _magPlaylistOrder(); + const i = order.findIndex((item) => item.id === state.playlistCurrent); + const progress = $('mag-queue-progress-2'); + if (progress) progress.textContent = 'Article ' + (i + 1) + ' / ' + order.length; + } + } + magPlaylistSync(); } function syncDrivingPlayButton() { const btn = $('mag-driving-play'); if (!btn) return; - btn.innerHTML = state.playing + const playlistAudio = $('mag-playlist-audio'); + const isPlaying = state.playlistCurrent && playlistAudio ? !playlistAudio.paused : state.playing; + btn.innerHTML = isPlaying ? '' : ''; - btn.setAttribute('aria-label', state.playing ? 'Pause' : 'Play'); + btn.setAttribute('aria-label', isPlaying ? 'Pause' : 'Play'); +} + +function magDrivingTogglePlayback() { + const audio = $('mag-playlist-audio'); + if (state.playlistCurrent && audio) { + if (audio.paused) audio.play().catch(() => {}); else audio.pause(); + return; + } + magPlay(); +} + +function magDrivingNext() { + if (state.playlistCurrent) return _magPlaylistNext(false); + magQueueNext(); +} + +function magDrivingPrevious() { + if (state.playlistCurrent) return magPlaylistPrevious(); + if (state.queue.length && state.queueIndex > 0) { + state.queueIndex -= 2; + magQueueNext(); + } +} + +function wirePlaylistAudio() { + const audio = $('mag-playlist-audio'); + if (!audio || audio.dataset.wired) return; + audio.dataset.wired = '1'; + audio.addEventListener('ended', _magPlaylistNext); + audio.addEventListener('timeupdate', () => { magPlaylistSync(); savePlaylistProgress(false, false); }); + audio.addEventListener('loadedmetadata', () => { + const a = state.articles.find((x) => x.id === state.playlistCurrent); + if (a && Number.isFinite(audio.duration)) a.audio_seconds = audio.duration; + renderPlaylistTracks(); + magPlaylistSync(); + }); + audio.addEventListener('play', syncDrivingPlayButton); + audio.addEventListener('pause', () => { + if (!state.playlistSwitching) savePlaylistProgress(false, true); + syncDrivingPlayButton(); + }); } // ── Boot ───────────────────────────────────────────────────────────────────── @@ -3529,11 +3836,7 @@ function syncDrivingPlayButton() { async function magOnShow() { setupMagDropzone(); setupMagIngestPreviewResize(); - const playlistAudio = $('mag-playlist-audio'); - if (playlistAudio && !playlistAudio.dataset.wired) { - playlistAudio.dataset.wired = '1'; - playlistAudio.addEventListener('ended', _magPlaylistNext); - } + wirePlaylistAudio(); if ($('mag-library-list')) { magLoadLibrary(); magLoadVoices(); @@ -3543,11 +3846,7 @@ async function magOnShow() { async function magInit() { setupMagDropzone(); setupMagIngestPreviewResize(); - const playlistAudio = $('mag-playlist-audio'); - if (playlistAudio && !playlistAudio.dataset.wired) { - playlistAudio.dataset.wired = '1'; - playlistAudio.addEventListener('ended', _magPlaylistNext); - } + wirePlaylistAudio(); try { const savedView = localStorage.getItem(MAG_CONTENTS_VIEW_KEY); if (MAG_CONTENTS_VIEW_CYCLE.includes(savedView)) state.contentsView = savedView; @@ -3588,8 +3887,11 @@ root.magContentsCommitTitle = magContentsCommitTitle; root.magGenerateAllAudio = magGenerateAllAudio; root.magTogglePlaylist = magTogglePlaylist; root.magReviewPage = magReviewPage; +root.magCommitDocField = magCommitDocField; root.magSetPagesCols = magSetPagesCols; root.magPlaylistPlay = magPlaylistPlay; +root.magPlaylistSeekSentence = magPlaylistSeekSentence; +root.magPlaylistSync = magPlaylistSync; root.magContentsToggleArticle = magContentsToggleArticle; root.magContentsSelectAllVisible = magContentsSelectAllVisible; root.magContentsClearSelection = magContentsClearSelection; @@ -3601,6 +3903,7 @@ root.magReviewSetActivePage = magReviewSetActivePage; root.magReviewFocusBlock = magReviewFocusBlock; root.magReviewHighlightBox = magReviewHighlightBox; root.magReviewToggleBox = magReviewToggleBox; +root.magReviewToggleAllPage = magReviewToggleAllPage; root.magReviewClearCurrentPage = magReviewClearCurrentPage; root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes; root.magReocrPage = magReocrPage; @@ -3652,6 +3955,10 @@ root.magPlayAll = magPlayAll; root.magQueueNext = magQueueNext; root.magStopQueue = magStopQueue; root.magToggleDriving = magToggleDriving; +root.magOpenDriving = magOpenDriving; +root.magDrivingTogglePlayback = magDrivingTogglePlayback; +root.magDrivingNext = magDrivingNext; +root.magDrivingPrevious = magDrivingPrevious; root.magInit = magInit; MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages }; diff --git a/static/js/voice-library.js b/static/js/voice-library.js index b9ee2bf..add6dec 100644 --- a/static/js/voice-library.js +++ b/static/js/voice-library.js @@ -375,7 +375,9 @@ function libraryFilterMatch(v) { if (wanted > 1 && r < wanted) return false; } if (f.text) { - const hay = [v.id, v.transcript, v.note, v.file_type, v.flag, v.gender, v.group, v.origin].map(x => String(x || '').toLowerCase()).join(' '); + const tags = Array.isArray(v.tags) ? v.tags.join(' ') : (v.tags || v.tag || ''); + const hay = [v.id, v.name, v.transcript, v.note, v.file_type, v.flag, v.gender, v.group, v.origin, tags] + .map(x => String(x || '').toLowerCase()).join(' '); if (!hay.includes(f.text)) return false; } return true; diff --git a/static/js/voice-picker.js b/static/js/voice-picker.js index dca33b8..a5e1421 100644 --- a/static/js/voice-picker.js +++ b/static/js/voice-picker.js @@ -41,6 +41,12 @@ return meta ? `${id} ${meta}` : id; } + function _voiceSearchText(id, label) { + const v = _voiceData(id) || {}; + const tags = Array.isArray(v.tags) ? v.tags.join(' ') : (v.tags || v.tag || ''); + return [id, label, v.name, tags].map(x => String(x || '').toLowerCase()).join(' '); + } + // Shared gender/type avatar icons (male/female/neutral/robot/animal). Returns null // when the voice has no avatar key, so callers fall back to photo/flag/letter. const VOICE_AVATAR_ICONS = { @@ -142,7 +148,7 @@ function _renderList(filter) { const opts = Array.from(sel.options).filter(o => o.value); const q = (filter || '').toLowerCase(); - const filtered = q ? opts.filter(o => o.value.toLowerCase().includes(q) || o.textContent.toLowerCase().includes(q)) : opts; + const filtered = q ? opts.filter(o => _voiceSearchText(o.value, o.textContent).includes(q)) : opts; if (!filtered.length) { list.innerHTML = '
    No voices found
    '; return; } list.innerHTML = filtered.map(o => _buildItem(o.value, o.textContent)).join(''); // highlight current @@ -295,7 +301,7 @@ function _renderList(filter) { const q = (filter || '').toLowerCase(); - const filtered = q ? _voices().filter(v => v.id.toLowerCase().includes(q)) : _voices(); + const filtered = q ? _voices().filter(v => _voiceSearchText(v.id, v.name || v.id).includes(q)) : _voices(); if (!filtered.length) { list.innerHTML = '
    No voices found
    '; return; } list.innerHTML = filtered.map(v => _buildItem(v.id, v.id)).join(''); list.querySelectorAll('.vp-item').forEach(el => el.classList.toggle('selected', el.dataset.value === input.value)); diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index 08ede58..1a4c000 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -56,7 +56,13 @@ -

    Magazine

    +

    Magazine

    +
    + @@ -88,10 +98,18 @@
    + + + @@ -102,6 +120,16 @@