180 lines
6.8 KiB
Python
180 lines
6.8 KiB
Python
|
|
"""The reader's note editor and the list of a book's bookmarks, highlights and notes."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import dataclass
|
||
|
|
|
||
|
|
from rich.text import Text
|
||
|
|
from textual.binding import Binding
|
||
|
|
from textual.containers import Container
|
||
|
|
from textual.screen import ModalScreen
|
||
|
|
from textual.widgets import Input, Label, ListItem, ListView, Static
|
||
|
|
|
||
|
|
from . import annotations, blocks
|
||
|
|
|
||
|
|
|
||
|
|
class NoteScreen(ModalScreen[str]):
|
||
|
|
"""Type a note; Enter keeps it, Escape leaves it as it was (dismisses with None)."""
|
||
|
|
|
||
|
|
BINDINGS = [Binding("escape", "cancel", "Abbrechen")]
|
||
|
|
|
||
|
|
def __init__(self, title: str, text: str = "") -> None:
|
||
|
|
super().__init__()
|
||
|
|
self._title = title
|
||
|
|
self._text = text
|
||
|
|
|
||
|
|
def compose(self):
|
||
|
|
with Container(id="note-body"):
|
||
|
|
yield Label(self._title, id="note-title")
|
||
|
|
yield Input(value=self._text, placeholder="Notiz …", id="note-input")
|
||
|
|
yield Label("Enter: speichern Escape: abbrechen", id="note-hint")
|
||
|
|
|
||
|
|
def on_mount(self) -> None:
|
||
|
|
self.query_one(Input).focus()
|
||
|
|
|
||
|
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||
|
|
self.dismiss(event.value.strip())
|
||
|
|
|
||
|
|
def action_cancel(self) -> None:
|
||
|
|
self.dismiss(None)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Entry:
|
||
|
|
kind: str # 'bookmarks' | 'highlights'
|
||
|
|
item: dict
|
||
|
|
block: int # where it belongs, for the jump
|
||
|
|
percent: int
|
||
|
|
|
||
|
|
|
||
|
|
def entries(book: blocks.FlatBook, ann: annotations.BookAnnotations, mark_index=None) -> list[Entry]:
|
||
|
|
out: list[Entry] = []
|
||
|
|
for bm in ann.bookmarks:
|
||
|
|
block = bookmark_block(book, bm)
|
||
|
|
out.append(Entry("bookmarks", bm, block, round(blocks.fraction_for_anchor(book, block, 0.0) * 100)))
|
||
|
|
for h in sorted(ann.highlights, key=annotations.sort_key):
|
||
|
|
a = h.get("anchor") or {}
|
||
|
|
block = a.get("startBlockIndex") if isinstance(a.get("startBlockIndex"), int) else None
|
||
|
|
if block is None and mark_index is not None: # an old anchor: where its quote is
|
||
|
|
found = mark_index._find_quote(a)
|
||
|
|
block = found[0] if found else None
|
||
|
|
block = min(max(block or 0, 0), len(book.blocks) - 1)
|
||
|
|
out.append(Entry("highlights", h, block, round(blocks.fraction_for_anchor(book, block, 0.0) * 100)))
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def bookmark_block(book: blocks.FlatBook, bm: dict) -> int:
|
||
|
|
"""The block a bookmark points at: its chapter's first block; else its fraction of the text."""
|
||
|
|
src = bm.get("anchor") or ""
|
||
|
|
if src in book.chapter_srcs:
|
||
|
|
return book.chapter_start_block[book.chapter_srcs.index(src)]
|
||
|
|
target = float(bm.get("scrollFraction") or 0) * book.total_chars
|
||
|
|
return next((i for i, b in enumerate(book.blocks) if b.char_start + b.char_len >= target), 0)
|
||
|
|
|
||
|
|
|
||
|
|
class AnnotationsScreen(ModalScreen[int]):
|
||
|
|
"""Lesezeichen, then Markierungen and Notizen in the order of the book. Enter jumps (dismisses
|
||
|
|
with the block), d deletes, e edits a note; Escape closes."""
|
||
|
|
|
||
|
|
BINDINGS = [
|
||
|
|
Binding("escape,q,a", "close", "Schließen"),
|
||
|
|
Binding("d", "delete", "Löschen"),
|
||
|
|
Binding("e", "edit", "Notiz"),
|
||
|
|
]
|
||
|
|
|
||
|
|
def __init__(self, book: blocks.FlatBook, store: annotations.AnnotationStore, key: str, on_change, mark_index=None) -> None:
|
||
|
|
super().__init__()
|
||
|
|
self.book = book
|
||
|
|
self.store = store
|
||
|
|
self.key = key
|
||
|
|
self.mark_index = mark_index
|
||
|
|
self.on_change = on_change # called after a delete or an edit: marks and the queue follow
|
||
|
|
self.rows: list[Entry] = []
|
||
|
|
|
||
|
|
def compose(self):
|
||
|
|
with Container(id="ann-body"):
|
||
|
|
yield Label("Lesezeichen, Markierungen und Notizen", id="ann-title")
|
||
|
|
yield ListView(id="ann-list")
|
||
|
|
yield Static("", id="ann-detail")
|
||
|
|
yield Label("Enter: hinspringen d: löschen e: Notiz bearbeiten Escape: schließen", id="ann-hint")
|
||
|
|
|
||
|
|
def on_mount(self) -> None:
|
||
|
|
self._fill()
|
||
|
|
|
||
|
|
def _fill(self, keep: int = 0) -> None:
|
||
|
|
self.rows = entries(self.book, self.store.get(self.key), self.mark_index)
|
||
|
|
lv = self.query_one(ListView)
|
||
|
|
lv.clear()
|
||
|
|
if not self.rows:
|
||
|
|
lv.append(ListItem(Label("Noch nichts. b: Lesezeichen, v: Text markieren, im Lesen.")))
|
||
|
|
for r in self.rows:
|
||
|
|
lv.append(ListItem(Label(Text(self._label(r)))))
|
||
|
|
if self.rows:
|
||
|
|
lv.index = min(keep, len(self.rows) - 1)
|
||
|
|
lv.focus()
|
||
|
|
self._detail()
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _label(r: Entry) -> str:
|
||
|
|
if r.kind == "bookmarks":
|
||
|
|
return f"★ {r.item.get('label') or 'Lesezeichen'} {r.percent} %"
|
||
|
|
h = r.item
|
||
|
|
quote = " ".join(((h.get("anchor") or {}).get("quote") or "").split())
|
||
|
|
quote = quote if len(quote) <= 60 else quote[:59] + "…"
|
||
|
|
if h.get("type") == "note":
|
||
|
|
return f"✎ {h.get('note') or ''} {r.percent} %"
|
||
|
|
mark = {"yellow": "▌", "green": "▌", "blue": "▌", "red": "▌"}.get(h.get("color") or "yellow", "▌")
|
||
|
|
note = f" ✎ {h['note']}" if h.get("note") else ""
|
||
|
|
return f"{mark} „{quote}“{note} {r.percent} %"
|
||
|
|
|
||
|
|
def _detail(self) -> None:
|
||
|
|
lv = self.query_one(ListView)
|
||
|
|
detail = self.query_one("#ann-detail", Static)
|
||
|
|
if not self.rows or lv.index is None or lv.index >= len(self.rows):
|
||
|
|
detail.update("")
|
||
|
|
return
|
||
|
|
h = self.rows[lv.index].item
|
||
|
|
text = ""
|
||
|
|
if self.rows[lv.index].kind == "highlights":
|
||
|
|
text = " ".join(((h.get("anchor") or {}).get("quote") or "").split())
|
||
|
|
if h.get("note"):
|
||
|
|
text += "\n✎ " + h["note"]
|
||
|
|
detail.update(Text(text))
|
||
|
|
|
||
|
|
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
||
|
|
self._detail()
|
||
|
|
|
||
|
|
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||
|
|
lv = self.query_one(ListView)
|
||
|
|
if self.rows and lv.index is not None and lv.index < len(self.rows):
|
||
|
|
self.dismiss(self.rows[lv.index].block)
|
||
|
|
|
||
|
|
def _current(self) -> Entry | None:
|
||
|
|
lv = self.query_one(ListView)
|
||
|
|
return self.rows[lv.index] if self.rows and lv.index is not None and lv.index < len(self.rows) else None
|
||
|
|
|
||
|
|
def action_close(self) -> None:
|
||
|
|
self.dismiss(None)
|
||
|
|
|
||
|
|
def action_delete(self) -> None:
|
||
|
|
r = self._current()
|
||
|
|
if r is None:
|
||
|
|
return
|
||
|
|
idx = self.query_one(ListView).index or 0
|
||
|
|
self.store.delete(self.key, r.kind, r.item["id"])
|
||
|
|
self.on_change()
|
||
|
|
self._fill(idx)
|
||
|
|
|
||
|
|
def action_edit(self) -> None:
|
||
|
|
r = self._current()
|
||
|
|
if r is None or r.kind != "highlights":
|
||
|
|
return
|
||
|
|
idx = self.query_one(ListView).index or 0
|
||
|
|
|
||
|
|
def edited(text: str | None) -> None:
|
||
|
|
if text is not None and self.store.set_note(self.key, r.item["id"], text):
|
||
|
|
self.on_change()
|
||
|
|
self._fill(idx)
|
||
|
|
|
||
|
|
self.app.push_screen(NoteScreen("Notiz", r.item.get("note") or ""), edited)
|