")
158 | return Soup(text_with_spaced_divs, features="html.parser").text
159 |
160 | def display_note_editor(self, item_clicked, note=False):
161 | """
162 | When note clicked in note_list_widget, reveal editor for the note
163 | """
164 | if hasattr(self, "editor"):
165 | if self.last_list_item == item_clicked:
166 | return
167 | if isinstance(self.editor, add_note_widget.AddNoteWidget):
168 | can_close = self.editor.cancel()
169 | if not can_close:
170 | self.form.note_list_widget.setCurrentItem(self.last_list_item)
171 | return
172 | self.clear_note_editors()
173 |
174 | self.last_list_item = item_clicked
175 | index = self.form.note_list_widget.currentRow()
176 | if not note:
177 | note_id = self.note_ids[index]
178 | note = self.mw.col.getNote(note_id)
179 |
180 | widget = QWidget()
181 | self.editor = aqt.editor.Editor(self.mw, widget, self)
182 | self.editor.setNote(note, focusTo=0)
183 |
184 | self.form.note_stacked_widget.addWidget(widget)
185 | self.form.note_stacked_widget.setCurrentIndex(index)
186 |
187 | def reset_list(self):
188 | """
189 | Reset list widget (and close editor) on new word select
190 | """
191 | self.note_ids = []
192 | self.form.note_list_widget.clear()
193 | self.clear_note_editors()
194 |
195 | def clear_note_editors(self):
196 | """
197 | Close any aqt.Editor instances (usually just one)
198 | """
199 | utils.clear_stacked_widget(self.form.note_stacked_widget)
200 | self.delete_editor()
201 |
202 | def render_note_creation_preset_buttons(self):
203 | """
204 | If note creation presets were made in the config, show their buttons.
205 | If no presets exist, show button to create them.
206 | :return:
207 | """
208 | self.remove_note_creation_preset_buttons()
209 | config = mw.addonManager.getConfig(__name__)
210 | presets = config[ConfigProperties.NOTE_CREATION_PRESETS.value]
211 | for preset in presets.values():
212 | text = preset["preset_name"]
213 | btn = QPushButton(text)
214 | self.form.create_btns_hbox.addWidget(btn)
215 | btn.clicked.connect(functools.partial(self.create_note_from_preset, preset))
216 | self.prompt_preset_config_dialog()
217 |
218 | def remove_note_creation_preset_buttons(self):
219 | utils.clear_layout(self.form.create_btns_hbox)
220 |
221 | def prompt_preset_config_dialog(self):
222 | btn = QPushButton("+")
223 | btn.setToolTip("Define a new note preset")
224 | btn.clicked.connect(self.display_note_creation_config)
225 | self.form.create_btns_hbox.addWidget(btn)
226 |
227 | def display_note_creation_config(self):
228 | self.mw.find_missing_words_config = config_dialog = config.ConfigDialog(mw)
229 | config_dialog.form.tab_widget.setCurrentIndex(1)
230 | config_dialog.finished.connect(self.render_note_creation_preset_buttons)
231 | config_dialog.open()
232 |
233 | def update_deck(self):
234 | deck = mw.col.decks.byName(self.deck_name)
235 | self.mw.col.conf["curDeck"] = deck["id"]
236 | self.mw.col.decks.save(deck)
237 |
238 | def update_model(self, model):
239 | self.mw.col.conf['curModel'] = model['id']
240 | current_deck = self.mw.col.decks.current()
241 | current_deck['mid'] = model['id']
242 | self.mw.col.decks.save(current_deck)
243 |
244 | def create_note_from_preset(self, preset):
245 | """
246 | Load note creation preset information into an AddNote widget.
247 | Must update the current deck (if defined in search filter) and model so that the editor will display
248 | the correct editor fields.
249 | :param preset: preset passed in from button click
250 | """
251 | if hasattr(self, "editor") and isinstance(self.editor, add_note_widget.AddNoteWidget):
252 | can_close = self.editor.cancel()
253 | if not can_close:
254 | return
255 | else:
256 | self.clear_note_editors()
257 | note_type = preset["preset_data"]["note_type"]
258 | word_dest_field = preset["preset_data"]["word_destination"]
259 | sentences_allowed = preset["preset_data"].get("sentences_allowed", False)
260 | model = self.mw.col.models.byName(note_type)
261 | if self.deck_name:
262 | self.update_deck()
263 | self.update_model(model)
264 | note = self.mw.col.newNote()
265 | note[word_dest_field] = self.current_word
266 | if sentences_allowed:
267 | sentence_presets = preset["preset_data"]["sentence_presets"]
268 | for sentence_preset_id in sentence_presets:
269 | sentence_preset = sentence_presets[sentence_preset_id]
270 | sentence_dest_field = sentence_preset["sentence_destination"]
271 | sentence_type = sentence_preset["sentence_type"]
272 | note[sentence_dest_field] = self.find_sentences(self.current_word, sentence_type)
273 | self.create_list_item_for_preset(preset["preset_name"], note)
274 | self.editor = add_note_widget.AddNoteWidget(mw, note, self.on_note_add, self.on_note_cancel, parent=self)
275 | self.form.note_stacked_widget.addWidget(self.editor)
276 |
277 | def on_note_add(self, note):
278 | """
279 | Note created in AddNote widget
280 | Overwrite temp list item with new saved note; refresh word select pane.
281 | :param note: new note saved into Anki
282 | """
283 | self.clear_note_editors()
284 | self.form.note_list_widget.takeItem(self.form.note_list_widget.count() - 1)
285 | self.note_ids.pop()
286 | self.note_ids.append(note.id)
287 | self.form.note_list_widget.addItem(self.get_note_representation(note))
288 | row_to_select = self.form.note_list_widget.count() - 1
289 | self.form.note_list_widget.setCurrentRow(row_to_select)
290 | self.last_list_item = self.form.note_list_widget.currentItem()
291 | runHook("search_missing_words")
292 |
293 | def on_note_cancel(self):
294 | self.clear_note_editors()
295 | self.form.note_list_widget.takeItem(self.form.note_list_widget.count() - 1)
296 | self.note_ids.pop()
297 | self.last_list_item = None
298 | if not self.note_ids:
299 | self.toggle_note_creation_widgets_visibility(False)
300 |
301 | def create_list_item_for_preset(self, preset_name, note):
302 | """
303 | Create temporory list item for the temporary note. Will be overridden later on note add.
304 | :param note: temporary note
305 | """
306 | if not self.note_ids:
307 | self.toggle_note_creation_widgets_visibility(True)
308 | self.note_ids.append(note.id)
309 | self.form.note_list_widget.addItem(f"New \"{preset_name}\"")
310 | row_to_select = self.form.note_list_widget.count() - 1
311 | self.form.note_list_widget.setCurrentRow(row_to_select)
312 | self.last_list_item = self.form.note_list_widget.currentItem()
313 |
314 | def delete_editor(self):
315 | """
316 | Delete singleton editor object for memory management.
317 | """
318 | if hasattr(self, "editor") and self.editor is not None:
319 | self.editor.cleanup()
320 | del self.editor
321 |
322 | def closeEvent(self, event):
323 | remHook("load_word", self.load_word)
324 | self.delete_editor()
325 | event.accept()
326 |
--------------------------------------------------------------------------------
/src/find_missing_words/gui/search_results/word_select.py:
--------------------------------------------------------------------------------
1 | """
2 | Module for the word select pane displayed on the left side of the search results window.
3 | Displays the text from the search window and highlights the words not found in the search query.
4 | """
5 |
6 | import re
7 |
8 | from aqt.qt import *
9 | from anki.hooks import runHook
10 |
11 | from .. import utils
12 |
13 |
14 | class WordSelect(QTextBrowser):
15 | """
16 | Text browser that accepts a string and a word model that defines what should be highlighted.
17 | Highlighting done via CSS.
18 | """
19 |
20 | STYLE = """
21 | body {
22 | font-size: 11pt;
23 | line-height: 1.5;
24 | }
25 | a {
26 | color: black;
27 | text-decoration: none !important;
28 | }
29 | .unknown {
30 | background: lightgreen;
31 | }
32 | """
33 |
34 | def __init__(self, text, word_model, parent=None):
35 | super().__init__(parent)
36 | self.text = text
37 | self.word_model = word_model
38 | self.build()
39 | self.setOpenLinks(False)
40 | self.anchorClicked.connect(self.intercept_click)
41 |
42 | def build(self):
43 | self.html = self.build_html()
44 | self.setText(self.html)
45 |
46 | def build_html(self):
47 | """
48 | Build the html which includes the stylesheet and the text.
49 | Use anchor
tags for new words.
50 | Use classname for CSS targeting/coloring and use href attr. for word string.
51 | On link click, look at link's href to determine word clicked on.
52 | Somewhat of a hack, but also very simple.
53 | """
54 |
55 | html = ""
56 | tokens = utils.split_words(self.text)
57 | for token in tokens:
58 | if not utils.is_word(token):
59 | # Not a word, don't allow clicking
60 | # Render double spacing correctly in HTML
61 | token = re.sub(r"\n{2,}", "
", token)
62 | html += token
63 | continue
64 | known = self.word_model[token]["known"]
65 | if known:
66 | html += f"{token}"
67 | else:
68 | html += f"
{token} "
69 | html += ""
70 | return html
71 |
72 | def set_word_model(self, word_model):
73 | self.word_model = word_model
74 |
75 | def intercept_click(self, link):
76 | """
77 | Listen for anchor
tag click, get word by looking at the href value.
78 | """
79 |
80 | word = link.toString()
81 | note_ids = self.word_model[word]["note_ids"]
82 | known = self.word_model[word]["known"]
83 | runHook("load_word", word, note_ids, known)
84 |
85 | def ignore_word(self, word):
86 | """
87 | Ignore word by changing the data model and re-rendering the html.
88 | """
89 |
90 | for token in self.word_model:
91 | if token.lower() == word.lower():
92 | self.word_model[token]["known"] = True
93 | self.build()
94 |
--------------------------------------------------------------------------------
/src/find_missing_words/gui/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from . import *
2 |
3 | import uuid
4 | import re
5 |
6 | token_regex = r"(\b[^\s]+\b)"
7 |
8 |
9 | def split_words(text):
10 | """
11 | Split words in a text by word boundary (see above regex pattern)
12 | """
13 | return re.split(token_regex, text)
14 |
15 |
16 | def is_word(text):
17 | return re.match(token_regex, text)
18 |
19 |
20 | def clear_layout(layout):
21 | for i in reversed(range(layout.count())):
22 | widget = layout.itemAt(i).widget()
23 | layout.removeWidget(widget)
24 | widget.deleteLater()
25 |
26 |
27 | def clear_stacked_widget(stacked_widget):
28 | for i in reversed(range(stacked_widget.count())):
29 | widget = stacked_widget.widget(i)
30 | if widget:
31 | stacked_widget.removeWidget(widget)
32 | widget.deleteLater()
33 |
34 |
35 | def print_object_tree(obj, indent=0):
36 | print(" " * indent, obj)
37 | for child in obj.children():
38 | print_object_tree(child, indent+1)
39 |
40 |
41 | def generate_uuid():
42 | """
43 | Unique id given to each preset for easy addressing
44 | """
45 | return str(uuid.uuid4().hex)[-6:]
46 |
--------------------------------------------------------------------------------
/src/find_missing_words/gui/utils/list_chooser.py:
--------------------------------------------------------------------------------
1 | from aqt.qt import *
2 | from aqt import mw
3 |
4 | from . import list_dialog
5 |
6 |
7 | class ListChooser(QPushButton):
8 | """
9 | Choose a single item from a list of strings
10 | """
11 | def __init__(self, title, choices, choice=None, callback=None, parent=None):
12 | super().__init__(parent)
13 | self.mw = mw
14 | self.parent = parent
15 | self.title = title
16 | self.choices = choices
17 | self.choice = choice
18 | self.callback = callback
19 | self.update_button()
20 | self.clicked.connect(self.on_choice_change)
21 |
22 | def set_choice(self, choice):
23 | self.choice = choice
24 | self.update_button()
25 | self.callback(self.choice)
26 |
27 | def set_choices(self, choices):
28 | self.choices = choices
29 | self.choice = choices[0]
30 | self.update_button()
31 | self.callback(self.choice)
32 |
33 | def on_choice_change(self):
34 | returned_list = list_dialog.ListDialog(self.title, self.choices, self.choice, self.parent)
35 | if not returned_list.selected_item:
36 | return
37 | self.choice = returned_list.selected_item
38 | self.update_button()
39 | self.callback(self.choice)
40 |
41 | def update_button(self):
42 | if not self.choice:
43 | self.choice = self.choices[0]
44 | self.text = self.choice
45 | if len(self.text) > 15:
46 | self.text = self.text[:15] + "..."
47 | self.setText(self.text)
48 | self.mw.reset()
49 |
--------------------------------------------------------------------------------
/src/find_missing_words/gui/utils/list_dialog.py:
--------------------------------------------------------------------------------
1 | from aqt.qt import *
2 |
3 | from ..forms import list_dialog as list_dialog_form
4 |
5 |
6 | class ListDialog(QDialog):
7 | """
8 | Dialog which shows a list of strings and allows the user to pick one item
9 | """
10 | def __init__(self, window_title, items, selected_item=None, parent=None):
11 | super().__init__(parent)
12 | self.form = list_dialog_form.Ui_Dialog()
13 | self.form.setupUi(self)
14 | self.setWindowTitle(window_title)
15 | self.items = items
16 | self.selected_item = selected_item
17 | self.populate_list()
18 | self.exec_()
19 |
20 | def populate_list(self):
21 | self.form.list_widget.addItems(self.items)
22 | if self.selected_item:
23 | self.set_current_item(self.selected_item)
24 |
25 | def get_current_item(self):
26 | return self.form.list_widget.currentItem().text()
27 |
28 | def set_current_item(self, item_text):
29 | if item_text in self.items:
30 | self.form.list_widget.setCurrentRow(self.items.index(item_text))
31 |
32 | def accept(self):
33 | self.selected_item = self.get_current_item()
34 | super().accept()
35 |
--------------------------------------------------------------------------------
/src/find_missing_words/gui/utils/note_field_chooser.py:
--------------------------------------------------------------------------------
1 | from aqt.qt import *
2 | from aqt import mw
3 |
4 | from .note_field_tree import NoteFieldTree
5 |
6 |
7 | class NoteFieldChooser(QPushButton):
8 | """
9 | Button to invoke note and field tree, whose text displays current tree selection.
10 | The chooser is an interface to the note_field_tree.py and handles the data before and after choosing from the tree.
11 | Influenced by aqt.deckchooser.DeckChooser.
12 | """
13 | def __init__(self, on_update_callback=None, parent=None):
14 | super().__init__(parent)
15 | self.mw = mw
16 | self.on_update_callback = on_update_callback
17 | self.note_field_items = []
18 | self.selected_items = []
19 | self.btn_text = "None"
20 | self.clicked.connect(self.invoke_note_field_tree)
21 | self.setAutoDefault(False)
22 |
23 | self.setup_note_field_data()
24 | self.update_btn()
25 |
26 | def setup_note_field_data(self):
27 | all_note_types = self.mw.col.models.all()
28 | self.note_field_items = []
29 | for note_type in all_note_types:
30 | note_dict = {"name": note_type["name"], "state": Qt.Unchecked}
31 | note_fields = []
32 | for field in note_type["flds"]:
33 | field_dict = {"name": field["name"], "state": Qt.Unchecked}
34 | note_fields.append(field_dict)
35 | note_dict["fields"] = note_fields
36 | self.note_field_items.append(note_dict)
37 |
38 | def set_selected_items(self, selected_note_field_items):
39 | """
40 | Merge note field tree with another note field tree of checked items
41 | :param selected_note_field_items: note field tree with checked items
42 | """
43 | for note in self.note_field_items:
44 | note_name = note["name"]
45 | fields = note["fields"]
46 | for new_note in selected_note_field_items:
47 | if note_name == new_note["name"]:
48 | note["state"] = new_note["state"]
49 | new_note_fields = new_note["fields"]
50 | for field in fields:
51 | for new_field in new_note_fields:
52 | if field["name"] == new_field["name"]:
53 | field["state"] = new_field["state"]
54 | self.update_btn(selected_note_field_items)
55 |
56 | def invoke_note_field_tree(self):
57 | ret = NoteFieldTree(self.note_field_items, self)
58 | self.note_field_items = ret.all_items
59 | self.update_btn(ret.selected_items)
60 |
61 | def update_btn(self, selected_items=None):
62 | """
63 | Make choice, update chooser button text with formatted text no longer than 15 chars
64 | :param selected_items: note/field tree selection
65 | """
66 | if not selected_items:
67 | self.btn_text = "None"
68 | else:
69 | formatted_selected_items = self.format_btn_text(selected_items)
70 | if len(formatted_selected_items) > 15:
71 | self.btn_text = formatted_selected_items[:15] + '...'
72 | else:
73 | self.btn_text = formatted_selected_items
74 | self.setToolTip(formatted_selected_items)
75 | self.selected_items = selected_items
76 | self.setText(self.btn_text)
77 | if self.on_update_callback:
78 | self.on_update_callback()
79 |
80 | @staticmethod
81 | def format_btn_text(items):
82 | """
83 | Format: Note Type (Field1, Field 2), Note Type 2, ...
84 | :param items: note/field tree selection
85 | :return: formatted string of the tree selection
86 | """
87 | note_field_list = []
88 | for note in items:
89 | name = note["name"]
90 | fields = ', '.join([field["name"] for field in note["fields"]])
91 | result = name + '(' + fields + ')'
92 | note_field_list.append(result)
93 | return ', '.join(note_field_list)
94 |
--------------------------------------------------------------------------------
/src/find_missing_words/gui/utils/note_field_tree.py:
--------------------------------------------------------------------------------
1 | """
2 | A hierarchical view for the relationship between note types and their fields.
3 | See docs/data-structures/note_field_tree.md for details on the data structure.
4 | """
5 |
6 | from aqt.qt import *
7 |
8 | from ..forms import note_field_tree as tree_form
9 |
10 |
11 | class NoteFieldTree(QDialog):
12 | """
13 | Dialog containing tree widget that organizes notes/models and their fields
14 | """
15 |
16 | def __init__(self, note_fields, parent=None):
17 | super().__init__(parent)
18 | self.note_fields = note_fields
19 | self.form = tree_form.Ui_Dialog()
20 | self.form.setupUi(self)
21 |
22 | self.selected_items = []
23 | self.all_items = []
24 |
25 | self.form.btn_expand_all.clicked.connect(self.expand_all)
26 | self.form.btn_expand_none.clicked.connect(self.expand_none)
27 | self.form.btn_select_all.clicked.connect(self.select_all)
28 | self.form.btn_select_none.clicked.connect(self.select_none)
29 | self.form.buttonBox.accepted.connect(self.accept)
30 |
31 | self.render_tree()
32 | self.exec_()
33 |
34 | def get_all_items(self, only_checked=False):
35 | items = []
36 | num_notes = self.form.tree_widget.topLevelItemCount()
37 | for i in range(num_notes):
38 | note = self.form.tree_widget.topLevelItem(i)
39 | if only_checked and note.checkState(0) == Qt.Unchecked:
40 | continue
41 | note_dict = {"name": note.text(0), "state": note.checkState(0)}
42 | fields = []
43 | for j in range(note.childCount()):
44 | field = note.child(j)
45 | if only_checked and field.checkState(0) == Qt.Unchecked:
46 | continue
47 | field_dict = {"name": field.text(0), "state": field.checkState(0)}
48 | fields.append(field_dict)
49 | note_dict["fields"] = fields
50 | items.append(note_dict)
51 | return items
52 |
53 | def select_all(self):
54 | for i in range(self.form.tree_widget.topLevelItemCount()):
55 | self.form.tree_widget.topLevelItem(i).setCheckState(0, Qt.Checked)
56 |
57 | def select_none(self):
58 | for i in range(self.form.tree_widget.topLevelItemCount()):
59 | self.form.tree_widget.topLevelItem(i).setCheckState(0, Qt.Unchecked)
60 |
61 | def expand_all(self):
62 | for i in range(self.form.tree_widget.topLevelItemCount()):
63 | self.form.tree_widget.topLevelItem(i).setExpanded(True)
64 |
65 | def expand_none(self):
66 | for i in range(self.form.tree_widget.topLevelItemCount()):
67 | self.form.tree_widget.topLevelItem(i).setExpanded(False)
68 |
69 | def render_tree(self):
70 | self.form.tree_widget.setHeaderLabel("")
71 | for note in self.note_fields:
72 | note_tree_item = QTreeWidgetItem(self.form.tree_widget, [note["name"]])
73 | for field in note["fields"]:
74 | field_tree_item = QTreeWidgetItem(note_tree_item, [field["name"]])
75 | field_tree_item.setCheckState(0, field["state"])
76 | note_tree_item.setCheckState(0, note["state"])
77 | note_tree_item.setFlags(note_tree_item.flags() | Qt.ItemIsAutoTristate)
78 | note_tree_item.setExpanded(False)
79 |
80 | def accept(self):
81 | self.all_items = self.get_all_items()
82 | self.selected_items = self.get_all_items(True)
83 | super().accept()
84 |
--------------------------------------------------------------------------------