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>
79 lines
3.8 KiB
Python
79 lines
3.8 KiB
Python
"""Anchors the way the web reader writes them: block index plus character offsets into the
|
|
block's raw text (whitespace as in the file)."""
|
|
from diora_tui import blocks, marks
|
|
from diora_tui.blocks import collapse_map, load_flat_book
|
|
|
|
BODY = (
|
|
"<h1>Titel</h1>\n"
|
|
"<p>\n Erster Satz mit\n Umbruch. <i>Kursiv</i> Ende.\n</p>\n"
|
|
"<p>Zweiter Absatz.</p>"
|
|
)
|
|
|
|
|
|
def test_collapse_map():
|
|
raw = "\n ab cd \n"
|
|
norm_of_raw, raw_of_norm, length = collapse_map(raw)
|
|
assert length == 5 and "".join(raw[i] if raw[i] != " " else " " for i in raw_of_norm) == "ab cd"
|
|
assert raw_of_norm == [3, 4, 5, 8, 9] # a, b, the first space of the run, c, d
|
|
assert norm_of_raw[3] == 0 and norm_of_raw[5] == 2 and norm_of_raw[len(raw)] == 5
|
|
|
|
|
|
def test_anchor_counts_in_raw_text_and_resolves_back(epub):
|
|
book = load_flat_book(epub([BODY]))
|
|
idx = marks.MarkIndex(book)
|
|
para = next(i for i, b in enumerate(book.blocks) if b.text.startswith("Erster"))
|
|
text = book.blocks[para].text
|
|
assert text == "Erster Satz mit Umbruch. Kursiv Ende."
|
|
s = text.index("Satz")
|
|
e = text.index("Umbruch") + len("Umbruch")
|
|
anchor = idx.anchor((para, s), (para, e))
|
|
raw = idx.maps(para).raw
|
|
assert raw[anchor["startChar"]:anchor["endChar"]] == "Satz mit\n Umbruch" # raw offsets: whitespace as in the file
|
|
assert anchor["startBlockIndex"] == anchor["endBlockIndex"] == para and anchor["type"] == "epub"
|
|
assert anchor["quote"] == "Satz mit\n Umbruch" and anchor["chapterSrc"].endswith("c0.xhtml")
|
|
spans = idx.spans([{"type": "highlight", "color": "green", "anchor": anchor}])
|
|
assert list(spans) == [para] and spans[para][0][:2] == (s, e)
|
|
|
|
|
|
def test_range_over_blocks_and_notes_and_bad_anchors(epub):
|
|
book = load_flat_book(epub([BODY]))
|
|
idx = marks.MarkIndex(book)
|
|
first = next(i for i, b in enumerate(book.blocks) if b.text.startswith("Erster"))
|
|
last = first + 1
|
|
anchor = idx.anchor((first, 7), (last, 6))
|
|
spans = idx.spans([{"type": "highlight", "anchor": anchor}])
|
|
assert spans[first][0][:2] == (7, len(book.blocks[first].text)) and spans[last][0][:2] == (0, 6)
|
|
note = {"type": "note", "anchor": idx.anchor((first, 0), (first, 6))}
|
|
assert idx.spans([note])[first][0][:2] == (0, 6)
|
|
junk = [{"anchor": {"startBlockIndex": 9999}}, {"anchor": None}, {"anchor": {"startBlockIndex": "x"}}, {}]
|
|
assert idx.spans(junk) == {}
|
|
|
|
|
|
def test_a_footnote_marker_with_spaces_cannot_be_addressed_so_the_quote_is_used(epub):
|
|
body = '<p>Vor <a href="#n"><sup> 1 </sup></a>nach dem Marker.</p><p id="n">Fuß</p>'
|
|
book = load_flat_book(epub([body]))
|
|
idx = marks.MarkIndex(book)
|
|
assert idx.maps(0) is None
|
|
assert idx.anchor((0, 0), (0, 3)) is None # refused: not exact
|
|
spans = idx.spans([{"type": "highlight", "anchor": {"startBlockIndex": 0, "endBlockIndex": 0, "startChar": 3, "endChar": 9, "quote": "nach dem"}}])
|
|
t = book.blocks[0].text
|
|
assert spans[0][0][:2] == (t.index("nach dem"), t.index("nach dem") + 8)
|
|
|
|
|
|
def test_words():
|
|
t = "ab cd e"
|
|
assert marks.word_starts(t) == [0, 4, 7]
|
|
assert marks.next_word(t, 0) == 4 and marks.next_word(t, 7) is None
|
|
assert marks.prev_word(t, 4) == 0 and marks.prev_word(t, 0) is None
|
|
assert marks.word_at(t, 2) == (4, 6) # in the gap: the next word
|
|
|
|
|
|
def test_an_old_xpath_anchor_is_found_by_its_quote(epub):
|
|
book = load_flat_book(epub([BODY]))
|
|
idx = marks.MarkIndex(book)
|
|
old = {"type": "note", "anchor": {"type": "epub", "chapterSrc": book.chapter_srcs[0], "startXpath": "/p[1]", "startOffset": 3,
|
|
"endXpath": "/p[1]", "endOffset": 8, "quote": "Zweiter Absatz"}}
|
|
spans = idx.spans([old])
|
|
(bi, [(s, e, _)]), = spans.items()
|
|
assert book.blocks[bi].text[s:e] == "Zweiter Absatz"
|
|
assert idx.spans([{"anchor": {"type": "epub", "chapterSrc": "elsewhere.xhtml", "quote": "x"}}]) == {}
|