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 +1 -0
- wordflow/anki_flashcard.py +283 -0
- wordflow/cli.py +115 -0
- wordflow/clipboard.py +104 -0
- wordflow/configuration.py +171 -0
- wordflow/data/default_config.toml +103 -0
- wordflow/data/logo.txt +11 -0
- wordflow/data/logo2.txt +6 -0
- wordflow/my_classes.py +38 -0
- wordflow/notifications.py +77 -0
- wordflow/text_to_speech.py +49 -0
- wordflow/translator.py +170 -0
- wordflow/wizard.py +191 -0
- wordflow/workflows.py +146 -0
- wordflow_cli-0.1.4.dist-info/METADATA +195 -0
- wordflow_cli-0.1.4.dist-info/RECORD +20 -0
- wordflow_cli-0.1.4.dist-info/WHEEL +5 -0
- wordflow_cli-0.1.4.dist-info/entry_points.txt +2 -0
- wordflow_cli-0.1.4.dist-info/licenses/LICENSE +21 -0
- wordflow_cli-0.1.4.dist-info/top_level.txt +1 -0
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>• ",
|
|
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>• ",
|
|
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
|