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 +12 -0
- apyanki/anki.py +494 -0
- apyanki/cards.py +75 -0
- apyanki/cli.py +480 -0
- apyanki/config.py +77 -0
- apyanki/console.py +45 -0
- apyanki/fields.py +292 -0
- apyanki/note.py +713 -0
- apyanki/utilities.py +107 -0
- apyanki-0.15.0.dist-info/LICENSE.md +21 -0
- apyanki-0.15.0.dist-info/METADATA +366 -0
- apyanki-0.15.0.dist-info/RECORD +14 -0
- apyanki-0.15.0.dist-info/WHEEL +4 -0
- apyanki-0.15.0.dist-info/entry_points.txt +3 -0
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 "?"
|