xkcdkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xkcdkit/__init__.py +56 -0
- xkcdkit/cli.py +179 -0
- xkcdkit/client.py +233 -0
- xkcdkit/models.py +108 -0
- xkcdkit/py.typed +0 -0
- xkcdkit/render.py +43 -0
- xkcdkit/sources.py +89 -0
- xkcdkit/store.py +156 -0
- xkcdkit/tui.py +188 -0
- xkcdkit-0.1.0.dist-info/METADATA +293 -0
- xkcdkit-0.1.0.dist-info/RECORD +13 -0
- xkcdkit-0.1.0.dist-info/WHEEL +4 -0
- xkcdkit-0.1.0.dist-info/entry_points.txt +3 -0
xkcdkit/sources.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Fetchers for xkcd.com and explainxkcd.com.
|
|
2
|
+
|
|
3
|
+
Every function takes an ``httpx2.AsyncClient`` so callers control pooling,
|
|
4
|
+
timeouts and, in tests, the transport.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import html
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx2
|
|
12
|
+
|
|
13
|
+
from xkcdkit import ComicNotFound, XKCDError, __version__
|
|
14
|
+
from xkcdkit.models import ArchiveEntry, Comic
|
|
15
|
+
|
|
16
|
+
XKCD = "https://xkcd.com"
|
|
17
|
+
EXPLAIN_API = "https://www.explainxkcd.com/wiki/api.php"
|
|
18
|
+
ARCHIVE_LINK = re.compile(r'<a href="/(\d+)/" title="([^"]+)">(.*?)</a>', re.DOTALL)
|
|
19
|
+
HTML_TAG = re.compile(r"</?[a-z][a-z0-9]*(?:\s[^>]*)?>")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def user_agent() -> str:
|
|
23
|
+
"""A User-Agent that names the project, as both sites ask of bots."""
|
|
24
|
+
return f"xkcdkit/{__version__} (+https://github.com/eivl/xkcdkit)"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def request(client: httpx2.AsyncClient, url: str, **params: Any) -> httpx2.Response:
|
|
28
|
+
"""GET ``url`` and wrap transport failures in ``XKCDError``."""
|
|
29
|
+
try:
|
|
30
|
+
return await client.get(url, params=params or None)
|
|
31
|
+
except httpx2.HTTPError as exc:
|
|
32
|
+
raise XKCDError(f"{url}: {exc}") from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def fetch_comic(client: httpx2.AsyncClient, num: int | None) -> Comic:
|
|
36
|
+
"""Fetch comic ``num`` from xkcd.com, or the latest comic when ``num`` is None."""
|
|
37
|
+
url = f"{XKCD}/info.0.json" if num is None else f"{XKCD}/{num}/info.0.json"
|
|
38
|
+
response = await request(client, url)
|
|
39
|
+
if response.status_code == 404:
|
|
40
|
+
raise ComicNotFound(f"xkcd has no comic {num}")
|
|
41
|
+
if response.status_code != 200:
|
|
42
|
+
raise XKCDError(f"{url}: HTTP {response.status_code}")
|
|
43
|
+
return Comic.model_validate(response.json())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_archive(page: str) -> list[ArchiveEntry]:
|
|
47
|
+
"""Turn the archive page's anchors into entries, in page order (newest first)."""
|
|
48
|
+
return [
|
|
49
|
+
ArchiveEntry(num=int(num), date=date, title=html.unescape(HTML_TAG.sub("", title)))
|
|
50
|
+
for num, date, title in ARCHIVE_LINK.findall(page)
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def fetch_archive(client: httpx2.AsyncClient) -> list[ArchiveEntry]:
|
|
55
|
+
"""Fetch and parse https://xkcd.com/archive/."""
|
|
56
|
+
response = await request(client, f"{XKCD}/archive/")
|
|
57
|
+
if response.status_code != 200:
|
|
58
|
+
raise XKCDError(f"archive: HTTP {response.status_code}")
|
|
59
|
+
return parse_archive(response.text)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def fetch_explanation(client: httpx2.AsyncClient, num: int) -> tuple[str, int] | None:
|
|
63
|
+
"""Fetch the explainxkcd page for ``num`` as ``(wikitext, revid)``, or None if absent."""
|
|
64
|
+
response = await request(
|
|
65
|
+
client,
|
|
66
|
+
EXPLAIN_API,
|
|
67
|
+
action="parse",
|
|
68
|
+
page=str(num),
|
|
69
|
+
redirects=1,
|
|
70
|
+
prop="wikitext|revid",
|
|
71
|
+
format="json",
|
|
72
|
+
formatversion=2,
|
|
73
|
+
)
|
|
74
|
+
if response.status_code != 200:
|
|
75
|
+
raise XKCDError(f"explainxkcd: HTTP {response.status_code}")
|
|
76
|
+
payload = response.json()
|
|
77
|
+
if "error" in payload:
|
|
78
|
+
if payload["error"].get("code") == "missingtitle":
|
|
79
|
+
return None
|
|
80
|
+
raise XKCDError(f"explainxkcd: {payload['error'].get('info', 'unknown error')}")
|
|
81
|
+
return payload["parse"]["wikitext"], int(payload["parse"]["revid"])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def fetch_image(client: httpx2.AsyncClient, comic: Comic) -> bytes:
|
|
85
|
+
"""Download the comic's image bytes."""
|
|
86
|
+
response = await request(client, comic.img)
|
|
87
|
+
if response.status_code != 200:
|
|
88
|
+
raise XKCDError(f"{comic.img}: HTTP {response.status_code}")
|
|
89
|
+
return response.content
|
xkcdkit/store.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Local SQLite cache of comics, the archive index and sync metadata."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Self
|
|
9
|
+
|
|
10
|
+
from platformdirs import user_data_path
|
|
11
|
+
from rapidfuzz import fuzz
|
|
12
|
+
|
|
13
|
+
from xkcdkit.models import ArchiveEntry, Comic
|
|
14
|
+
|
|
15
|
+
COLUMNS = list(Comic.model_fields)
|
|
16
|
+
SCHEMA = f"""
|
|
17
|
+
CREATE TABLE IF NOT EXISTS comics ({", ".join(COLUMNS)}, PRIMARY KEY (num));
|
|
18
|
+
CREATE TABLE IF NOT EXISTS archive (num INTEGER PRIMARY KEY, date TEXT, title TEXT);
|
|
19
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
20
|
+
"""
|
|
21
|
+
SEARCH_CUTOFF = 60.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def default_path() -> Path:
|
|
25
|
+
"""Database location: ``$XKCDKIT_DB`` or the platform user data directory."""
|
|
26
|
+
override = os.environ.get("XKCDKIT_DB")
|
|
27
|
+
if override:
|
|
28
|
+
return Path(override)
|
|
29
|
+
return user_data_path("xkcdkit") / "xkcd.sqlite3"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def utc_now() -> datetime:
|
|
33
|
+
"""Current time as a timezone-aware UTC datetime."""
|
|
34
|
+
return datetime.now(UTC)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Store:
|
|
38
|
+
"""Insert-only cache of comics with fuzzy search.
|
|
39
|
+
|
|
40
|
+
Use as a context manager so the connection is closed::
|
|
41
|
+
|
|
42
|
+
with Store() as store:
|
|
43
|
+
comic = store.get(614)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, path: Path | str | None = None) -> None:
|
|
47
|
+
"""Open (and create if needed) the database at ``path``."""
|
|
48
|
+
self.path = Path(path) if path else default_path()
|
|
49
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
self.db = sqlite3.connect(self.path)
|
|
51
|
+
self.db.row_factory = sqlite3.Row
|
|
52
|
+
self.db.executescript(SCHEMA)
|
|
53
|
+
|
|
54
|
+
def __enter__(self) -> Self:
|
|
55
|
+
"""Enter the context manager."""
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def __exit__(
|
|
59
|
+
self, *exc: tuple[type[BaseException] | None, BaseException | None, TracebackType | None]
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Close the connection on exit."""
|
|
62
|
+
self.close()
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
"""Close the database connection."""
|
|
66
|
+
self.db.close()
|
|
67
|
+
|
|
68
|
+
def write(self, comic: Comic, verb: str) -> None:
|
|
69
|
+
"""Insert a comic using ``verb`` (``OR IGNORE`` or ``OR REPLACE``)."""
|
|
70
|
+
stamped = comic.model_copy(update={"synced_at": comic.synced_at or utc_now()})
|
|
71
|
+
row = stamped.model_dump(mode="json")
|
|
72
|
+
columns, marks = ", ".join(COLUMNS), ", ".join("?" for _ in COLUMNS)
|
|
73
|
+
sql = f"INSERT {verb} INTO comics ({columns}) VALUES ({marks})" # noqa: S608 - all constants
|
|
74
|
+
with self.db:
|
|
75
|
+
self.db.execute(sql, [row[column] for column in COLUMNS])
|
|
76
|
+
|
|
77
|
+
def put(self, comic: Comic) -> None:
|
|
78
|
+
"""Store a comic unless one with the same number already exists."""
|
|
79
|
+
self.write(comic, "OR IGNORE")
|
|
80
|
+
|
|
81
|
+
def replace(self, comic: Comic) -> None:
|
|
82
|
+
"""Store a comic, overwriting any existing row with the same number."""
|
|
83
|
+
self.write(comic, "OR REPLACE")
|
|
84
|
+
|
|
85
|
+
def get(self, num: int) -> Comic | None:
|
|
86
|
+
"""Return the cached comic ``num`` or None."""
|
|
87
|
+
row = self.db.execute("SELECT * FROM comics WHERE num = ?", (num,)).fetchone()
|
|
88
|
+
return Comic.model_validate(dict(row)) if row else None
|
|
89
|
+
|
|
90
|
+
def has(self, num: int) -> bool:
|
|
91
|
+
"""Whether comic ``num`` is cached."""
|
|
92
|
+
return self.get(num) is not None
|
|
93
|
+
|
|
94
|
+
def nums(self) -> list[int]:
|
|
95
|
+
"""All cached comic numbers, ascending."""
|
|
96
|
+
return [row[0] for row in self.db.execute("SELECT num FROM comics ORDER BY num")]
|
|
97
|
+
|
|
98
|
+
def count(self) -> int:
|
|
99
|
+
"""Number of cached comics."""
|
|
100
|
+
return int(self.db.execute("SELECT COUNT(*) FROM comics").fetchone()[0])
|
|
101
|
+
|
|
102
|
+
def all(self) -> list[Comic]:
|
|
103
|
+
"""Every cached comic, ascending by number."""
|
|
104
|
+
rows = self.db.execute("SELECT * FROM comics ORDER BY num")
|
|
105
|
+
return [Comic.model_validate(dict(row)) for row in rows]
|
|
106
|
+
|
|
107
|
+
def put_archive(self, entries: list[ArchiveEntry]) -> None:
|
|
108
|
+
"""Replace the archive index with ``entries``."""
|
|
109
|
+
with self.db:
|
|
110
|
+
self.db.execute("DELETE FROM archive")
|
|
111
|
+
self.db.executemany(
|
|
112
|
+
"INSERT OR REPLACE INTO archive VALUES (?, ?, ?)",
|
|
113
|
+
[(e.num, e.date.isoformat(), e.title) for e in entries],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def archive(self) -> list[ArchiveEntry]:
|
|
117
|
+
"""The archive index, newest first."""
|
|
118
|
+
rows = self.db.execute("SELECT * FROM archive ORDER BY num DESC")
|
|
119
|
+
return [ArchiveEntry.model_validate(dict(row)) for row in rows]
|
|
120
|
+
|
|
121
|
+
def set_meta(self, key: str, value: str) -> None:
|
|
122
|
+
"""Store a metadata value such as ``last_sync_at``."""
|
|
123
|
+
with self.db:
|
|
124
|
+
self.db.execute("INSERT OR REPLACE INTO meta VALUES (?, ?)", (key, value))
|
|
125
|
+
|
|
126
|
+
def get_meta(self, key: str) -> str | None:
|
|
127
|
+
"""Read a metadata value, or None when unset."""
|
|
128
|
+
row = self.db.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
|
|
129
|
+
return str(row[0]) if row else None
|
|
130
|
+
|
|
131
|
+
def stale(self, max_age: timedelta) -> list[int]:
|
|
132
|
+
"""Numbers of comics older than ``max_age`` or still missing an explanation."""
|
|
133
|
+
cutoff = (utc_now() - max_age).isoformat()
|
|
134
|
+
rows = self.db.execute(
|
|
135
|
+
"SELECT num FROM comics WHERE synced_at < ? OR explanation_missing ORDER BY num",
|
|
136
|
+
(cutoff,),
|
|
137
|
+
)
|
|
138
|
+
return [row[0] for row in rows]
|
|
139
|
+
|
|
140
|
+
def search(self, query: str, limit: int = 10) -> list[tuple[Comic, float]]:
|
|
141
|
+
"""Fuzzy-search titles, alt text, transcripts and explanations.
|
|
142
|
+
|
|
143
|
+
Returns ``(comic, score)`` pairs, best first, scores from 0 to 100.
|
|
144
|
+
"""
|
|
145
|
+
scored = [(comic, score_comic(comic, query)) for comic in self.all()]
|
|
146
|
+
hits = [(comic, score) for comic, score in scored if score >= SEARCH_CUTOFF]
|
|
147
|
+
hits.sort(key=lambda hit: (-hit[1], hit[0].num))
|
|
148
|
+
return hits[:limit]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def score_comic(comic: Comic, query: str) -> float:
|
|
152
|
+
"""Score a comic against a query: exact-ish titles win, long text counts a bit less."""
|
|
153
|
+
title = float(fuzz.WRatio(query, comic.title, processor=str.lower))
|
|
154
|
+
body = " ".join([comic.alt, comic.transcript, comic.explanation_text])
|
|
155
|
+
text = float(fuzz.token_set_ratio(query, body, processor=str.lower))
|
|
156
|
+
return max(title, 0.9 * text)
|
xkcdkit/tui.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""A lazygit-style browser: archive on the left, image and text panes on the right."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from PIL import Image as PILImage
|
|
7
|
+
from rapidfuzz import fuzz
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual import work
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.binding import Binding, BindingType
|
|
12
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
13
|
+
from textual.widgets import Footer, Header, Input, OptionList, Static, TabbedContent, TabPane
|
|
14
|
+
from textual.widgets.option_list import Option
|
|
15
|
+
from textual_image.widget import Image
|
|
16
|
+
|
|
17
|
+
from xkcdkit import XKCDError
|
|
18
|
+
from xkcdkit.client import AsyncXKCD
|
|
19
|
+
from xkcdkit.models import ArchiveEntry, Comic
|
|
20
|
+
from xkcdkit.sources import fetch_image
|
|
21
|
+
|
|
22
|
+
HELP = "j/k move / filter enter open r random l latest i image s sync q quit"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def option(entry: ArchiveEntry) -> Option:
|
|
26
|
+
"""An archive row: bold number, then the title, keyed by the comic number."""
|
|
27
|
+
return Option(Text.assemble((f"{entry.num:>5}", "bold"), " ", entry.title), id=str(entry.num))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class XKCDApp(App[None]):
|
|
31
|
+
"""Browse the xkcd archive with images, alt text, transcripts and explanations."""
|
|
32
|
+
|
|
33
|
+
TITLE = "xkcd"
|
|
34
|
+
CSS = """
|
|
35
|
+
#archive-pane { width: 40; border: round $primary; }
|
|
36
|
+
#archive { height: 1fr; }
|
|
37
|
+
#image-pane { height: 2fr; border: round $secondary; align: center middle; }
|
|
38
|
+
TabbedContent { height: 1fr; }
|
|
39
|
+
.text { padding: 0 1; }
|
|
40
|
+
"""
|
|
41
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
42
|
+
Binding("j", "cursor('down')", "Down"),
|
|
43
|
+
Binding("k", "cursor('up')", "Up"),
|
|
44
|
+
Binding("slash", "focus_filter", "Filter", key_display="/"),
|
|
45
|
+
Binding("r", "random", "Random"),
|
|
46
|
+
Binding("l", "latest", "Latest"),
|
|
47
|
+
Binding("i", "toggle_image", "Image"),
|
|
48
|
+
Binding("s", "sync", "Sync"),
|
|
49
|
+
Binding("question_mark", "help", "Help", key_display="?"),
|
|
50
|
+
Binding("q", "quit", "Quit"),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
def __init__(self, client: AsyncXKCD) -> None:
|
|
54
|
+
"""Keep the client; the store it holds feeds the archive list."""
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.client = client
|
|
57
|
+
self.entries: list[ArchiveEntry] = []
|
|
58
|
+
|
|
59
|
+
def compose(self) -> ComposeResult:
|
|
60
|
+
"""Build the two-column layout."""
|
|
61
|
+
yield Header()
|
|
62
|
+
with Horizontal():
|
|
63
|
+
with Vertical(id="archive-pane"):
|
|
64
|
+
yield Input(placeholder="filter titles", id="filter")
|
|
65
|
+
yield OptionList(id="archive")
|
|
66
|
+
with Vertical():
|
|
67
|
+
yield Vertical(Image(id="image"), id="image-pane")
|
|
68
|
+
with TabbedContent():
|
|
69
|
+
with TabPane("Alt text", id="tab-alt"):
|
|
70
|
+
yield VerticalScroll(Static(id="alt", classes="text", markup=False))
|
|
71
|
+
with TabPane("Transcript", id="tab-transcript"):
|
|
72
|
+
yield VerticalScroll(Static(id="transcript", classes="text", markup=False))
|
|
73
|
+
with TabPane("Explanation", id="tab-explanation"):
|
|
74
|
+
yield VerticalScroll(Static(id="explanation", classes="text", markup=False))
|
|
75
|
+
yield Footer()
|
|
76
|
+
|
|
77
|
+
def on_mount(self) -> None:
|
|
78
|
+
"""Show the cached archive (syncing it on first run) and open the latest comic."""
|
|
79
|
+
self.query_one(OptionList).focus()
|
|
80
|
+
self.entries = self.client.store.archive()
|
|
81
|
+
self.show_entries(self.entries)
|
|
82
|
+
if not self.entries:
|
|
83
|
+
self.action_sync()
|
|
84
|
+
self.open(None)
|
|
85
|
+
|
|
86
|
+
def show_entries(self, entries: list[ArchiveEntry]) -> None:
|
|
87
|
+
"""Replace the list contents."""
|
|
88
|
+
archive = self.query_one(OptionList)
|
|
89
|
+
archive.clear_options()
|
|
90
|
+
archive.add_options(option(entry) for entry in entries)
|
|
91
|
+
archive.highlighted = 0 if entries else None
|
|
92
|
+
|
|
93
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
94
|
+
"""Fuzzy-filter the archive list as the user types."""
|
|
95
|
+
query = event.value.strip().lower()
|
|
96
|
+
if not query:
|
|
97
|
+
self.show_entries(self.entries)
|
|
98
|
+
return
|
|
99
|
+
scored = [(fuzz.WRatio(query, e.title.lower()), e) for e in self.entries]
|
|
100
|
+
self.show_entries([e for score, e in scored if score >= 70])
|
|
101
|
+
|
|
102
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
103
|
+
"""Enter in the filter opens the first match."""
|
|
104
|
+
archive = self.query_one(OptionList)
|
|
105
|
+
if archive.option_count:
|
|
106
|
+
self.open(int(str(archive.get_option_at_index(0).id)))
|
|
107
|
+
|
|
108
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
109
|
+
"""Enter on a row opens that comic."""
|
|
110
|
+
self.open(int(str(event.option.id)))
|
|
111
|
+
|
|
112
|
+
@work(exclusive=True, group="open")
|
|
113
|
+
async def open(self, num: int | None) -> None:
|
|
114
|
+
"""Fetch comic ``num`` (or the latest, or a random one for -1) and show it."""
|
|
115
|
+
try:
|
|
116
|
+
if num is None:
|
|
117
|
+
comic = await self.client.latest()
|
|
118
|
+
elif num < 0:
|
|
119
|
+
comic = await self.client.random()
|
|
120
|
+
else:
|
|
121
|
+
comic = await self.client.get(num)
|
|
122
|
+
await self.show_comic(comic)
|
|
123
|
+
except XKCDError as exc:
|
|
124
|
+
self.notify(str(exc), severity="error")
|
|
125
|
+
|
|
126
|
+
async def show_comic(self, comic: Comic) -> None:
|
|
127
|
+
"""Fill the panes from a comic."""
|
|
128
|
+
self.sub_title = f"#{comic.num} {comic.title} ({comic.date.isoformat()})"
|
|
129
|
+
self.query_one("#alt", Static).update(comic.alt)
|
|
130
|
+
self.query_one("#transcript", Static).update(comic.transcript or "No transcript.")
|
|
131
|
+
explanation = comic.explanation_text or "No explanation on explainxkcd yet."
|
|
132
|
+
self.query_one("#explanation", Static).update(explanation)
|
|
133
|
+
if self.query_one("#image-pane").display:
|
|
134
|
+
await self.show_image(comic)
|
|
135
|
+
|
|
136
|
+
async def show_image(self, comic: Comic) -> None:
|
|
137
|
+
"""Download and display the comic image, reporting rather than crashing on bad data."""
|
|
138
|
+
try:
|
|
139
|
+
data = await fetch_image(self.client.http, comic)
|
|
140
|
+
self.query_one("#image", Image).image = PILImage.open(io.BytesIO(data))
|
|
141
|
+
except (XKCDError, OSError) as exc:
|
|
142
|
+
self.notify(f"Image unavailable: {exc}", severity="warning")
|
|
143
|
+
|
|
144
|
+
def action_cursor(self, direction: str) -> None:
|
|
145
|
+
"""Move the archive cursor with j/k."""
|
|
146
|
+
archive = self.query_one(OptionList)
|
|
147
|
+
archive.action_cursor_down() if direction == "down" else archive.action_cursor_up()
|
|
148
|
+
|
|
149
|
+
def action_focus_filter(self) -> None:
|
|
150
|
+
"""Jump to the filter box."""
|
|
151
|
+
self.query_one("#filter", Input).focus()
|
|
152
|
+
|
|
153
|
+
def action_random(self) -> None:
|
|
154
|
+
"""Open a random comic."""
|
|
155
|
+
self.open(-1)
|
|
156
|
+
|
|
157
|
+
def action_latest(self) -> None:
|
|
158
|
+
"""Open the newest comic."""
|
|
159
|
+
self.open(None)
|
|
160
|
+
|
|
161
|
+
def action_toggle_image(self) -> None:
|
|
162
|
+
"""Show or hide the image pane."""
|
|
163
|
+
pane = self.query_one("#image-pane")
|
|
164
|
+
pane.display = not pane.display
|
|
165
|
+
|
|
166
|
+
@work(exclusive=True, group="sync")
|
|
167
|
+
async def action_sync(self) -> None:
|
|
168
|
+
"""Refresh the archive and latest comic, then report."""
|
|
169
|
+
try:
|
|
170
|
+
added = await self.client.sync()
|
|
171
|
+
except XKCDError as exc:
|
|
172
|
+
self.notify(str(exc), severity="error")
|
|
173
|
+
return
|
|
174
|
+
self.entries = self.client.store.archive()
|
|
175
|
+
self.show_entries(self.entries)
|
|
176
|
+
self.notify(f"Synced: {added} new comic(s).")
|
|
177
|
+
|
|
178
|
+
def action_help(self) -> None:
|
|
179
|
+
"""Show the key bindings."""
|
|
180
|
+
self.notify(HELP, title="Keys", timeout=8)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def run_tui(client: AsyncXKCD) -> None:
|
|
184
|
+
"""Start the browser and close the client when it exits."""
|
|
185
|
+
try:
|
|
186
|
+
XKCDApp(client).run()
|
|
187
|
+
finally:
|
|
188
|
+
client.store.close()
|