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.
- lastread/__init__.py +21 -0
- lastread/__main__.py +3 -0
- lastread/appbooks.py +153 -0
- lastread/booklist.py +302 -0
- lastread/cli.py +1296 -0
- lastread/computer/__init__.py +10 -0
- lastread/computer/config.py +98 -0
- lastread/computer/history.py +69 -0
- lastread/computer/linux.py +222 -0
- lastread/computer/macos.py +330 -0
- lastread/computer/page.py +238 -0
- lastread/computer/platform.py +98 -0
- lastread/computer/remote.py +123 -0
- lastread/computer/runner.py +494 -0
- lastread/computer/update.py +139 -0
- lastread/computer/webhook.py +84 -0
- lastread/doctor.py +480 -0
- lastread/engine/__init__.py +0 -0
- lastread/engine/collection.py +613 -0
- lastread/engine/export.py +156 -0
- lastread/engine/hardcover.py +616 -0
- lastread/engine/job.py +270 -0
- lastread/engine/kobo_db.py +180 -0
- lastread/engine/oauth.py +351 -0
- lastread/engine/plan.py +109 -0
- lastread/engine/state.py +365 -0
- lastread/engine/syncback.py +149 -0
- lastread/env.py +9 -0
- lastread/logs.py +71 -0
- lastread/server/__init__.py +0 -0
- lastread/server/accounts.py +506 -0
- lastread/server/proxy.py +50 -0
- lastread/server/stats.py +53 -0
- lastread/server/upload.py +109 -0
- lastread/start.py +132 -0
- lastread/term.py +129 -0
- lastread/tip.py +72 -0
- lastread/web/__init__.py +0 -0
- lastread/web/account_pages.py +506 -0
- lastread/web/app.py +1818 -0
- lastread/web/covers.py +60 -0
- lastread/web/fmt.py +49 -0
- lastread/web/marks.py +57 -0
- lastread/web/static/apple-touch-icon.png +0 -0
- lastread/web/static/favicon.svg +10 -0
- lastread/web/static/fonts/README.txt +19 -0
- lastread/web/static/fonts/SchibstedGrotesk-LICENSE.txt +93 -0
- lastread/web/static/fonts/SchibstedGrotesk-italic-latin-ext.woff2 +0 -0
- lastread/web/static/fonts/SchibstedGrotesk-italic-latin.woff2 +0 -0
- lastread/web/static/fonts/SchibstedGrotesk-latin-ext.woff2 +0 -0
- lastread/web/static/fonts/SchibstedGrotesk-latin.woff2 +0 -0
- lastread/web/static/fonts/SourceSerif4-LICENSE.txt +93 -0
- lastread/web/static/fonts/SourceSerif4-latin-ext.woff2 +0 -0
- lastread/web/static/fonts/SourceSerif4-latin.woff2 +0 -0
- lastread/web/static/kobo.css +699 -0
- lastread/web/static/kobo.js +278 -0
- lastread/web/strings.py +502 -0
- lastread-0.20.0.dist-info/METADATA +546 -0
- lastread-0.20.0.dist-info/RECORD +63 -0
- lastread-0.20.0.dist-info/WHEEL +4 -0
- lastread-0.20.0.dist-info/entry_points.txt +3 -0
- lastread-0.20.0.dist-info/licenses/LICENSE +21 -0
- 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
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
|