Dieselben verschlüsselten Listen wie der Web-Reader (annotations.py). Die Anker des Webs (Block-Index plus Zeichenposition im Rohtext) werden auf den angezeigten Text umgerechnet (marks.py, blocks.collapse_map), alte XPath-Anker über das Zitat gefunden. Im Reader: v markiert (Cursor je Wort, Anfang/Ende, Farbe oder Notiz), n legt eine Notiz an, b ein Lesezeichen, a öffnet die Liste (springen, Notiz ändern, löschen). Der Server ersetzt die Liste als Ganzes; die TUI legt ihre Änderungen vor dem Schreiben dreiwege je Eintrag-ID auf die aktuelle Liste des Servers, offline bleiben sie vorgemerkt. Geprüft: die echten resolveAnchorRange des Webs lösen 81 TUI-Anker exakt auf (tools/parity/web_anchors.cjs), 34 von 34 echten Web-Markierungen werden angezeigt, der Server-Smoke schreibt und mischt zwei Geräte. 75 Tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
164 lines
6.6 KiB
Python
164 lines
6.6 KiB
Python
"""Highlights in the terminal text: from the web's anchors to spans of the displayed text, and
|
|
back from a selection to an anchor.
|
|
|
|
An anchor addresses characters of a block's *raw* text (every text node, whitespace as in the
|
|
file); the TUI shows the block with whitespace collapsed (blocks.collapse_map relates the two).
|
|
The raw text is read from the file for the blocks involved only, a chapter at a time.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from rich.style import Style
|
|
|
|
from . import annotations, blocks
|
|
|
|
SELECTION_STYLE = Style(reverse=True)
|
|
NOTE_STYLE = Style(underline=True, dim=False)
|
|
_WORD_RE = re.compile(r"\S+")
|
|
|
|
|
|
def highlight_style(color: str | None) -> Style:
|
|
return Style(color="black", bgcolor=annotations.COLORS.get(color or "yellow", annotations.COLORS["yellow"]))
|
|
|
|
|
|
# ---- words (the cursor moves by word)
|
|
|
|
def word_starts(text: str) -> list[int]:
|
|
return [m.start() for m in _WORD_RE.finditer(text)]
|
|
|
|
|
|
def word_at(text: str, pos: int) -> tuple[int, int] | None:
|
|
"""(start, end) of the word at or after `pos`, or None if there is none."""
|
|
for m in _WORD_RE.finditer(text):
|
|
if m.end() > pos:
|
|
return m.start(), m.end()
|
|
return None
|
|
|
|
|
|
def next_word(text: str, pos: int) -> int | None:
|
|
return next((s for s in word_starts(text) if s > pos), None)
|
|
|
|
|
|
def prev_word(text: str, pos: int) -> int | None:
|
|
return next((s for s in reversed(word_starts(text)) if s < pos), None)
|
|
|
|
|
|
@dataclass
|
|
class Maps:
|
|
raw: str
|
|
norm_of_raw: list[int]
|
|
raw_of_norm: list[int]
|
|
length: int
|
|
|
|
|
|
class MarkIndex:
|
|
"""Relates a book's displayed blocks to the raw text anchors count in."""
|
|
|
|
def __init__(self, book: blocks.FlatBook) -> None:
|
|
self.book = book
|
|
self._raws: dict[int, list[str]] = {} # chapter -> raw text of its blocks
|
|
self._maps: dict[int, Maps | None] = {}
|
|
|
|
def maps(self, block_index: int) -> Maps | None:
|
|
"""None when the displayed text isn't the plain collapse of the raw text (a footnote
|
|
marker with spaces inside): such a block can't be addressed exactly."""
|
|
if block_index in self._maps:
|
|
return self._maps[block_index]
|
|
b = self.book.blocks[block_index]
|
|
chapter = b.chapter_index
|
|
if chapter not in self._raws:
|
|
self._raws[chapter] = blocks.raw_block_texts(self.book.path, chapter)
|
|
first = self.book.chapter_start_block[chapter]
|
|
raws = self._raws[chapter]
|
|
result = None
|
|
if 0 <= block_index - first < len(raws):
|
|
raw = raws[block_index - first]
|
|
norm_of_raw, raw_of_norm, length = blocks.collapse_map(raw)
|
|
if length == len(b.text) and "".join(raw[i] if not blocks._JS_SPACE_RE.fullmatch(raw[i]) else " " for i in raw_of_norm) == b.text:
|
|
result = Maps(raw, norm_of_raw, raw_of_norm, length)
|
|
self._maps[block_index] = result
|
|
return result
|
|
|
|
# ---- annotation -> spans
|
|
|
|
def spans(self, items: list[dict]) -> dict[int, list[tuple[int, int, Style]]]:
|
|
"""Per block the (start, end, style) spans of the highlights and notes."""
|
|
out: dict[int, list[tuple[int, int, Style]]] = {}
|
|
n = len(self.book.blocks)
|
|
for h in items:
|
|
a = h.get("anchor") or {}
|
|
if "startBlockIndex" not in a: # an old anchor (XPath): the web falls back to the quote, so do we
|
|
found = self._find_quote(a)
|
|
if found is not None:
|
|
bi, s0, e0 = found
|
|
out.setdefault(bi, []).append((s0, e0, NOTE_STYLE if h.get("type") == "note" else highlight_style(h.get("color"))))
|
|
continue
|
|
try:
|
|
sb = int(a["startBlockIndex"])
|
|
eb = int(a.get("endBlockIndex", sb))
|
|
sc, ec = int(a.get("startChar", 0)), int(a.get("endChar", 0))
|
|
except (KeyError, TypeError, ValueError):
|
|
continue
|
|
if not (0 <= sb < n and sb <= eb < n):
|
|
continue
|
|
style = NOTE_STYLE if h.get("type") == "note" else highlight_style(h.get("color"))
|
|
for bi, s, e in self._ranges(sb, sc, eb, ec, a.get("quote") or ""):
|
|
if e > s:
|
|
out.setdefault(bi, []).append((s, e, style))
|
|
return out
|
|
|
|
def _find_quote(self, anchor: dict) -> tuple[int, int, int] | None:
|
|
"""(block, start, end) of the first place in the anchor's chapter that holds its quote."""
|
|
needle = " ".join((anchor.get("quote") or "").split())
|
|
src = anchor.get("chapterSrc")
|
|
if not needle or src not in self.book.chapter_srcs:
|
|
return None
|
|
chapter = self.book.chapter_srcs.index(src)
|
|
first = self.book.chapter_start_block[chapter]
|
|
last = self.book.chapter_start_block[chapter + 1] if chapter + 1 < len(self.book.chapter_start_block) else len(self.book.blocks)
|
|
for bi in range(first, last):
|
|
i = self.book.blocks[bi].text.find(needle)
|
|
if i >= 0:
|
|
return bi, i, i + len(needle)
|
|
return None
|
|
|
|
def _ranges(self, sb: int, sc: int, eb: int, ec: int, quote: str):
|
|
text = self.book.blocks[sb].text
|
|
m = self.maps(sb)
|
|
if m is None or (eb != sb and self.maps(eb) is None):
|
|
# not addressable by offsets: find the quote
|
|
i = text.find(" ".join(quote.split())) if quote else -1
|
|
if i >= 0:
|
|
yield sb, i, i + len(" ".join(quote.split()))
|
|
return
|
|
s = m.norm_of_raw[min(sc, len(m.raw))]
|
|
if sb == eb:
|
|
yield sb, s, m.norm_of_raw[min(ec, len(m.raw))]
|
|
return
|
|
yield sb, s, m.length
|
|
for bi in range(sb + 1, eb):
|
|
yield bi, 0, len(self.book.blocks[bi].text)
|
|
me = self.maps(eb)
|
|
yield eb, 0, me.norm_of_raw[min(ec, len(me.raw))]
|
|
|
|
# ---- selection -> anchor
|
|
|
|
def anchor(self, start: tuple[int, int], end: tuple[int, int]) -> dict | None:
|
|
"""The web anchor for the displayed range start..end ((block, char) each, end exclusive),
|
|
or None when a block can't be addressed exactly."""
|
|
(sb, sc), (eb, ec) = start, end
|
|
ms, me = self.maps(sb), self.maps(eb)
|
|
if ms is None or me is None:
|
|
return None
|
|
s_raw = ms.raw_of_norm[sc] if sc < ms.length else len(ms.raw)
|
|
e_raw = me.raw_of_norm[ec - 1] + 1 if ec > 0 else 0
|
|
if sb == eb:
|
|
quote = ms.raw[s_raw:e_raw]
|
|
else:
|
|
quote = ms.raw[s_raw:] + me.raw[:e_raw]
|
|
chapter_src = self.book.chapter_srcs[self.book.blocks[sb].chapter_index] if self.book.chapter_srcs else ""
|
|
return annotations.make_anchor(chapter_src, sb, s_raw, eb, e_raw, quote)
|