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.
Files changed (41) hide show
  1. notability_extractor/__init__.py +3 -0
  2. notability_extractor/__main__.py +3 -0
  3. notability_extractor/anki.py +297 -0
  4. notability_extractor/archive/__init__.py +1 -0
  5. notability_extractor/archive/backup.py +198 -0
  6. notability_extractor/archive/config.py +109 -0
  7. notability_extractor/archive/filter.py +44 -0
  8. notability_extractor/archive/scheduler.py +65 -0
  9. notability_extractor/archive/scheduler_install.py +186 -0
  10. notability_extractor/archive/store.py +217 -0
  11. notability_extractor/build/__init__.py +1 -0
  12. notability_extractor/build/flashcards.py +91 -0
  13. notability_extractor/build/notes.py +31 -0
  14. notability_extractor/build/reader.py +108 -0
  15. notability_extractor/build/summaries.py +38 -0
  16. notability_extractor/cli.py +263 -0
  17. notability_extractor/extract/__init__.py +1 -0
  18. notability_extractor/extract/exporter.py +45 -0
  19. notability_extractor/extract/http_cache.py +87 -0
  20. notability_extractor/extract/nbn.py +78 -0
  21. notability_extractor/extract/platform_check.py +35 -0
  22. notability_extractor/gui/__init__.py +0 -0
  23. notability_extractor/gui/app.py +68 -0
  24. notability_extractor/gui/main_window.py +119 -0
  25. notability_extractor/gui/pages/__init__.py +0 -0
  26. notability_extractor/gui/pages/export.py +123 -0
  27. notability_extractor/gui/pages/library.py +203 -0
  28. notability_extractor/gui/pages/notes.py +102 -0
  29. notability_extractor/gui/pages/settings.py +349 -0
  30. notability_extractor/gui/pages/summaries.py +101 -0
  31. notability_extractor/gui/theme.py +61 -0
  32. notability_extractor/gui/widgets/__init__.py +0 -0
  33. notability_extractor/gui/widgets/card_editor.py +180 -0
  34. notability_extractor/gui/widgets/tag_filter.py +101 -0
  35. notability_extractor/gui/widgets/tag_input.py +161 -0
  36. notability_extractor/model.py +76 -0
  37. notability_extractor/utils.py +80 -0
  38. notability_extractor-0.1.0.dist-info/METADATA +205 -0
  39. notability_extractor-0.1.0.dist-info/RECORD +41 -0
  40. notability_extractor-0.1.0.dist-info/WHEEL +4 -0
  41. notability_extractor-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,3 @@
1
+ """notability-extractor: pull flashcards out of Notability and into Anki."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from notability_extractor.cli import main
2
+
3
+ main()
@@ -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)