aa-tui 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.
- aa_tui/__init__.py +1 -0
- aa_tui/app.py +422 -0
- aa_tui/capture.py +47 -0
- aa_tui/cli.py +37 -0
- aa_tui/details_screen.py +100 -0
- aa_tui/edit_screen.py +88 -0
- aa_tui/error_screen.py +56 -0
- aa_tui/loading_screen.py +39 -0
- aa_tui/metadata.py +121 -0
- aa_tui/py.typed +0 -0
- aa_tui/service.py +83 -0
- aa_tui/tags.py +219 -0
- aa_tui-0.1.0.dist-info/METADATA +40 -0
- aa_tui-0.1.0.dist-info/RECORD +16 -0
- aa_tui-0.1.0.dist-info/WHEEL +4 -0
- aa_tui-0.1.0.dist-info/entry_points.txt +3 -0
aa_tui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Textual front-end for aa-meta."""
|
aa_tui/app.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""The Textual application: search box, results table, and detail pane."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import webbrowser
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, ClassVar
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
from aa_meta.authors import format_authors
|
|
14
|
+
from aa_meta.cli import format_ext, format_size
|
|
15
|
+
from aa_meta.config import Settings
|
|
16
|
+
from aa_meta.models import BookRecord
|
|
17
|
+
from aa_meta.search import Book
|
|
18
|
+
from loguru import logger
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
from textual import work
|
|
21
|
+
from textual.app import App, ComposeResult
|
|
22
|
+
from textual.binding import Binding, BindingType
|
|
23
|
+
from textual.containers import Horizontal
|
|
24
|
+
from textual.widgets import DataTable, Footer, Header, Input, Static
|
|
25
|
+
from textual.worker import get_current_worker
|
|
26
|
+
|
|
27
|
+
from . import service
|
|
28
|
+
from .capture import ToastStream
|
|
29
|
+
from .details_screen import DetailsScreen
|
|
30
|
+
from .edit_screen import EditScreen
|
|
31
|
+
from .error_screen import ErrorScreen
|
|
32
|
+
from .loading_screen import LoadingScreen
|
|
33
|
+
from .metadata import DEFAULT_STORE, EditableMeta, MetadataStore
|
|
34
|
+
from .tags import TagError, write_tags
|
|
35
|
+
|
|
36
|
+
# (label, width) of each results column; the MD5 stays last and full-width.
|
|
37
|
+
COLUMNS: tuple[tuple[str, int | None], ...] = (
|
|
38
|
+
("#", 3),
|
|
39
|
+
("Ext", 6),
|
|
40
|
+
("Title", 44),
|
|
41
|
+
("Author", 26),
|
|
42
|
+
("Size", 9),
|
|
43
|
+
("Lang", 5),
|
|
44
|
+
("Year", 5),
|
|
45
|
+
("MD5", 32),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _plain(value: str) -> Text:
|
|
50
|
+
"""Return ``value`` in light grey, or a dim dash when empty."""
|
|
51
|
+
return Text(value, style="#C2C2C2") if value else Text("-", style="dim")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def result_rows(books: list[Book]) -> list[tuple[Text | str, ...]]:
|
|
55
|
+
"""Build one table row per book, splitting the parsed record into columns.
|
|
56
|
+
|
|
57
|
+
Books without a parsed record (bare links) fall back to their raw ``meta``
|
|
58
|
+
text in the Author column and dashes elsewhere.
|
|
59
|
+
"""
|
|
60
|
+
authors = format_authors([b.record.author if b.record else b.meta for b in books])
|
|
61
|
+
rows: list[tuple[Text | str, ...]] = []
|
|
62
|
+
for i, (book, author) in enumerate(zip(books, authors, strict=True), 1):
|
|
63
|
+
rec = book.record
|
|
64
|
+
rows.append(
|
|
65
|
+
(
|
|
66
|
+
Text(str(i), style="dim italic"),
|
|
67
|
+
format_ext(rec.extension if rec else None),
|
|
68
|
+
Text(book.title, style="bold"),
|
|
69
|
+
author,
|
|
70
|
+
format_size(rec.size_text if rec else None),
|
|
71
|
+
_plain(rec.language if rec else ""),
|
|
72
|
+
_plain(str(rec.year) if rec and rec.year else ""),
|
|
73
|
+
Text(book.md5, style="#00ccff"),
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
return rows
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def apply_meta(rec: BookRecord, meta: EditableMeta) -> BookRecord:
|
|
80
|
+
"""Return ``rec`` with its editable fields replaced by ``meta``."""
|
|
81
|
+
year = int(meta.year) if meta.year.isdigit() else None
|
|
82
|
+
identifiers = rec.identifiers
|
|
83
|
+
if meta.isbn != (rec.isbns[0] if rec.isbns else ""):
|
|
84
|
+
# ``isbns`` derives from the identifiers, so an edited ISBN replaces both.
|
|
85
|
+
identifiers = {k: v for k, v in rec.identifiers.items() if k[:4] != "isbn"}
|
|
86
|
+
if meta.isbn:
|
|
87
|
+
identifiers["isbn13"] = (meta.isbn,)
|
|
88
|
+
return replace(
|
|
89
|
+
rec,
|
|
90
|
+
title=meta.title,
|
|
91
|
+
author=meta.author,
|
|
92
|
+
publisher=meta.publisher,
|
|
93
|
+
series=meta.series,
|
|
94
|
+
series_index=meta.series_index,
|
|
95
|
+
year=year,
|
|
96
|
+
languages=(meta.language,) if meta.language else (),
|
|
97
|
+
identifiers=identifiers,
|
|
98
|
+
description=meta.description,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def render_record(rec: BookRecord, *, edited: bool = False) -> Text:
|
|
103
|
+
"""Format ``rec`` as styled text for the detail pane."""
|
|
104
|
+
text = Text()
|
|
105
|
+
if edited:
|
|
106
|
+
text.append("✎ edited locally\n", style="yellow")
|
|
107
|
+
text.append(f"{rec.title or rec.md5}\n", style="bold")
|
|
108
|
+
series = f"{rec.series} #{rec.series_index}" if rec.series_index else rec.series
|
|
109
|
+
fields = [
|
|
110
|
+
("Author", rec.author),
|
|
111
|
+
("Publisher", rec.publisher),
|
|
112
|
+
("Series", series),
|
|
113
|
+
("Year", str(rec.year) if rec.year else ""),
|
|
114
|
+
("Language", ", ".join(rec.languages)),
|
|
115
|
+
("Format", f"{rec.extension} {rec.size_text}".strip()),
|
|
116
|
+
("ISBN", ", ".join(rec.isbns)),
|
|
117
|
+
]
|
|
118
|
+
for label, value in fields:
|
|
119
|
+
if value:
|
|
120
|
+
text.append(f"{label}: ", style="cyan")
|
|
121
|
+
text.append(f"{value}\n")
|
|
122
|
+
if rec.description:
|
|
123
|
+
text.append(f"\n{rec.description}")
|
|
124
|
+
return text
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class AaApp(App[None]):
|
|
128
|
+
"""Search Anna's Archive, inspect a result, and download it."""
|
|
129
|
+
|
|
130
|
+
CSS = """
|
|
131
|
+
#main { height: 1fr; }
|
|
132
|
+
DataTable { width: 2fr; height: 100%; }
|
|
133
|
+
#detail { width: 1fr; height: 100%; border-left: solid $primary; padding: 0 1;
|
|
134
|
+
overflow-y: auto; }
|
|
135
|
+
"""
|
|
136
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
137
|
+
Binding("/", "focus_search", "Search"),
|
|
138
|
+
Binding("d", "download", "Download"),
|
|
139
|
+
Binding("o", "open_browser", "Open in browser"),
|
|
140
|
+
Binding("e", "edit", "Edit metadata"),
|
|
141
|
+
Binding("w", "write_tags", "Write tags"),
|
|
142
|
+
Binding("q", "quit", "Quit"),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
settings: Settings,
|
|
148
|
+
query: str | None = None,
|
|
149
|
+
store: MetadataStore | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Create the app with resolved ``settings``, a first query and edit store."""
|
|
152
|
+
super().__init__()
|
|
153
|
+
self._settings = settings
|
|
154
|
+
self._initial_query = query
|
|
155
|
+
self._store = store or MetadataStore(DEFAULT_STORE)
|
|
156
|
+
self._records: dict[str, BookRecord] = {}
|
|
157
|
+
self._last_query = ""
|
|
158
|
+
self._loading: LoadingScreen | None = None
|
|
159
|
+
|
|
160
|
+
def compose(self) -> ComposeResult:
|
|
161
|
+
"""Lay out the widgets."""
|
|
162
|
+
yield Header()
|
|
163
|
+
yield Input(placeholder="Search Anna's Archive…", id="query")
|
|
164
|
+
with Horizontal(id="main"):
|
|
165
|
+
yield DataTable(id="results", cursor_type="row")
|
|
166
|
+
yield Static("Select a result to see its details.", id="detail")
|
|
167
|
+
yield Footer()
|
|
168
|
+
|
|
169
|
+
def on_mount(self) -> None:
|
|
170
|
+
"""Route stray output to toasts, prepare the table and run any first query."""
|
|
171
|
+
self._capture_output()
|
|
172
|
+
table = self.query_one(DataTable)
|
|
173
|
+
for label, width in COLUMNS:
|
|
174
|
+
table.add_column(label, width=width)
|
|
175
|
+
if self._initial_query:
|
|
176
|
+
self.query_one(Input).value = self._initial_query
|
|
177
|
+
self._last_query = self._initial_query
|
|
178
|
+
self._search(self._initial_query)
|
|
179
|
+
|
|
180
|
+
def _capture_output(self) -> None:
|
|
181
|
+
"""Send stdout, stderr and loguru output to toasts instead of the screen."""
|
|
182
|
+
self._saved_streams = (sys.stdout, sys.stderr)
|
|
183
|
+
self._out = ToastStream(self.toast, "information")
|
|
184
|
+
self._err = ToastStream(self.toast, "error")
|
|
185
|
+
sys.stdout, sys.stderr = self._out, self._err
|
|
186
|
+
logger.remove()
|
|
187
|
+
self._log_id = logger.add(self._log_sink, level="INFO", format="{message}")
|
|
188
|
+
|
|
189
|
+
def on_unmount(self) -> None:
|
|
190
|
+
"""Restore the real streams and flush any partial line."""
|
|
191
|
+
self._out.flush_partial()
|
|
192
|
+
self._err.flush_partial()
|
|
193
|
+
sys.stdout, sys.stderr = self._saved_streams
|
|
194
|
+
logger.remove(self._log_id)
|
|
195
|
+
|
|
196
|
+
def _log_sink(self, message: Any) -> None:
|
|
197
|
+
"""Show a loguru record as a toast at the matching severity."""
|
|
198
|
+
level = message.record["level"].no
|
|
199
|
+
severity = (
|
|
200
|
+
"error" if level >= 40 else "warning" if level >= 30 else "information"
|
|
201
|
+
)
|
|
202
|
+
self.toast(message.record["message"], severity)
|
|
203
|
+
|
|
204
|
+
def toast(self, message: str, severity: str = "information") -> None:
|
|
205
|
+
"""Show a toast from any thread (errors linger longer)."""
|
|
206
|
+
timeout = 10.0 if severity == "error" else 5.0
|
|
207
|
+
if self._thread_id == threading.get_ident():
|
|
208
|
+
self.notify(message, severity=severity, timeout=timeout) # type: ignore[arg-type]
|
|
209
|
+
else:
|
|
210
|
+
self.call_from_thread(
|
|
211
|
+
self.notify, message, severity=severity, timeout=timeout
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def _handle_exception(self, error: Exception) -> None:
|
|
215
|
+
"""Show unexpected exceptions in a modal instead of exiting the app.
|
|
216
|
+
|
|
217
|
+
Overrides Textual's private hook, which would otherwise print the
|
|
218
|
+
traceback and quit. A failed background worker leaves the app fully
|
|
219
|
+
usable; an exception in a UI handler may leave that widget unresponsive,
|
|
220
|
+
so the modal says to restart if the app stops reacting.
|
|
221
|
+
"""
|
|
222
|
+
from textual.worker import WorkerFailed
|
|
223
|
+
|
|
224
|
+
recoverable = isinstance(error, WorkerFailed)
|
|
225
|
+
cause = error.error if isinstance(error, WorkerFailed) else error
|
|
226
|
+
self._end_loading()
|
|
227
|
+
self.push_screen(ErrorScreen(cause, recoverable=recoverable))
|
|
228
|
+
|
|
229
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
230
|
+
"""Search for the submitted text; ignore blank input."""
|
|
231
|
+
if query := event.value.strip():
|
|
232
|
+
self._last_query = query
|
|
233
|
+
self._search(query)
|
|
234
|
+
|
|
235
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
236
|
+
"""Load details for the highlighted row."""
|
|
237
|
+
if event.row_key.value:
|
|
238
|
+
self._load_details(event.row_key.value)
|
|
239
|
+
|
|
240
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
241
|
+
"""Pop up the full details for a clicked (or Enter-ed) row."""
|
|
242
|
+
md5 = event.row_key.value
|
|
243
|
+
if md5 is None:
|
|
244
|
+
return
|
|
245
|
+
if md5 not in self._records:
|
|
246
|
+
self.notify("Details are still loading…")
|
|
247
|
+
return
|
|
248
|
+
rec = self._records[md5]
|
|
249
|
+
edited = self._store_has_edits(md5)
|
|
250
|
+
if edited and (entry := self._store.get(md5)):
|
|
251
|
+
rec = apply_meta(rec, entry.meta)
|
|
252
|
+
self.push_screen(DetailsScreen(rec, edited=edited))
|
|
253
|
+
|
|
254
|
+
def action_open_browser(self) -> None:
|
|
255
|
+
"""Open the current search's results page on the site in the browser."""
|
|
256
|
+
if not self._last_query:
|
|
257
|
+
self.notify("Run a search first.", severity="warning")
|
|
258
|
+
return
|
|
259
|
+
url = f"{self._settings.origin}/search?{urlencode({'q': self._last_query})}"
|
|
260
|
+
if not webbrowser.open(url):
|
|
261
|
+
self.notify(f"Could not open a browser. URL: {url}", severity="error")
|
|
262
|
+
|
|
263
|
+
def action_focus_search(self) -> None:
|
|
264
|
+
"""Move focus to the search box."""
|
|
265
|
+
self.query_one(Input).focus()
|
|
266
|
+
|
|
267
|
+
def _highlighted_md5(self) -> str | None:
|
|
268
|
+
"""MD5 of the highlighted row, or ``None`` when the table is empty."""
|
|
269
|
+
table = self.query_one(DataTable)
|
|
270
|
+
if not table.row_count:
|
|
271
|
+
return None
|
|
272
|
+
return table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
|
|
273
|
+
|
|
274
|
+
def action_download(self) -> None:
|
|
275
|
+
"""Download the highlighted book."""
|
|
276
|
+
if md5 := self._highlighted_md5():
|
|
277
|
+
self._download(md5)
|
|
278
|
+
|
|
279
|
+
def action_edit(self) -> None:
|
|
280
|
+
"""Open the metadata form for the highlighted book."""
|
|
281
|
+
md5 = self._highlighted_md5()
|
|
282
|
+
rec = self._records.get(md5 or "")
|
|
283
|
+
if md5 is None or rec is None:
|
|
284
|
+
self.notify("Wait for the details to load first.", severity="warning")
|
|
285
|
+
return
|
|
286
|
+
entry = self._store.get(md5)
|
|
287
|
+
meta = entry.meta if entry else EditableMeta.from_record(rec)
|
|
288
|
+
|
|
289
|
+
def done(new: EditableMeta | None) -> None:
|
|
290
|
+
if new is None:
|
|
291
|
+
return
|
|
292
|
+
entry = self._store.set_meta(md5, new)
|
|
293
|
+
self._show_record(md5)
|
|
294
|
+
if entry.file and entry.file.exists():
|
|
295
|
+
self._embed(entry.file, entry.meta)
|
|
296
|
+
else:
|
|
297
|
+
self.notify(
|
|
298
|
+
"Saved. Tags are written when the file is downloaded (or press w)."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
self.push_screen(EditScreen(meta), done)
|
|
302
|
+
|
|
303
|
+
def action_write_tags(self) -> None:
|
|
304
|
+
"""Embed the stored metadata into the downloaded file."""
|
|
305
|
+
md5 = self._highlighted_md5()
|
|
306
|
+
entry = self._store.get(md5) if md5 else None
|
|
307
|
+
if entry is None or entry.file is None or not entry.file.exists():
|
|
308
|
+
self.notify(
|
|
309
|
+
"Edit metadata and download the file first.", severity="warning"
|
|
310
|
+
)
|
|
311
|
+
return
|
|
312
|
+
self._embed(entry.file, entry.meta)
|
|
313
|
+
|
|
314
|
+
def _embed(self, path: Path, meta: EditableMeta) -> None:
|
|
315
|
+
"""Write ``meta`` into ``path`` and notify the outcome."""
|
|
316
|
+
try:
|
|
317
|
+
write_tags(path, meta)
|
|
318
|
+
except TagError as exc:
|
|
319
|
+
self.notify(str(exc), severity="error")
|
|
320
|
+
else:
|
|
321
|
+
self.notify(f"Tagged {path.name}")
|
|
322
|
+
|
|
323
|
+
def _show_record(self, md5: str) -> None:
|
|
324
|
+
"""Render the record for ``md5`` with any local edits applied."""
|
|
325
|
+
rec = self._records[md5]
|
|
326
|
+
edited = self._store_has_edits(md5)
|
|
327
|
+
if edited and (entry := self._store.get(md5)):
|
|
328
|
+
rec = apply_meta(rec, entry.meta)
|
|
329
|
+
self.query_one("#detail", Static).update(render_record(rec, edited=edited))
|
|
330
|
+
|
|
331
|
+
def _search(self, query: str) -> None:
|
|
332
|
+
"""Show the loading modal and start the search worker."""
|
|
333
|
+
self._loading = LoadingScreen(query)
|
|
334
|
+
self.push_screen(self._loading, self._on_loading_closed)
|
|
335
|
+
self._run_search(query)
|
|
336
|
+
|
|
337
|
+
def _on_loading_closed(self, cancelled: bool | None) -> None:
|
|
338
|
+
"""Abandon the in-flight search if the user pressed ``esc``."""
|
|
339
|
+
self._loading = None
|
|
340
|
+
if cancelled:
|
|
341
|
+
self.workers.cancel_group(self, "search")
|
|
342
|
+
|
|
343
|
+
def _end_loading(self) -> None:
|
|
344
|
+
"""Close the loading modal if it is still showing."""
|
|
345
|
+
if self._loading is not None:
|
|
346
|
+
loading, self._loading = self._loading, None
|
|
347
|
+
loading.dismiss(False)
|
|
348
|
+
|
|
349
|
+
@work(thread=True, exclusive=True, group="search")
|
|
350
|
+
def _run_search(self, query: str) -> None:
|
|
351
|
+
"""Run a search off the UI thread and show the results or the error."""
|
|
352
|
+
worker = get_current_worker()
|
|
353
|
+
try:
|
|
354
|
+
books = service.search(self._settings, query)
|
|
355
|
+
except service.SERVICE_ERRORS as exc:
|
|
356
|
+
if not worker.is_cancelled:
|
|
357
|
+
self.call_from_thread(self._search_failed, str(exc))
|
|
358
|
+
return
|
|
359
|
+
if not worker.is_cancelled:
|
|
360
|
+
self.call_from_thread(self._show_results, books)
|
|
361
|
+
|
|
362
|
+
def _search_failed(self, message: str) -> None:
|
|
363
|
+
"""Close the loading modal and report ``message``."""
|
|
364
|
+
self._end_loading()
|
|
365
|
+
self.notify(message, severity="error")
|
|
366
|
+
|
|
367
|
+
def _show_results(self, books: list[Book]) -> None:
|
|
368
|
+
"""Replace the table rows with ``books`` and focus the table."""
|
|
369
|
+
self._end_loading()
|
|
370
|
+
table = self.query_one(DataTable)
|
|
371
|
+
table.clear()
|
|
372
|
+
for book, row in zip(books, result_rows(books), strict=True):
|
|
373
|
+
table.add_row(*row, key=book.md5)
|
|
374
|
+
if books:
|
|
375
|
+
table.focus()
|
|
376
|
+
else:
|
|
377
|
+
self.notify("No results.")
|
|
378
|
+
|
|
379
|
+
@work(thread=True, exclusive=True, group="details")
|
|
380
|
+
def _load_details(self, md5: str) -> None:
|
|
381
|
+
"""Fetch a record off the UI thread and render it unless superseded."""
|
|
382
|
+
worker = get_current_worker()
|
|
383
|
+
try:
|
|
384
|
+
rec = service.details(self._settings, md5)
|
|
385
|
+
except service.SERVICE_ERRORS as exc:
|
|
386
|
+
if not worker.is_cancelled:
|
|
387
|
+
self.call_from_thread(self.notify, str(exc), severity="error")
|
|
388
|
+
return
|
|
389
|
+
if not worker.is_cancelled:
|
|
390
|
+
self.call_from_thread(self._remember, rec)
|
|
391
|
+
|
|
392
|
+
def _remember(self, rec: BookRecord) -> None:
|
|
393
|
+
"""Cache ``rec`` and show it unless the cursor has moved on."""
|
|
394
|
+
self._records[rec.md5] = rec
|
|
395
|
+
if self._highlighted_md5() == rec.md5:
|
|
396
|
+
self._show_record(rec.md5)
|
|
397
|
+
|
|
398
|
+
@work(thread=True, group="download")
|
|
399
|
+
def _download(self, md5: str) -> None:
|
|
400
|
+
"""Download ``md5`` off the UI thread and report the outcome."""
|
|
401
|
+
self.call_from_thread(self.notify, f"Downloading {md5}…")
|
|
402
|
+
try:
|
|
403
|
+
path = service.download(self._settings, md5, f"{md5}.bin")
|
|
404
|
+
except service.SERVICE_ERRORS as exc:
|
|
405
|
+
self.call_from_thread(self.notify, str(exc), severity="error")
|
|
406
|
+
return
|
|
407
|
+
self.call_from_thread(self._downloaded, md5, path)
|
|
408
|
+
|
|
409
|
+
def _downloaded(self, md5: str, path: Path) -> None:
|
|
410
|
+
"""Record the saved ``path`` and embed any stored edits into it."""
|
|
411
|
+
rec = self._records.get(md5)
|
|
412
|
+
seed = EditableMeta.from_record(rec) if rec else EditableMeta()
|
|
413
|
+
entry = self._store.set_file(md5, path, seed)
|
|
414
|
+
self.notify(f"Saved {path}")
|
|
415
|
+
if self._store_has_edits(md5):
|
|
416
|
+
self._embed(path, entry.meta)
|
|
417
|
+
|
|
418
|
+
def _store_has_edits(self, md5: str) -> bool:
|
|
419
|
+
"""Whether the user edited ``md5`` (as opposed to just recording a path)."""
|
|
420
|
+
rec = self._records.get(md5)
|
|
421
|
+
entry = self._store.get(md5)
|
|
422
|
+
return bool(entry and rec and entry.meta != EditableMeta.from_record(rec))
|
aa_tui/capture.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Turn writes to stdout/stderr and loguru messages into app toasts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
Severity = str # a Textual notify severity: "information", "warning" or "error"
|
|
11
|
+
Emit = Callable[[str, Severity], None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ToastStream(io.TextIOBase):
|
|
15
|
+
"""A text stream that emits each completed non-blank line to ``emit``.
|
|
16
|
+
|
|
17
|
+
ANSI colour codes (from Rich, loguru, ...) are stripped. A trailing partial
|
|
18
|
+
line is emitted by :meth:`flush_partial`, which the app calls on shutdown.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, emit: Emit, severity: Severity) -> None:
|
|
22
|
+
"""Send lines to ``emit`` with the given ``severity``."""
|
|
23
|
+
self._emit = emit
|
|
24
|
+
self._severity = severity
|
|
25
|
+
self._buffer = ""
|
|
26
|
+
|
|
27
|
+
def writable(self) -> bool:
|
|
28
|
+
"""This stream accepts writes."""
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
def write(self, s: str) -> int:
|
|
32
|
+
"""Buffer ``s`` and emit every complete line in it."""
|
|
33
|
+
self._buffer += s
|
|
34
|
+
*lines, self._buffer = self._buffer.split("\n")
|
|
35
|
+
for line in lines:
|
|
36
|
+
self._send(line)
|
|
37
|
+
return len(s)
|
|
38
|
+
|
|
39
|
+
def flush_partial(self) -> None:
|
|
40
|
+
"""Emit whatever is left in the buffer."""
|
|
41
|
+
rest, self._buffer = self._buffer, ""
|
|
42
|
+
self._send(rest)
|
|
43
|
+
|
|
44
|
+
def _send(self, line: str) -> None:
|
|
45
|
+
"""Emit ``line`` if it has visible content."""
|
|
46
|
+
if text := Text.from_ansi(line).plain.strip():
|
|
47
|
+
self._emit(text, self._severity)
|
aa_tui/cli.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Typer entry point: ``aa-tui [QUERY]``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from aa_meta.config import DEFAULT_BASE_URL, Settings
|
|
10
|
+
from aa_meta.cookies import read_cookie_file
|
|
11
|
+
|
|
12
|
+
from .app import AaApp
|
|
13
|
+
|
|
14
|
+
DEFAULT_OUTPUT_DIR = Path.home() / "Downloads"
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(help="Terminal UI for searching and downloading from Anna's Archive.")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def main(
|
|
21
|
+
query: Annotated[str | None, typer.Argument(help="Search to run on start.")] = None,
|
|
22
|
+
base_url: Annotated[
|
|
23
|
+
str, typer.Option("--base-url", envvar="AA_BASE_URL", help="Mirror domain.")
|
|
24
|
+
] = DEFAULT_BASE_URL,
|
|
25
|
+
cookie: Annotated[
|
|
26
|
+
str | None,
|
|
27
|
+
typer.Option("--cookie", envvar="AA_COOKIE", help="Browser Cookie header."),
|
|
28
|
+
] = None,
|
|
29
|
+
output: Annotated[
|
|
30
|
+
Path, typer.Option("--output", "-o", help="Download directory.")
|
|
31
|
+
] = DEFAULT_OUTPUT_DIR,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Launch the TUI, optionally running QUERY immediately."""
|
|
34
|
+
settings = Settings(
|
|
35
|
+
base_url=base_url, cookie=cookie or read_cookie_file(), download_dir=output
|
|
36
|
+
)
|
|
37
|
+
AaApp(settings, query).run()
|
aa_tui/details_screen.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Modal popup showing every field of a book record."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from aa_meta.models import BookRecord
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.binding import Binding, BindingType
|
|
12
|
+
from textual.containers import VerticalScroll
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widgets import Static
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _section(text: Text, title: str, rows: Mapping[str, tuple[str, ...]]) -> None:
|
|
18
|
+
"""Append a titled ``label: a, b`` block to ``text`` unless ``rows`` is empty."""
|
|
19
|
+
rows = {k: v for k, v in rows.items() if v}
|
|
20
|
+
if not rows:
|
|
21
|
+
return
|
|
22
|
+
text.append(f"\n{title}\n", style="bold underline")
|
|
23
|
+
for label, values in rows.items():
|
|
24
|
+
text.append(f"{label}: ", style="cyan")
|
|
25
|
+
text.append("; ".join(values) + "\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_full(rec: BookRecord, *, edited: bool = False) -> Text:
|
|
29
|
+
"""Format every populated field of ``rec`` as styled text."""
|
|
30
|
+
text = Text()
|
|
31
|
+
if edited:
|
|
32
|
+
text.append("✎ edited locally\n", style="yellow")
|
|
33
|
+
text.append(f"{rec.title or rec.md5}\n", style="bold")
|
|
34
|
+
series = f"{rec.series} #{rec.series_index}" if rec.series_index else rec.series
|
|
35
|
+
fields = [
|
|
36
|
+
("Author", rec.author),
|
|
37
|
+
("Publisher", rec.publisher_line or rec.publisher),
|
|
38
|
+
("Series", series),
|
|
39
|
+
("Year", str(rec.year) if rec.year else ""),
|
|
40
|
+
("Language", ", ".join(rec.languages)),
|
|
41
|
+
("ISBN", ", ".join(rec.isbns)),
|
|
42
|
+
("Format", f"{rec.extension} {rec.size_text}".strip()),
|
|
43
|
+
("Type", rec.content_type),
|
|
44
|
+
("Sources", ", ".join(rec.sources)),
|
|
45
|
+
("Filename", rec.filename),
|
|
46
|
+
("MD5", rec.md5),
|
|
47
|
+
]
|
|
48
|
+
for label, value in fields:
|
|
49
|
+
if value:
|
|
50
|
+
text.append(f"{label}: ", style="cyan")
|
|
51
|
+
text.append(f"{value}\n")
|
|
52
|
+
if rec.description:
|
|
53
|
+
text.append(f"\n{rec.description}\n")
|
|
54
|
+
_section(text, "Identifiers", rec.identifiers)
|
|
55
|
+
_section(text, "Alternatives", rec.alternatives)
|
|
56
|
+
if rec.filepaths:
|
|
57
|
+
_section(text, "Paths", {"": rec.filepaths})
|
|
58
|
+
if not rec.detailed:
|
|
59
|
+
text.append("\n(search-card data only)\n", style="dim")
|
|
60
|
+
return text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DetailsScreen(ModalScreen[None]):
|
|
64
|
+
"""Read-only popup for one record; ``e`` and ``d`` act on it via the app."""
|
|
65
|
+
|
|
66
|
+
DEFAULT_CSS = """
|
|
67
|
+
DetailsScreen { align: center middle; }
|
|
68
|
+
#popup { width: 85%; max-width: 110; height: 85%; border: thick $primary;
|
|
69
|
+
background: $surface; padding: 1 2; }
|
|
70
|
+
"""
|
|
71
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
72
|
+
Binding("escape,q,enter", "close", "Close"),
|
|
73
|
+
Binding("e", "edit", "Edit"),
|
|
74
|
+
Binding("d", "download", "Download"),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
def __init__(self, rec: BookRecord, *, edited: bool = False) -> None:
|
|
78
|
+
"""Show ``rec``; ``edited`` adds the local-edit marker."""
|
|
79
|
+
super().__init__()
|
|
80
|
+
self._rec = rec
|
|
81
|
+
self._edited = edited
|
|
82
|
+
|
|
83
|
+
def compose(self) -> ComposeResult:
|
|
84
|
+
"""A scrollable text body."""
|
|
85
|
+
with VerticalScroll(id="popup"):
|
|
86
|
+
yield Static(render_full(self._rec, edited=self._edited))
|
|
87
|
+
|
|
88
|
+
def action_close(self) -> None:
|
|
89
|
+
"""Dismiss the popup."""
|
|
90
|
+
self.dismiss(None)
|
|
91
|
+
|
|
92
|
+
def action_edit(self) -> None:
|
|
93
|
+
"""Close, then open the app's edit form."""
|
|
94
|
+
self.dismiss(None)
|
|
95
|
+
self.app.call_later(self.app.run_action, "edit")
|
|
96
|
+
|
|
97
|
+
def action_download(self) -> None:
|
|
98
|
+
"""Close, then download via the app."""
|
|
99
|
+
self.dismiss(None)
|
|
100
|
+
self.app.call_later(self.app.run_action, "download")
|
aa_tui/edit_screen.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Modal form for editing a book's metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, fields
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.binding import Binding, BindingType
|
|
10
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Button, Input, Label
|
|
13
|
+
|
|
14
|
+
from .metadata import EditableMeta
|
|
15
|
+
|
|
16
|
+
LABELS = {
|
|
17
|
+
"title": "Title",
|
|
18
|
+
"author": "Author",
|
|
19
|
+
"publisher": "Publisher",
|
|
20
|
+
"series": "Series",
|
|
21
|
+
"series_index": "Series #",
|
|
22
|
+
"year": "Year",
|
|
23
|
+
"language": "Language",
|
|
24
|
+
"isbn": "ISBN",
|
|
25
|
+
"description": "Description",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EditScreen(ModalScreen[EditableMeta | None]):
|
|
30
|
+
"""Edit an :class:`EditableMeta`; dismisses with the new value, or ``None``."""
|
|
31
|
+
|
|
32
|
+
DEFAULT_CSS = """
|
|
33
|
+
EditScreen { align: center middle; }
|
|
34
|
+
#form { width: 80%; max-width: 100; height: 80%; border: thick $primary;
|
|
35
|
+
background: $surface; padding: 1 2; }
|
|
36
|
+
#form Label { margin-top: 1; }
|
|
37
|
+
#buttons { height: auto; margin-top: 1; }
|
|
38
|
+
#buttons Button { margin-right: 2; }
|
|
39
|
+
"""
|
|
40
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
41
|
+
Binding("escape", "cancel", "Cancel"),
|
|
42
|
+
Binding("ctrl+s", "save", "Save"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
def __init__(self, meta: EditableMeta) -> None:
|
|
46
|
+
"""Start the form pre-filled from ``meta``."""
|
|
47
|
+
super().__init__()
|
|
48
|
+
self._meta = meta
|
|
49
|
+
|
|
50
|
+
def compose(self) -> ComposeResult:
|
|
51
|
+
"""One labelled input per editable field, then Save/Cancel."""
|
|
52
|
+
values = asdict(self._meta)
|
|
53
|
+
with VerticalScroll(id="form"):
|
|
54
|
+
for f in fields(EditableMeta):
|
|
55
|
+
yield Label(LABELS[f.name])
|
|
56
|
+
yield Input(values[f.name], id=f"f-{f.name}")
|
|
57
|
+
with Horizontal(id="buttons"):
|
|
58
|
+
yield Button("Save (ctrl+s)", variant="primary", id="save")
|
|
59
|
+
yield Button("Cancel (esc)", id="cancel")
|
|
60
|
+
|
|
61
|
+
def _collect(self) -> EditableMeta:
|
|
62
|
+
"""Read the inputs back into an :class:`EditableMeta`."""
|
|
63
|
+
return EditableMeta(
|
|
64
|
+
**{
|
|
65
|
+
f.name: self.query_one(f"#f-{f.name}", Input).value
|
|
66
|
+
for f in fields(EditableMeta)
|
|
67
|
+
}
|
|
68
|
+
).normalized()
|
|
69
|
+
|
|
70
|
+
def action_save(self) -> None:
|
|
71
|
+
"""Dismiss with the edited metadata."""
|
|
72
|
+
self.dismiss(self._collect())
|
|
73
|
+
|
|
74
|
+
def action_cancel(self) -> None:
|
|
75
|
+
"""Dismiss without changes."""
|
|
76
|
+
self.dismiss(None)
|
|
77
|
+
|
|
78
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
79
|
+
"""Enter in any field saves."""
|
|
80
|
+
event.stop()
|
|
81
|
+
self.action_save()
|
|
82
|
+
|
|
83
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
84
|
+
"""Route the Save/Cancel buttons."""
|
|
85
|
+
if event.button.id == "save":
|
|
86
|
+
self.action_save()
|
|
87
|
+
else:
|
|
88
|
+
self.action_cancel()
|
aa_tui/error_screen.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Modal for unexpected errors, with the full traceback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from rich.console import Group, RenderableType
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from rich.traceback import Traceback
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.binding import Binding, BindingType
|
|
12
|
+
from textual.containers import VerticalScroll
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widgets import Static
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ErrorScreen(ModalScreen[None]):
|
|
18
|
+
"""Show an unhandled exception without exiting the app."""
|
|
19
|
+
|
|
20
|
+
DEFAULT_CSS = """
|
|
21
|
+
ErrorScreen { align: center middle; }
|
|
22
|
+
#error { width: 90%; max-width: 120; height: 80%; border: thick $error;
|
|
23
|
+
background: $surface; padding: 1 2; }
|
|
24
|
+
"""
|
|
25
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
26
|
+
Binding("escape,enter,q", "close", "Close")
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
def __init__(self, error: BaseException, *, recoverable: bool = True) -> None:
|
|
30
|
+
"""Show ``error`` and its traceback; ``recoverable`` softens the hint."""
|
|
31
|
+
super().__init__()
|
|
32
|
+
self._error = error
|
|
33
|
+
self._recoverable = recoverable
|
|
34
|
+
|
|
35
|
+
def compose(self) -> ComposeResult:
|
|
36
|
+
"""Headline, traceback, and a close hint."""
|
|
37
|
+
body: RenderableType = Group(
|
|
38
|
+
Text(f"{type(self._error).__name__}: {self._error}", style="bold red"),
|
|
39
|
+
Text(""),
|
|
40
|
+
Traceback.from_exception(
|
|
41
|
+
type(self._error), self._error, self._error.__traceback__, width=100
|
|
42
|
+
),
|
|
43
|
+
Text(
|
|
44
|
+
"\nPress esc to close."
|
|
45
|
+
+ (
|
|
46
|
+
"" if self._recoverable else " Restart if the app stops responding."
|
|
47
|
+
),
|
|
48
|
+
style="dim",
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
with VerticalScroll(id="error"):
|
|
52
|
+
yield Static(body)
|
|
53
|
+
|
|
54
|
+
def action_close(self) -> None:
|
|
55
|
+
"""Dismiss the modal."""
|
|
56
|
+
self.dismiss(None)
|
aa_tui/loading_screen.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Modal spinner shown while a search is in flight."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import Binding, BindingType
|
|
9
|
+
from textual.containers import Vertical
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Label, LoadingIndicator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LoadingScreen(ModalScreen[bool]):
|
|
15
|
+
"""Spinner popup; dismisses with ``True`` if the user cancels with ``esc``."""
|
|
16
|
+
|
|
17
|
+
DEFAULT_CSS = """
|
|
18
|
+
LoadingScreen { align: center middle; }
|
|
19
|
+
#loading { width: 44; height: 9; border: thick $primary; background: $surface;
|
|
20
|
+
padding: 1 2; align: center middle; }
|
|
21
|
+
#loading LoadingIndicator { height: 3; }
|
|
22
|
+
#loading Label { width: 100%; content-align: center middle; }
|
|
23
|
+
"""
|
|
24
|
+
BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "cancel", "Cancel")]
|
|
25
|
+
|
|
26
|
+
def __init__(self, query: str) -> None:
|
|
27
|
+
"""Show ``query`` in the message."""
|
|
28
|
+
super().__init__()
|
|
29
|
+
self._query = query
|
|
30
|
+
|
|
31
|
+
def compose(self) -> ComposeResult:
|
|
32
|
+
"""Spinner above a one-line message."""
|
|
33
|
+
with Vertical(id="loading"):
|
|
34
|
+
yield LoadingIndicator()
|
|
35
|
+
yield Label(f"Searching for “{self._query}”… (esc to cancel)")
|
|
36
|
+
|
|
37
|
+
def action_cancel(self) -> None:
|
|
38
|
+
"""Dismiss, flagging a user cancel."""
|
|
39
|
+
self.dismiss(True)
|
aa_tui/metadata.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Editable metadata and the local store that remembers edits per MD5."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict, dataclass, fields, replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from aa_meta.models import BookRecord
|
|
12
|
+
|
|
13
|
+
DEFAULT_STORE = (
|
|
14
|
+
Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
15
|
+
/ "aa-tui"
|
|
16
|
+
/ "metadata.json"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class EditableMeta:
|
|
22
|
+
"""The subset of a record the user can edit and embed in a file."""
|
|
23
|
+
|
|
24
|
+
title: str = ""
|
|
25
|
+
author: str = ""
|
|
26
|
+
publisher: str = ""
|
|
27
|
+
series: str = ""
|
|
28
|
+
series_index: str = ""
|
|
29
|
+
year: str = ""
|
|
30
|
+
language: str = ""
|
|
31
|
+
isbn: str = ""
|
|
32
|
+
description: str = ""
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_record(cls, rec: BookRecord) -> EditableMeta:
|
|
36
|
+
"""Seed editable fields from a scraped ``rec``."""
|
|
37
|
+
return cls(
|
|
38
|
+
title=rec.title,
|
|
39
|
+
author=rec.author,
|
|
40
|
+
publisher=rec.publisher,
|
|
41
|
+
series=rec.series,
|
|
42
|
+
series_index=rec.series_index,
|
|
43
|
+
year=str(rec.year) if rec.year else "",
|
|
44
|
+
language=rec.language,
|
|
45
|
+
isbn=rec.isbns[0] if rec.isbns else "",
|
|
46
|
+
description=rec.description,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_dict(cls, data: dict[str, Any]) -> EditableMeta:
|
|
51
|
+
"""Build from stored JSON, ignoring unknown keys."""
|
|
52
|
+
names = {f.name for f in fields(cls)}
|
|
53
|
+
return cls(**{k: str(v) for k, v in data.items() if k in names})
|
|
54
|
+
|
|
55
|
+
def normalized(self) -> EditableMeta:
|
|
56
|
+
"""Return a copy with every field stripped of surrounding whitespace."""
|
|
57
|
+
return replace(self, **{k: v.strip() for k, v in asdict(self).items()})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class Entry:
|
|
62
|
+
"""A stored edit: the metadata plus the downloaded file it applies to."""
|
|
63
|
+
|
|
64
|
+
meta: EditableMeta
|
|
65
|
+
file: Path | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class MetadataStore:
|
|
69
|
+
"""JSON file mapping MD5 -> :class:`Entry`; written atomically on every change."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, path: Path = DEFAULT_STORE) -> None:
|
|
72
|
+
"""Use ``path`` as the backing file (created lazily)."""
|
|
73
|
+
self._path = path
|
|
74
|
+
|
|
75
|
+
def _load(self) -> dict[str, Any]:
|
|
76
|
+
"""Read the whole file; a missing or corrupt file is an empty store."""
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
79
|
+
except OSError, ValueError:
|
|
80
|
+
return {}
|
|
81
|
+
return data if isinstance(data, dict) else {}
|
|
82
|
+
|
|
83
|
+
def _save(self, data: dict[str, Any]) -> None:
|
|
84
|
+
"""Atomically replace the backing file with ``data``."""
|
|
85
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
tmp = self._path.with_suffix(".tmp")
|
|
87
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
88
|
+
tmp.replace(self._path)
|
|
89
|
+
|
|
90
|
+
def get(self, md5: str) -> Entry | None:
|
|
91
|
+
"""Return the entry for ``md5`` or ``None``."""
|
|
92
|
+
raw = self._load().get(md5)
|
|
93
|
+
if not isinstance(raw, dict):
|
|
94
|
+
return None
|
|
95
|
+
file = raw.get("file")
|
|
96
|
+
return Entry(
|
|
97
|
+
EditableMeta.from_dict(raw.get("meta", {})), Path(file) if file else None
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def put(self, md5: str, entry: Entry) -> None:
|
|
101
|
+
"""Insert or replace the entry for ``md5``."""
|
|
102
|
+
data = self._load()
|
|
103
|
+
data[md5] = {
|
|
104
|
+
"meta": asdict(entry.meta),
|
|
105
|
+
"file": str(entry.file) if entry.file else None,
|
|
106
|
+
}
|
|
107
|
+
self._save(data)
|
|
108
|
+
|
|
109
|
+
def set_meta(self, md5: str, meta: EditableMeta) -> Entry:
|
|
110
|
+
"""Store ``meta`` for ``md5``, keeping any recorded file."""
|
|
111
|
+
old = self.get(md5)
|
|
112
|
+
entry = Entry(meta.normalized(), old.file if old else None)
|
|
113
|
+
self.put(md5, entry)
|
|
114
|
+
return entry
|
|
115
|
+
|
|
116
|
+
def set_file(self, md5: str, file: Path, fallback: EditableMeta) -> Entry:
|
|
117
|
+
"""Record the downloaded ``file``; ``fallback`` seeds meta if none stored."""
|
|
118
|
+
old = self.get(md5)
|
|
119
|
+
entry = Entry(old.meta if old else fallback, file)
|
|
120
|
+
self.put(md5, entry)
|
|
121
|
+
return entry
|
aa_tui/py.typed
ADDED
|
File without changes
|
aa_tui/service.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Thin synchronous wrappers over aa-meta, so the app talks to one module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from aa_meta.api import fast_download
|
|
11
|
+
from aa_meta.config import USER_AGENT, Settings, read_secret_key
|
|
12
|
+
from aa_meta.details import fetch_details
|
|
13
|
+
from aa_meta.download import filename_from_response
|
|
14
|
+
from aa_meta.models import BookRecord
|
|
15
|
+
from aa_meta.search import Book, search_books
|
|
16
|
+
|
|
17
|
+
# Everything the aa-meta layers raise (all ``RuntimeError`` subclasses) plus I/O.
|
|
18
|
+
SERVICE_ERRORS = (RuntimeError, httpx.HTTPError, OSError)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def _session(client: httpx.Client | None) -> Generator[httpx.Client]:
|
|
23
|
+
"""Yield ``client``, or a fresh one that is closed afterwards."""
|
|
24
|
+
if client is not None:
|
|
25
|
+
yield client
|
|
26
|
+
return
|
|
27
|
+
with httpx.Client(timeout=30, follow_redirects=True) as owned:
|
|
28
|
+
yield owned
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def search(
|
|
32
|
+
settings: Settings,
|
|
33
|
+
query: str,
|
|
34
|
+
*,
|
|
35
|
+
limit: int = 20,
|
|
36
|
+
client: httpx.Client | None = None,
|
|
37
|
+
) -> list[Book]:
|
|
38
|
+
"""Search Anna's Archive for ``query``.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
SearchError: if the site blocks the request.
|
|
42
|
+
"""
|
|
43
|
+
with _session(client) as c:
|
|
44
|
+
return search_books(c, settings, query, limit=limit)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def details(
|
|
48
|
+
settings: Settings, md5: str, *, client: httpx.Client | None = None
|
|
49
|
+
) -> BookRecord:
|
|
50
|
+
"""Fetch the full metadata record for ``md5``."""
|
|
51
|
+
with _session(client) as c:
|
|
52
|
+
return fetch_details(c, settings, md5)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def download(
|
|
56
|
+
settings: Settings,
|
|
57
|
+
md5: str,
|
|
58
|
+
fallback_name: str,
|
|
59
|
+
*,
|
|
60
|
+
client: httpx.Client | None = None,
|
|
61
|
+
) -> Path:
|
|
62
|
+
"""Download ``md5`` into ``settings.download_dir`` and return the saved path.
|
|
63
|
+
|
|
64
|
+
Streams without Rich progress output, which would corrupt the TUI screen.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ConfigError: if the API key is unavailable.
|
|
68
|
+
ApiError: if the API refuses the download.
|
|
69
|
+
httpx.HTTPError: on a network failure.
|
|
70
|
+
"""
|
|
71
|
+
key = read_secret_key(settings.op_ref)
|
|
72
|
+
with _session(client) as c:
|
|
73
|
+
fd = fast_download(c, settings, md5, key)
|
|
74
|
+
settings.download_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
with c.stream(
|
|
76
|
+
"GET", fd.url, headers={"User-Agent": USER_AGENT}, follow_redirects=True
|
|
77
|
+
) as resp:
|
|
78
|
+
resp.raise_for_status()
|
|
79
|
+
path = settings.download_dir / filename_from_response(resp, fallback_name)
|
|
80
|
+
with path.open("wb") as fh:
|
|
81
|
+
for chunk in resp.iter_bytes(65536):
|
|
82
|
+
fh.write(chunk)
|
|
83
|
+
return path
|
aa_tui/tags.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Write :class:`~aa_tui.metadata.EditableMeta` into EPUB, PDF, MP4/M4B and MP3 files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
import zipfile
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import IO
|
|
11
|
+
from xml.etree import ElementTree as ET
|
|
12
|
+
|
|
13
|
+
from mutagen.id3 import ID3, TALB, TDRC, TIT2, TPE1, TPUB, ID3NoHeaderError
|
|
14
|
+
from mutagen.mp4 import MP4
|
|
15
|
+
from pypdf import PdfReader, PdfWriter
|
|
16
|
+
|
|
17
|
+
from .metadata import EditableMeta
|
|
18
|
+
|
|
19
|
+
DC = "http://purl.org/dc/elements/1.1/"
|
|
20
|
+
OPF = "http://www.idpf.org/2007/opf"
|
|
21
|
+
CONTAINER = "urn:oasis:names:tc:opendocument:xmlns:container"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TagError(RuntimeError):
|
|
25
|
+
"""The file could not be tagged (unsupported type or malformed contents)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_tags(path: Path, meta: EditableMeta) -> None:
|
|
29
|
+
"""Embed ``meta`` into ``path``, dispatching on its extension.
|
|
30
|
+
Args:
|
|
31
|
+
path: The file path to write tags into.
|
|
32
|
+
meta: The metadata to embed.
|
|
33
|
+
Raises:
|
|
34
|
+
TagError: for an unsupported extension or an unreadable file.
|
|
35
|
+
"""
|
|
36
|
+
writers = {
|
|
37
|
+
".epub": _write_epub,
|
|
38
|
+
".pdf": _write_pdf,
|
|
39
|
+
".m4b": _write_mp4,
|
|
40
|
+
".m4a": _write_mp4,
|
|
41
|
+
".mp4": _write_mp4,
|
|
42
|
+
".mp3": _write_mp3,
|
|
43
|
+
}
|
|
44
|
+
writer = writers.get(path.suffix.lower())
|
|
45
|
+
if writer is None:
|
|
46
|
+
raise TagError(f"Cannot embed metadata in {path.suffix or 'this'} files.")
|
|
47
|
+
try:
|
|
48
|
+
writer(path, meta)
|
|
49
|
+
except TagError:
|
|
50
|
+
raise
|
|
51
|
+
except Exception as exc: # third-party parsers raise many unrelated types
|
|
52
|
+
raise TagError(f"{path.name}: {exc}") from exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _write_mp4(path: Path, meta: EditableMeta) -> None:
|
|
56
|
+
"""Set iTunes-style atoms on an MP4/M4B/M4A file.
|
|
57
|
+
Args:
|
|
58
|
+
path: The path to the MP4/M4B/M4A file.
|
|
59
|
+
meta: The metadata to embed.
|
|
60
|
+
"""
|
|
61
|
+
tags = MP4(path)
|
|
62
|
+
if tags.tags is None:
|
|
63
|
+
tags.add_tags()
|
|
64
|
+
assert tags.tags is not None
|
|
65
|
+
t = tags.tags
|
|
66
|
+
for key, value in (
|
|
67
|
+
("\xa9nam", meta.title),
|
|
68
|
+
("\xa9alb", meta.title),
|
|
69
|
+
("\xa9ART", meta.author),
|
|
70
|
+
("aART", meta.author),
|
|
71
|
+
("\xa9day", meta.year),
|
|
72
|
+
("desc", meta.description),
|
|
73
|
+
):
|
|
74
|
+
if value:
|
|
75
|
+
t[key] = [value]
|
|
76
|
+
else:
|
|
77
|
+
t.pop(key, None)
|
|
78
|
+
if meta.series:
|
|
79
|
+
t["\xa9grp"] = [meta.series]
|
|
80
|
+
tags.save()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _write_mp3(path: Path, meta: EditableMeta) -> None:
|
|
84
|
+
"""Set ID3v2 frames on an MP3 file.
|
|
85
|
+
Args:
|
|
86
|
+
path: The path to the MP3 file.
|
|
87
|
+
meta: The metadata to embed.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
id3 = ID3(path)
|
|
91
|
+
except ID3NoHeaderError:
|
|
92
|
+
id3 = ID3()
|
|
93
|
+
for frame, value in (
|
|
94
|
+
(TIT2, meta.title),
|
|
95
|
+
(TALB, meta.title),
|
|
96
|
+
(TPE1, meta.author),
|
|
97
|
+
(TPUB, meta.publisher),
|
|
98
|
+
(TDRC, meta.year),
|
|
99
|
+
):
|
|
100
|
+
id3.delall(frame.__name__)
|
|
101
|
+
if value:
|
|
102
|
+
id3.add(frame(encoding=3, text=[value]))
|
|
103
|
+
id3.save(path)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write_pdf(path: Path, meta: EditableMeta) -> None:
|
|
107
|
+
"""Rewrite the PDF with an updated document-information dictionary.
|
|
108
|
+
Args:
|
|
109
|
+
path: The path to the PDF file.
|
|
110
|
+
meta: The metadata to embed.
|
|
111
|
+
"""
|
|
112
|
+
reader = PdfReader(path)
|
|
113
|
+
writer = PdfWriter(clone_from=reader)
|
|
114
|
+
info = {"/Title": meta.title, "/Author": meta.author, "/Subject": meta.description}
|
|
115
|
+
writer.add_metadata({k: v for k, v in info.items() if v})
|
|
116
|
+
_replace_via_temp(path, lambda fh: writer.write(fh) and None)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _replace_via_temp(path: Path, write: Callable[[IO[bytes]], None]) -> None:
|
|
120
|
+
"""Run ``write(tmp_path)`` then atomically swap it over ``path``.
|
|
121
|
+
Args:
|
|
122
|
+
path: The path to the file to replace.
|
|
123
|
+
write: A function that writes to a file-like object.
|
|
124
|
+
"""
|
|
125
|
+
with tempfile.NamedTemporaryFile(
|
|
126
|
+
dir=path.parent, suffix=path.suffix, delete=False
|
|
127
|
+
) as tmp:
|
|
128
|
+
tmp_path = Path(tmp.name)
|
|
129
|
+
try:
|
|
130
|
+
with tmp_path.open("wb") as fh:
|
|
131
|
+
write(fh)
|
|
132
|
+
shutil.copymode(path, tmp_path)
|
|
133
|
+
tmp_path.replace(path)
|
|
134
|
+
finally:
|
|
135
|
+
tmp_path.unlink(missing_ok=True)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _write_epub(path: Path, meta: EditableMeta) -> None:
|
|
139
|
+
"""Update Dublin Core and calibre series entries in the EPUB's OPF package.
|
|
140
|
+
Args:
|
|
141
|
+
path: The path to the EPUB file.
|
|
142
|
+
meta: The metadata to embed.
|
|
143
|
+
"""
|
|
144
|
+
with zipfile.ZipFile(path) as zin:
|
|
145
|
+
container = ET.fromstring(zin.read("META-INF/container.xml"))
|
|
146
|
+
rootfile = container.find(f".//{{{CONTAINER}}}rootfile")
|
|
147
|
+
if rootfile is None or not rootfile.get("full-path"):
|
|
148
|
+
raise TagError("EPUB has no package document.")
|
|
149
|
+
opf_name = rootfile.get("full-path", "")
|
|
150
|
+
opf_xml = _update_opf(zin.read(opf_name), meta)
|
|
151
|
+
|
|
152
|
+
def write(fh: IO[bytes]) -> None:
|
|
153
|
+
with zipfile.ZipFile(fh, "w") as zout:
|
|
154
|
+
# The mimetype entry must come first and be stored uncompressed.
|
|
155
|
+
for item in sorted(
|
|
156
|
+
zin.infolist(), key=lambda i: i.filename != "mimetype"
|
|
157
|
+
):
|
|
158
|
+
data = (
|
|
159
|
+
opf_xml
|
|
160
|
+
if item.filename == opf_name
|
|
161
|
+
else zin.read(item.filename)
|
|
162
|
+
)
|
|
163
|
+
zout.writestr(item, data, compress_type=item.compress_type)
|
|
164
|
+
|
|
165
|
+
_replace_via_temp(path, write)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _update_opf(xml: bytes, meta: EditableMeta) -> bytes:
|
|
169
|
+
"""Return ``xml`` (an OPF package) with ``meta`` applied; empty fields are removed.
|
|
170
|
+
Args:
|
|
171
|
+
xml: The OPF package XML.
|
|
172
|
+
meta: The metadata to embed.
|
|
173
|
+
Returns:
|
|
174
|
+
The updated OPF package XML.
|
|
175
|
+
Raises:
|
|
176
|
+
TagError: If the EPUB package has no metadata block.
|
|
177
|
+
"""
|
|
178
|
+
ET.register_namespace("dc", DC)
|
|
179
|
+
ET.register_namespace("opf", OPF)
|
|
180
|
+
root = ET.fromstring(xml)
|
|
181
|
+
md = root.find(f"{{{OPF}}}metadata")
|
|
182
|
+
if md is None:
|
|
183
|
+
raise TagError("EPUB package has no metadata block.")
|
|
184
|
+
|
|
185
|
+
def set_dc(tag: str, value: str) -> None:
|
|
186
|
+
for el in md.findall(f"{{{DC}}}{tag}"):
|
|
187
|
+
md.remove(el)
|
|
188
|
+
if value:
|
|
189
|
+
ET.SubElement(md, f"{{{DC}}}{tag}").text = value
|
|
190
|
+
|
|
191
|
+
set_dc("title", meta.title)
|
|
192
|
+
set_dc("creator", meta.author)
|
|
193
|
+
set_dc("publisher", meta.publisher)
|
|
194
|
+
set_dc("language", meta.language)
|
|
195
|
+
set_dc("description", meta.description)
|
|
196
|
+
set_dc("date", meta.year)
|
|
197
|
+
for el in md.findall(f"{{{DC}}}identifier"):
|
|
198
|
+
if (el.get(f"{{{OPF}}}scheme") or "").upper() == "ISBN":
|
|
199
|
+
md.remove(el)
|
|
200
|
+
if meta.isbn:
|
|
201
|
+
ET.SubElement(
|
|
202
|
+
md, f"{{{DC}}}identifier", {f"{{{OPF}}}scheme": "ISBN"}
|
|
203
|
+
).text = meta.isbn
|
|
204
|
+
|
|
205
|
+
for el in md.findall(f"{{{OPF}}}meta"):
|
|
206
|
+
if el.get("name") in {"calibre:series", "calibre:series_index"}:
|
|
207
|
+
md.remove(el)
|
|
208
|
+
if meta.series:
|
|
209
|
+
ET.SubElement(
|
|
210
|
+
md, f"{{{OPF}}}meta", {"name": "calibre:series", "content": meta.series}
|
|
211
|
+
)
|
|
212
|
+
if meta.series_index:
|
|
213
|
+
ET.SubElement(
|
|
214
|
+
md,
|
|
215
|
+
f"{{{OPF}}}meta",
|
|
216
|
+
{"name": "calibre:series_index", "content": meta.series_index},
|
|
217
|
+
)
|
|
218
|
+
out: bytes = ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
|
219
|
+
return out
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: aa-tui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Maxwell Ludden
|
|
6
|
+
Author-email: Maxwell Ludden <maxludden@me.com>
|
|
7
|
+
Requires-Dist: aa-meta
|
|
8
|
+
Requires-Dist: httpx>=0.28.1
|
|
9
|
+
Requires-Dist: loguru>=0.7.3
|
|
10
|
+
Requires-Dist: mutagen>=1.48.1
|
|
11
|
+
Requires-Dist: pypdf>=6.20.0
|
|
12
|
+
Requires-Dist: rich>=15.0.0
|
|
13
|
+
Requires-Dist: textual>=8.2.8
|
|
14
|
+
Requires-Dist: typer>=0.27.3
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# aa-tui
|
|
19
|
+
|
|
20
|
+
Textual front-end for [aa-meta](../aa-meta): search Anna's Archive, inspect a result, download it, and edit its metadata.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv sync
|
|
24
|
+
uv run aa-tui ["query"] [--base-url ...] [--cookie ...] [-o DIR]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Results are shown with `aa-meta`'s columns: #, Ext, Title, Author (colour-coded per author), Size, Lang, Year and MD5.
|
|
28
|
+
|
|
29
|
+
Keys: `Enter` in the search box searches (a spinner popup shows while waiting; `esc` cancels); `/` focuses search; `↑/↓` selects (details load automatically); `Enter` or a click on a row opens a details popup (`e`/`d` work inside it, `esc` closes); `o` opens the current search on the site in your browser; `d` downloads (needs `AA_SECRET_KEY` or 1Password); `q` quits (when not typing).
|
|
30
|
+
|
|
31
|
+
## Editing metadata
|
|
32
|
+
|
|
33
|
+
`e` opens a form for the highlighted book (title, author, publisher, series, year, language, ISBN, description). `ctrl+s` saves, `esc` cancels.
|
|
34
|
+
Edits are stored per MD5 in `~/.config/aa-tui/metadata.json` and shown in the detail pane. When the book has been downloaded, saving also embeds the tags in the file; otherwise they are embedded as soon as it downloads. `w` re-embeds on demand.
|
|
35
|
+
|
|
36
|
+
Supported formats: EPUB (OPF Dublin Core + calibre series), PDF (info dictionary), M4B/M4A/MP4 (iTunes atoms), MP3 (ID3v2). Other formats are left untouched with an error notice.
|
|
37
|
+
|
|
38
|
+
## Errors and output
|
|
39
|
+
|
|
40
|
+
Nothing is printed over the TUI. Anything written to stdout/stderr or logged through `loguru` (including from `aa-meta`) appears as a toast: info for stdout and `INFO`, warning for `WARNING`, error (shown longer) for stderr and `ERROR`+. Expected failures (blocked search, API refusals, network errors, unsupported tag formats) are toasts too. An unexpected exception opens a modal with the full traceback instead of quitting; `esc` closes it.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
aa_tui/__init__.py,sha256=GhZi1VKb4YRmqCeHzQi0l0_cvhQIJa3T5k7J8lWhk4Q,37
|
|
2
|
+
aa_tui/app.py,sha256=fVQQ7ygcTc4kduhpAEWCEZetEzuut6yR43bbZPch-yY,16744
|
|
3
|
+
aa_tui/capture.py,sha256=TnszhvAay8TeO00baxgBBYI1v4GEubscL9a35TRLbZ8,1512
|
|
4
|
+
aa_tui/cli.py,sha256=qiBV2lSz_BaRb-6VqWvLfaVOVYybI44L0szLCwvmxTY,1152
|
|
5
|
+
aa_tui/details_screen.py,sha256=b6eRXkfM2T4eTVKDCyLovq2KP60ppZrTOsPG0CAUeik,3599
|
|
6
|
+
aa_tui/edit_screen.py,sha256=mY9Lb7gje1oU_F26ovrdZ6S_elbr5RXNjoBJZq3WAbg,2856
|
|
7
|
+
aa_tui/error_screen.py,sha256=WtYTrrK5SRuPOei_84v2-xjcE5_QVpDB6rAcoKkrEYY,1897
|
|
8
|
+
aa_tui/loading_screen.py,sha256=KgA_080ScvNURNu24hGZ_ccYiGxF_ZyV_PRg1lIxDpo,1364
|
|
9
|
+
aa_tui/metadata.py,sha256=n_GPuOnUPLPUmO7lA1TCN4HmVomtnyTLrguRCsmhPSI,4045
|
|
10
|
+
aa_tui/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
aa_tui/service.py,sha256=_DfshPkHl2gc1EzvFA8GBxdEN1aR1IZ81pAFoxtQ5Gw,2600
|
|
12
|
+
aa_tui/tags.py,sha256=CdFdjNpYBdkysKlGPtkoXNqF-GSWBxMiLGscKQKE1dc,7133
|
|
13
|
+
aa_tui-0.1.0.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
|
|
14
|
+
aa_tui-0.1.0.dist-info/entry_points.txt,sha256=RSrmSkw2AYYtcvOiqOvR3ilhaWlqb0XE1BqstM-_A6A,43
|
|
15
|
+
aa_tui-0.1.0.dist-info/METADATA,sha256=hF5i-rZEu_5_iCEJ6UOqbPP-PGxwNYEo4XQZ1xQDhwY,2238
|
|
16
|
+
aa_tui-0.1.0.dist-info/RECORD,,
|