wordflow-cli 0.1.4__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.
wordflow/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Makes the folder an importable package
@@ -0,0 +1,283 @@
1
+ import time
2
+ import requests
3
+ import re
4
+ import urllib.parse
5
+
6
+ from .text_to_speech import get_audio
7
+ from .my_classes import AnkiConfig, TranslationData
8
+
9
+
10
+ class AnkiConfigError(Exception):
11
+ """Raised if there is an error in the anki configuration"""
12
+
13
+
14
+ class AnkiConnectError(Exception):
15
+ """raised if failure to connect to Anki through AnkiConnect"""
16
+
17
+ pass
18
+
19
+
20
+ class DuplicateNoteError(Exception):
21
+ """raised if a duplicate note exists, so no card was created"""
22
+
23
+
24
+ def invoke(url: str, action: str, **params):
25
+ """
26
+ Standard helper to send actions to AnkiConnect and handle its error format.
27
+ """
28
+ payload = {"action": action, "version": 6, "params": params}
29
+ try:
30
+ response = requests.post(url, json=payload, timeout=5).json()
31
+
32
+ # AnkiConnect returns {"result": ..., "error": ...}
33
+ if response.get("error"):
34
+ raise AnkiConnectError(f"AnkiConnect Error: {response['error']}")
35
+
36
+ return response.get("result")
37
+
38
+ except requests.exceptions.RequestException as e:
39
+ raise AnkiConnectError(f"Could not reach Anki. Is it open? ({e})")
40
+
41
+
42
+ def setup_anki_model(url: str, model_name: str, fields: dict):
43
+ """
44
+ Checks if the required Cloze model exists.
45
+ If it does exist, it verifies the fields match to prevent silent crashes.
46
+ If not, it creates it.
47
+ """
48
+ if not fields:
49
+ raise ValueError("fields must contain at least one field.")
50
+
51
+ existing_models = invoke(url, "modelNames")
52
+ field_names = list(fields.keys())
53
+
54
+ if model_name in existing_models:
55
+ existing_fields = invoke(url, "modelFieldNames", modelName=model_name)
56
+
57
+ if existing_fields != field_names:
58
+ raise AnkiConfigError(
59
+ f"\nFATAL: The Anki model '{model_name}' already exists, but its fields do not match your config.toml.\n"
60
+ f"Anki expects : {existing_fields}\n"
61
+ f"Config sends : {field_names}\n"
62
+ )
63
+ return
64
+
65
+ # if wordflow cloze model does not exist, we create it
66
+ if model_name not in existing_models:
67
+ css = """
68
+ .card { font-family: arial; font-size: 20px; text-align: center; color: white; background-color: #282a36; }
69
+ .cloze { font-weight: bold; color: #ffb86c; }
70
+ #answer { border-top: 1px solid #6272a4; margin-top: 8px; padding-top: 10px; }
71
+ .anki-field { margin-top: 12px; }
72
+ """
73
+ # front. Works since keys are kept in order in python dict
74
+ front_anki = "{{cloze:" + field_names[0] + "}}"
75
+
76
+ # back
77
+ back_anki_parts = [f'{front_anki}<div id="answer">']
78
+ for field in field_names[1:]:
79
+ # Using Anki conditional rendering
80
+ # This ensures no whitespace is added if the field is left blank.
81
+ conditional_render = (
82
+ "{{#"
83
+ + field
84
+ + "}}<div class='anki-field'>{{"
85
+ + field
86
+ + "}}</div>{{/"
87
+ + field
88
+ + "}}"
89
+ )
90
+ back_anki_parts.append(conditional_render)
91
+
92
+ back_anki_parts.append("</div>")
93
+ back_anki = "".join(back_anki_parts)
94
+
95
+ invoke(
96
+ url,
97
+ "createModel",
98
+ modelName=model_name,
99
+ inOrderFields=field_names,
100
+ isCloze=True,
101
+ css=css,
102
+ cardTemplates=[
103
+ {
104
+ "Name": "Wordflow Cloze",
105
+ "Front": front_anki,
106
+ "Back": back_anki,
107
+ }
108
+ ],
109
+ )
110
+
111
+
112
+ def make_cloze(
113
+ anki_config: AnkiConfig, sentence_data: TranslationData, word_data: TranslationData
114
+ ):
115
+ """
116
+ makes a cloze flashcard and sends it to anki through AnkiConnect
117
+ """
118
+ # before doing anything, checks that the word is in the sentence
119
+ if word_data.source_data.lower() not in sentence_data.source_data.lower():
120
+ raise ValueError(
121
+ f"Could not find the word '{word_data.source_data}' inside the sentence. Cloze creation failed."
122
+ )
123
+
124
+ # check environment (create card type and deck if non-existing)
125
+ setup_anki_model(anki_config.url, anki_config.card_model, anki_config.fields)
126
+
127
+ # make sure deck exists
128
+ invoke(anki_config.url, "createDeck", deck=anki_config.deck)
129
+
130
+ # Make each field match required instructions in config
131
+ formatted_anki_fields = process_fields(
132
+ anki_config=anki_config, sentence_data=sentence_data, word_data=word_data
133
+ )
134
+
135
+ # create payload
136
+ note = {
137
+ "deckName": anki_config.deck,
138
+ "modelName": anki_config.card_model,
139
+ "fields": formatted_anki_fields,
140
+ "tags": anki_config.tags,
141
+ "options": {"allowDuplicate": anki_config.allow_duplicates},
142
+ }
143
+
144
+ try:
145
+ invoke(anki_config.url, "addNote", note=note)
146
+ except AnkiConnectError as e:
147
+ if "duplicate" in str(e).lower():
148
+ raise DuplicateNoteError(
149
+ f"A note for '{word_data.source_data}' already exists."
150
+ )
151
+ raise e
152
+ return True
153
+
154
+
155
+ def add_audio_to_anki(url: str, text: str, lang_code: str, accent: str):
156
+ """uses text-to-speech to create an audio of the given text and returns the [sound:...] tag."""
157
+ # generate unique name for the tag
158
+ filename = f"wordflow_{int(time.time_ns())}.mp3"
159
+
160
+ # generate audio
161
+ audio = get_audio(text=text, language=lang_code, accent=accent)
162
+
163
+ # send anki request
164
+ payload = {"filename": filename, "data": audio}
165
+ invoke(url=url, action="storeMediaFile", **payload)
166
+
167
+ # return audio tag
168
+ return f"[sound:{filename}]"
169
+
170
+
171
+ def process_fields(
172
+ anki_config: AnkiConfig, sentence_data: TranslationData, word_data: TranslationData
173
+ ) -> dict:
174
+ """
175
+ Builds the replacement dictionary, generating audio only if required,
176
+ and formats the user's custom Anki fields.
177
+ """
178
+
179
+ # 1. LAZY AUDIO
180
+ all_fields_template = "".join(anki_config.fields.values())
181
+
182
+ word_audio_instruction = ""
183
+ if "{word_audio}" in all_fields_template:
184
+ tag = add_audio_to_anki(
185
+ anki_config.url,
186
+ word_data.source_data,
187
+ word_data.source_language,
188
+ anki_config.audio_accent,
189
+ )
190
+ word_audio_instruction = (
191
+ f"<span style='font-size: 15px; color: #8be9fd;'><b>Word:</b> {tag}</span>"
192
+ )
193
+
194
+ sentence_audio_instruction = ""
195
+ if "{sentence_audio}" in all_fields_template:
196
+ tag = add_audio_to_anki(
197
+ anki_config.url,
198
+ sentence_data.source_data,
199
+ sentence_data.source_language,
200
+ anki_config.audio_accent,
201
+ )
202
+ sentence_audio_instruction = f"<span style='font-size: 15px; color: #8be9fd;'><b>Sentence:</b> {tag}</span>"
203
+
204
+ # 2. CLOZE GENERATION + optionnal dictionary url
205
+ if anki_config.dict_url:
206
+ safe_word = urllib.parse.quote(word_data.source_data)
207
+ final_url = anki_config.dict_url.format(
208
+ word=safe_word,
209
+ source_language=word_data.source_language,
210
+ target_language=word_data.target_language,
211
+ )
212
+ linked_word = f'<a href="{final_url}" style="text-decoration: none;">{word_data.source_data}</a>'
213
+ cloze_tag = "{{c1::" + linked_word + "::" + word_data.translated_data + "}}"
214
+ else:
215
+ cloze_tag = (
216
+ "{{c1::" + word_data.source_data + "::" + word_data.translated_data + "}}"
217
+ )
218
+
219
+ pattern = re.compile(re.escape(word_data.source_data), re.IGNORECASE)
220
+ cloze_instruction = pattern.sub(cloze_tag, sentence_data.source_data)
221
+
222
+ # 3. BUILD THE DICTIONARY
223
+ def format_labeled_list(data_list, max_items, label, separator=", "):
224
+ if not data_list:
225
+ return ""
226
+ items = separator.join(data_list[:max_items])
227
+ return f"<b>{label}:</b> {items}"
228
+
229
+ format_dict = {
230
+ # base
231
+ "cloze": cloze_instruction,
232
+ "translation": sentence_data.translated_data,
233
+ "source_word": word_data.source_data,
234
+ # audio
235
+ "word_audio": word_audio_instruction,
236
+ "sentence_audio": sentence_audio_instruction,
237
+ # phonetic
238
+ "sentence_phonetic": format_labeled_list(
239
+ getattr(sentence_data, "phonetic", ""), 1, "sentence phonetic"
240
+ ),
241
+ "word_phonetic": format_labeled_list(
242
+ getattr(word_data, "phonetic", ""), 1, "word phonetic"
243
+ ),
244
+ # other
245
+ "alternates": format_labeled_list(
246
+ getattr(word_data, "alternate_translations", None),
247
+ anki_config.max_alternates,
248
+ "Alternates",
249
+ ),
250
+ "definitions": format_labeled_list(
251
+ getattr(word_data, "definitions", None),
252
+ anki_config.max_definitions,
253
+ "Definitions",
254
+ separator="<br>&bull; ",
255
+ ),
256
+ "synonyms": format_labeled_list(
257
+ getattr(word_data, "synonyms", None), anki_config.max_synonyms, "Synonyms"
258
+ ),
259
+ "examples": format_labeled_list(
260
+ getattr(word_data, "examples", None),
261
+ anki_config.max_examples,
262
+ "Examples",
263
+ separator="<br>&bull; ",
264
+ ),
265
+ }
266
+
267
+ # 4. FORMAT FIELDS
268
+ formatted_anki_fields = {}
269
+
270
+ for field_name, field_template in anki_config.fields.items():
271
+ if not field_template:
272
+ formatted_anki_fields[field_name] = ""
273
+ continue
274
+
275
+ try:
276
+ formatted_anki_fields[field_name] = field_template.format(**format_dict)
277
+ except KeyError as e:
278
+ raise AnkiConfigError(
279
+ f"Warning: Unknown placeholder {e} in config field '{field_name}'"
280
+ )
281
+
282
+ print("fields sent to anki: ", formatted_anki_fields)
283
+ return formatted_anki_fields
wordflow/cli.py ADDED
@@ -0,0 +1,115 @@
1
+ import sys
2
+ import argparse
3
+ import traceback
4
+
5
+ # local modules
6
+ from .configuration import create_config, load_config, print_config
7
+ from .workflows import translate_workflow, cloze_workflow
8
+ from .wizard import launch_wizard
9
+ from .my_classes import GlobalConfig, AnkiConfig
10
+ from .notifications import notify
11
+
12
+
13
+ def get_parser():
14
+ parser = argparse.ArgumentParser(
15
+ prog="wordflow",
16
+ description="Automate Anki flashcard creation and text translation directly from your clipboard!",
17
+ epilog="Run 'wordflow --manual' to read the full detailed documentation.",
18
+ )
19
+ group = parser.add_mutually_exclusive_group(required=True)
20
+
21
+ group.add_argument(
22
+ "-t",
23
+ "--translate",
24
+ action="store_true",
25
+ help="Translate the highlighted text and show a notification.",
26
+ )
27
+ group.add_argument(
28
+ "-c",
29
+ "--cloze",
30
+ action="store_true",
31
+ help="Create a cloze flashcard from highlighted text.",
32
+ )
33
+ group.add_argument(
34
+ "-w",
35
+ "--wizard",
36
+ action="store_true",
37
+ help="Launch the interactive configuration wizard.",
38
+ )
39
+
40
+ group.add_argument(
41
+ "--print_config",
42
+ action="store_true",
43
+ help="print current configuration file and path to file",
44
+ )
45
+ # Commandline Overrides
46
+ parser.add_argument(
47
+ "-sl",
48
+ "--source_language",
49
+ type=str,
50
+ default=None,
51
+ help="Override the source language used for translation",
52
+ )
53
+ parser.add_argument(
54
+ "-tl",
55
+ "--target_language",
56
+ type=str,
57
+ default=None,
58
+ help="Override the target language used for translation",
59
+ )
60
+ parser.add_argument(
61
+ "--notify",
62
+ action=argparse.BooleanOptionalAction,
63
+ help="Enable or disable notifications (overrides config.toml)",
64
+ )
65
+
66
+ return parser
67
+
68
+
69
+ def main():
70
+
71
+ # 1. Parse CL arguments
72
+ parser = get_parser()
73
+
74
+ if len(sys.argv) == 1:
75
+ parser.print_help(sys.stderr)
76
+ sys.exit(1)
77
+
78
+ args = parser.parse_args()
79
+
80
+ try:
81
+ # 2. Handle config and wizard
82
+ if args.wizard:
83
+ config = launch_wizard()
84
+ create_config(config)
85
+ sys.exit(0)
86
+ if args.print_config:
87
+ print_config()
88
+ sys.exit(0)
89
+
90
+ # 3. loads configuration
91
+ global_config, raw_anki_data = load_config(
92
+ args.source_language, args.target_language, args.notify
93
+ )
94
+
95
+ # 4. trigger workflow
96
+ if args.translate:
97
+ translate_workflow(global_config)
98
+
99
+ elif args.cloze:
100
+ cloze_workflow(global_config, raw_anki_data)
101
+
102
+ except KeyboardInterrupt:
103
+ notify(
104
+ "Wordflow",
105
+ "Operation cancelled.",
106
+ enable_notifications=global_config.enable_notifications,
107
+ )
108
+ except Exception as e:
109
+ print("\n[DEBUG TRACEBACK]:", file=sys.stderr)
110
+ traceback.print_exc() # Prints exact file, line number, and call stack
111
+ sys.exit(1)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
wordflow/clipboard.py ADDED
@@ -0,0 +1,104 @@
1
+ # Abstraction for Wayland/X11 clipboard reading
2
+ # Checks if the user is on Wayland ($WAYLAND_DISPLAY) or X11 ($XDG_SESSION_TYPE).
3
+ # It then calls the appropriate system tool (wl-paste or xclip) or uses a Python library to return clean, string-formatted text.
4
+
5
+ import os
6
+ import subprocess
7
+ import shutil
8
+ from time import sleep
9
+
10
+
11
+ class ClipboardError(Exception):
12
+ """raised if the user is missing clipboard dependency"""
13
+
14
+ pass
15
+
16
+
17
+ class TimeOutError(Exception):
18
+ """raised if no user input is detected for a while"""
19
+
20
+
21
+ def get_text(exclusive_text: str = "") -> str:
22
+ """
23
+ gets text from clipboard, waiting for user input.
24
+ can pass exclusive_text to omit input equal to the excluded text
25
+ throws timeout and clipboard errors.
26
+ """
27
+ for _ in range(10):
28
+ current_highlight = get_clipboard_content()
29
+ if current_highlight and current_highlight != exclusive_text:
30
+ return current_highlight
31
+ sleep(1)
32
+ raise TimeOutError
33
+
34
+
35
+ def get_clipboard_content() -> str:
36
+ is_wayland = bool(os.environ.get("WAYLAND_DISPLAY"))
37
+ if is_wayland:
38
+ # Ensure wl-clipboard is actually installed
39
+ if not shutil.which("wl-paste"):
40
+ raise ClipboardError(
41
+ "Missing dependency: 'wl-clipboard' is not installed. (e.g., sudo pacman -S wl-clipboard)"
42
+ )
43
+ # Try highlighted text first, fall back to standard clipboard
44
+ try:
45
+ result = subprocess.run(
46
+ ["wl-paste", "-p"], capture_output=True, text=True, check=True
47
+ )
48
+ if result.stdout.strip():
49
+ return result.stdout.strip()
50
+ except subprocess.CalledProcessError:
51
+ pass
52
+
53
+ # Fallback to normal clipboard BLOCKED
54
+ # try:
55
+ # result = subprocess.run(
56
+ # ["wl-paste"], capture_output=True, text=True, check=True
57
+ # )
58
+ # if result.stdout.strip():
59
+ # return result.stdout.strip()
60
+ # except subprocess.CalledProcessError:
61
+ # pass # Standard clipboard is also empty
62
+
63
+ else:
64
+ # X11 approach using xclip
65
+ if not shutil.which("xclip"):
66
+ raise ClipboardError(
67
+ "Missing dependency: 'xclip' is not installed. (e.g., sudo pacman -S xclip)"
68
+ )
69
+ try:
70
+ result = subprocess.run(
71
+ ["xclip", "-o", "-selection", "primary"],
72
+ capture_output=True,
73
+ text=True,
74
+ check=True,
75
+ )
76
+ if result.stdout.strip():
77
+ return result.stdout.strip()
78
+ except subprocess.CalledProcessError:
79
+ pass
80
+ # # Fallback to standard clipboard BLOCKED
81
+ # try:
82
+ # result = subprocess.run(
83
+ # ["xclip", "-o", "-selection", "clipboard"],
84
+ # capture_output=True,
85
+ # text=True,
86
+ # check=True
87
+ # )
88
+ # if result.stdout.strip():
89
+ # return result.stdout.strip()
90
+ # except subprocess.CalledProcessError:
91
+ # pass
92
+ # blank return otherwise
93
+ return ""
94
+ # raise ClipboardError("No text found in primary selection.")
95
+
96
+
97
+ def copy_to_clipboard(message: str):
98
+ """sends a message to store in the clipboard"""
99
+ if os.environ.get("WAYLAND_DISPLAY"):
100
+ clipboard_cmd = ["wl-copy"]
101
+ else:
102
+ clipboard_cmd = ["xclip", "-selection", "clipboard"]
103
+ # Push the translation to the clipboard
104
+ subprocess.run(clipboard_cmd, input=message, text=True, check=True)
@@ -0,0 +1,171 @@
1
+ # Reads/writes user settings from ~/.config/
2
+ # if no config file exists, creates a default one
3
+ import tomllib
4
+ from pathlib import Path
5
+ from importlib.resources import files
6
+ import tomli_w
7
+
8
+ from .my_classes import GlobalConfig, AnkiConfig
9
+
10
+
11
+ config_dir = Path.home() / ".config" / "wordflow"
12
+ config_file = config_dir / "config.toml"
13
+
14
+
15
+ class ConfigError(Exception):
16
+ """Custom exception raised when configuration loading fails."""
17
+
18
+ pass
19
+
20
+
21
+ def print_config():
22
+ """
23
+ Reads and prints the current configuration file
24
+ """
25
+
26
+ print("default config path: .config/wordflow/config.toml")
27
+ try:
28
+ print(config_file.read_text(encoding="utf-8"))
29
+ except FileNotFoundError:
30
+ raise ConfigError(
31
+ "Error: Could not find config.toml in .config/wordflow. \n",
32
+ "If this is the first time you run this program, wordflow will automatically create one when called with --translate, --cloze, or --wizard",
33
+ )
34
+
35
+
36
+ def create_config(config_dict: dict | None = None):
37
+ # create default config
38
+ if not config_dict:
39
+ try:
40
+ default_config = (
41
+ files("wordflow")
42
+ .joinpath("data", "default_config.toml")
43
+ .read_text(encoding="utf-8")
44
+ )
45
+ except FileNotFoundError:
46
+ raise ConfigError(
47
+ "CRITICAL: Could not find default_config.toml in package data. Try a clean install."
48
+ )
49
+
50
+ config_dir.mkdir(parents=True, exist_ok=True)
51
+ config_file.write_text(default_config.strip(), encoding="utf-8")
52
+ return True
53
+ # else we make a custom config (from the wizard)
54
+ try:
55
+ config_dir.mkdir(parents=True, exist_ok=True)
56
+ with open(config_file, "wb") as f:
57
+ tomli_w.dump(config_dict, f)
58
+
59
+ commented_examples = """
60
+ # ------------------------------------------------------------------------------
61
+ # LANGUAGE-SPECIFIC OVERRIDES (EXAMPLES)
62
+ # ------------------------------------------------------------------------------
63
+ # Uncomment and modify these to change settings dynamically based on the detected language.
64
+
65
+ # Override for Dutch (-sl nl)
66
+ # [anki.nl]
67
+ # deck = "Languages::Nederlands"
68
+
69
+ # Override for Spanish (-sl es)
70
+ # [anki.es]
71
+ # audio_accent = "com.mx" # Uses Mexican Spanish pronunciation
72
+ """
73
+ with open(config_file, "a", encoding="utf-8") as f:
74
+ f.write(commented_examples)
75
+ return True
76
+
77
+ except Exception as e:
78
+ # Fallback error handling if there's a permission issue
79
+ raise ConfigError(
80
+ f"\n[-] Critical Error: Failed to write config file. \n Details: {e}"
81
+ )
82
+
83
+
84
+ def load_config(
85
+ source_language_override: str | None = None,
86
+ target_language_override: str | None = None,
87
+ notify: bool | None = None,
88
+ ):
89
+ """
90
+ reads user config or creates one if there is none
91
+ returns GlobalConfig, and raw anki data dictionnary
92
+ """
93
+
94
+ # create default config the first time
95
+ if not config_file.exists():
96
+ create_config()
97
+
98
+ try:
99
+ with open(config_file, "rb") as f:
100
+ config = tomllib.load(f)
101
+
102
+ global_data = config.get("global", {})
103
+ global_config = GlobalConfig(
104
+ source_language=source_language_override
105
+ or global_data.get("source_language", "auto"),
106
+ target_language=target_language_override
107
+ or global_data.get("target_language", "en"),
108
+ enable_notifications=notify
109
+ if notify is not None
110
+ else global_data.get("enable_notifications", True),
111
+ )
112
+ raw_anki_data = config.get("anki", {})
113
+
114
+ return global_config, raw_anki_data
115
+
116
+ except tomllib.TOMLDecodeError as e:
117
+ # Catch syntax errors
118
+ raise ConfigError(
119
+ f"Syntax error in config file ({config_file}):\n -> {e}\n\nPlease fix the typo, or delete the file to regenerate the defaults."
120
+ )
121
+ except PermissionError:
122
+ raise ConfigError(f"Permission denied: Cannot read {config_file}.")
123
+
124
+
125
+ def resolve_anki_config(
126
+ raw_anki_data: dict, active_language: str | None = None
127
+ ) -> AnkiConfig:
128
+ """
129
+ Merges [anki.default] with language-specific [anki.<lang>] overrides.
130
+ Returns a final AnkiConfig class with resolved names.
131
+ if there is no override, simply returns the default anki config
132
+ """
133
+
134
+ anki_defaults = raw_anki_data.get("default", {})
135
+ anki_override = raw_anki_data.get(str(active_language), {})
136
+
137
+ anki_defaults_fields = anki_defaults.get("fields", {})
138
+ anki_override_fields = anki_override.get("fields", {})
139
+
140
+ anki_config = AnkiConfig(
141
+ url=anki_override.get("url", anki_defaults.get("url")),
142
+ deck=anki_override.get(
143
+ "deck", anki_defaults.get("deck", "Wordflow::{source_language}")
144
+ ).replace("{source_language}", str(active_language)),
145
+ card_model=anki_override.get("card_model", anki_defaults.get("card_model")),
146
+ tags=anki_override.get("tags", anki_defaults.get("tags", "")),
147
+ allow_duplicates=anki_override.get(
148
+ "allow_duplicates", anki_defaults.get("allow_duplicates", False)
149
+ ),
150
+ dict_url=anki_override.get("dict_url", anki_defaults.get("dict_url", "")),
151
+ fields=anki_override_fields if anki_override_fields else anki_defaults_fields,
152
+ audio_mode=anki_override.get(
153
+ "audio_mode", anki_defaults.get("audio_mode", "none")
154
+ ),
155
+ audio_accent=anki_override.get(
156
+ "audio_accent", anki_defaults.get("audio_accent", "com")
157
+ ),
158
+ max_synonyms=anki_override.get(
159
+ "max_synonyms", anki_defaults.get("max_synonyms")
160
+ ),
161
+ max_alternates=anki_override.get(
162
+ "max_alternates", anki_defaults.get("max_alternates")
163
+ ),
164
+ max_definitions=anki_override.get(
165
+ "max_definitions", anki_defaults.get("max_definitions")
166
+ ),
167
+ max_examples=anki_override.get(
168
+ "max_examples", anki_defaults.get("max_examples")
169
+ ),
170
+ )
171
+ return anki_config