├── .gitignore ├── README.md ├── addon.json ├── demo.gif ├── designer ├── add_note_widget.ui ├── config.ui ├── config_note_creation_tab.ui ├── config_note_preset.ui ├── config_search_tab.ui ├── config_sentence_preset.ui ├── list_dialog.ui ├── note_creation.ui ├── note_field_tree.ui └── search.ui ├── docs ├── DEVELOPMENT.md └── data-structures │ ├── note_field_tree.md │ └── word_select.md └── src ├── Find Missing Words.py └── find_missing_words ├── __init__.py ├── config.json ├── config.md └── gui ├── __init__.py ├── config ├── __init__.py ├── note_creation_tab.py ├── note_preset.py ├── properties.py ├── search_tab.py └── sentence_preset.py ├── forms └── __init__.py ├── options.py ├── search.py ├── search_results ├── __init__.py ├── add_note_widget.py ├── note_creation.py └── word_select.py └── utils ├── __init__.py ├── list_chooser.py ├── list_dialog.py ├── note_field_chooser.py └── note_field_tree.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Linux FM 2 | .hidden 3 | .directory 4 | 5 | # Byte-compiled / optimized / DLL files 6 | *.pyo 7 | *.pyc 8 | __pycache__/ 9 | 10 | # Python 11 | .venv 12 | venv/ 13 | .pylintrc 14 | 15 | # IDEs 16 | *.sublime-project 17 | *.sublime-workspace 18 | .sublime-backup/ 19 | .idea/ 20 | .vscode/ 21 | 22 | # Build files 23 | build 24 | src/*/gui/forms/anki* 25 | src/*/gui/forms/LICENSE* 26 | src/*/gui/resources/anki* 27 | src/*/gui/resources/LICENSE* 28 | *-anki2*.zip 29 | 30 | # Anki 31 | src/*/meta.json 32 | src/*/manifest.json 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Find Missing Words in Anki 2 | 3 | An Anki add-on for searching through your collection to find gaps in your vocabulary and quickly create notes to fill in those gaps. 4 | 5 | This is a similar flow to LingQ or ReadLang. But instead of starting your vocab from scratch, it uses your existing Anki collection to find which words you don't know. 6 | 7 | ## Demo 8 | 9 | ![Video demo](https://raw.githubusercontent.com/ll-in-anki/find-missing-words/master/demo.gif) 10 | 11 | ## Installation 12 | 13 | See [AnkiWeb](https://ankiweb.net/shared/info/754868802) for quick install instructions. 14 | 15 | Addon code: **754868802** 16 | 17 | ## Usage 18 | 19 | ### Search 20 | 21 | ![](https://i.imgur.com/dPQW4wc.png) 22 | 23 | 1. Open the "Find Missing Words" addon from the Anki Tools menu 24 | 1. Optionally filter your search 25 | - By deck and/or by models and their fields 26 | - Check the box next to the filter to enable it, disable to leave search open 27 | - This filter will determine if the words in the text are considered 'known' or 'missing.' 28 | - **Note:** including the fields in the search is much more accurate than leaving it blank (all fields). 29 | 1. Enter text into the text area 30 | 1. Click "Search" 31 | 32 | --- 33 | 34 | Example of model/field filter. I usually add words to these two note types/fields: 35 | 36 | ![](https://i.imgur.com/KDPYhLp.png) 37 | 38 | Search with populated info: 39 | 40 | ![](https://i.imgur.com/b4JCuvZ.png) 41 | 42 | ### Word Select 43 | 44 | ![](https://i.imgur.com/ThY9QJ2.png) 45 | 46 | Upon search, you are presented with a window that shows the text, with words not found in your query colored green. 47 | 48 | #### Known Words 49 | 50 | These will not be colored green, but can be clicked on to view the current notes that contain the word. 51 | 52 | 1. Click on word in left pane 53 | 1. In right pane, view the list of notes that contain the word 54 | - Note: these will only be notes that contain the word _and_ fall within the filter parameters. 55 | 1. Click on a note entry in the list 56 | 1. Use the editor to make any adjustments, if necessary 57 | 58 | #### New Words 59 | 60 | 1. Click on a green word bubble that holds a new word 61 | ![](https://i.imgur.com/8lLGJ02.png) 62 | 1. In the right pane, notice there are two options 63 | 1. Ignore the word (marks the word as known, removes green highlighting) 64 | 2. Create a note based on a note preset 65 | 66 | ##### Ignoring 67 | 68 | Ignoring a word is handy when you are just starting to use the addon and many little, insignificant words (articles, stop words, proper nouns, words you know by heart) are shown in green. You don't need cards for them, so you can ignore them and they will cease to show up as green from now on. 69 | 70 | ##### Create Note from Note Preset 71 | 72 | Now for the fun part: actually creating notes for missing words. 73 | 74 | When you first start out, you won't have any **note creation presets**. A note creation preset is a mapping that tells the extension what you'd like to do with a new word. This includes the location (deck and model/field) as well as the surrounding sentences for context (more on that later). 75 | 76 | To create a preset: 77 | 78 | 1. Click the plus ('+') button 79 | - This brings up the config (more on that later, too) 80 | ![](https://i.imgur.com/LYSk5dT.png) 81 | 1. On the "Note Creation" tab of the config, click on "Add" at the bottom of the list pane 82 | ![](https://i.imgur.com/ipm1Ze2.png) 83 | 1. Choose a name for your preset 84 | - E.g. "Fill in the Blank," "New Word Form," "Word Order" 85 | 1. Choose a destination for the word 86 | 1. Note type (the model, e.g. "Basic") 87 | 2. Word destination Field (e.g. "Front") 88 | 1. Optionally use the surrounding sentence(s) for context 89 | 1. Save, you will be taken back to the Note Creation view 90 | 1. Click on the new Note Creation Preset button (e.g. "Fill in the Blank") 91 | ![](https://i.imgur.com/vkdiUUq.png) 92 | 1. View the pre-populated fields, fill in any extra info for your note 93 | ![](https://i.imgur.com/XdfBXbv.png) 94 | 95 | Upon adding a note, the word that was selected (and any duplicates) will be marked as "known" and cease to be highlighted in the word selection view. 96 | 97 | ![](https://i.imgur.com/eGgdbVc.png) 98 | 99 | Clicking on the same word again will bring up the (new) note(s) associated with it. The regular note editor will be used instead of the "Add Note Editor" (notice not "Add" or "Cancel" buttons at the bottom). 100 | 101 | ![](https://i.imgur.com/1MUVoiS.png) 102 | 103 | ###### Sentences 104 | 105 | For a note creation preset, you can include the surrounding sentences for helpful context. 106 | 107 | To add sentences: 108 | 109 | 1. Enable the sentence box by checking the box next to "Sentences" 110 | 1. Pick a field (based on the "Note Type" model fields from above) 111 | 1. Pick a sentence configuration type 112 | 1. Whole sentence 113 | 1. Word blanked out (word -> "__") 114 | 1. Word removed (word -> "") 115 | 1. Word clozed (reuse cloze for all occurrences) 116 | - "{{c1::word}} ... {{c1::Word}} ... {{c1:: word}}" 117 | - All word occurrences revealed on card flip 118 | 1. Word clozed (separate cloze for each occurrence) 119 | - "{{c1::word}} ... {{c2::Word}} ... {{c3:: word}}" 120 | - Helpful for creating multiple cards 121 | 1. Optionally add more sentence configurations 122 | - E.g. one for whole sentence in the "whole sentence" field and another for a "sentence with word blanked out" field 123 | ![](https://i.imgur.com/KO8Vpo1.png) 124 | 125 | ### Config 126 | 127 | The config has two parts: 128 | 1. Search configuration 129 | 2. Note creation presets 130 | 131 | We've just discussed #2 above, so here's some info on search configuration: 132 | 133 | #### Search Configuration 134 | 135 | ![](https://i.imgur.com/6mly1qu.png) 136 | 137 | This tab of the config lets you set defaults to save time by not having to re-enter the search filters. 138 | 139 | ##### Filters 140 | 141 | Here, you can set a default deck and note/field combination for future searches. The steps are the same as the above Search section. If you use the same deck and fields for your words in Anki, these are for you. 142 | 143 | 144 | ##### Ignored Words 145 | 146 | In this text box, you can enter the words you don't want showing up as 'new' in the word select step. 147 | 148 | The words are separated by new lines; and common punctuation is ignored by default. When you ignore words in the Word Select view, they will save to your config and be sorted for you to see here. 149 | 150 | **Before and after ignoring a word**: 151 | 152 | Clicking "Ignore" in the top right in the Note Creation pane: 153 | 154 | ![](https://i.imgur.com/JrmgkZP.png) 155 | 156 | Seeing the word ignored in the config: 157 | 158 | ![](https://i.imgur.com/873jwLL.png) 159 | 160 | ### Saving and Cancelling 161 | 162 | Clicking "Cancel" will revert the config to the state/preferences it had when you opened it. 163 | Clicking "Save All" will save any changes on both tabs that were made since opening the config. 164 | 165 | ## Contributing 166 | 167 | - See docs/DEVELOPMENT.md for setup 168 | - See other files in docs/ for understanding some of the data structures 169 | - Fill out issues and PRs accordingly 170 | - For issues, make sure to include stack trace(s) 171 | 172 | --- 173 | 174 | Enjoy! 175 | -------------------------------------------------------------------------------- /addon.json: -------------------------------------------------------------------------------- 1 | { 2 | "display_name": "Find Missing Words", 3 | "module_name": "find_missing_words", 4 | "repo_name": "find-missing-words", 5 | "ankiweb_id": "", 6 | "author": "ll-in-anki", 7 | "contact": "https://github.com/ll-in-anki", 8 | "homepage": "https://github.com/ll-in-anki/find-missing-words", 9 | "copyright_start": 2019, 10 | "tags": "vocab reading texts", 11 | "conflicts": [], 12 | "targets": [ 13 | "anki20", 14 | "anki21" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ll-in-anki/find-missing-words/8071385eeed4cfe5d811636cc07bcb3b8bd50028/demo.gif -------------------------------------------------------------------------------- /designer/add_note_widget.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 300 11 | 12 | 13 | 14 | Add Note 15 | 16 | 17 | 18 | 19 | 20 | 6 21 | 22 | 23 | 0 24 | 25 | 26 | 27 | 28 | 29 | 0 30 | 10 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | Qt::Horizontal 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 0 52 | 10 53 | 54 | 55 | 56 | true 57 | 58 | 59 | 60 | 61 | 62 | 63 | QDialogButtonBox::NoButton 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /designer/config.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 483 10 | 344 11 | 12 | 13 | 14 | Find Mising Words Config 15 | 16 | 17 | 18 | 19 | 20 | -1 21 | 22 | 23 | 24 | 25 | 26 | 27 | Qt::Horizontal 28 | 29 | 30 | QDialogButtonBox::Cancel|QDialogButtonBox::SaveAll 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | buttonBox 40 | accepted() 41 | Dialog 42 | accept() 43 | 44 | 45 | 248 46 | 254 47 | 48 | 49 | 157 50 | 274 51 | 52 | 53 | 54 | 55 | buttonBox 56 | rejected() 57 | Dialog 58 | reject() 59 | 60 | 61 | 316 62 | 260 63 | 64 | 65 | 286 66 | 274 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /designer/config_note_creation_tab.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 603 10 | 300 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | font-weight: bold 23 | 24 | 25 | Note Presets 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | Qt::Horizontal 38 | 39 | 40 | 41 | 40 42 | 20 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | Remove 51 | 52 | 53 | 54 | 55 | 56 | 57 | Add 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /designer/config_note_preset.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 372 10 | 239 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Preset Name 23 | 24 | 25 | 26 | 27 | 28 | 29 | Qt::Horizontal 30 | 31 | 32 | 33 | 40 34 | 20 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | Note Type 50 | 51 | 52 | 53 | 54 | 55 | 56 | Qt::Horizontal 57 | 58 | 59 | 60 | 40 61 | 20 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | Word Destination Field 74 | 75 | 76 | 77 | 78 | 79 | 80 | Qt::Horizontal 81 | 82 | 83 | 84 | 40 85 | 20 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 0 97 | 0 98 | 99 | 100 | 101 | Sentences 102 | 103 | 104 | true 105 | 106 | 107 | true 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | Qt::Horizontal 116 | 117 | 118 | 119 | 40 120 | 20 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | true 129 | 130 | 131 | 132 | 133 | 134 | Add sentence destination 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /designer/config_search_tab.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 300 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | 19 | 20 | Filters 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Default Deck 30 | 31 | 32 | 33 | 34 | 35 | 36 | Qt::Horizontal 37 | 38 | 39 | 40 | 40 41 | 20 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | Default Notes and Fields 54 | 55 | 56 | 57 | 58 | 59 | 60 | Qt::Horizontal 61 | 62 | 63 | 64 | 40 65 | 20 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | true 76 | 77 | 78 | Ignored words 79 | 80 | 81 | 82 | 83 | 84 | 85 | true 86 | 87 | 88 | font-size: 11px 89 | 90 | 91 | Words you know, but don't need cards for (separated by line) 92 | 93 | 94 | 95 | 96 | 97 | 98 | true 99 | 100 | 101 | false 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /designer/config_sentence_preset.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 501 10 | 68 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | 19 | 20 | Field 21 | 22 | 23 | 24 | 25 | 26 | 27 | Qt::Horizontal 28 | 29 | 30 | 31 | 40 32 | 20 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | Type 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | Whole sentence 49 | 50 | 51 | 52 | 53 | Word blanked out 54 | 55 | 56 | 57 | 58 | Word removed 59 | 60 | 61 | 62 | 63 | Word clozed (reuse cloze for all occurrences) 64 | 65 | 66 | 67 | 68 | Word clozed (separate cloze for each occurrence) 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | color: #E44236; font-weight: bold; padding: 2.5 5 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /designer/list_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 343 10 | 300 11 | 12 | 13 | 14 | Dialog 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | Qt::Horizontal 24 | 25 | 26 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | buttonBox 36 | accepted() 37 | Dialog 38 | accept() 39 | 40 | 41 | 248 42 | 254 43 | 44 | 45 | 157 46 | 274 47 | 48 | 49 | 50 | 51 | buttonBox 52 | rejected() 53 | Dialog 54 | reject() 55 | 56 | 57 | 316 58 | 260 59 | 60 | 61 | 286 62 | 274 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /designer/note_creation.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 1098 10 | 590 11 | 12 | 13 | 14 | Word Select 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | font-weight: bold; font-size: 18px 23 | 24 | 25 | Pick new words you want to learn 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | Key 35 | 36 | 37 | 38 | 39 | 40 | 41 | 0 42 | 0 43 | 44 | 45 | 46 | Known words 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 0 57 | 0 58 | 59 | 60 | 61 | background-color: lightgreen 62 | 63 | 64 | New 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 0 73 | 0 74 | 75 | 76 | 77 | background-color: lightgreen 78 | 79 | 80 | Words 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | Qt::Horizontal 93 | 94 | 95 | 96 | 40 97 | 20 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | Qt::Vertical 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | font-size: 32px; font-weight: bold 124 | 125 | 126 | Word 127 | 128 | 129 | 130 | 131 | 132 | 133 | Qt::Horizontal 134 | 135 | 136 | 137 | 40 138 | 20 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | font-size: 12px 147 | 148 | 149 | Ignore 150 | 151 | 152 | false 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | font-size: 12px 162 | 163 | 164 | Model/field filter 165 | 166 | 167 | 168 | 169 | 170 | 171 | font-size: 12px 172 | 173 | 174 | Deck filter 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 0 185 | 186 | 187 | 188 | Notes 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | Create from preset: 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | Qt::Horizontal 208 | 209 | 210 | 211 | 40 212 | 20 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | Pick a word from the left pane to create a note 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | Notes Created 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | Note Editor 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | -------------------------------------------------------------------------------- /designer/note_field_tree.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 421 10 | 410 11 | 12 | 13 | 14 | Note Types and Fields 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Qt::Horizontal 23 | 24 | 25 | 26 | 40 27 | 20 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | Expand: 36 | 37 | 38 | 39 | 40 | 41 | 42 | None 43 | 44 | 45 | 46 | 47 | 48 | 49 | All 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 0 60 | 61 | 62 | 0 63 | 64 | 65 | 0 66 | 67 | 68 | 0 69 | 70 | 71 | 72 | 73 | Qt::Horizontal 74 | 75 | 76 | 77 | 40 78 | 20 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | Select: 87 | 88 | 89 | 90 | 91 | 92 | 93 | None 94 | 95 | 96 | 97 | 98 | 99 | 100 | All 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | Qt::Horizontal 120 | 121 | 122 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | buttonBox 132 | accepted() 133 | Dialog 134 | accept() 135 | 136 | 137 | 248 138 | 254 139 | 140 | 141 | 157 142 | 274 143 | 144 | 145 | 146 | 147 | buttonBox 148 | rejected() 149 | Dialog 150 | reject() 151 | 152 | 153 | 316 154 | 260 155 | 156 | 157 | 286 158 | 274 159 | 160 | 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /designer/search.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 389 10 | 376 11 | 12 | 13 | 14 | Find Missing Words 15 | 16 | 17 | 18 | 19 | 20 | 21 | 0 22 | 0 23 | 24 | 25 | 26 | QTabWidget::South 27 | 28 | 29 | 0 30 | 31 | 32 | 33 | 34 | 0 35 | 0 36 | 37 | 38 | 39 | Filters 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | Filter decks? 48 | 49 | 50 | 51 | 52 | 53 | 54 | Qt::Horizontal 55 | 56 | 57 | 58 | 40 59 | 20 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | Filter note types/fields? 72 | 73 | 74 | 75 | 76 | 77 | 78 | Qt::Horizontal 79 | 80 | 81 | 82 | 40 83 | 20 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 0 96 | 0 97 | 98 | 99 | 100 | Query Preview 101 | 102 | 103 | 104 | 105 | 106 | 107 | 0 108 | 0 109 | 110 | 111 | 112 | 113 | 16777215 114 | 60 115 | 116 | 117 | 118 | true 119 | 120 | 121 | false 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | Text: 133 | 134 | 135 | 136 | 137 | 138 | 139 | true 140 | 141 | 142 | false 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | Qt::Horizontal 152 | 153 | 154 | 155 | 40 156 | 20 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | Search 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | -------------------------------------------------------------------------------- /docs/DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Find Missing Words - Addon Development 2 | 3 | ## Installation 4 | 5 | 1. Clone this repo 6 | 1. Install [ll-in-anki's fork of aab](https://github.com/ll-in-anki/anki-addon-builder) 7 | 1. Run `aab build` in the clone dir 8 | 1. Setup a symlink to the build in Anki addons dir 9 | - `ln -s /build/dist/find_missing_words/ ~/.local/share/Anki2/addons21/find_missing_words` 10 | 11 | ## Usage 12 | 13 | 1. For code changes, edit files in src/ 14 | 1. Run `aab build` in the clone dir 15 | 1. Reload addon in Anki (see bottom for reloader link) or restart Anki 16 | 17 | ### Qt Designer UI Files 18 | 19 | UI files are used to speed up visual development and reduce extra code in the business logic. 20 | 21 | 1. Install Qt Designer 22 | - `sudo apt install build-essentials qtcreator` 23 | 1. Open Qt Designer and start a UI file 24 | - Not MainWindows, opt for Widgets or Dialogs 25 | 1. Name each object you want to reference later in Qt Designer's Object Inspector (top right) 26 | - i.e. `pushButton_2` -> `next_step_button` 27 | 1. Edit attributes of the objects in the Property Editor (under Object Inspector) 28 | - i.e. height, title, text, tooltip, etc. 29 | 1. Save UI file to `designer/` in this repo 30 | - i.e. `designer/my_component.ui` 31 | 1. Create Python file in `src/find_missing_words/gui` 32 | - i.e. `src/find_missing_words/gui/my_component.py` 33 | 1. Import and setup the UI form in a class 34 | ```python 35 | from aqt import mw 36 | 37 | from .forms import my_component as my_component_form 38 | 39 | # Inherit whichever Qt window you chose in the designer 40 | # Could be QDialog or QWidget 41 | class MyComponent(QDialog): 42 | def __init__(self, parent=None): 43 | super().__init__(parent) 44 | 45 | # Check the compiled UI file's class to call on the next line 46 | # Could be Ui_Form or Ui_Dialog 47 | self.form = my_component_form.Ui_Dialog() 48 | self.form.setupUi(self) 49 | 50 | def foo(self): 51 | # Reference objects (e.g. widgets, layouts) from Qt Designer by form.name 52 | self.form.next_step_button.clicked.connect(self.bar) 53 | ``` 54 | 1. Invoke the UI in Anki 55 | - Not sure of definitive answer yet, but this addon works as follows so far: 56 | ```python 57 | # For top-level widgets 58 | mw.my_top_level_widget = local_widget_var_name = MyWidget() 59 | local_widget_var_name.show() 60 | 61 | # For dialogs called within the addon 62 | self.exec_() # at the bottom of __init__()) 63 | ``` 64 | 1. Rebuild addon using `aab build` in the project's root 65 | 66 | If you want to alter the UI, open up the .ui file in Qt Designer and adjust. The XML inside the .ui file will be what gets updated and committed to git. 67 | 68 | ## Tips 69 | 70 | - Use [our forked addon reloader](https://github.com/ll-in-anki/AnkiAddonReloader) so you don't have to wait for Anki to restart 71 | - Guidelines and help in [the wiki](https://github.com/ll-in-anki/anki-LL/wiki/Qt---Notes-and-Guidelines). 72 | - Especially useful is PyCharm's 'Attach to Process' so you can use a debugger for the files in `build/dist` 73 | -------------------------------------------------------------------------------- /docs/data-structures/note_field_tree.md: -------------------------------------------------------------------------------- 1 | # Note Field Tree 2 | 3 | Used in search to filter on notes and their fields. 4 | 5 | The structure holds data useful for the QTreeWidget that represents this structure. 6 | 7 | - State: 8 | - 0: Unchecked (`Qt.Unchecked`) 9 | - 1: Partially Checked (`Qt.PartiallyChecked`) 10 | - Only for note tree items 11 | - 2: Checked (`Qt.Checked`) 12 | - See [Qt documentation](https://doc.qt.io/qt-5/qt.html#CheckState-enum) 13 | 14 | ```json 15 | [ 16 | { 17 | "name": "Note Name with No Fields Selected", 18 | "state": 0, 19 | "fields": [ 20 | { 21 | "name": "Field Name 1", 22 | "state": 0 23 | },{ 24 | "name": "Field Name 2", 25 | "state": 0 26 | } 27 | ] 28 | }, { 29 | "name": "Note Name with Some Fields Selected", 30 | "state": 1, 31 | "fields": [ 32 | { 33 | "name": "Field Name 1", 34 | "state": 2 35 | },{ 36 | "name": "Field Name 2", 37 | "state": 0 38 | } 39 | ] 40 | }, { 41 | "name": "Note Name with All Fields Selected", 42 | "state": 2, 43 | "fields": [ 44 | { 45 | "name": "Field Name 1", 46 | "state": 2 47 | },{ 48 | "name": "Field Name 2", 49 | "state": 2 50 | } 51 | ] 52 | } 53 | ] 54 | ``` -------------------------------------------------------------------------------- /docs/data-structures/word_select.md: -------------------------------------------------------------------------------- 1 | # Word Select 2 | 3 | This is the data structure for the flow layout word-select. 4 | 5 | It describes how words are differentiated based on learning state. 6 | 7 | ```json 8 | { 9 | "bee": { 10 | "known": false, 11 | "card_ids": [] 12 | }, 13 | "bird": { 14 | "known": true, 15 | "card_ids": ["1567961370129"] 16 | } 17 | } 18 | 19 | ``` -------------------------------------------------------------------------------- /src/Find Missing Words.py: -------------------------------------------------------------------------------- 1 | """ 2 | Entry point for Anki 2.0 3 | """ 4 | 5 | import find_missing_words 6 | -------------------------------------------------------------------------------- /src/find_missing_words/__init__.py: -------------------------------------------------------------------------------- 1 | def addon_reloader_before(): 2 | """ 3 | Remove any instance(s) of the addon in the Anki Tools menu to prevent duplicates 4 | """ 5 | from .gui.options import cleanup_menu_items 6 | cleanup_menu_items() 7 | 8 | 9 | def addon_reloader_after(): 10 | """ 11 | Add hook for AnkiAddonReloader to auto-show the addon after reloading to save clicks 12 | """ 13 | from .gui.options import invoke_addon_window 14 | invoke_addon_window() 15 | 16 | 17 | def initialize_addon(): 18 | from .gui.options import initialize_menu_item, initialize_config_menu_item 19 | initialize_menu_item() 20 | initialize_config_menu_item() 21 | 22 | 23 | initialize_addon() 24 | -------------------------------------------------------------------------------- /src/find_missing_words/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "filter_on_deck": false, 3 | "filter_on_note_fields": false, 4 | "default_deck": "", 5 | "default_notes_and_fields": [], 6 | "ignored_words": [], 7 | "previous_filters": [], 8 | "note_creation_presets": {} 9 | } -------------------------------------------------------------------------------- /src/find_missing_words/config.md: -------------------------------------------------------------------------------- 1 | # Find Missing Words - Config 2 | 3 | ## Search 4 | 5 | ### `default_deck` 6 | 7 | - Type: `string` 8 | - Description: Deck name to search through by default on searches 9 | - Example: `"My French Deck"` 10 | 11 | ### `default_notes_and_fields` 12 | 13 | - Type: `list[dict]` 14 | - Description: list of notes/models and their fields to search on by default 15 | - Notes must be populated with at least one field 16 | - Example: 17 | ```json 18 | [ 19 | { 20 | "name": "Cloze", 21 | "state": 1, 22 | "fields": [ 23 | { 24 | "name": "Text", 25 | "state": 2 26 | } 27 | ] 28 | } 29 | ] 30 | ``` 31 | 32 | ### `ignored_words` (coming soon) 33 | 34 | - Type: `list[string]` 35 | - Description: list of words to ignore, even if you don't have notes for them 36 | - Example: `["I", "me", "the", "you"]` 37 | 38 | ### Note Creation 39 | 40 | ### `note_creation_presets` 41 | 42 | - Type: `dict` 43 | - Description: Preset notes and fields used for creating notes, indexed by uuid 44 | - Example: 45 | ```json 46 | { 47 | "3d0c26": { 48 | "preset_id": "3d0c26", 49 | "preset_name": "My Vocab Preset", 50 | "preset_data": { 51 | "note_type": "Cloze", 52 | "word_destination": "Text", 53 | "sentences_allowed": true, 54 | "sentence_presets": { 55 | "2t2jvs": { 56 | "sentence_preset_id": "2t2jvs", 57 | "sentence_destination": "The full sentence (no words blanked out)", 58 | "sentence_type": "WHOLE" 59 | }, 60 | "f34ojn": { 61 | "sentence_preset_id": "f34ojn", 62 | "sentence_destination": "Cloze (Front)", 63 | "sentence_type": "CLOZE_REPEAT" 64 | }, 65 | "32kjgn": { 66 | "sentence_preset_id": "32kjgn", 67 | "sentence_destination": "Front (Example with word blanked out or missing)", 68 | "sentence_type": "BLANK" 69 | }, 70 | "2kjsgj": { 71 | "sentence_preset_id": "2kjsgj", 72 | "sentence_destination": "Another front field", 73 | "sentence_type": "MISSING" 74 | } 75 | } 76 | } 77 | } 78 | ``` 79 | 80 | -------------------------------------------------------------------------------- /src/find_missing_words/gui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ll-in-anki/find-missing-words/8071385eeed4cfe5d811636cc07bcb3b8bd50028/src/find_missing_words/gui/__init__.py -------------------------------------------------------------------------------- /src/find_missing_words/gui/config/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Config dialog entry point. 3 | This module will load the config initially for the tabs. 4 | Each tab of the config will write to the config itself. 5 | Write actions will be triggered here when the user clicks the dialog "Save All" button. 6 | """ 7 | from aqt import mw 8 | from aqt.qt import * 9 | from anki.hooks import runHook 10 | 11 | from ..forms import config as config_form 12 | from .search_tab import SearchTab 13 | from .note_creation_tab import NoteCreationTab 14 | 15 | 16 | class ConfigDialog(QDialog): 17 | def __init__(self, parent=None): 18 | super().__init__(parent) 19 | self.config = mw.addonManager.getConfig(__name__) 20 | self.form = config_form.Ui_Dialog() 21 | self.form.setupUi(self) 22 | self.render_tabs() 23 | 24 | def render_tabs(self): 25 | self.search_tab = search_tab.SearchTab(self.config) 26 | self.note_creation_tab = NoteCreationTab(self.config) 27 | self.form.tab_widget.addTab(self.search_tab, "Search") 28 | self.form.tab_widget.addTab(self.note_creation_tab, "Note Creation") 29 | 30 | def accept(self): 31 | self.search_tab.set_default_deck() 32 | self.search_tab.set_default_note_fields() 33 | self.search_tab.set_default_deck_toggle() 34 | self.search_tab.set_default_note_fields_toggle() 35 | self.search_tab.save_ignored_words() 36 | self.note_creation_tab.save_presets() 37 | self.cleanup() 38 | super().accept() 39 | 40 | def reject(self): 41 | self.note_creation_tab.clear_preset_widgets() 42 | self.cleanup() 43 | super().reject() 44 | 45 | @staticmethod 46 | def cleanup(): 47 | runHook("find_missing_words_config.tear_down") 48 | 49 | def closeEvent(self, event): 50 | self.cleanup() 51 | super().closeEvent(event) 52 | -------------------------------------------------------------------------------- /src/find_missing_words/gui/config/note_creation_tab.py: -------------------------------------------------------------------------------- 1 | 2 | from aqt import mw 3 | from aqt.qt import * 4 | 5 | from ..forms import config_note_creation_tab as note_creation_form 6 | from .. import utils 7 | from . import note_preset 8 | from .properties import ConfigProperties 9 | 10 | 11 | class NoteCreationTab(QDialog): 12 | """ 13 | Config dialog tab for users to define where missing words (and the contextual sentences) are to go. 14 | A preset holds a name, note type/model, and word destination. 15 | If the user wants to save sentences, it will also save sentence destination. 16 | """ 17 | def __init__(self, config, parent=None): 18 | super().__init__(parent) 19 | self.config = config 20 | self.form = note_creation_form.Ui_Form() 21 | self.form.setupUi(self) 22 | self.clear_preset_widgets() 23 | self.presets = self.config[ConfigProperties.NOTE_CREATION_PRESETS.value] 24 | self.order = [] 25 | self.render_note_creation_settings() 26 | 27 | def render_note_creation_settings(self): 28 | self.populate_presets() 29 | self.form.note_preset_list_widget.itemClicked.connect(self.display_preset) 30 | self.form.btn_preset_add.clicked.connect(self.add_preset) 31 | self.form.btn_preset_remove.clicked.connect(self.remove_preset) 32 | 33 | def populate_presets(self): 34 | """ 35 | Generate the list widget and the linked stacked widget which holds all the presets. 36 | """ 37 | for preset_id in self.presets: 38 | self.order.append(preset_id) 39 | preset = self.presets[preset_id] 40 | self.form.note_preset_list_widget.addItem(preset["preset_name"]) 41 | note_preset_widget = note_preset.NotePreset(self.config, preset, self.update_preset, parent=self) 42 | self.form.note_preset_stack.addWidget(note_preset_widget) 43 | self.form.note_preset_list_widget.setCurrentRow(0) 44 | self.display_preset() 45 | 46 | def add_preset(self): 47 | preset_name = "New Preset" 48 | preset_id = utils.generate_uuid() 49 | preset = { 50 | "preset_id": preset_id, 51 | "preset_name": preset_name, 52 | "preset_data": { 53 | "sentences_allowed": False, 54 | }, 55 | } 56 | self.presets[preset_id] = preset 57 | self.order.append(preset_id) 58 | self.form.note_preset_list_widget.addItem(preset_name) 59 | self.current_index = self.form.note_preset_list_widget.count() - 1 60 | self.form.note_preset_list_widget.setCurrentRow(self.current_index) 61 | note_preset_widget = note_preset.NotePreset(self.config, preset, self.update_preset, parent=self) 62 | self.form.note_preset_stack.addWidget(note_preset_widget) 63 | self.display_preset() 64 | 65 | def update_preset(self, preset): 66 | self.update_list(preset) 67 | self.presets[preset["preset_id"]] = preset 68 | 69 | def update_list(self, preset): 70 | """ 71 | Update preset text in the list widget on preset name change 72 | :param preset: preset object 73 | """ 74 | list_item = self.form.note_preset_list_widget.currentItem() 75 | list_item.setText(preset["preset_name"]) 76 | 77 | def remove_preset(self): 78 | if len(self.order) == 0: 79 | return 80 | self.current_index = self.form.note_preset_list_widget.currentRow() 81 | preset_id = self.order[self.current_index] 82 | 83 | widget = self.form.note_preset_stack.currentWidget() 84 | if widget is not None: 85 | self.form.note_preset_stack.removeWidget(widget) 86 | widget.deleteLater() 87 | 88 | self.presets.pop(preset_id) 89 | self.order.pop(self.current_index) 90 | item = self.form.note_preset_list_widget.takeItem(self.current_index) 91 | del item 92 | 93 | def display_preset(self): 94 | self.current_index = self.form.note_preset_list_widget.currentRow() 95 | self.form.note_preset_stack.setCurrentIndex(self.current_index) 96 | 97 | def save_presets(self): 98 | """ 99 | Save action from parent config dialog; save all presets from the stacked widget 100 | """ 101 | for i in reversed(range(self.form.note_preset_stack.count())): 102 | note_preset_widget = self.form.note_preset_stack.widget(i) 103 | preset = note_preset_widget.preset 104 | self.presets[preset["preset_id"]] = preset 105 | mw.addonManager.writeConfig(__name__, self.config) 106 | 107 | def clear_preset_widgets(self): 108 | """ 109 | Close any note creator preset widget instances 110 | """ 111 | self.form.note_preset_list_widget.clear() 112 | utils.clear_stacked_widget(self.form.note_preset_stack) 113 | -------------------------------------------------------------------------------- /src/find_missing_words/gui/config/note_preset.py: -------------------------------------------------------------------------------- 1 | from aqt import mw 2 | from aqt.qt import * 3 | from anki.hooks import runHook 4 | 5 | from ..utils import list_chooser, generate_uuid 6 | from ..forms import config_note_preset as preset_form 7 | from ..config.properties import SentenceTypes 8 | from . import sentence_preset 9 | 10 | 11 | class NotePreset(QWidget): 12 | """ 13 | Interface to change a note preset, holds basic form fields and a callback fired on every change 14 | """ 15 | def __init__(self, config, preset, on_update_callback=None, parent=None): 16 | super().__init__(parent) 17 | self.config = config 18 | self.preset = preset 19 | self.data = preset["preset_data"] 20 | self.on_update_callback = on_update_callback 21 | self.mw = mw 22 | self.note_type = self.data.get("note_type", mw.col.models.current()["name"]) 23 | 24 | self.form = preset_form.Ui_Form() 25 | self.form.setupUi(self) 26 | 27 | self.form.preset_name_input.setText(self.preset["preset_name"]) 28 | self.form.preset_name_input.textEdited.connect(self.update_preset_name) 29 | if "sentences_allowed" in self.data: 30 | self.form.sentence_groupbox.setChecked(self.data["sentences_allowed"]) 31 | 32 | if "sentence_presets" not in self.data: 33 | self.add_sentence_preset() 34 | else: 35 | self.render_sentence_destination_presets() 36 | 37 | self.render_note_type_chooser() 38 | self.render_word_destination_chooser() 39 | self.form.sentence_groupbox.toggled.connect(self.toggle_sentences) 40 | self.form.add_sentence_preset_button.clicked.connect(self.add_sentence_preset) 41 | 42 | def render_note_type_chooser(self): 43 | note_types = [model["name"] for model in self.mw.col.models.all()] 44 | self.note_type_chooser = list_chooser.ListChooser("Note Type", note_types, 45 | choice=self.note_type, 46 | callback=self.update_note_type, 47 | parent=self) 48 | self.form.note_type_hbox.addWidget(self.note_type_chooser) 49 | if "note_type" not in self.data: 50 | self.data["note_type"] = self.note_type 51 | self.update_preset() 52 | 53 | def render_word_destination_chooser(self): 54 | fields = [field["name"] for field in mw.col.models.byName(self.note_type)["flds"]] 55 | choice = self.data.get("word_destination", fields[0]) 56 | self.word_destination_chooser = list_chooser.ListChooser("Word Destination Field", fields, 57 | choice=choice, 58 | callback=self.update_word_destination, 59 | parent=self) 60 | self.form.word_destination_hbox.addWidget(self.word_destination_chooser) 61 | if "word_destination" not in self.data: 62 | self.update_word_destination(choice) 63 | 64 | def render_sentence_destination_presets(self): 65 | for preset_id in self.data["sentence_presets"]: 66 | preset = self.data["sentence_presets"][preset_id] 67 | preset_widget = sentence_preset.SentencePreset(self.config, 68 | preset, 69 | self.note_type, 70 | on_update=self.update_sentence_preset, 71 | on_delete=self.delete_sentence_preset, 72 | parent=self) 73 | self.form.sentence_groupbox.layout().addWidget(preset_widget) 74 | 75 | def toggle_sentences(self, new_state): 76 | self.data["sentences_allowed"] = new_state 77 | self.update_preset() 78 | 79 | def add_sentence_preset(self): 80 | if "sentence_presets" not in self.data: 81 | self.data["sentence_presets"] = {} 82 | preset_id = generate_uuid() 83 | preset = { 84 | "sentence_preset_id": preset_id, 85 | "sentence_type": SentenceTypes.WHOLE.name 86 | } 87 | self.data["sentence_presets"][preset_id] = preset 88 | preset_widget = sentence_preset.SentencePreset(self.config, 89 | preset, 90 | self.note_type, 91 | self.update_sentence_preset, 92 | self.delete_sentence_preset, 93 | self) 94 | self.form.sentence_groupbox.layout().addWidget(preset_widget) 95 | 96 | def update_sentence_preset(self, preset): 97 | self.data["sentence_presets"][preset["sentence_preset_id"]] = preset 98 | self.update_preset() 99 | 100 | def delete_sentence_preset(self, preset_widget): 101 | self.data["sentence_presets"].pop(preset_widget.sentence_preset["sentence_preset_id"]) 102 | self.form.sentence_groupbox.layout().removeWidget(preset_widget) 103 | preset_widget.deleteLater() 104 | 105 | def update_preset_name(self): 106 | self.preset["preset_name"] = self.form.preset_name_input.text() 107 | self.update_preset() 108 | 109 | def update_note_type(self, choice): 110 | """ 111 | If note type is updated, the word and sentence fields must also stay updated so things don't break. 112 | Select first field for word (by default) and second field for sentences so that they don't both go to the same 113 | field and break things. 114 | :param choice: note type/model string 115 | """ 116 | self.note_type = choice 117 | self.data["note_type"] = self.note_type 118 | fields = [field["name"] for field in mw.col.models.byName(self.note_type)["flds"]] 119 | self.word_destination_chooser.set_choices(fields) 120 | self.update_word_destination(self.word_destination_chooser.choice) 121 | runHook("sentence_preset.update_destination_fields", fields) 122 | self.update_preset() 123 | 124 | def update_word_destination(self, choice): 125 | self.data["word_destination"] = choice 126 | self.update_preset() 127 | 128 | def update_preset(self): 129 | if self.on_update_callback: 130 | self.on_update_callback(self.preset) 131 | -------------------------------------------------------------------------------- /src/find_missing_words/gui/config/properties.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class ConfigProperties(enum.Enum): 5 | FILTER_DECK = "filter_on_deck" 6 | FILTER_NOTE_FIELDS = "filter_on_note_fields" 7 | DECK = "default_deck" 8 | NOTE_FIELDS = "default_notes_and_fields" 9 | IGNORED_WORDS = "ignored_words" 10 | PREVIOUS_FILTERS = "previous_filters" 11 | NOTE_CREATION_PRESETS = "note_creation_presets" 12 | 13 | 14 | class SentenceTypes(enum.Enum): 15 | WHOLE = "Whole sentence" 16 | BLANK = "Word blanked out" 17 | MISSING = "Word removed" 18 | CLOZE_REPEAT = "Word clozed (reuse cloze for all occurrences)" 19 | CLOZE_SEPARATE = "Word clozed (separate cloze for each occurrence)" 20 | -------------------------------------------------------------------------------- /src/find_missing_words/gui/config/search_tab.py: -------------------------------------------------------------------------------- 1 | from aqt import mw, deckchooser 2 | from aqt.qt import * 3 | 4 | from ..utils import note_field_chooser 5 | from ..forms import config_search_tab as search_form 6 | from .properties import ConfigProperties 7 | 8 | 9 | class SearchTab(QWidget): 10 | """ 11 | Config tab to handle user preferences for search filters. 12 | Holds config for default deck, default notes and fields, and ignored words. 13 | """ 14 | def __init__(self, config, parent=None): 15 | super().__init__(parent) 16 | self.config = config 17 | self.default_deck = "" 18 | self.default_note_fields = [] 19 | 20 | self.form = search_form.Ui_Form() 21 | self.form.setupUi(self) 22 | self.load_config() 23 | self.render_default_deck_chooser() 24 | self.render_default_note_field_chooser() 25 | self.populate_ignored_words() 26 | self.form.ignored_words_text_area.textChanged.connect(self.text_change) 27 | 28 | def load_config(self): 29 | self.filter_deck = self.config[ConfigProperties.FILTER_DECK.value] 30 | self.filter_note_fields = self.config[ConfigProperties.FILTER_NOTE_FIELDS.value] 31 | self.default_deck = self.config[ConfigProperties.DECK.value] 32 | self.default_note_fields = self.config[ConfigProperties.NOTE_FIELDS.value] 33 | self.ignored_words = self.config[ConfigProperties.IGNORED_WORDS.value] 34 | 35 | def render_default_deck_chooser(self): 36 | self.default_deck_chooser_parent_widget = QWidget() 37 | self.default_deck_chooser = deckchooser.DeckChooser(mw, self.default_deck_chooser_parent_widget, label=False) 38 | self.form.default_deck_checkbox.setChecked(self.filter_deck) 39 | self.default_deck_chooser_parent_widget.setEnabled(self.filter_deck) 40 | if self.default_deck: 41 | self.default_deck_chooser.setDeckName(self.default_deck) 42 | self.form.default_deck_checkbox.stateChanged.connect(self.toggle_default_deck) 43 | self.form.default_deck_hbox.addWidget(self.default_deck_chooser_parent_widget) 44 | 45 | def toggle_default_deck(self): 46 | self.filter_deck = not self.filter_deck 47 | self.default_deck_chooser_parent_widget.setEnabled(self.filter_deck) 48 | 49 | def set_default_deck_toggle(self): 50 | self.config.update({ConfigProperties.FILTER_DECK.value: self.filter_deck}) 51 | mw.addonManager.writeConfig(__name__, self.config) 52 | 53 | def set_default_deck(self): 54 | new_deck_name = self.default_deck_chooser.deckName() 55 | self.config.update({"default_deck": new_deck_name}) 56 | mw.addonManager.writeConfig(__name__, self.config) 57 | 58 | def render_default_note_field_chooser(self): 59 | self.default_note_field_chooser = note_field_chooser.NoteFieldChooser(parent=self) 60 | self.form.default_note_field_checkbox.setChecked(self.filter_note_fields) 61 | self.default_note_field_chooser.setEnabled(self.filter_note_fields) 62 | if self.default_note_fields: 63 | self.default_note_field_chooser.set_selected_items(self.default_note_fields) 64 | self.form.default_note_field_checkbox.stateChanged.connect(self.toggle_default_note_fields) 65 | self.form.default_note_field_hbox.addWidget(self.default_note_field_chooser) 66 | 67 | def toggle_default_note_fields(self): 68 | self.filter_note_fields = not self.filter_note_fields 69 | self.default_note_field_chooser.setEnabled(self.filter_note_fields) 70 | 71 | def set_default_note_fields_toggle(self): 72 | self.config.update({ConfigProperties.FILTER_NOTE_FIELDS.value: self.filter_note_fields}) 73 | mw.addonManager.writeConfig(__name__, self.config) 74 | 75 | def set_default_note_fields(self): 76 | new_selected_items = self.default_note_field_chooser.selected_items 77 | self.config.update({"default_notes_and_fields": new_selected_items}) 78 | mw.addonManager.writeConfig(__name__, self.config) 79 | 80 | def populate_ignored_words(self): 81 | if self.ignored_words: 82 | ignored_words_text = "\n".join(sorted(self.ignored_words)) 83 | self.form.ignored_words_text_area.setText(ignored_words_text) 84 | else: 85 | self.set_text_area_placeholder() 86 | 87 | def set_text_area_placeholder(self): 88 | self.form.ignored_words_text_area.setPlaceholderText("I\nme\nthe\nyou") 89 | 90 | def text_change(self): 91 | current_text = self.form.ignored_words_text_area.toPlainText() 92 | if not current_text: 93 | self.set_text_area_placeholder() 94 | 95 | def save_ignored_words(self): 96 | text = self.form.ignored_words_text_area.toPlainText() 97 | if not text: 98 | ignored_words = [] 99 | else: 100 | text_area_words = set([word.strip().lower() for word in text.split("\n")]) 101 | ignored_words = list(text_area_words) 102 | self.config.update({ConfigProperties.IGNORED_WORDS.value: ignored_words}) 103 | mw.addonManager.writeConfig(__name__, self.config) 104 | -------------------------------------------------------------------------------- /src/find_missing_words/gui/config/sentence_preset.py: -------------------------------------------------------------------------------- 1 | from aqt import mw 2 | from aqt.qt import * 3 | from anki.hooks import addHook, remHook 4 | 5 | from ..utils import list_chooser 6 | from ..forms import config_sentence_preset as sentence_form 7 | from ..config.properties import SentenceTypes 8 | 9 | 10 | class SentencePreset(QWidget): 11 | def __init__(self, config, sentence_preset, note_type, on_update=None, on_delete=None, parent=None): 12 | super().__init__(parent) 13 | self.config = config 14 | self.sentence_preset = sentence_preset 15 | self.note_type = note_type 16 | self.on_update = on_update 17 | self.on_delete = on_delete 18 | self.mw = mw 19 | 20 | self.form = sentence_form.Ui_Form() 21 | self.form.setupUi(self) 22 | if "sentence_type" in self.sentence_preset: 23 | type_description = SentenceTypes[self.sentence_preset["sentence_type"]].value 24 | self.form.sentence_type_combo_box.setCurrentText(type_description) 25 | self.render_sentence_destination() 26 | self.form.delete_button.clicked.connect(self.delete) 27 | self.form.sentence_type_combo_box.currentTextChanged.connect(self.update_sentence_preset_type) 28 | addHook("sentence_preset.update_destination_fields", self.update_sentence_destination_fields) 29 | addHook("find_missing_words_config.tear_down", self.tear_down) 30 | 31 | def render_sentence_destination(self): 32 | fields = [field["name"] for field in mw.col.models.byName(self.note_type)["flds"]] 33 | choice = self.sentence_preset.get("sentence_destination", fields[1]) 34 | self.sentence_destination_chooser = list_chooser.ListChooser("Sentence Destination Field", fields, 35 | choice=choice, 36 | callback=self.update_sentence_destination, 37 | parent=self) 38 | self.layout().insertWidget(1, self.sentence_destination_chooser) 39 | if "sentence_destination" not in self.sentence_preset: 40 | self.update_sentence_destination(choice) 41 | 42 | def update_sentence_preset_type(self, text): 43 | sentence_type = SentenceTypes(text).name 44 | self.sentence_preset["sentence_type"] = sentence_type 45 | self.on_update(self.sentence_preset) 46 | 47 | def update_sentence_destination_fields(self, fields): 48 | self.sentence_destination_chooser.set_choices(fields) 49 | 50 | def update_sentence_destination(self, choice): 51 | self.sentence_preset["sentence_destination"] = choice 52 | self.on_update(self.sentence_preset) 53 | 54 | def tear_down(self): 55 | remHook("sentence_preset.update_destination_fields", self.update_sentence_destination_fields) 56 | remHook("find_missing_words_config.remHooks", self.tear_down) 57 | 58 | def delete(self): 59 | self.tear_down() 60 | self.on_delete(self) 61 | -------------------------------------------------------------------------------- /src/find_missing_words/gui/forms/__init__.py: -------------------------------------------------------------------------------- 1 | from .anki21 import * 2 | -------------------------------------------------------------------------------- /src/find_missing_words/gui/options.py: -------------------------------------------------------------------------------- 1 | """ 2 | Holds the Anki menu integration and configuration options for the addon 3 | """ 4 | 5 | from aqt import mw 6 | from aqt.qt import * 7 | 8 | from .search import Search 9 | from .config import ConfigDialog 10 | 11 | ADDON_NAME = "Find Missing Words" 12 | 13 | 14 | def invoke_config_window(): 15 | """ 16 | Launch custom GUI on config change instead of default Anki JSON editor 17 | """ 18 | mw.find_missing_words_config = config = ConfigDialog(mw) 19 | config.exec_() 20 | 21 | 22 | def initialize_config_menu_item(): 23 | """ 24 | Add option for addon's config in Anki 25 | :return: 26 | """ 27 | mw.addonManager.setConfigAction(__name__, invoke_config_window) 28 | 29 | 30 | def invoke_addon_window(): 31 | """ 32 | Load and open the addon 33 | 34 | In order to display the widget, we must assign it to the top-level mw. 35 | This prevents garbage collection once this function has exited. 36 | https://apps.ankiweb.net/docs/addons.html#qt 37 | """ 38 | mw.find_missing_words_widget = window = Search(mw) 39 | window.show() 40 | 41 | 42 | def initialize_menu_item(): 43 | """ 44 | Create menu item for addon in Anki "Tools" menu 45 | """ 46 | action = QAction(ADDON_NAME, mw) 47 | action.triggered.connect(invoke_addon_window) 48 | mw.form.menuTools.addAction(action) 49 | 50 | 51 | def cleanup_menu_items(): 52 | """ 53 | Remove any duplicate menu items for the addon in Anki "Tools" menu 54 | Used in development when the addon (at therefore its menu item) is reloaded multiple times within an Anki session. 55 | """ 56 | old_actions = [action for action in mw.form.menuTools.actions() if action.text() == ADDON_NAME] 57 | for action in old_actions: 58 | mw.form.menuTools.removeAction(action) 59 | -------------------------------------------------------------------------------- /src/find_missing_words/gui/search.py: -------------------------------------------------------------------------------- 1 | """ 2 | First view of the addon: search 3 | 4 | Enter search queries and filter by decks, note types, and fields 5 | """ 6 | 7 | from aqt import mw, deckchooser 8 | from aqt.qt import * 9 | from anki.hooks import addHook, remHook 10 | 11 | from .forms import search as search_form 12 | from .utils import note_field_chooser 13 | from .search_results import note_creation 14 | from .config.properties import ConfigProperties 15 | from . import utils 16 | 17 | 18 | class Search(QDialog): 19 | def __init__(self, parent=None): 20 | super().__init__(parent) 21 | # Set up UI from pre-generated UI form: 22 | self.form = search_form.Ui_Dialog() 23 | self.form.setupUi(self) 24 | 25 | self.REPLACEMENT_STRING = "[[[WORD]]]" 26 | 27 | self.deck_selection_enabled = True 28 | self.note_field_selection_enabled = False 29 | self.note_field_items = self.note_field_selected_items = [] 30 | self.selected_decks = [] 31 | self.deck_name = "" 32 | self.note_creation_window = None 33 | 34 | self.render_deck_chooser() 35 | self.render_note_field_chooser() 36 | self.form.search_button.clicked.connect(self.search) 37 | addHook("search_missing_words", self.search) 38 | 39 | def render_deck_chooser(self): 40 | self.deck_chooser_parent_widget = QWidget() 41 | self.deck_chooser = deckchooser.DeckChooser(mw, self.deck_chooser_parent_widget, label=False) 42 | filter_on_decks = mw.addonManager.getConfig(__name__)[ConfigProperties.FILTER_DECK.value] 43 | if filter_on_decks: 44 | default_deck_name = mw.addonManager.getConfig(__name__)[ConfigProperties.DECK.value] 45 | self.deck_chooser.setDeckName(default_deck_name) 46 | self.deck_selection_enabled = True 47 | self.deck_chooser_parent_widget.setEnabled(self.deck_selection_enabled) 48 | self.form.filter_decks_checkbox.setChecked(self.deck_selection_enabled) 49 | 50 | self.form.filter_decks_checkbox.stateChanged.connect(self.toggle_deck_selection) 51 | self.deck_chooser.deck.clicked.connect(self.update_deck_name) 52 | 53 | self.update_init_search() 54 | 55 | self.form.filter_decks_hbox.addWidget(self.deck_chooser_parent_widget) 56 | 57 | def update_deck_name(self): 58 | self.deck_name = self.deck_chooser.deckName() 59 | self.update_init_search() 60 | 61 | def render_note_field_chooser(self): 62 | self.note_field_chooser = note_field_chooser.NoteFieldChooser(on_update_callback=self.update_note_fields, parent=self) 63 | filter_on_note_fields = mw.addonManager.getConfig(__name__)[ConfigProperties.FILTER_NOTE_FIELDS.value] 64 | if filter_on_note_fields: 65 | default_note_fields = mw.addonManager.getConfig(__name__)[ConfigProperties.NOTE_FIELDS.value] 66 | self.note_field_chooser.set_selected_items(default_note_fields) 67 | self.note_field_selected_items = default_note_fields 68 | self.note_field_selection_enabled = True 69 | self.note_field_chooser.setEnabled(self.note_field_selection_enabled) 70 | self.form.filter_note_fields_checkbox.setChecked(self.note_field_selection_enabled) 71 | 72 | self.form.filter_note_fields_checkbox.stateChanged.connect(self.toggle_note_field_selection) 73 | 74 | self.update_init_search() 75 | 76 | self.form.filter_note_fields_hbox.addWidget(self.note_field_chooser) 77 | 78 | def update_note_fields(self): 79 | if hasattr(self, "note_field_chooser"): 80 | self.note_field_selected_items = self.note_field_chooser.selected_items 81 | self.update_init_search() 82 | 83 | def toggle_deck_selection(self): 84 | self.deck_selection_enabled = not self.deck_selection_enabled 85 | self.deck_chooser_parent_widget.setEnabled(self.deck_selection_enabled) 86 | self.update_init_search() 87 | 88 | def toggle_note_field_selection(self): 89 | self.note_field_selection_enabled = not self.note_field_selection_enabled 90 | self.note_field_chooser.setEnabled(self.note_field_selection_enabled) 91 | self.update_init_search() 92 | 93 | def update_init_search(self): 94 | deck_name = self.deck_selection_enabled and (self.deck_name or self.deck_chooser.deckName()) 95 | note_fields = self.note_field_selection_enabled and self.note_field_selected_items 96 | 97 | self.init_query = "" 98 | self.init_query += self.search_formatter(True, "deck", deck_name) if deck_name else "" 99 | if self.note_field_selection_enabled: 100 | note_type_selected = [mod['name'] for mod in note_fields] if note_fields else "" 101 | fields_selected = list( 102 | {fields['name'] for mod in note_fields for fields in mod['fields']} if note_fields else "") 103 | self.init_query += self.search_multiple_terms_formatter("note", note_type_selected) 104 | self.init_query += self.search_multiple_fields_formatter(fields_selected, self.REPLACEMENT_STRING) 105 | else: 106 | self.init_query += self.REPLACEMENT_STRING 107 | 108 | self.form.query_preview.setText(self.get_final_search("[Word]")) 109 | 110 | def search(self): 111 | """ 112 | Search through fetched cards here 113 | Display search results in a new window (note creation and word select) 114 | """ 115 | self.update_init_search() 116 | 117 | text = self.form.text_area.toPlainText() 118 | word_model = self.build_word_model(text) 119 | 120 | deck_name = self.deck_selection_enabled and (self.deck_name or self.deck_chooser.deckName()) 121 | note_fields = self.note_field_selection_enabled and self.note_field_selected_items 122 | 123 | if not self.note_creation_window or not self.note_creation_window.isVisible(): 124 | self.reset_note_creation_window() 125 | self.note_creation_window = note_creation_window = note_creation.NoteCreation(word_model, text, deck_name, note_fields, parent=self) 126 | note_creation_window.show() 127 | else: 128 | self.note_creation_window.word_select.set_word_model(word_model) 129 | self.note_creation_window.word_select.build() 130 | 131 | def build_word_model(self, text): 132 | """ 133 | Create map of words to the note ids that contain the word and filter out known words 134 | :param text: input text to tokenize and search on 135 | """ 136 | 137 | word_model = {} 138 | tokens = utils.split_words(text) 139 | for token in tokens: 140 | if not utils.is_word(token): 141 | continue 142 | query = self.get_final_search(token) 143 | found_note_ids = mw.col.findNotes(query) 144 | config = mw.addonManager.getConfig(__name__) 145 | ignored_words = config[ConfigProperties.IGNORED_WORDS.value] 146 | known = len(found_note_ids) > 0 or token.lower() in [ignored_word.lower() for ignored_word in ignored_words] 147 | word_model[token] = { 148 | "note_ids": found_note_ids, 149 | "known": known 150 | } 151 | return word_model 152 | 153 | @staticmethod 154 | def search_formatter(encapsulated, field, term, with_space=True): 155 | search = "" 156 | 157 | if encapsulated: 158 | search += "\"" 159 | 160 | if field is False: 161 | search += str(term) 162 | else: 163 | search += str(field) + ":" + str(term) 164 | 165 | if encapsulated: 166 | search += "\"" 167 | 168 | if with_space: 169 | search += " " 170 | 171 | return search 172 | 173 | def search_multiple_terms_formatter(self, field, terms): 174 | if not terms: 175 | return "" 176 | if len(terms) == 1: 177 | return self.search_formatter(True, field, terms[0]) 178 | 179 | search = "(" 180 | search += " or ".join(self.search_formatter(True, field, x, False) for x in terms) 181 | search += ") " 182 | 183 | return search 184 | 185 | def search_multiple_fields_formatter(self, fields, term): 186 | if not fields: 187 | return "" 188 | if len(fields) == 1: 189 | return self.search_formatter(True, fields[0], term) 190 | 191 | search = "(" 192 | search += " or ".join(self.search_formatter(True, x, term, False) for x in fields) 193 | search += ") " 194 | 195 | return search 196 | 197 | def get_final_search(self, word): 198 | return self.init_query.replace(self.REPLACEMENT_STRING, word) 199 | 200 | def closeEvent(self, event): 201 | remHook("search_missing_words", self.search) 202 | event.accept() 203 | 204 | def reset_note_creation_window(self): 205 | del self.note_creation_window 206 | self.note_creation_window = None 207 | -------------------------------------------------------------------------------- /src/find_missing_words/gui/search_results/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ll-in-anki/find-missing-words/8071385eeed4cfe5d811636cc07bcb3b8bd50028/src/find_missing_words/gui/search_results/__init__.py -------------------------------------------------------------------------------- /src/find_missing_words/gui/search_results/add_note_widget.py: -------------------------------------------------------------------------------- 1 | from anki.lang import _ 2 | from aqt.qt import * 3 | from aqt.utils import showWarning, askUser, shortcut, tooltip, openHelp, addCloseShortcut, downArrow 4 | from anki.sound import clearAudioQueue 5 | from anki.hooks import addHook, remHook, runHook 6 | from anki.utils import htmlToTextLine, isMac 7 | import aqt.editor, aqt.modelchooser, aqt.deckchooser 8 | 9 | from ..forms import add_note_widget as add_note_form 10 | 11 | 12 | class AddNoteWidget(QWidget): 13 | """ 14 | Widget version of the original Anki Add Cards dialog. 15 | Allows for nesting in parent widget. 16 | Not much changed other than the QWidget overrides. 17 | """ 18 | def __init__(self, mw, note=None, on_add_callback=None, on_cancel_callback=None, parent=None): 19 | super().__init__(parent) 20 | self.mw = mw 21 | self.note = note 22 | self.on_add_callback = on_add_callback 23 | self.on_cancel_callback = on_cancel_callback 24 | self.form = add_note_form.Ui_Form() 25 | self.form.setupUi(self) 26 | self.setupChoosers() 27 | self.setupEditor() 28 | self.setupButtons() 29 | self.onReset() 30 | self.history = [] 31 | addHook('reset', self.onReset) 32 | addHook('currentModelChanged', self.onModelChange) 33 | self.show() 34 | 35 | def setupEditor(self): 36 | self.editor = aqt.editor.Editor( 37 | self.mw, self.form.editor_widget, self, True) 38 | self.editor.setNote(self.note) 39 | 40 | def setupChoosers(self): 41 | self.modelChooser = aqt.modelchooser.ModelChooser( 42 | self.mw, self.form.model_chooser_widget) 43 | self.deckChooser = aqt.deckchooser.DeckChooser( 44 | self.mw, self.form.deck_chooser_widget) 45 | 46 | def helpRequested(self): 47 | openHelp("addingnotes") 48 | 49 | def setupButtons(self): 50 | bb = self.form.buttonBox 51 | ar = QDialogButtonBox.ActionRole 52 | # add 53 | self.addButton = bb.addButton(_("Add"), ar) 54 | self.addButton.clicked.connect(self.addCards) 55 | self.addButton.setShortcut(QKeySequence("Ctrl+Return")) 56 | self.addButton.setToolTip(shortcut(_("Add (shortcut: ctrl+enter)"))) 57 | # cancel 58 | self.cancel_button = QPushButton(_("Cancel")) 59 | self.cancel_button.setAutoDefault(False) 60 | bb.addButton(self.cancel_button, QDialogButtonBox.RejectRole) 61 | self.cancel_button.clicked.connect(self.cancel) 62 | # help 63 | self.helpButton = QPushButton(_("Help"), clicked=self.helpRequested) 64 | self.helpButton.setAutoDefault(False) 65 | bb.addButton(self.helpButton, QDialogButtonBox.HelpRole) 66 | # history 67 | b = bb.addButton( 68 | _("History")+ " "+downArrow(), ar) 69 | if isMac: 70 | sc = "Ctrl+Shift+H" 71 | else: 72 | sc = "Ctrl+H" 73 | b.setShortcut(QKeySequence(sc)) 74 | b.setToolTip(_("Shortcut: %s") % shortcut(sc)) 75 | b.clicked.connect(self.onHistory) 76 | b.setEnabled(False) 77 | self.historyButton = b 78 | 79 | def setAndFocusNote(self, note): 80 | self.editor.setNote(note, focusTo=0) 81 | 82 | def onModelChange(self): 83 | oldNote = self.editor.note 84 | note = self.mw.col.newNote() 85 | if oldNote: 86 | oldFields = list(oldNote.keys()) 87 | newFields = list(note.keys()) 88 | for n, f in enumerate(note.model()['flds']): 89 | fieldName = f['name'] 90 | try: 91 | oldFieldName = oldNote.model()['flds'][n]['name'] 92 | except IndexError: 93 | oldFieldName = None 94 | # copy identical fields 95 | if fieldName in oldFields: 96 | note[fieldName] = oldNote[fieldName] 97 | # set non-identical fields by field index 98 | elif oldFieldName and oldFieldName not in newFields: 99 | try: 100 | note.fields[n] = oldNote.fields[n] 101 | except IndexError: 102 | pass 103 | self.removeTempNote(oldNote) 104 | self.editor.setNote(note) 105 | 106 | def onReset(self, model=None, keep=False): 107 | oldNote = self.editor.note 108 | note = self.mw.col.newNote() 109 | flds = note.model()['flds'] 110 | # copy fields from old note 111 | if oldNote: 112 | if not keep: 113 | self.removeTempNote(oldNote) 114 | for n in range(len(note.fields)): 115 | try: 116 | if not keep or flds[n]['sticky']: 117 | note.fields[n] = oldNote.fields[n] 118 | else: 119 | note.fields[n] = "" 120 | except IndexError: 121 | break 122 | self.setAndFocusNote(note) 123 | 124 | def removeTempNote(self, note): 125 | if not note or not note.id: 126 | return 127 | # we don't have to worry about cards; just the note 128 | self.mw.col._remNotes([note.id]) 129 | 130 | def addHistory(self, note): 131 | self.history.insert(0, note.id) 132 | self.history = self.history[:15] 133 | self.historyButton.setEnabled(True) 134 | 135 | def onHistory(self): 136 | m = QMenu(self) 137 | for nid in self.history: 138 | if self.mw.col.findNotes("nid:%s" % nid): 139 | fields = self.mw.col.getNote(nid).fields 140 | txt = htmlToTextLine(", ".join(fields)) 141 | if len(txt) > 30: 142 | txt = txt[:30] + "..." 143 | a = m.addAction(_("Edit \"%s\"") % txt) 144 | a.triggered.connect(lambda b, nid=nid: self.editHistory(nid)) 145 | else: 146 | a = m.addAction(_("(Note deleted)")) 147 | a.setEnabled(False) 148 | runHook("AddCards.onHistory", self, m) 149 | m.exec_(self.historyButton.mapToGlobal(QPoint(0,0))) 150 | 151 | def editHistory(self, nid): 152 | browser = aqt.dialogs.open("Browser", self.mw) 153 | browser.form.searchEdit.lineEdit().setText("nid:%d" % nid) 154 | browser.onSearchActivated() 155 | 156 | def addNote(self, note): 157 | note.model()['did'] = self.deckChooser.selectedId() 158 | ret = note.dupeOrEmpty() 159 | if ret == 1: 160 | showWarning(_( 161 | "The first field is empty."), 162 | help="AddItems#AddError") 163 | return 164 | if '{{cloze:' in note.model()['tmpls'][0]['qfmt']: 165 | if not self.mw.col.models._availClozeOrds( 166 | note.model(), note.joinedFields(), False): 167 | if not askUser(_("You have a cloze deletion note type " 168 | "but have not made any cloze deletions. Proceed?")): 169 | return 170 | cards = self.mw.col.addNote(note) 171 | if not cards: 172 | showWarning(_("The input you have provided would make an empty question on all cards."), help="AddItems") 173 | return 174 | self.addHistory(note) 175 | self.mw.requireReset() 176 | return note 177 | 178 | def addCards(self): 179 | self.editor.saveNow(self._addCards) 180 | 181 | def _addCards(self): 182 | self.editor.saveAddModeVars() 183 | note = self.editor.note 184 | note = self.addNote(note) 185 | if not note: 186 | return 187 | tooltip(_("Added"), period=500) 188 | # stop anything playing 189 | clearAudioQueue() 190 | self.onReset(keep=True) 191 | self.mw.col.autosave() 192 | self.close() 193 | self.on_add_callback(note) 194 | 195 | def keyPressEvent(self, evt): 196 | "Show answer on RET or register answer." 197 | if (evt.key() in (Qt.Key_Enter, Qt.Key_Return) 198 | and self.editor.tags.hasFocus()): 199 | evt.accept() 200 | return 201 | return super().keyPressEvent(evt) 202 | 203 | def cancel(self): 204 | can_close = self.confirm_close() 205 | if can_close: 206 | self.close() 207 | self.on_cancel_callback() 208 | return can_close 209 | 210 | def cleanup(self): 211 | remHook('reset', self.onReset) 212 | remHook('currentModelChanged', self.onModelChange) 213 | clearAudioQueue() 214 | self.removeTempNote(self.editor.note) 215 | self.editor.cleanup() 216 | self.modelChooser.cleanup() 217 | self.deckChooser.cleanup() 218 | self.mw.maybeReset() 219 | 220 | def closeEvent(self, event): 221 | self.cleanup() 222 | event.accept() 223 | 224 | def confirm_close(self): 225 | return (self.editor.fieldsAreBlank() or 226 | askUser(_("Close and lose current input?"), defaultno=True)) 227 | -------------------------------------------------------------------------------- /src/find_missing_words/gui/search_results/note_creation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Second view of the addon: search results and note creation 3 | 4 | Displays the word and buttons to create notes based on presets. 5 | On word select, if notes currently exist for the word, show the basic editor. 6 | If no notes currently exist and a note is created from a preset, show the add notes form/editor. 7 | """ 8 | 9 | import re 10 | import functools 11 | 12 | from bs4 import BeautifulSoup as Soup 13 | from aqt import mw 14 | import aqt.editor 15 | from aqt.qt import * 16 | from anki.hooks import addHook, remHook, runHook 17 | 18 | from ..config.properties import ConfigProperties, SentenceTypes 19 | from ..forms import note_creation as creation_form 20 | from ..utils.note_field_chooser import NoteFieldChooser 21 | from .. import config, utils 22 | from . import add_note_widget, word_select 23 | 24 | 25 | class NoteCreation(QDialog): 26 | def __init__(self, word_model, text, deck_name, note_fields, parent=None): 27 | super().__init__(parent) 28 | self.word_model = word_model 29 | self.text = text 30 | self.deck_name = deck_name 31 | self.note_fields = note_fields 32 | self.note_ids = [] 33 | self.current_word = "" 34 | self.mw = mw 35 | self.editor = None 36 | self.last_list_item = None 37 | 38 | self.form = creation_form.Ui_Dialog() 39 | self.form.setupUi(self) 40 | self.form.note_list_widget.itemClicked.connect(self.display_note_editor) 41 | self.form.ignore_button.clicked.connect(self.ignore_word) 42 | self.setup_note_widgets() 43 | self.render_word_select() 44 | 45 | addHook("load_word", self.load_word) 46 | 47 | def setup_note_widgets(self): 48 | """ 49 | Don't show list and stacked widgets at first since word may not have any notes to display 50 | Keep size policy of widgets even if hidden so as to not alter positioning of nearby widgets 51 | """ 52 | self.form.create_hbox_2.hide() 53 | self.form.no_word_selected_tip.show() 54 | self.toggle_note_creation_widgets_visibility(False) 55 | list_policy = self.form.note_list_widget.sizePolicy() 56 | list_policy.setRetainSizeWhenHidden(True) 57 | self.form.note_list_widget.setSizePolicy(list_policy) 58 | stack_policy = self.form.note_stacked_widget.sizePolicy() 59 | stack_policy.setRetainSizeWhenHidden(True) 60 | self.form.note_stacked_widget.setSizePolicy(stack_policy) 61 | self.form.word_details.hide() 62 | 63 | def toggle_note_creation_widgets_visibility(self, visible): 64 | self.form.note_list_widget.setVisible(visible) 65 | self.form.note_stacked_widget.setVisible(visible) 66 | self.form.notes_created_label.setVisible(visible) 67 | self.form.note_editor_label.setVisible(visible) 68 | 69 | def render_word_select(self): 70 | """ 71 | Load the left pane (word select) to display the search text and highlight missing words. 72 | :return: 73 | """ 74 | self.word_select = word_select_widget = word_select.WordSelect(self.text, self.word_model, parent=self) 75 | self.form.word_select_pane_vbox.addWidget(word_select_widget) 76 | 77 | def load_word(self, word, note_ids, known): 78 | self.reset_list() 79 | self.display_word(word, known) 80 | self.populate_notes(note_ids) 81 | self.render_note_creation_preset_buttons() 82 | 83 | def ignore_word(self): 84 | config = mw.addonManager.getConfig(__name__) 85 | ignored_word_set = set(config[ConfigProperties.IGNORED_WORDS.value]) 86 | ignored_word_set.add(self.current_word.lower()) 87 | config[ConfigProperties.IGNORED_WORDS.value] = list(ignored_word_set) 88 | mw.addonManager.writeConfig(__name__, config) 89 | self.word_select.ignore_word(self.current_word) 90 | self.toggle_note_creation_widgets_visibility(False) 91 | self.form.word_details.hide() 92 | self.form.create_hbox_2.hide() 93 | self.form.no_word_selected_tip.show() 94 | 95 | def populate_notes(self, note_ids): 96 | """ 97 | Display list of notes and their first fields 98 | :param note_ids: ids found in the original search 99 | """ 100 | self.form.create_hbox_2.show() 101 | self.form.no_word_selected_tip.hide() 102 | if not note_ids: 103 | self.toggle_note_creation_widgets_visibility(False) 104 | return 105 | self.toggle_note_creation_widgets_visibility(True) 106 | for note_id in note_ids: 107 | self.note_ids.append(note_id) 108 | note = self.mw.col.getNote(note_id) 109 | self.form.note_list_widget.addItem(self.get_note_representation(note)) 110 | 111 | def display_word(self, word, known): 112 | self.form.ignore_button.setEnabled(not known) 113 | self.form.word_details.show() 114 | self.current_word = word 115 | deck_name = self.deck_name or "All" 116 | if self.note_fields: 117 | note_fields = NoteFieldChooser.format_btn_text(self.note_fields) 118 | else: 119 | note_fields = "All" 120 | self.form.word_label.setText(word) 121 | self.form.deck_label.setText("Deck filter: " + deck_name) 122 | self.form.note_field_label.setText("Model/Field filter: " + note_fields) 123 | 124 | def find_sentences(self, word, sentence_type): 125 | """ 126 | Find sentences surrounding a word in the original text 127 | :param word: word in the text 128 | :return: list of sentences including the word 129 | """ 130 | sentence_pattern = rf"[^.?!]*\s?{word}\s?[^.?!]*[.?!]" 131 | word_regex = re.compile(re.escape(word), re.IGNORECASE) 132 | s = "\n".join( 133 | [match.strip() for match in re.findall(sentence_pattern, self.text, re.IGNORECASE | re.MULTILINE)]) 134 | if sentence_type == SentenceTypes.BLANK.name: 135 | s = word_regex.sub("__", s) 136 | elif sentence_type == SentenceTypes.MISSING.name: 137 | s = word_regex.sub("", s) 138 | elif sentence_type == SentenceTypes.CLOZE_REPEAT.name: 139 | s = word_regex.sub(r"{{c1::\g<0>}}", s) 140 | elif sentence_type == SentenceTypes.CLOZE_SEPARATE.name: 141 | self.word_occurence_count = 0 142 | s = re.sub(word_regex, self.replace_with_numbered_cloze, s) 143 | return s 144 | 145 | def replace_with_numbered_cloze(self, match): 146 | self.word_occurence_count += 1 147 | return "{{c" + str(self.word_occurence_count) + "::" + match.group(0) + "}}" 148 | 149 | @staticmethod 150 | def get_note_representation(note): 151 | """ 152 | Convert literal HTML markup to plaintext with proper spacing 153 | :param note: note which holds field values 154 | :return: text with no HTML 155 | """ 156 | sort_field = note.fields[0] 157 | text_with_spaced_divs = sort_field.replace("
", "
") 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 | --------------------------------------------------------------------------------