lastread 0.20.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.
Files changed (63) hide show
  1. lastread/__init__.py +21 -0
  2. lastread/__main__.py +3 -0
  3. lastread/appbooks.py +153 -0
  4. lastread/booklist.py +302 -0
  5. lastread/cli.py +1296 -0
  6. lastread/computer/__init__.py +10 -0
  7. lastread/computer/config.py +98 -0
  8. lastread/computer/history.py +69 -0
  9. lastread/computer/linux.py +222 -0
  10. lastread/computer/macos.py +330 -0
  11. lastread/computer/page.py +238 -0
  12. lastread/computer/platform.py +98 -0
  13. lastread/computer/remote.py +123 -0
  14. lastread/computer/runner.py +494 -0
  15. lastread/computer/update.py +139 -0
  16. lastread/computer/webhook.py +84 -0
  17. lastread/doctor.py +480 -0
  18. lastread/engine/__init__.py +0 -0
  19. lastread/engine/collection.py +613 -0
  20. lastread/engine/export.py +156 -0
  21. lastread/engine/hardcover.py +616 -0
  22. lastread/engine/job.py +270 -0
  23. lastread/engine/kobo_db.py +180 -0
  24. lastread/engine/oauth.py +351 -0
  25. lastread/engine/plan.py +109 -0
  26. lastread/engine/state.py +365 -0
  27. lastread/engine/syncback.py +149 -0
  28. lastread/env.py +9 -0
  29. lastread/logs.py +71 -0
  30. lastread/server/__init__.py +0 -0
  31. lastread/server/accounts.py +506 -0
  32. lastread/server/proxy.py +50 -0
  33. lastread/server/stats.py +53 -0
  34. lastread/server/upload.py +109 -0
  35. lastread/start.py +132 -0
  36. lastread/term.py +129 -0
  37. lastread/tip.py +72 -0
  38. lastread/web/__init__.py +0 -0
  39. lastread/web/account_pages.py +506 -0
  40. lastread/web/app.py +1818 -0
  41. lastread/web/covers.py +60 -0
  42. lastread/web/fmt.py +49 -0
  43. lastread/web/marks.py +57 -0
  44. lastread/web/static/apple-touch-icon.png +0 -0
  45. lastread/web/static/favicon.svg +10 -0
  46. lastread/web/static/fonts/README.txt +19 -0
  47. lastread/web/static/fonts/SchibstedGrotesk-LICENSE.txt +93 -0
  48. lastread/web/static/fonts/SchibstedGrotesk-italic-latin-ext.woff2 +0 -0
  49. lastread/web/static/fonts/SchibstedGrotesk-italic-latin.woff2 +0 -0
  50. lastread/web/static/fonts/SchibstedGrotesk-latin-ext.woff2 +0 -0
  51. lastread/web/static/fonts/SchibstedGrotesk-latin.woff2 +0 -0
  52. lastread/web/static/fonts/SourceSerif4-LICENSE.txt +93 -0
  53. lastread/web/static/fonts/SourceSerif4-latin-ext.woff2 +0 -0
  54. lastread/web/static/fonts/SourceSerif4-latin.woff2 +0 -0
  55. lastread/web/static/kobo.css +699 -0
  56. lastread/web/static/kobo.js +278 -0
  57. lastread/web/strings.py +502 -0
  58. lastread-0.20.0.dist-info/METADATA +546 -0
  59. lastread-0.20.0.dist-info/RECORD +63 -0
  60. lastread-0.20.0.dist-info/WHEEL +4 -0
  61. lastread-0.20.0.dist-info/entry_points.txt +3 -0
  62. lastread-0.20.0.dist-info/licenses/LICENSE +21 -0
  63. lastread-0.20.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +114 -0
