apyanki 0.15.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.
apyanki/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Package for interfacing and manipulating Anki decks"""
2
+
3
+ from importlib.metadata import version
4
+ import os
5
+
6
+ __version__ = version("apyanki")
7
+
8
+
9
+ # Reduce rust verbosity, unless already explicitly increased. Anki by default
10
+ # sets it to debug
11
+ if "RUST_LOG" not in os.environ:
12
+ os.environ["RUST_LOG"] = "warn,anki::media=info,anki::sync=info,anki::dbcheck=info"
apyanki/anki.py ADDED
@@ -0,0 +1,494 @@
1
+ """An Anki collection wrapper class."""
2
+
3
+ from __future__ import annotations
4
+ import os
5
+ from pathlib import Path
6
+ import pickle
7
+ import sqlite3
8
+ import tempfile
9
+ import time
10
+ from types import TracebackType
11
+ from typing import Any, Generator, Optional, Sequence, TYPE_CHECKING, Type
12
+
13
+ from click import Abort
14
+ from rich.progress import Progress, SpinnerColumn, TextColumn
15
+
16
+ from apyanki import cards
17
+ from apyanki.config import cfg
18
+ from apyanki.console import console
19
+ from apyanki.note import Note, NoteData, markdown_file_to_notes
20
+ from apyanki.utilities import cd, choose, editor, suppress_stdout
21
+
22
+ if TYPE_CHECKING:
23
+ from anki.notes import NoteId
24
+ from anki.models import NotetypeDict
25
+ from anki.cards import CardId
26
+
27
+
28
+ class Anki:
29
+ """My Anki collection wrapper class."""
30
+
31
+ def __init__(
32
+ self,
33
+ base_path: Optional[str] = None,
34
+ collection_db_path: Optional[str] = None,
35
+ profile_name: Optional[str] = None,
36
+ **_kwargs: dict[str, Any],
37
+ ):
38
+ self.modified = False
39
+
40
+ self._meta = None
41
+ self._collection_db_path = ""
42
+ self._profile_name = profile_name
43
+ self._profile = None
44
+
45
+ self._init_load_profile(base_path, collection_db_path)
46
+ self._init_load_collection()
47
+ self._init_load_config()
48
+
49
+ with suppress_stdout():
50
+ self.today: int = self.col.sched.today
51
+
52
+ self.model_name_to_id: dict[str, int] = {
53
+ m["name"]: m["id"] for m in self.col.models.all()
54
+ }
55
+ self.model_names = list(self.model_name_to_id.keys())
56
+
57
+ self.deck_name_to_id = {d["name"]: d["id"] for d in self.col.decks.all()}
58
+ self.deck_names = self.deck_name_to_id.keys()
59
+ self.n_decks: int = len(self.deck_names)
60
+
61
+ def _init_load_profile(
62
+ self, base_path_str: Optional[str], collection_db_path: Optional[str]
63
+ ) -> None:
64
+ """Load the Anki profile from database"""
65
+ if base_path_str is None:
66
+ if collection_db_path:
67
+ self._collection_db_path = str(Path(collection_db_path).absolute())
68
+ return
69
+
70
+ console.print("Base path is not properly set!")
71
+ raise Abort()
72
+
73
+ base_path = Path(base_path_str)
74
+ db_path = base_path / "prefs21.db"
75
+
76
+ if not db_path.exists():
77
+ console.print("Invalid base path!")
78
+ console.print(f"path = {base_path.absolute()}")
79
+ raise Abort()
80
+
81
+ # Load metadata and profiles from database
82
+ conn = sqlite3.connect(db_path)
83
+ try:
84
+ res = conn.execute(
85
+ "select cast(data as blob) from profiles where name = '_global'"
86
+ )
87
+ self._meta = pickle.loads(res.fetchone()[0])
88
+
89
+ profiles = conn.execute(
90
+ "select name, cast(data as blob) from profiles where name != '_global'"
91
+ ).fetchall()
92
+ finally:
93
+ conn.close()
94
+
95
+ profiles_dict = {name: pickle.loads(data) for name, data in profiles}
96
+
97
+ if self._profile_name is None:
98
+ self._profile_name = self._meta.get(
99
+ "last_loaded_profile_name", profiles[0][0]
100
+ )
101
+
102
+ self._collection_db_path = str(
103
+ base_path / self._profile_name / "collection.anki2"
104
+ )
105
+ self._profile = profiles_dict[self._profile_name]
106
+
107
+ def _init_load_collection(self) -> None:
108
+ """Load the Anki collection"""
109
+ # pylint: disable=import-outside-toplevel
110
+ from anki.collection import Collection
111
+ from anki.errors import DBError
112
+
113
+ # Save CWD (because Anki changes it)
114
+ save_cwd = os.getcwd()
115
+
116
+ try:
117
+ self.col = Collection(self._collection_db_path)
118
+ except AssertionError as error:
119
+ console.print("Path to database is not valid!")
120
+ console.print(f"path = {self._collection_db_path}")
121
+ raise Abort() from error
122
+ except DBError as error:
123
+ console.print("Database is NA/locked!")
124
+ raise Abort() from error
125
+
126
+ # Restore CWD (because Anki changes it)
127
+ os.chdir(save_cwd)
128
+
129
+ @staticmethod
130
+ def _init_load_config() -> None:
131
+ """Load custom configuration"""
132
+ # pylint: disable=import-outside-toplevel
133
+ from anki import latex
134
+
135
+ # Update LaTeX commands
136
+ # * Idea based on Anki addon #1546037973 ("Edit LaTeX build process")
137
+ if "pngCommands" in cfg:
138
+ latex.pngCommands = cfg["pngCommands"]
139
+ if "svgCommands" in cfg:
140
+ latex.svgCommands = cfg["svgCommands"]
141
+
142
+ def __enter__(self) -> Anki:
143
+ return self
144
+
145
+ def __exit__(
146
+ self,
147
+ exc_type: Optional[Type[BaseException]],
148
+ exc_val: Optional[BaseException],
149
+ exc_tb: Optional[TracebackType],
150
+ ) -> None:
151
+ if self.modified:
152
+ console.print("Database was modified.")
153
+ if self._profile is not None and self._profile["syncKey"]:
154
+ console.print("[blue]Remember to sync!")
155
+
156
+ self.col.close()
157
+
158
+ def sync(self) -> None:
159
+ """Sync collection to AnkiWeb"""
160
+ # pylint: disable=import-outside-toplevel
161
+ from anki.sync import SyncAuth
162
+
163
+ if self._profile is None:
164
+ return
165
+
166
+ hkey = self._profile.get("syncKey")
167
+ if not hkey:
168
+ return
169
+
170
+ auth = SyncAuth(
171
+ hkey=hkey,
172
+ endpoint=self._profile.get("currentSyncUrl")
173
+ or self._profile.get("customSyncUrl")
174
+ or None,
175
+ io_timeout_secs=self._profile.get("networkTimeout") or 30,
176
+ )
177
+
178
+ if auth is None:
179
+ return
180
+
181
+ with Progress(
182
+ TextColumn(
183
+ "Syncing {task.fields[name]} [green]…[/green] {task.description}"
184
+ ),
185
+ SpinnerColumn(spinner_name="point", finished_text=""),
186
+ console=console,
187
+ ) as progress:
188
+ t1 = progress.add_task("", total=None, name="deck")
189
+ t2 = progress.add_task("", total=None, name="media")
190
+
191
+ # Perform main sync
192
+ with suppress_stdout():
193
+ self.col.sync_collection(auth, True)
194
+ progress.update(t1, total=1, completed=1, description="[green]done!")
195
+
196
+ # Perform media sync
197
+ with cd(self.col.media.dir()):
198
+ status_str = ""
199
+ self.col.sync_media(auth)
200
+ try:
201
+ while True:
202
+ time.sleep(0.01)
203
+ status = self.col.media_sync_status()
204
+ if p := status.progress:
205
+ status_str = f"{p.added}, {p.removed}, {p.checked}".lower()
206
+ progress.update(t2, description=f"[blue]({status_str})")
207
+ if not status.active:
208
+ break
209
+
210
+ except Exception as error:
211
+ if "sync cancelled" in str(error):
212
+ progress.update(
213
+ t2,
214
+ total=1,
215
+ completed=1,
216
+ description="[yellow]cancelled!",
217
+ )
218
+ return
219
+ raise Abort() from error
220
+
221
+ progress.update(
222
+ t2,
223
+ total=1,
224
+ completed=1,
225
+ description=f"[blue]({status_str}) [green]done!",
226
+ )
227
+
228
+ def check_media(self) -> None:
229
+ """Check media (will rebuild missing LaTeX files)"""
230
+ # pylint: disable=import-outside-toplevel
231
+ from anki.notes import NoteId
232
+
233
+ with cd(self.col.media.dir()):
234
+ with Progress(
235
+ TextColumn("{task.description}"),
236
+ SpinnerColumn(spinner_name="point", finished_text=""),
237
+ console=console,
238
+ ) as progress:
239
+ t1 = progress.add_task("Checking media DB [green]… ", total=None)
240
+ output = self.col.media.check()
241
+ progress.update(
242
+ t1,
243
+ total=1,
244
+ completed=1,
245
+ description="Checking media DB [green]… done!",
246
+ )
247
+
248
+ if len(output.missing) + len(output.unused) == 0:
249
+ console.print("[white]No unused or missing files found.")
250
+ return
251
+
252
+ for file in output.missing:
253
+ console.print(f"[red]Missing: {file}")
254
+
255
+ if len(output.missing) > 0 and console.confirm("Render missing LaTeX?"):
256
+ out = self.col.media.render_all_latex()
257
+ if out is not None:
258
+ nid = NoteId(out[0])
259
+ console.print(f"[red]Error processing note: {nid}")
260
+
261
+ if console.confirm("Review note?"):
262
+ note = Note(self, self.col.get_note(nid))
263
+ note.review()
264
+
265
+ for file in output.unused:
266
+ console.print(f"[red]Unused: {file}")
267
+
268
+ if len(output.unused) > 0 and console.confirm("Delete unused media?"):
269
+ for file in output.unused:
270
+ if os.path.isfile(file):
271
+ os.remove(file)
272
+
273
+ def find_cards(self, query: str) -> Sequence[CardId]:
274
+ """Find card ids in Collection that match query"""
275
+ return self.col.find_cards(query)
276
+
277
+ def find_notes(self, query: str) -> Generator[Note, None, None]:
278
+ """Find notes in Collection and return Note objects"""
279
+ return (
280
+ Note(self, self.col.get_note(i)) for i in set(self.col.find_notes(query))
281
+ )
282
+
283
+ def delete_notes(self, ids: NoteId | list[NoteId]) -> None:
284
+ """Delete notes by note ids"""
285
+ if not isinstance(ids, list):
286
+ ids = [ids]
287
+
288
+ self.col.remove_notes(ids)
289
+ self.modified = True
290
+
291
+ def get_model(self, model_name: str) -> Optional[NotetypeDict]:
292
+ """Get model from model name"""
293
+ # pylint: disable=import-outside-toplevel
294
+ from anki.models import NotetypeId
295
+
296
+ model_id = self.model_name_to_id.get(model_name)
297
+ if not isinstance(model_id, int):
298
+ return None
299
+
300
+ return self.col.models.get(NotetypeId(model_id))
301
+
302
+ def set_model(self, model_name: str) -> NotetypeDict:
303
+ """Set current model based on model name"""
304
+ current = self.col.models.current(for_deck=False)
305
+ if current["name"] == model_name:
306
+ return current
307
+
308
+ model = self.get_model(model_name)
309
+ if model is None:
310
+ console.print(f'Model "{model_name}" was not recognized!')
311
+ raise Abort()
312
+
313
+ self.col.models.set_current(model)
314
+ return model
315
+
316
+ def rename_model(self, old_model_name: str, new_model_name: str) -> None:
317
+ """Rename a model"""
318
+ model = self.get_model(old_model_name)
319
+ if not model:
320
+ console.print("Can't rename model!")
321
+ console.print(f"No such model: {old_model_name}")
322
+ raise Abort()
323
+
324
+ # Change the name
325
+ model["name"] = new_model_name
326
+
327
+ # Update local storage
328
+ self.model_name_to_id = {m["name"]: m["id"] for m in self.col.models.all()}
329
+ self.model_names = list(self.model_name_to_id.keys())
330
+
331
+ # Save changes
332
+ self.col.models.update_dict(model)
333
+ self.modified = True
334
+
335
+ def list_tags(self) -> None:
336
+ """List all tags"""
337
+ tags = [(t, len(self.col.find_notes(f"tag:{t}"))) for t in self.col.tags.all()]
338
+ width = len(max(tags, key=lambda x: len(x[0]))[0]) + 2
339
+ filler = " " * (cfg["width"] - 2 * width - 8)
340
+
341
+ for (t1, n1), (t2, n2) in zip(
342
+ sorted(tags, key=lambda x: x[0]), sorted(tags, key=lambda x: x[1])
343
+ ):
344
+ console.print(f"{t1:{width}s}{n1:4d}{filler}{t2:{width}s}{n2:4d}")
345
+
346
+ def change_tags(self, query: str, tags: str, add: bool = True) -> None:
347
+ """Add/Remove tags from notes that match query"""
348
+ note_ids = self.col.find_notes(query)
349
+ if add:
350
+ self.col.tags.bulk_add(note_ids, tags)
351
+ else:
352
+ self.col.tags.bulk_remove(note_ids, tags)
353
+
354
+ self.modified = True
355
+
356
+ def edit_model_css(self, model_name: str) -> None:
357
+ """Edit the CSS part of a given model."""
358
+ model = self.get_model(model_name)
359
+ if not model:
360
+ console.print(f"Could not find model: {model_name}!")
361
+ return
362
+
363
+ with tempfile.NamedTemporaryFile(
364
+ mode="w+", prefix="_apy_edit_", suffix=".css", delete=False
365
+ ) as tf:
366
+ tf.write(model["css"])
367
+ tf.flush()
368
+
369
+ retcode = editor(tf.name)
370
+ if retcode != 0:
371
+ console.print(f"Editor return with exit code {retcode}!")
372
+ return
373
+
374
+ with open(tf.name, "r", encoding="utf8") as f:
375
+ new_content = f.read()
376
+
377
+ if model["css"] != new_content:
378
+ model["css"] = new_content
379
+ self.col.models.save(model, templates=True)
380
+ self.modified = True
381
+
382
+ def list_notes(self, query: str) -> None:
383
+ """List notes that match a query"""
384
+ for note in self.find_notes(query):
385
+ cards.print_question(note.n.cards()[0])
386
+
387
+ def list_cards(self, query: str, verbose: bool = False) -> None:
388
+ """List cards that match a query"""
389
+ for cid in self.find_cards(query):
390
+ card = self.col.get_card(cid)
391
+ cards.print_question(card)
392
+
393
+ if verbose:
394
+ cards.print_answer(card)
395
+ cards.print_stats(card)
396
+
397
+ def add_notes_with_editor(
398
+ self,
399
+ tags: str = "",
400
+ model_name: Optional[str] = None,
401
+ deck_name: Optional[str] = None,
402
+ template: Optional[Note] = None,
403
+ ) -> list[Note]:
404
+ """Add new notes to collection with editor"""
405
+ if template:
406
+ input_string = str(template)
407
+ else:
408
+ if model_name is None or model_name.lower() == "ask":
409
+ model_name = choose(sorted(self.model_names), "Choose model:")
410
+
411
+ if deck_name is None:
412
+ deck_name = self.col.decks.current()["name"]
413
+ elif deck_name.lower() == "ask":
414
+ deck_name = choose(sorted(self.deck_names), "Choose deck:")
415
+
416
+ input_strings = [f"model: {model_name}"]
417
+
418
+ if self.n_decks > 1:
419
+ input_strings += [f"deck: {deck_name}"]
420
+
421
+ input_strings += [f"tags: {tags}"]
422
+
423
+ if model_name not in cfg["markdown_models"]:
424
+ input_strings += ["markdown: false"]
425
+
426
+ input_strings += ["\n# Note\n"]
427
+
428
+ model = self.set_model(model_name)
429
+ input_strings += [
430
+ x
431
+ for y in [[f'## {field["name"]}', ""] for field in model["flds"]]
432
+ for x in y
433
+ ]
434
+
435
+ input_string = "\n".join(input_strings) + "\n"
436
+
437
+ with tempfile.NamedTemporaryFile(
438
+ mode="w+", prefix="apy_note_", suffix=".md", delete=False
439
+ ) as tf:
440
+ tf.write(input_string)
441
+ tf.flush()
442
+ retcode = editor(tf.name)
443
+
444
+ if retcode != 0:
445
+ console.print(f"Editor return with exit code {retcode}!")
446
+ return []
447
+
448
+ return self.add_notes_from_file(tf.name)
449
+
450
+ def add_notes_from_file(
451
+ self, filename: str, tags: str = "", deck: Optional[str] = None
452
+ ) -> list[Note]:
453
+ """Add new notes to collection from Markdown file"""
454
+ notes = markdown_file_to_notes(filename)
455
+ return self.add_notes_from_list(notes, tags, deck)
456
+
457
+ def add_notes_from_list(
458
+ self,
459
+ parsed_notes: list[NoteData],
460
+ tags: str = "",
461
+ deck: Optional[str] = None,
462
+ ) -> list[Note]:
463
+ """Add new notes to collection from note list (from parsed file)"""
464
+ notes = []
465
+ for note in parsed_notes:
466
+ if note.deck is None:
467
+ note.deck = deck
468
+ note.tags = f"{tags} {note.tags}"
469
+ notes.append(note.add_to_collection(self))
470
+
471
+ return notes
472
+
473
+ def add_notes_single(
474
+ self,
475
+ field_values: list[str],
476
+ markdown: bool,
477
+ tags: str = "",
478
+ model_name_in: Optional[str] = None,
479
+ deck: Optional[str] = None,
480
+ ) -> Note:
481
+ """Add new note to collection from args"""
482
+ model_name: str
483
+ if model_name_in:
484
+ model = self.set_model(model_name_in)
485
+ model_name = model_name_in
486
+ else:
487
+ model = self.col.models.current(for_deck=False)
488
+ model_name = model["name"]
489
+
490
+ field_names: list[str] = [field["name"] for field in model["flds"]]
491
+ fields = dict(zip(field_names, field_values))
492
+
493
+ new_note = NoteData(model_name, tags, fields, markdown, deck)
494
+ return new_note.add_to_collection(self)
apyanki/cards.py ADDED
@@ -0,0 +1,75 @@
1
+ """Utility functions for working with Anki cards"""
2
+
3
+ from __future__ import annotations
4
+ from typing import TYPE_CHECKING
5
+
6
+ from rich.text import Text
7
+
8
+ from apyanki.console import console
9
+ from apyanki.fields import prepare_field_for_cli_oneline
10
+
11
+ if TYPE_CHECKING:
12
+ from anki.cards import Card
13
+
14
+
15
+ def print_question(card: Card) -> None:
16
+ """Print the card question"""
17
+ question = Text("Q: ")
18
+ question.stylize("yellow", 0, 2)
19
+ question.append_text(
20
+ Text.from_markup(prepare_field_for_cli_oneline(card.question()))
21
+ )
22
+ console.print(question.fit(console.width))
23
+
24
+
25
+ def print_answer(card: Card) -> None:
26
+ """Print the card answer"""
27
+ answer = Text("A: ")
28
+ answer.stylize("yellow", 0, 2)
29
+ answer.append_text(Text.from_markup(prepare_field_for_cli_oneline(card.answer())))
30
+ console.print(answer.fit(console.width))
31
+
32
+
33
+ def print_stats(card: Card) -> None:
34
+ """Print the card statistics"""
35
+ cardtype = int(card.type)
36
+ card_type = ["new", "learning", "review", "relearning"][cardtype]
37
+
38
+ style = "green"
39
+ console.print(
40
+ Text.assemble(("model: ", style), card.note_type()["name"]),
41
+ Text.assemble(("due: ", style), str(card.due)),
42
+ Text.assemble(("type: ", style), card_type),
43
+ Text.assemble(("ease: ", style), str(card.factor / 10)),
44
+ Text.assemble(("lapses: ", style), str(card.lapses)),
45
+ "\n",
46
+ )
47
+
48
+
49
+ def get_flag(card: Card, text: str = "  ") -> str:
50
+ """Get rich formatted flag of card"""
51
+ style = {
52
+ 1: "red",
53
+ 2: "orange",
54
+ 3: "green",
55
+ 4: "blue",
56
+ 5: "pink1",
57
+ 6: "medium_turquoise",
58
+ 7: "purple",
59
+ }.get(card.flags)
60
+
61
+ if style:
62
+ return f"[{style}]{text}[/{style}]"
63
+
64
+ return ""
65
+
66
+
67
+ def get_due_days(card: Card, today: int) -> str:
68
+ """Get number of days until card is due"""
69
+ if card.type < 2:
70
+ return "0"
71
+
72
+ if card.type == 2:
73
+ return str(card.due - today)
74
+
75
+ return "?"