notability-extractor 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.
- notability_extractor/__init__.py +3 -0
- notability_extractor/__main__.py +3 -0
- notability_extractor/anki.py +297 -0
- notability_extractor/archive/__init__.py +1 -0
- notability_extractor/archive/backup.py +198 -0
- notability_extractor/archive/config.py +109 -0
- notability_extractor/archive/filter.py +44 -0
- notability_extractor/archive/scheduler.py +65 -0
- notability_extractor/archive/scheduler_install.py +186 -0
- notability_extractor/archive/store.py +217 -0
- notability_extractor/build/__init__.py +1 -0
- notability_extractor/build/flashcards.py +91 -0
- notability_extractor/build/notes.py +31 -0
- notability_extractor/build/reader.py +108 -0
- notability_extractor/build/summaries.py +38 -0
- notability_extractor/cli.py +263 -0
- notability_extractor/extract/__init__.py +1 -0
- notability_extractor/extract/exporter.py +45 -0
- notability_extractor/extract/http_cache.py +87 -0
- notability_extractor/extract/nbn.py +78 -0
- notability_extractor/extract/platform_check.py +35 -0
- notability_extractor/gui/__init__.py +0 -0
- notability_extractor/gui/app.py +68 -0
- notability_extractor/gui/main_window.py +119 -0
- notability_extractor/gui/pages/__init__.py +0 -0
- notability_extractor/gui/pages/export.py +123 -0
- notability_extractor/gui/pages/library.py +203 -0
- notability_extractor/gui/pages/notes.py +102 -0
- notability_extractor/gui/pages/settings.py +349 -0
- notability_extractor/gui/pages/summaries.py +101 -0
- notability_extractor/gui/theme.py +61 -0
- notability_extractor/gui/widgets/__init__.py +0 -0
- notability_extractor/gui/widgets/card_editor.py +180 -0
- notability_extractor/gui/widgets/tag_filter.py +101 -0
- notability_extractor/gui/widgets/tag_input.py +161 -0
- notability_extractor/model.py +76 -0
- notability_extractor/utils.py +80 -0
- notability_extractor-0.1.0.dist-info/METADATA +205 -0
- notability_extractor-0.1.0.dist-info/RECORD +41 -0
- notability_extractor-0.1.0.dist-info/WHEEL +4 -0
- notability_extractor-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Build and write an Anki .apkg package from a list of front/back card dicts.
|
|
3
|
+
|
|
4
|
+
An .apkg is a ZIP file containing:
|
|
5
|
+
- collection.anki2 -- a minimal SQLite database understood by Anki 2.1+
|
|
6
|
+
- media -- a JSON object mapping media filenames (empty here)
|
|
7
|
+
|
|
8
|
+
Reference: https://github.com/ankidroid/Anki-Android/wiki/Database-Structure
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import random
|
|
14
|
+
import sqlite3
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
import zipfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from notability_extractor.utils import field_checksum, get_logger
|
|
22
|
+
|
|
23
|
+
log = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
_BASIC_MODEL_ID = 1702000000000
|
|
26
|
+
_DECK_ID = 1702000000001
|
|
27
|
+
_CONF_ID = 1
|
|
28
|
+
|
|
29
|
+
_SCHEMA = """
|
|
30
|
+
CREATE TABLE IF NOT EXISTS cards (
|
|
31
|
+
id integer PRIMARY KEY,
|
|
32
|
+
nid integer NOT NULL,
|
|
33
|
+
did integer NOT NULL,
|
|
34
|
+
ord integer NOT NULL,
|
|
35
|
+
mod integer NOT NULL,
|
|
36
|
+
usn integer NOT NULL,
|
|
37
|
+
type integer NOT NULL,
|
|
38
|
+
queue integer NOT NULL,
|
|
39
|
+
due integer NOT NULL,
|
|
40
|
+
ivl integer NOT NULL,
|
|
41
|
+
factor integer NOT NULL,
|
|
42
|
+
reps integer NOT NULL,
|
|
43
|
+
lapses integer NOT NULL,
|
|
44
|
+
left integer NOT NULL,
|
|
45
|
+
odue integer NOT NULL,
|
|
46
|
+
odid integer NOT NULL,
|
|
47
|
+
flags integer NOT NULL,
|
|
48
|
+
data text NOT NULL
|
|
49
|
+
);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS col (
|
|
51
|
+
id integer PRIMARY KEY,
|
|
52
|
+
crt integer NOT NULL,
|
|
53
|
+
mod integer NOT NULL,
|
|
54
|
+
scm integer NOT NULL,
|
|
55
|
+
ver integer NOT NULL,
|
|
56
|
+
dty integer NOT NULL,
|
|
57
|
+
usn integer NOT NULL,
|
|
58
|
+
ls integer NOT NULL,
|
|
59
|
+
conf text NOT NULL,
|
|
60
|
+
models text NOT NULL,
|
|
61
|
+
decks text NOT NULL,
|
|
62
|
+
dconf text NOT NULL,
|
|
63
|
+
tags text NOT NULL
|
|
64
|
+
);
|
|
65
|
+
CREATE TABLE IF NOT EXISTS graves (
|
|
66
|
+
usn integer NOT NULL,
|
|
67
|
+
oid integer NOT NULL,
|
|
68
|
+
type integer NOT NULL
|
|
69
|
+
);
|
|
70
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
71
|
+
id integer PRIMARY KEY,
|
|
72
|
+
guid text NOT NULL,
|
|
73
|
+
mid integer NOT NULL,
|
|
74
|
+
mod integer NOT NULL,
|
|
75
|
+
usn integer NOT NULL,
|
|
76
|
+
tags text NOT NULL,
|
|
77
|
+
flds text NOT NULL,
|
|
78
|
+
sfld text NOT NULL,
|
|
79
|
+
csum integer NOT NULL,
|
|
80
|
+
flags integer NOT NULL,
|
|
81
|
+
data text NOT NULL
|
|
82
|
+
);
|
|
83
|
+
CREATE TABLE IF NOT EXISTS revlog (
|
|
84
|
+
id integer PRIMARY KEY,
|
|
85
|
+
cid integer NOT NULL,
|
|
86
|
+
usn integer NOT NULL,
|
|
87
|
+
ease integer NOT NULL,
|
|
88
|
+
ivl integer NOT NULL,
|
|
89
|
+
lastIvl integer NOT NULL,
|
|
90
|
+
factor integer NOT NULL,
|
|
91
|
+
time integer NOT NULL,
|
|
92
|
+
type integer NOT NULL
|
|
93
|
+
);
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _guid() -> str:
|
|
98
|
+
return base64.b64encode(random.randbytes(9)).decode("ascii")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _build_collection(
|
|
102
|
+
conn: sqlite3.Connection, cards: list[dict[str, Any]], deck_name: str, now: int
|
|
103
|
+
) -> None:
|
|
104
|
+
conn.executescript(_SCHEMA)
|
|
105
|
+
|
|
106
|
+
model = {
|
|
107
|
+
str(_BASIC_MODEL_ID): {
|
|
108
|
+
"id": _BASIC_MODEL_ID,
|
|
109
|
+
"name": "Notability Basic",
|
|
110
|
+
"type": 0,
|
|
111
|
+
"mod": now,
|
|
112
|
+
"usn": -1,
|
|
113
|
+
"sortf": 0,
|
|
114
|
+
"did": None,
|
|
115
|
+
"tmpls": [
|
|
116
|
+
{
|
|
117
|
+
"name": "Card 1",
|
|
118
|
+
"ord": 0,
|
|
119
|
+
"qfmt": "{{Front}}",
|
|
120
|
+
"afmt": "{{FrontSide}}<hr id=answer>{{Back}}",
|
|
121
|
+
"bqfmt": "",
|
|
122
|
+
"bafmt": "",
|
|
123
|
+
"did": None,
|
|
124
|
+
"bfont": "",
|
|
125
|
+
"bsize": 0,
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
"flds": [
|
|
129
|
+
{
|
|
130
|
+
"name": "Front",
|
|
131
|
+
"ord": 0,
|
|
132
|
+
"sticky": False,
|
|
133
|
+
"rtl": False,
|
|
134
|
+
"font": "Arial",
|
|
135
|
+
"size": 20,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"name": "Back",
|
|
139
|
+
"ord": 1,
|
|
140
|
+
"sticky": False,
|
|
141
|
+
"rtl": False,
|
|
142
|
+
"font": "Arial",
|
|
143
|
+
"size": 20,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
"css": ".card { font-family: arial; font-size: 20px; text-align: center; }",
|
|
147
|
+
"latexPre": "",
|
|
148
|
+
"latexPost": "",
|
|
149
|
+
"tags": [],
|
|
150
|
+
"vers": [],
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
deck = {
|
|
155
|
+
str(_DECK_ID): {
|
|
156
|
+
"id": _DECK_ID,
|
|
157
|
+
"name": deck_name,
|
|
158
|
+
"desc": "",
|
|
159
|
+
"mod": now,
|
|
160
|
+
"usn": -1,
|
|
161
|
+
"collapsed": False,
|
|
162
|
+
"browserCollapsed": False,
|
|
163
|
+
"extendNew": 0,
|
|
164
|
+
"extendRev": 0,
|
|
165
|
+
"conf": _CONF_ID,
|
|
166
|
+
"dyn": 0,
|
|
167
|
+
"newToday": [0, 0],
|
|
168
|
+
"revToday": [0, 0],
|
|
169
|
+
"lrnToday": [0, 0],
|
|
170
|
+
"timeToday": [0, 0],
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
dconf = {
|
|
175
|
+
str(_CONF_ID): {
|
|
176
|
+
"id": _CONF_ID,
|
|
177
|
+
"name": "Default",
|
|
178
|
+
"replayq": True,
|
|
179
|
+
"lapse": {"delays": [10], "leechAction": 0, "leechFails": 8, "minInt": 1, "mult": 0},
|
|
180
|
+
"rev": {
|
|
181
|
+
"ease4": 1.3,
|
|
182
|
+
"fuzz": 0.05,
|
|
183
|
+
"ivlFct": 1,
|
|
184
|
+
"maxIvl": 36500,
|
|
185
|
+
"minSpace": 1,
|
|
186
|
+
"perDay": 100,
|
|
187
|
+
},
|
|
188
|
+
"new": {
|
|
189
|
+
"bury": True,
|
|
190
|
+
"delays": [1, 10],
|
|
191
|
+
"initialFactor": 2500,
|
|
192
|
+
"ints": [1, 4, 7],
|
|
193
|
+
"order": 1,
|
|
194
|
+
"perDay": 20,
|
|
195
|
+
"separate": True,
|
|
196
|
+
},
|
|
197
|
+
"timer": 0,
|
|
198
|
+
"autoplay": True,
|
|
199
|
+
"mod": now,
|
|
200
|
+
"usn": -1,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
col_conf = {
|
|
205
|
+
"nextPos": 1,
|
|
206
|
+
"estTimes": True,
|
|
207
|
+
"activeDecks": [_DECK_ID],
|
|
208
|
+
"sortType": "noteFld",
|
|
209
|
+
"timeLim": 0,
|
|
210
|
+
"sortBackwards": False,
|
|
211
|
+
"addToCur": True,
|
|
212
|
+
"curDeck": _DECK_ID,
|
|
213
|
+
"newBury": True,
|
|
214
|
+
"newSpread": 0,
|
|
215
|
+
"dueCounts": True,
|
|
216
|
+
"curModel": str(_BASIC_MODEL_ID),
|
|
217
|
+
"collapseTime": 1200,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
conn.execute(
|
|
221
|
+
"INSERT INTO col VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
222
|
+
(
|
|
223
|
+
1,
|
|
224
|
+
now,
|
|
225
|
+
now,
|
|
226
|
+
now,
|
|
227
|
+
11,
|
|
228
|
+
0,
|
|
229
|
+
-1,
|
|
230
|
+
0,
|
|
231
|
+
json.dumps(col_conf),
|
|
232
|
+
json.dumps(model),
|
|
233
|
+
json.dumps(deck),
|
|
234
|
+
json.dumps(dconf),
|
|
235
|
+
"{}",
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
for i, card in enumerate(cards):
|
|
240
|
+
note_id = now * 1000 + i
|
|
241
|
+
card_id = note_id + 1
|
|
242
|
+
front, back = card["front"], card["back"]
|
|
243
|
+
flds = f"{front}\x1f{back}"
|
|
244
|
+
conn.execute(
|
|
245
|
+
"INSERT INTO notes VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
|
246
|
+
(
|
|
247
|
+
note_id,
|
|
248
|
+
_guid(),
|
|
249
|
+
_BASIC_MODEL_ID,
|
|
250
|
+
now,
|
|
251
|
+
-1,
|
|
252
|
+
"",
|
|
253
|
+
flds,
|
|
254
|
+
front,
|
|
255
|
+
field_checksum(front),
|
|
256
|
+
0,
|
|
257
|
+
"",
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
conn.execute(
|
|
261
|
+
"INSERT INTO cards VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
262
|
+
(card_id, note_id, _DECK_ID, 0, now, -1, 0, 0, i, 0, 0, 0, 0, 0, 0, 0, 0, ""),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
conn.commit()
|
|
266
|
+
log.debug("Inserted %d notes into temporary Anki collection", len(cards))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def write_apkg(cards: list[dict[str, Any]], deck_name: str, out_path: Path) -> None:
|
|
270
|
+
"""
|
|
271
|
+
Write *cards* as an Anki .apkg file at *out_path*.
|
|
272
|
+
|
|
273
|
+
Each card in *cards* must have ``"front"`` and ``"back"`` string keys.
|
|
274
|
+
Raises ValueError when *cards* is empty.
|
|
275
|
+
"""
|
|
276
|
+
if not cards:
|
|
277
|
+
raise ValueError("No cards to write -- the .apkg would be empty.")
|
|
278
|
+
|
|
279
|
+
now = int(time.time())
|
|
280
|
+
|
|
281
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
282
|
+
db_path = Path(tmpdir) / "collection.anki2"
|
|
283
|
+
conn = sqlite3.connect(str(db_path))
|
|
284
|
+
_build_collection(conn, cards, deck_name, now)
|
|
285
|
+
conn.close()
|
|
286
|
+
|
|
287
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
with zipfile.ZipFile(str(out_path), "w", zipfile.ZIP_DEFLATED) as zf:
|
|
289
|
+
zf.write(str(db_path), "collection.anki2")
|
|
290
|
+
zf.writestr("media", "{}")
|
|
291
|
+
|
|
292
|
+
log.info(
|
|
293
|
+
"Wrote %d cards to Anki package: %s (deck: '%s')",
|
|
294
|
+
len(cards),
|
|
295
|
+
out_path,
|
|
296
|
+
deck_name,
|
|
297
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Archive layer: JSONL CRUD, filtering, backups, scheduler."""
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Backup operations: snapshot, restore, export, import, prune."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from notability_extractor.archive.store import DEFAULT_ARCHIVE
|
|
14
|
+
from notability_extractor.archive.store import load as _load_archive
|
|
15
|
+
from notability_extractor.archive.store import merge as _merge_cards
|
|
16
|
+
from notability_extractor.archive.store import save_all as _save_all
|
|
17
|
+
from notability_extractor.model import Card
|
|
18
|
+
from notability_extractor.utils import get_logger
|
|
19
|
+
|
|
20
|
+
log = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
DEFAULT_BACKUPS = Path.home() / ".notability_extractor" / "backups"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Snapshot:
|
|
27
|
+
path: Path
|
|
28
|
+
timestamp: datetime
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def snapshot(
|
|
32
|
+
path: Path = DEFAULT_ARCHIVE,
|
|
33
|
+
backups_dir: Path = DEFAULT_BACKUPS,
|
|
34
|
+
) -> Path | None:
|
|
35
|
+
"""Copy archive to backups_dir with timestamped filename.
|
|
36
|
+
|
|
37
|
+
Returns None if (a) archive doesn't exist, (b) archive hash matches the most
|
|
38
|
+
recent snapshot, or (c) the copy fails. Never raises -- a failed backup must
|
|
39
|
+
not crash a save flow.
|
|
40
|
+
"""
|
|
41
|
+
if not path.is_file():
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
backups_dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
current_hash = _hash(path)
|
|
46
|
+
latest = _latest_snapshot(backups_dir)
|
|
47
|
+
if latest is not None and _hash(latest.path) == current_hash:
|
|
48
|
+
return None
|
|
49
|
+
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
|
50
|
+
target = backups_dir / f"cards-{stamp}.jsonl"
|
|
51
|
+
shutil.copy2(path, target)
|
|
52
|
+
return target
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
log.error("Backup snapshot failed: %s", exc)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_snapshots(backups_dir: Path = DEFAULT_BACKUPS) -> list[Snapshot]:
|
|
59
|
+
"""Return all snapshots, newest first."""
|
|
60
|
+
if not backups_dir.is_dir():
|
|
61
|
+
return []
|
|
62
|
+
out: list[Snapshot] = []
|
|
63
|
+
for p in backups_dir.glob("cards-*.jsonl"):
|
|
64
|
+
ts = _parse_timestamp(p.name)
|
|
65
|
+
if ts is not None:
|
|
66
|
+
out.append(Snapshot(path=p, timestamp=ts))
|
|
67
|
+
out.sort(key=lambda s: s.timestamp, reverse=True)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def prune(backups_dir: Path = DEFAULT_BACKUPS, keep: int = 10) -> int:
|
|
72
|
+
"""Delete oldest snapshots so only `keep` remain. Returns deletion count."""
|
|
73
|
+
snaps = list_snapshots(backups_dir)
|
|
74
|
+
if len(snaps) <= keep:
|
|
75
|
+
return 0
|
|
76
|
+
to_delete = snaps[keep:]
|
|
77
|
+
for s in to_delete:
|
|
78
|
+
try:
|
|
79
|
+
s.path.unlink()
|
|
80
|
+
except OSError as exc:
|
|
81
|
+
log.warning("Failed to prune %s: %s", s.path, exc)
|
|
82
|
+
return len(to_delete)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def restore_snapshot(
|
|
86
|
+
snapshot_name: str,
|
|
87
|
+
archive_path: Path = DEFAULT_ARCHIVE,
|
|
88
|
+
backups_dir: Path = DEFAULT_BACKUPS,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Replace archive contents with this snapshot's contents.
|
|
91
|
+
|
|
92
|
+
SAFETY: snapshots the current archive first, so a restore-by-mistake can be
|
|
93
|
+
undone by restoring the pre-restore snapshot.
|
|
94
|
+
"""
|
|
95
|
+
source = backups_dir / snapshot_name
|
|
96
|
+
if not source.is_file():
|
|
97
|
+
raise FileNotFoundError(f"No snapshot at {source}")
|
|
98
|
+
snapshot(archive_path, backups_dir)
|
|
99
|
+
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
shutil.copy2(source, archive_path)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def export_archive(
|
|
104
|
+
target: Path,
|
|
105
|
+
fmt: Literal["jsonl", "json"] = "jsonl",
|
|
106
|
+
archive_path: Path = DEFAULT_ARCHIVE,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Dump archive to target. jsonl = byte-copy. json = pretty, {cards: [...]}."""
|
|
109
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
if fmt == "jsonl":
|
|
111
|
+
shutil.copy2(archive_path, target)
|
|
112
|
+
return
|
|
113
|
+
cards = _load_archive(archive_path)
|
|
114
|
+
payload = {
|
|
115
|
+
"exported_at": datetime.now(UTC).isoformat(),
|
|
116
|
+
"cards": [
|
|
117
|
+
{
|
|
118
|
+
"id": c.id,
|
|
119
|
+
"created_at": c.created_at.isoformat(),
|
|
120
|
+
"updated_at": c.updated_at.isoformat(),
|
|
121
|
+
"question": c.card.question,
|
|
122
|
+
"options": c.card.options,
|
|
123
|
+
"correct_answer": c.card.correct_answer,
|
|
124
|
+
"source_file": c.card.source_file,
|
|
125
|
+
"index": c.card.index,
|
|
126
|
+
"tags": c.card.tags,
|
|
127
|
+
}
|
|
128
|
+
for c in cards
|
|
129
|
+
],
|
|
130
|
+
}
|
|
131
|
+
target.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def import_archive(
|
|
135
|
+
source: Path,
|
|
136
|
+
mode: Literal["merge", "replace"] = "merge",
|
|
137
|
+
archive_path: Path = DEFAULT_ARCHIVE,
|
|
138
|
+
) -> tuple[int, int]:
|
|
139
|
+
"""Load cards from source (.jsonl or pretty .json). Merge or replace."""
|
|
140
|
+
incoming_cards = _read_cards_for_import(source)
|
|
141
|
+
if mode == "replace":
|
|
142
|
+
snapshot(archive_path)
|
|
143
|
+
_save_all([], archive_path)
|
|
144
|
+
return _merge_cards(incoming_cards, archive_path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _hash(path: Path) -> str:
|
|
148
|
+
h = hashlib.sha256()
|
|
149
|
+
with path.open("rb") as f:
|
|
150
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
151
|
+
h.update(chunk)
|
|
152
|
+
return h.hexdigest()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _latest_snapshot(backups_dir: Path) -> Snapshot | None:
|
|
156
|
+
snaps = list_snapshots(backups_dir)
|
|
157
|
+
return snaps[0] if snaps else None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_timestamp(filename: str) -> datetime | None:
|
|
161
|
+
stem = filename.removeprefix("cards-").removesuffix(".jsonl")
|
|
162
|
+
try:
|
|
163
|
+
return datetime.strptime(stem, "%Y%m%d-%H%M%S").replace(tzinfo=UTC)
|
|
164
|
+
except ValueError:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_cards_for_import(source: Path) -> list[Card]:
|
|
169
|
+
text = source.read_text()
|
|
170
|
+
# .json files are pretty-printed {cards: [...]}; .jsonl is one object per line.
|
|
171
|
+
# We also sniff the content for unknown extensions -- a top-level object that
|
|
172
|
+
# isn't JSONL (which would be line-delimited) gets the JSON path.
|
|
173
|
+
if source.suffix == ".json":
|
|
174
|
+
payload = json.loads(text)
|
|
175
|
+
rows = payload.get("cards", []) if isinstance(payload, dict) else payload
|
|
176
|
+
else:
|
|
177
|
+
# .jsonl or anything else: parse line by line
|
|
178
|
+
rows = [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
179
|
+
out: list[Card] = []
|
|
180
|
+
for r in rows:
|
|
181
|
+
# r is a dict parsed from JSON; we guard with isinstance for mypy
|
|
182
|
+
if not isinstance(r, dict):
|
|
183
|
+
continue
|
|
184
|
+
out.append(
|
|
185
|
+
Card(
|
|
186
|
+
question=str(r["question"]),
|
|
187
|
+
options=(
|
|
188
|
+
{str(k): str(v) for k, v in r["options"].items()}
|
|
189
|
+
if isinstance(r.get("options"), dict)
|
|
190
|
+
else {}
|
|
191
|
+
),
|
|
192
|
+
correct_answer=str(r["correct_answer"]),
|
|
193
|
+
source_file=str(r.get("source_file", "imported")),
|
|
194
|
+
index=int(str(r.get("index", 0))),
|
|
195
|
+
tags=list(r.get("tags", [])) if isinstance(r.get("tags"), list) else [],
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return out
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Persistent GUI/CLI config at ~/.notability_extractor/config.json.
|
|
2
|
+
|
|
3
|
+
Schema is a flat dict. Reads are tolerant -- missing keys fall back to
|
|
4
|
+
sensible defaults. Writes are atomic via tmpfile + os.replace.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".notability_extractor" / "config.json"
|
|
16
|
+
|
|
17
|
+
# Keys and what they do:
|
|
18
|
+
# theme - color scheme: light | dark | auto (follows OS)
|
|
19
|
+
# font_size - base point size for the GUI (applied to QApplication)
|
|
20
|
+
# log_level - info | debug; debug logs every archive mutation for auditing
|
|
21
|
+
# deck_name - Anki deck name used when building .apkg
|
|
22
|
+
# input_dir - path to the Notability export dir; empty string = unset
|
|
23
|
+
# export_dir - where backup snapshots are written
|
|
24
|
+
# schedule - headless backup cadence: off | hourly | daily | weekly
|
|
25
|
+
# retention - how many snapshots to keep
|
|
26
|
+
_DEFAULTS: dict[str, Any] = {
|
|
27
|
+
"theme": "auto",
|
|
28
|
+
"font_size": 11,
|
|
29
|
+
"log_level": "info",
|
|
30
|
+
"deck_name": "Notability Flashcards",
|
|
31
|
+
"input_dir": "",
|
|
32
|
+
"export_dir": str(Path.home() / "Documents" / "notability-backups"),
|
|
33
|
+
"schedule": "off",
|
|
34
|
+
"retention": 10,
|
|
35
|
+
# tag_colors: { "biology": "#2d7d4a", ... } - chip color per tag, global
|
|
36
|
+
"tag_colors": {},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load(path: Path = DEFAULT_CONFIG_PATH) -> dict[str, Any]:
|
|
41
|
+
"""Load config from disk, falling back to defaults for missing keys.
|
|
42
|
+
|
|
43
|
+
Returns a fresh dict each call -- callers can mutate freely without
|
|
44
|
+
affecting the on-disk state.
|
|
45
|
+
"""
|
|
46
|
+
out = dict(_DEFAULTS)
|
|
47
|
+
if not path.is_file():
|
|
48
|
+
return out
|
|
49
|
+
try:
|
|
50
|
+
raw = json.loads(path.read_text())
|
|
51
|
+
if isinstance(raw, dict):
|
|
52
|
+
for k, v in raw.items():
|
|
53
|
+
out[k] = v
|
|
54
|
+
except (OSError, json.JSONDecodeError):
|
|
55
|
+
# corrupt config -- fall back to defaults rather than crash the app
|
|
56
|
+
return dict(_DEFAULTS)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save(cfg: dict[str, Any], path: Path = DEFAULT_CONFIG_PATH) -> None:
|
|
61
|
+
"""Atomic write of config to disk."""
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
payload = json.dumps(cfg, indent=2, ensure_ascii=False) + "\n"
|
|
64
|
+
with tempfile.NamedTemporaryFile(
|
|
65
|
+
mode="w",
|
|
66
|
+
dir=path.parent,
|
|
67
|
+
prefix=path.name + ".",
|
|
68
|
+
suffix=".tmp",
|
|
69
|
+
delete=False,
|
|
70
|
+
encoding="utf-8",
|
|
71
|
+
) as tmp:
|
|
72
|
+
tmp.write(payload)
|
|
73
|
+
tmp.flush()
|
|
74
|
+
os.fsync(tmp.fileno())
|
|
75
|
+
tmp_name = tmp.name
|
|
76
|
+
os.replace(tmp_name, path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get(key: str, path: Path = DEFAULT_CONFIG_PATH) -> Any:
|
|
80
|
+
"""Convenience: load + get one key with default fallback."""
|
|
81
|
+
return load(path).get(key, _DEFAULTS.get(key))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def set_value(key: str, value: Any, path: Path = DEFAULT_CONFIG_PATH) -> None:
|
|
85
|
+
"""Convenience: load, set one key, save."""
|
|
86
|
+
cfg = load(path)
|
|
87
|
+
cfg[key] = value
|
|
88
|
+
save(cfg, path)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_tag_color(tag: str, path: Path = DEFAULT_CONFIG_PATH) -> str | None:
|
|
92
|
+
"""Return the saved chip color for this tag, or None for the default."""
|
|
93
|
+
cfg = load(path)
|
|
94
|
+
tag_colors = cfg.get("tag_colors", {})
|
|
95
|
+
if isinstance(tag_colors, dict):
|
|
96
|
+
val = tag_colors.get(tag)
|
|
97
|
+
return val if isinstance(val, str) else None
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def set_tag_color(tag: str, color: str, path: Path = DEFAULT_CONFIG_PATH) -> None:
|
|
102
|
+
"""Persist a chip color for this tag. Applied globally everywhere tag appears."""
|
|
103
|
+
cfg = load(path)
|
|
104
|
+
tag_colors = cfg.get("tag_colors", {})
|
|
105
|
+
if not isinstance(tag_colors, dict):
|
|
106
|
+
tag_colors = {}
|
|
107
|
+
tag_colors[tag] = color
|
|
108
|
+
cfg["tag_colors"] = tag_colors
|
|
109
|
+
save(cfg, path)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Pure filter helpers over a list[ArchivedCard]. No I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from notability_extractor.model import ArchivedCard
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def by_tags(
|
|
11
|
+
cards: list[ArchivedCard],
|
|
12
|
+
tags: list[str],
|
|
13
|
+
mode: Literal["any", "all"] = "any",
|
|
14
|
+
) -> list[ArchivedCard]:
|
|
15
|
+
"""Filter to cards matching given tags. mode='any' = union, 'all' = intersection."""
|
|
16
|
+
if not tags:
|
|
17
|
+
return list(cards)
|
|
18
|
+
wanted = set(tags)
|
|
19
|
+
if mode == "all":
|
|
20
|
+
return [c for c in cards if wanted.issubset(c.card.tags)]
|
|
21
|
+
return [c for c in cards if wanted.intersection(c.card.tags)]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def by_text(cards: list[ArchivedCard], query: str) -> list[ArchivedCard]:
|
|
25
|
+
"""Case-insensitive substring match against question + every option's text."""
|
|
26
|
+
if not query:
|
|
27
|
+
return list(cards)
|
|
28
|
+
needle = query.lower()
|
|
29
|
+
out: list[ArchivedCard] = []
|
|
30
|
+
for c in cards:
|
|
31
|
+
if needle in c.card.question.lower():
|
|
32
|
+
out.append(c)
|
|
33
|
+
continue
|
|
34
|
+
if any(needle in v.lower() for v in c.card.options.values()):
|
|
35
|
+
out.append(c)
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def all_tags(cards: list[ArchivedCard]) -> list[str]:
|
|
40
|
+
"""Sorted unique tags across all cards. Feeds tag-input autocomplete."""
|
|
41
|
+
seen: set[str] = set()
|
|
42
|
+
for c in cards:
|
|
43
|
+
seen.update(c.card.tags)
|
|
44
|
+
return sorted(seen, key=str.lower)
|