lastread/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """lastread: reading progress from a stock Kobo to Hardcover.
2
+
3
+ - engine: the Kobo database, the selection state, the plan, Hardcover and the
4
+ two-way sync. Knows nothing about the page, the commands or the computer.
5
+ - server: what only server mode has (readers and their tokens, uploads, the
6
+ stats endpoint).
7
+ - web: the page.
8
+ - computer: the tool on the computer the Kobo is plugged into.
9
+ - cli: the command.
10
+ - doctor: the check of everything a sync depends on, for the command and
11
+ the page. logs: the log and its two levels.
12
+ """
13
+
14
+ from importlib.metadata import PackageNotFoundError, version
15
+
16
+ try:
17
+ __version__ = version("lastread")
18
+ except PackageNotFoundError: # run from a checkout that was never installed
19
+ __version__ = "0+unknown"
20
+
21
+ ISSUES = "https://github.com/merlijntishauser/lastread/issues"
lastread/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
lastread/appbooks.py ADDED
@@ -0,0 +1,153 @@
1
+ """The Mac app's answers about books (`books --json`, `book ID --json`) and
2
+ the changes it makes to them. The same in local mode, where the tool
3
+ answers from this computer's database, and on the server (/api/app/),
4
+ where a device's token answers for its own reader. The rows are the
5
+ page's (booklist.py)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ from . import booklist, start, tip
12
+ from .engine import state
13
+ from .term import plural
14
+ from .web.strings import T
15
+
16
+
17
+ class Refused(Exception):
18
+ """A change that is not made; str() says why, status as the server answers it."""
19
+
20
+ def __init__(self, message: str, status: int = 400):
21
+ super().__init__(message)
22
+ self.status = status
23
+
24
+
25
+ def _quiet(con, reader: str) -> dict:
26
+ from .engine.plan import quiet_for
27
+
28
+ return quiet_for(con, reader) if con else {}
29
+
30
+
31
+ def _start(con, reader: str, live: bool, connected, mode: str) -> dict:
32
+ """Getting started and Needs you (start.py), as the app shows them. The
33
+ app is the computer the Kobo is plugged into, also in server mode.
34
+ connected() is asked only while Getting started is there."""
35
+ found = start.facts(con, reader)
36
+ steps = (
37
+ None
38
+ if live and found["books"]
39
+ else start.steps(connected=connected(), books=found["books"], on=found["on"], live=live, mode=mode, app=True)
40
+ )
41
+ return {"start": steps, "needs_you": start.needs_you(live=live, **found)}
42
+
43
+
44
+ def listing(con, reader: str, cover_of, connected, mode: str = "local") -> dict:
45
+ """Every book with its row and its Details, the filters it is in, the
46
+ page's sorts and words, and Getting started until the reader is live
47
+ (Needs you after). cover_of(row) is what the answer says of its cover;
48
+ connected() whether the reader has a Hardcover connection; mode "local",
49
+ or "server" when the server answers; con None before a Kobo was read."""
50
+ rows = booklist.query_rows(con, reader, "", "all", "last_read") if con else []
51
+ live = bool(con and booklist.summary(con, reader)["live"])
52
+ quiet = _quiet(con, reader)
53
+ member = {f: set(booklist.ids(con, reader, "", f)) if con else set() for f in booklist.FILTERS}
54
+ place = {r["content_id"]: n for n, r in enumerate(rows)}
55
+ books = [booklist.entry(r, live, quiet, [f for f in booklist.FILTERS if r["content_id"] in member[f]], cover_of(r)) for r in rows]
56
+ sorts = [ # each sort of the page, with its order as places in books
57
+ {
58
+ "key": k,
59
+ "label": T["sort_" + k],
60
+ "order": [place[r["content_id"]] for r in booklist.query_rows(con, reader, "", "all", k)] if con else [],
61
+ }
62
+ for k in booklist.SORTS
63
+ ]
64
+ summary = booklist.summary(con, reader) if con else {"user": "", "on": 0}
65
+ return {
66
+ "live": live,
67
+ "user": summary["user"],
68
+ **_start(con, reader, live, connected, mode),
69
+ "tip": tip.pick(con, reader) if con else None,
70
+ "books": books,
71
+ "sorts": sorts,
72
+ "words": { # the page's words, so that the app says the same
73
+ "filters": [{"group": T["g_" + g], "keys": [[k, T["f_" + k]] for k in keys]} for g, keys in booklist.FILTER_GROUPS],
74
+ "sections": [T["d_section_kobo"], T["d_section_hardcover"]],
75
+ "syncs": [[m, T[m]] for m in state.MODES],
76
+ "states": [[s, T[booklist.STATE_LABEL[s]]] for s in state.STATES],
77
+ "legend": [[k, T["legend_" + k]] for k in ("next", "wait", "ok", "stet", "warn", "err", "none")],
78
+ },
79
+ }
80
+
81
+
82
+ def _row(con, reader: str, cid: str):
83
+ row = con and con.execute("select * from reader_book where reader=? and content_id=?", (reader, cid)).fetchone()
84
+ if not row:
85
+ raise Refused(f"no book with the id '{cid}': `books --json` gives each book's id.", 404)
86
+ return row
87
+
88
+
89
+ def one(con, reader: str, cid: str, cover_of, connected=lambda: False, mode: str = "local") -> dict:
90
+ """One book as listing() has it, the counts that changed with it, and the
91
+ tip, Getting started and Needs you, which may have."""
92
+ row = _row(con, reader, cid)
93
+ summary = booklist.summary(con, reader)
94
+ return {
95
+ "book": booklist.entry(row, summary["live"], _quiet(con, reader), booklist.filters_of(con, reader, cid), cover_of(row)),
96
+ "books": summary,
97
+ "tip": tip.pick(con, reader),
98
+ **_start(con, reader, summary["live"], connected, mode),
99
+ }
100
+
101
+
102
+ def change(con, reader: str, cid: str, client, *, search="", pick="", sync="", state_="", date="", remove=False, not_now=False) -> None:
103
+ """The changes the page makes to one book. client(**kw) is a Hardcover
104
+ client, asked for only by a search and a removal; its HardcoverError
105
+ goes to the caller."""
106
+ from .engine import hardcover, job
107
+
108
+ row = _row(con, reader, cid)
109
+ if search: # the books it could be, while it has no match
110
+ if row["hc_book_id"]:
111
+ raise Refused("this book has its match already.")
112
+ found = hardcover.shown(client(tries=1).search(search))
113
+ con.execute(
114
+ "update book set hc_how=?, hc_candidates=? where reader=? and content_id=? and hc_book_id is null",
115
+ ("uncertain" if found else "none", json.dumps(found), reader, cid),
116
+ )
117
+ con.commit()
118
+ if pick:
119
+ row = _row(con, reader, cid)
120
+ chosen = [c for c in json.loads(row["hc_candidates"] or "[]") if str(c["book_id"]) == str(pick)]
121
+ if not chosen:
122
+ raise Refused(f"{pick} is not one of the books it could be (`book ID --json`, details, candidates).")
123
+ job.pick(con, reader, cid, int(chosen[0]["book_id"]), chosen[0]["title"], chosen[0].get("pages") or None)
124
+ if sync:
125
+ if sync not in state.MODES:
126
+ raise Refused(f"Sync is one of {', '.join(state.MODES)}, not '{sync}'.")
127
+ state.set_mode(con, reader, [cid], sync)
128
+ if state_:
129
+ if state_ not in state.STATES:
130
+ raise Refused(f"State is one of {', '.join(state.STATES)}, not '{state_}'.")
131
+ try:
132
+ state.set_state(con, reader, cid, state_, date if state_ in ("finished", "rereading") else "")
133
+ except ValueError as ex: # a date that is not one
134
+ raise Refused(str(ex)) from ex
135
+ if remove: # off the Hardcover shelf now, and no longer synced
136
+ job.remove(con, reader, cid, client())
137
+ if not_now: # not the tip for a month
138
+ tip.put_aside(con, reader, cid)
139
+
140
+
141
+ def set_all(con, reader: str, sync: str, filter_: str, search: str, expect: int | None) -> None:
142
+ """Set all, as on the page: Sync for every book the filter and search
143
+ match; with expect, only while that is still that many books."""
144
+ if con is None:
145
+ raise Refused("no books yet: nothing was read from a Kobo so far.")
146
+ if sync not in state.MODES:
147
+ raise Refused(f"Sync is one of {', '.join(state.MODES)}, not '{sync}'.")
148
+ if filter_ not in booklist.FILTERS:
149
+ raise Refused(f"--filter is one of {', '.join(booklist.FILTERS)}, not '{filter_}'.")
150
+ ids = booklist.ids(con, reader, search, filter_)
151
+ if expect is not None and expect != len(ids): # the list moved since it was counted: nothing changes
152
+ raise Refused(f"the list holds {plural(len(ids), 'book')} now, not {expect}: nothing was changed.", 409)
153
+ state.set_mode(con, reader, ids, sync)
lastread/booklist.py ADDED
@@ -0,0 +1,302 @@
1
+ """The list of books as the reader sees it, in words and not in markup:
2
+ which books a view holds and in what order, and per book the Hardcover
3
+ status line and the correction the next sync makes. The page draws it
4
+ (web/app.py), and so will the Mac app, through the tool's JSON; what a
5
+ sync does with a book is decided in engine/plan.py, once, for both."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ from .engine import state
12
+ from .engine.plan import action, desired, quiet_for
13
+ from .web.fmt import fmt_date, fmt_dt, fmt_dur
14
+ from .web.strings import T
15
+
16
+ FILTERS = {
17
+ "all": "1=1",
18
+ "reading": "((status=1 or (status=0 and percent>0)) and coalesce(finished_at,'')='' and state!='finished') or state='rereading'",
19
+ "finished": "(status=2 or coalesce(finished_at,'')!='' or state='finished') and state!='rereading'",
20
+ "unread": "(status=0 and coalesce(percent,0)=0)",
21
+ "history": "history=1",
22
+ "new": "history=0",
23
+ "on": "(mode='on' or (mode='auto' and history=0))",
24
+ "off": "not (mode='on' or (mode='auto' and history=0))",
25
+ "needs_match": "(mode='on' or (mode='auto' and history=0)) and hc_book_id is null and hc_how in ('uncertain','none')",
26
+ # On the Hardcover shelf with an edition that is not marked Ebook: to fix there.
27
+ "not_ebook": "(mode='on' or (mode='auto' and history=0)) and last_sent is not null and coalesce(hc_format_id,0)!=4",
28
+ }
29
+ FILTER_GROUPS = [
30
+ ("progress", ["all", "reading", "finished", "unread"]),
31
+ ("kobo", ["history", "new"]),
32
+ ("hardcover", ["on", "off", "needs_match", "not_ebook"]),
33
+ ]
34
+
35
+ # Each way round. A book never opened has no last-read date: it comes last either way. content_id ends
36
+ # every order, so that the list is the same list from one "Show more" to the next.
37
+ SORTS = {
38
+ "last_read": "last_read is null, last_read desc, content_id",
39
+ "last_read_old": "last_read is null, last_read asc, content_id",
40
+ "author": "author collate nocase, title collate nocase, content_id",
41
+ "author_za": "author collate nocase desc, title collate nocase desc, content_id",
42
+ "title": "title collate nocase, content_id",
43
+ "title_za": "title collate nocase desc, content_id",
44
+ }
45
+
46
+
47
+ def where(reader, q, f):
48
+ """The SQL condition of a view, a search q in filter f, and its arguments."""
49
+ # Each filter in its own parentheses: some contain a top-level OR, which
50
+ # would otherwise escape the reader condition.
51
+ parts, args = [f"({FILTERS.get(f, '1=1')})", "reader=?"], [reader]
52
+ if q:
53
+ parts.append("(title like ? or author like ?)")
54
+ args += [f"%{q}%", f"%{q}%"]
55
+ return " and ".join(parts), args
56
+
57
+
58
+ def query_rows(con, reader, q, f, sort, limit: int = -1, offset: int = 0):
59
+ """The books of a view, in its order; `limit` of them from `offset` (-1: all)."""
60
+ w, args = where(reader, q, f)
61
+ order = SORTS.get(sort, SORTS["last_read"])
62
+ return con.execute(f"select * from reader_book where {w} order by {order} limit ? offset ?", [*args, limit, offset]).fetchall()
63
+
64
+
65
+ def place_of(con, reader, q, f, sort, cid: str) -> int:
66
+ """Where a book stands in a view (0 = first), or -1 when the view does not hold it."""
67
+ w, args = where(reader, q, f)
68
+ order = SORTS.get(sort, SORTS["last_read"])
69
+ found = con.execute(
70
+ f"select n from (select content_id, row_number() over (order by {order}) - 1 as n from reader_book where {w}) where content_id=?",
71
+ [*args, cid],
72
+ ).fetchone()
73
+ return found[0] if found else -1
74
+
75
+
76
+ def filter_counts(con, reader, q):
77
+ out = {}
78
+ for k in FILTERS:
79
+ w, args = where(reader, q, k)
80
+ out[k] = con.execute(f"select count(*) from reader_book where {w}", args).fetchone()[0]
81
+ return out
82
+
83
+
84
+ def filters_of(con, reader, cid: str) -> list[str]:
85
+ """The filters a book is in."""
86
+ out = []
87
+ for f in FILTERS:
88
+ w, args = where(reader, "", f)
89
+ if con.execute(f"select 1 from reader_book where {w} and content_id=?", [*args, cid]).fetchone():
90
+ out.append(f)
91
+ return out
92
+
93
+
94
+ def ids(con, reader, q, f) -> list[str]:
95
+ """Every book a search and filter match, shown or not: what Set all changes."""
96
+ w, args = where(reader, q, f)
97
+ return [r[0] for r in con.execute(f"select content_id from reader_book where {w}", args)]
98
+
99
+
100
+ def hc_status(r, live: bool, quiet: dict) -> tuple[str, str]:
101
+ """(kind, text) of the Hardcover status line: what the next sync does.
102
+ kind: ok | next | wait | warn | err | none (marks.py). `quiet`: plan.quiet for this reader."""
103
+ if r["hc_error"]:
104
+ return "err", T["status_error"]
105
+ if (r["device"], r["content_id"]) in quiet:
106
+ return "none", T["other_copy"]
107
+ a = action(r)
108
+ if a.startswith("needs"):
109
+ return "warn", T["needs_match"]
110
+ if a.startswith("match pending"):
111
+ return "next", T["match_pending"]
112
+ if a:
113
+ return "next", f"{T['next_sync'] if live else T['would_send']}: {a}"
114
+ if state.syncs(r) and r["last_sent"]:
115
+ return "ok", T["up_to_date"]
116
+ if state.syncs(r):
117
+ return "wait", T["not_yet_sent"]
118
+ return "none", T["not_syncing"]
119
+
120
+
121
+ def marked(con, reader: str, live: bool, quiet: dict) -> int:
122
+ """How many books carry the red mark: what the next sync sends."""
123
+ return sum(1 for r in con.execute("select * from reader_book where reader=?", (reader,)) if hc_status(r, live, quiet)[0] == "next")
124
+
125
+
126
+ # The State a reader sets, in the page's words.
127
+ STATE_LABEL = {"kobo": "kobo", "finished": "finished_pin", "rereading": "rereading", "want": "want", "dnf": "dnf"}
128
+
129
+
130
+ def progress(r) -> tuple[int, bool, list[str]]:
131
+ """How far the book is, under its title: (percent, finished, the facts
132
+ in words: when it was finished or last read, and the time read)."""
133
+ want = desired(r)
134
+ done = bool(want and want["status"] == "read")
135
+ pct = 100 if done else max(0, min(100, int(r["percent"] or 0)))
136
+ meta = []
137
+ if done:
138
+ when = fmt_date(want.get("finished"))
139
+ meta.append(f"{T['finished_on']} {when}" if when else T["finished_pin"])
140
+ elif pct or r["status"] == 1:
141
+ meta.append(f"{pct}%")
142
+ if r["last_read"]:
143
+ meta.append(f"{T['last_read']} {fmt_date(r['last_read'])}")
144
+ else:
145
+ meta.append(T["not_started"])
146
+ if r["seconds_read"]:
147
+ meta.append(f"{fmt_dur(r['seconds_read'])} {T['read_time']}")
148
+ return pct, done, meta
149
+
150
+
151
+ def edition(r) -> tuple[str, bool]:
152
+ """('Blindness, Ebook', stet): the match and its edition, and whether
153
+ the edition was chosen on Hardcover, so that it is kept as it is."""
154
+ if not r["hc_book_id"]:
155
+ return "", False
156
+ sent = json.loads(r["last_sent"]) if r["last_sent"] else {}
157
+ parts = [r["hc_title"] or ""]
158
+ if r["hc_format"] or sent.get("user_book_id"):
159
+ parts.append(r["hc_format"] or T["format_unknown"])
160
+ return ", ".join(x for x in parts if x), r["hc_edition_by"] == "hardcover"
161
+
162
+
163
+ def details(r) -> dict:
164
+ """What Details shows of a book, in words: what the Kobo says and what
165
+ Hardcover has, as (label, value) pairs without the empty ones; the
166
+ link to the book on Hardcover; the books it could be, while it waits
167
+ for a match; and whether it is on the Hardcover shelf (so it can be
168
+ taken off)."""
169
+ sent = json.loads(r["last_sent"]) if r["last_sent"] else {}
170
+ kobo = [
171
+ (T["col_mode"], T[r["mode"]]),
172
+ (T["col_state"], T[STATE_LABEL[r["state"] or "kobo"]] + (f", {r['state_date']}" if r["state_date"] else "")),
173
+ (
174
+ T["d_kobo_status"],
175
+ {0: T["d_unread"], 1: T["d_reading"], 2: T["d_finished"]}.get(r["status"], "") + (f", {r['percent']}%" if r["percent"] else ""),
176
+ ),
177
+ (T["d_finished_on"], fmt_date(r["finished_at"])),
178
+ (T["d_last_read"], fmt_date(r["last_read"])),
179
+ (T["d_read_time"], fmt_dur(r["seconds_read"]) if r["seconds_read"] else ""),
180
+ (T["d_language"], r["language"] or ""),
181
+ ("ISBN", r["isbn"] or ""),
182
+ ]
183
+ hc = []
184
+ if r["hc_book_id"]:
185
+ how = T.get("how_" + (r["hc_how"] or ""), "")
186
+ hc.append((T["d_book"], (r["hc_title"] or "") + (f" ({how})" if how else "")))
187
+ if r["hc_format"] or r["hc_pages"]:
188
+ words = (r["hc_format"] or T["format_unknown"]) + (f", {r['hc_pages']} {T['d_pages']}" if r["hc_pages"] else "")
189
+ hc.append((T["d_edition"], words + (f" ({T['how_hardcover']})" if r["hc_edition_by"] == "hardcover" else "")))
190
+ if sent.get("user_book_id"):
191
+ shelf = T["hc_" + sent.get("status", "reading")]
192
+ if sent.get("status") == "reading" and sent.get("progress") is not None:
193
+ shelf += f", {sent['progress']}%"
194
+ if sent.get("finished"):
195
+ shelf += f", {fmt_date(sent['finished'])}"
196
+ hc.append((T["d_on_shelf"], shelf))
197
+ hc.append((T["d_last_sync"], fmt_dt(sent.get("at"))))
198
+ if r["hc_error"]:
199
+ hc.append((T["d_error"], r["hc_error"]))
200
+ waiting = not r["hc_book_id"] and r["hc_how"] in ("uncertain", "none")
201
+ return {
202
+ "kobo": [[k, v] for k, v in kobo if v],
203
+ "hardcover": [[k, v] for k, v in hc if v],
204
+ "link": f"https://hardcover.app/id/book/{int(r['hc_book_id'])}" if r["hc_book_id"] else "",
205
+ "candidates": [
206
+ {"id": int(c["book_id"]), "title": c["title"], "author": (c.get("authors") or [""])[0]}
207
+ for c in json.loads(r["hc_candidates"] or "[]")
208
+ ]
209
+ if waiting
210
+ else [],
211
+ "on_shelf": bool(sent.get("user_book_id")),
212
+ }
213
+
214
+
215
+ def opened(r) -> bool:
216
+ """Whether the book was ever opened on the Kobo: a status, a percentage
217
+ or any time read. Not its last read date: the Kobo sets that on books
218
+ never opened too (seen on a real Kobo, 2026-10-26)."""
219
+ return bool(r["status"] or r["percent"] or r["seconds_read"])
220
+
221
+
222
+ def entry(r, live: bool, quiet: dict, filters: list[str], cover: str = "") -> dict:
223
+ """A book as the tool's JSON has it (`books --json`, `book --json`):
224
+ its row, its Details, and the filters it is in. `cover`: its
225
+ thumbnail's file, when there is one."""
226
+ pct, done, meta = progress(r)
227
+ kind, words = hc_status(r, live, quiet)
228
+ words_of_edition, kept = edition(r)
229
+ return {
230
+ "id": r["content_id"],
231
+ "title": r["title"] or "",
232
+ "author": r["author"] or "",
233
+ "cover": cover,
234
+ "new": not r["history"],
235
+ "percent": pct,
236
+ "finished": done,
237
+ "opened": done or opened(r),
238
+ "facts": meta,
239
+ "last_read": r["last_read"] or "",
240
+ "sync": r["mode"],
241
+ "syncs": state.syncs(r),
242
+ "state": r["state"] or "kobo",
243
+ "state_date": r["state_date"] or "",
244
+ "status": {"kind": kind, "text": words},
245
+ "correction": list(correction(r) or []) if kind == "next" else [],
246
+ "edition": words_of_edition,
247
+ "stet": kept,
248
+ "filters": filters,
249
+ "details": details(r),
250
+ }
251
+
252
+
253
+ def summary(con, reader: str) -> dict:
254
+ """The books in a few counts, for the Mac app's overview: how many the
255
+ Kobo told of, how many are switched on, how many the next sync changes
256
+ and how many wait for a match; whether it sends (live) or only says
257
+ what it would, as whom, and the collection the switched-on books go to."""
258
+ counts = filter_counts(con, reader, "")
259
+ row = con.execute("select hardcover_live, hardcover_user, kobo_collection from reader where name=?", (reader,)).fetchone()
260
+ return {
261
+ "total": counts["all"],
262
+ "on": counts["on"],
263
+ "next": marked(con, reader, bool(row and row[0]), quiet_for(con, reader)),
264
+ "needs_match": counts["needs_match"],
265
+ "live": bool(row and row[0]),
266
+ "user": (row[1] if row else "") or "",
267
+ "collection": ((row[2] if row else "") or "").strip(),
268
+ }
269
+
270
+
271
+ def shelf_words(d: dict) -> str:
272
+ """'Currently reading, 37%' / 'Read, finished 30 Sep 2026': a shelf entry
273
+ in the page's words, for what Hardcover has (last_sent) or will get (desired)."""
274
+ st = d.get("status") if d else None
275
+ if not st:
276
+ return ""
277
+ words = T.get("hc_" + st, st)
278
+ if st == "reading" and d.get("progress") is not None:
279
+ words += f", {d['progress']}%"
280
+ if st == "read" and d.get("finished"):
281
+ words += ", " + T["shelf_finished"].format(date=fmt_date(d["finished"]))
282
+ return words
283
+
284
+
285
+ def correction(r) -> tuple[str, str, str] | None:
286
+ """The proof mark for a row the next sync changes: (kept, struck, inserted),
287
+ 'Currently reading,' '35%' '37%'. None when there is no shelf change to show
288
+ (no match yet, or only the edition changes)."""
289
+ a = action(r)
290
+ if not a or not r["hc_book_id"] or a.startswith(("needs", "match pending")):
291
+ return None
292
+ sent = json.loads(r["last_sent"]) if r["last_sent"] else {}
293
+ now = shelf_words(desired(r) or {})
294
+ was = shelf_words(sent) if sent.get("user_book_id") else ""
295
+ if not now or now == was:
296
+ return None
297
+ if was:
298
+ h1, _, t1 = was.partition(", ")
299
+ h2, _, t2 = now.partition(", ")
300
+ if h1 == h2 and t1 and t2:
301
+ return h1 + ",", t1, t2
302
+ return "", was, now