├── LICENSE ├── Makefile ├── README.md ├── src ├── browser_batch_remove_formatting │ ├── __init__.py │ └── browser_batch_remove_formatting.py ├── browser_create_duplicate │ ├── __init__.py │ └── browser_create_duplicate.py ├── browser_create_filtered_deck │ ├── __init__.py │ └── browser_create_filtered_deck.py ├── browser_field_to_tags │ ├── __init__.py │ └── browser_field_to_tags.py ├── browser_more_hotkeys │ ├── __init__.py │ └── browser_more_hotkeys.py ├── browser_refresh │ ├── __init__.py │ └── browser_refresh.py ├── browser_replace_tag │ ├── __init__.py │ └── browser_replace_tag.py ├── browser_search_hotkeys │ ├── __init__.py │ └── browser_search_hotkeys.py ├── browser_sidebar_tweaks │ ├── __init__.py │ └── browser_sidebar_tweaks.py ├── common_context_search │ ├── __init__.py │ ├── common_context_search.py │ ├── config.md │ ├── config.py │ └── platform.py ├── common_ctrlf_search │ ├── common ctrlf search.py │ └── common_ctrlf_search │ │ ├── __init__.py │ │ ├── common_ctrlf_search.py │ │ ├── config.json │ │ ├── config.md │ │ └── manifest.json ├── editor_autocomplete_whitelist │ ├── __init__.py │ └── editor_autocomplete_whitelist.py ├── editor_clear_all │ ├── __init__.py │ └── editor_clear_all.py ├── editor_cloze_highlighter │ ├── __init__.py │ └── editor_cloze_highlighter.py ├── editor_custom_stylesheet │ ├── __init__.py │ └── editor_custom_stylesheet.py ├── editor_field_history │ ├── __init__.py │ ├── config.md │ ├── config.schema.json │ └── editor_field_history.py ├── editor_field_navigation │ ├── __init__.py │ └── editor_field_navigation.py ├── editor_indentation_formatter │ ├── __init__.py │ └── editor_indentation_formatter.py ├── editor_paste_sources │ ├── __init__.py │ └── editor_paste_sources.py ├── editor_preserve_fields_on_switch │ ├── __init__.py │ └── editor_preserve_fields_on_switch.py ├── editor_random_list │ ├── __init__.py │ ├── config.md │ └── editor_random_list.py ├── editor_replace_linebreaks │ ├── __init__.py │ └── editor_replace_linebreaks.py ├── editor_reverse_toggle │ ├── __init__.py │ └── editor_reverse_toggle.py ├── editor_second_addcards_dialog │ ├── __init__.py │ └── editor_second_addcards_dialog.py ├── editor_sync_html_cursor │ ├── __init__.py │ └── editor_sync_html_cursor.py ├── editor_tag_hotkeys │ ├── __init__.py │ └── editor_tag_hotkeys.py ├── main_fullscreen │ ├── __init__.py │ └── main_fullscreen.py ├── main_ontop │ ├── __init__.py │ └── main_ontop.py ├── overview_browser_shortcuts │ ├── __init__.py │ └── overview_browser_shortcuts.py ├── overview_deck_switcher │ ├── __init__.py │ └── overview_deck_switcher.py ├── overview_deck_tooltip │ ├── __init__.py │ └── overview_deck_tooltip.py ├── overview_refresh_media │ ├── __init__.py │ └── overview_refresh_media.py ├── previewer_tag_browser │ ├── __init__.py │ └── previewer_tag_browser.py ├── reviewer_auto_rate_hotkey │ ├── __init__.py │ └── reviewer_auto_rate_hotkey.py ├── reviewer_browse_creation │ ├── __init__.py │ └── reviewer_browse_creation.py ├── reviewer_browse_today │ ├── __init__.py │ └── reviewer_browse_today.py ├── reviewer_card_stats │ ├── __init__.py │ └── reviewer_card_stats.py ├── reviewer_file_hyperlinks │ ├── __init__.py │ └── reviewer_file_hyperlinks.py ├── reviewer_hide_toolbar │ ├── __init__.py │ └── reviewer_hide_toolbar.py ├── reviewer_hint_hotkeys │ ├── __init__.py │ └── reviewer_hint_hotkeys.py ├── reviewer_letitsnow │ ├── __init__.py │ └── reviewer_letitsnow.py ├── reviewer_more_answer_buttons │ ├── More Answer Buttons.py │ └── more_answer_buttons │ │ ├── README.md │ │ ├── __init__.py │ │ ├── config.json │ │ ├── config.md │ │ ├── manifest.json │ │ ├── reviewer_more_answer_buttons_for_20.py │ │ └── reviewer_more_answer_buttons_for_21.py ├── reviewer_progress_bar │ ├── __init__.py │ └── reviewer_progress_bar.py ├── reviewer_refocus_card │ ├── __init__.py │ └── reviewer_refocus_card.py ├── reviewer_track_unseen │ ├── __init__.py │ └── reviewer_track_unseen.py ├── sched_advanced_newcard_limits │ ├── __init__.py │ └── sched_advanced_newcard_limits.py ├── sched_deck_orgactions │ ├── __init__.py │ └── sched_deck_orgactions.py ├── sched_filter_dailydue │ ├── __init__.py │ └── sched_filter_dailydue.py ├── sched_ignore_lapses_below_ivl │ ├── __init__.py │ └── sched_ignore_lapses_below_ivl.py ├── sched_sibling_spacing_whitelist │ ├── __init__.py │ └── sched_sibling_spacing_whitelist.py ├── search_last_edited │ ├── __init__.py │ └── search_last_edited.py ├── stats_true_retention_extended │ ├── __init__.py │ └── stats_true_retention_extended.py ├── tagedit_enhancements │ ├── __init__.py │ └── tagedit_enhancements.py └── tagedit_subtag_completer │ ├── __init__.py │ ├── manifest.json │ └── tagedit_subtag_completer.py └── tools ├── build_ankiaddon.sh └── build_zips.sh /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Anki add-ons 2 | # 3 | # Prepares zip file for upload to AnkiWeb 4 | # 5 | # Copyright: (c) 2017-2018 Glutanimate 6 | # License: GNU AGPLv3 7 | 8 | all: clean zip 9 | 10 | zip: 11 | ./tools/build_zips.sh 12 | 13 | ankiaddon: 14 | ./tools/build_ankiaddon.sh 15 | 16 | clean: 17 | rm -rf build 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Miscellaneous Anki Add-ons 2 | 3 | This repository contains most of the smaller Anki add-ons I have written over the years. For more information on each add-on please take a look at the comments in the respective source file, or at its [AnkiWeb description](docs/descriptions.md). 4 | 5 | 6 | 7 | - [Generic Installation Instructions](#generic-installation-instructions) 8 | - [Naming Scheme](#naming-scheme) 9 | - [Credits](#credits) 10 | - [License](#license) 11 | - [Other Anki-related Projects](#other-anki-related-projects) 12 | 13 | 14 | 15 | ## Generic Installation Instructions 16 | 17 | **Installation from AnkiWeb** 18 | 19 | Most add-ons in this repository have been published on [AnkiWeb](https://ankiweb.net/shared/addons/) and may be comfortably installed via Anki's own add-on management system. To correlate each source file with its listing on AnkiWeb please refer to the add-on titles in their [AnkiWeb descriptions](docs/descriptions.md). 20 | 21 | **Manual Installation** 22 | 23 | 1. Open Anki's add-on folder by navigating to *Tools* -> *Add-ons* -> *Open add-ons folder* from Anki's main screen 24 | 2. Download a copy of the [full repository zip archive](https://github.com/glutanimate/anki-addons-misc/archive/master.zip) 25 | 3. Locate the add-ons you want to install in the `src/` directory 26 | 4. Copy the add-on files to Anki's add-on directory 27 | 28 | - in case of *Anki 2.0*: For each of the add-ons, copy all files its folder aside from `__init__.py` into the top-most level of Anki's add-on directory (e.g. for `reviewer_auto_rate_hotkey` you would only copy `reviewer_auto_rate_hotkey.py`) 29 | - in case of *Anki 2.1*: For each of the add-ons, copy the entire add-on folder into the top-most level of Anki's add-on directory (e.g. for `reviewer_auto_rate_hotkey` you would copy the entire `reviewer_auto_rate_hotkey` folder). **Note:** Not all add-ons in this repository have been transitioned to Anki 2.1, yet. To learn more about the compatibility status of each add-on please see [here](docs/anki21.md). 30 | 31 | 5. Restart Anki to see the changes 32 | 33 | ## Naming Scheme 34 | 35 | The source file naming describes which part of Anki each add-on interacts with. As such, the names do not always correspond with their listings on AnkiWeb. 36 | 37 | Add-on file names also have an impact on their loading order. Files with a leading special character are designed to be imported after most other add-ons have been loaded, as they might interact with them. 38 | 39 | ## Credits 40 | 41 | Some of the add-ons found in this repository were either adopted from earlier works by other authors or simply constitute re-uploads of add-ons that disappeared from AnkiWeb, with no original code repository to be found. I have tried to document the development history of each of these add-ons in their source code header, but more detailed information may also be found [here](docs/credits.md). 42 | 43 | ## License 44 | 45 | Most of the add-ons in this repository are licensed under the same license as Anki, the [GNU AGPL, version 3 or later](https://www.gnu.org/licenses/agpl.html). Please check the source code comments for more details on the licensing of each add-on. 46 | 47 | ## Other Anki-related Projects 48 | 49 | Make sure to also check out my larger Anki projects: 50 | 51 | - [Image Occlusion Enhanced](https://github.com/Glutanimate/image-occlusion-enhanced) 52 | - [Cloze Overlapper](https://github.com/glutanimate/cloze-overlapper) 53 | - [Review Heatmap](https://github.com/Glutanimate/review-heatmap) 54 | - [Advanced Previewer](https://github.com/glutanimate/advanced-previewer) 55 | - [Sticky Searches](https://github.com/glutanimate/sticky-searches) 56 | - [Note Organizer](https://github.com/glutanimate/note-organizer) 57 | - [Sequence Inserter](https://github.com/glutanimate/sequence-inserter) 58 | - [HTML Cleaner](https://github.com/glutanimate/html-cleaner) 59 | - [Unified Remote for Anki](https://github.com/Glutanimate/unified-remote-anki) -------------------------------------------------------------------------------- /src/browser_batch_remove_formatting/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2018 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import browser_batch_remove_formatting -------------------------------------------------------------------------------- /src/browser_batch_remove_formatting/browser_batch_remove_formatting.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Anki Add-on: Batch-Remove Field Formatting 4 | 5 | Adds a menu entry to the card browser that removes specific 6 | HTML tags from all fields in all selected notes. 7 | 8 | The HTML tags to be removed can be specified in the 9 | user configuration section below this comment header. 10 | 11 | Based on "Clear Field Formatting (HTML) in Bulk" by Felix Esch 12 | (https://github.com/Araeos/ankiplugins) 13 | 14 | Copyright: (c) Felix Esch 2016 15 | (c) Glutanimate 2017 16 | License: GNU AGPLv3 or later 17 | """ 18 | 19 | from __future__ import (absolute_import, division, 20 | print_function, unicode_literals) 21 | 22 | ############## USER CONFIGURATION START ############## 23 | 24 | STRIP_TAGS = ['b', 'i', 'u'] # list of html tags to remove 25 | 26 | ############## USER CONFIGURATION END ############## 27 | 28 | from aqt.qt import * 29 | from aqt import mw 30 | from aqt.utils import tooltip 31 | from anki.hooks import addHook 32 | from anki import version as anki_version 33 | 34 | if anki_version.startswith("2.1"): 35 | from bs4 import BeautifulSoup 36 | ANKI21 = True 37 | else: 38 | from BeautifulSoup import BeautifulSoup 39 | ANKI21 = False 40 | 41 | 42 | def stripFormatting(fields): 43 | """ 44 | Uses BeautifulSoup to remove STRIP_TAGS from each string in 45 | supplied list. 46 | 47 | Parameters 48 | ---------- 49 | fields : list of strings 50 | list containing html of field contents 51 | Returns 52 | ------- 53 | stripped_fields : list of strings 54 | processed list of fields 55 | """ 56 | 57 | stripped_fields = [] 58 | 59 | for html in fields: 60 | soup = BeautifulSoup(html, "html.parser") 61 | for tag in STRIP_TAGS: 62 | for match in soup.findAll(tag): 63 | match.replaceWithChildren() 64 | text = str(soup) if ANKI21 else unicode(soup) 65 | stripped_fields.append(text) 66 | 67 | return stripped_fields 68 | 69 | 70 | def setupMenu(browser): 71 | """ 72 | Add the button to the browser menu "edit". 73 | """ 74 | a = browser.form.menuEdit.addAction('Batch-Remove Field Formatting') 75 | a.setShortcut(QKeySequence("Ctrl+Alt+Shift+R")) 76 | a.triggered.connect(lambda _, b=browser: onClearFormatting(b)) 77 | 78 | 79 | def onClearFormatting(browser): 80 | """ 81 | Clears the formatting for every selected note. 82 | Also creates a restore point, allowing a single undo operation. 83 | 84 | Parameters 85 | ---------- 86 | browser : Browser 87 | the anki browser from which the function is called 88 | """ 89 | 90 | nids = browser.selectedNotes() 91 | if not nids: 92 | tooltip(_("No cards selected."), period=2000) 93 | return 94 | 95 | mw.checkpoint("Batch-Remove Field Formatting") 96 | mw.progress.start() 97 | for nid in nids: 98 | note = mw.col.getNote(nid) 99 | note.fields = stripFormatting(note.fields) 100 | note.flush() 101 | mw.progress.finish() 102 | mw.reset() 103 | 104 | # Hooks 105 | 106 | addHook("browser.setupMenus", setupMenu) 107 | -------------------------------------------------------------------------------- /src/browser_create_duplicate/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import browser_create_duplicate -------------------------------------------------------------------------------- /src/browser_create_duplicate/browser_create_duplicate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Duplicate Selected Notes 5 | 6 | Select any number of cards in the card browser and duplicate their notes 7 | 8 | To use: 9 | 10 | 1) Open the card browser 11 | 2) Select the desired cards 12 | 3) Press CTRL+ALT+C or go to Edit > Duplicate Notes 13 | 14 | A few pointers: 15 | 16 | - All cards generated by each note will be duplicated alongside the note 17 | - All duplicated cards will end up in the deck of the first selected cards 18 | - The duplicated cards should look exactly like the originals 19 | - Tags are preserved in the duplicated notes 20 | - Review history is NOT duplicated to the new cards (they appear as new cards) 21 | - The notes will be marked as duplicates (because they are!) 22 | 23 | This add-on is based on "Create Copy of Selected Cards" by Kealan Hobelmann 24 | 25 | Based on: "Create Copy of Selected Cards" 26 | by Kealan Hobelmann (https://ankiweb.net/shared/info/787914845) 27 | 28 | Copyright: (c) Glutanimate 2016-2017 29 | License: GNU AGPLv3 or later 30 | """ 31 | 32 | from aqt.qt import * 33 | from anki.hooks import addHook 34 | from aqt.utils import tooltip 35 | from anki.utils import timestampID 36 | 37 | def createDuplicate(self): 38 | mw = self.mw 39 | # Get deck of first selected card 40 | cids = self.selectedCards() 41 | if not cids: 42 | tooltip(_("No cards selected."), period=2000) 43 | return 44 | did = mw.col.db.scalar( 45 | "select did from cards where id = ?", cids[0]) 46 | deck = mw.col.decks.get(did) 47 | if deck['dyn']: 48 | tooltip(_("Cards can't be duplicated when they are in a filtered deck."), period=2000) 49 | return 50 | 51 | # Set checkpoint 52 | mw.progress.start() 53 | mw.checkpoint("Duplicate Notes") 54 | self.model.beginReset() 55 | 56 | # Copy notes 57 | for nid in self.selectedNotes(): 58 | # print "Found note: %s" % (nid) 59 | note = mw.col.getNote(nid) 60 | model = note._model 61 | 62 | # Assign model to deck 63 | mw.col.decks.select(deck['id']) 64 | mw.col.decks.get(deck)['mid'] = model['id'] 65 | mw.col.decks.save(deck) 66 | 67 | # Assign deck to model 68 | mw.col.models.setCurrent(model) 69 | mw.col.models.current()['did'] = deck['id'] 70 | mw.col.models.save(model) 71 | 72 | # Create new note 73 | note_copy = mw.col.newNote() 74 | # Copy tags and fields (all model fields) from original note 75 | note_copy.tags = note.tags 76 | note_copy.fields = note.fields 77 | 78 | # Refresh note and add to database 79 | note_copy.flush() 80 | mw.col.addNote(note_copy) 81 | 82 | # Reset collection and main window 83 | self.model.endReset() 84 | mw.col.reset() 85 | mw.reset() 86 | mw.progress.finish() 87 | 88 | tooltip(_("Notes duplicated."), period=1000) 89 | 90 | 91 | def setupMenu(self): 92 | menu = self.form.menuEdit 93 | menu.addSeparator() 94 | 95 | a = menu.addAction('Create Duplicate') 96 | a.setShortcut(QKeySequence("Ctrl+Alt+C")) 97 | a.triggered.connect(lambda _, b=self: onCreateDuplicate(b)) 98 | 99 | def onCreateDuplicate(self): 100 | createDuplicate(self) 101 | 102 | addHook("browser.setupMenus", setupMenu) 103 | -------------------------------------------------------------------------------- /src/browser_create_filtered_deck/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import browser_create_filtered_deck -------------------------------------------------------------------------------- /src/browser_create_filtered_deck/browser_create_filtered_deck.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Create Filtered Deck from Browser 5 | 6 | Creates filtered deck based on current search / selected cards 7 | 8 | Copyright: (c) Glutanimate 2016-2018 9 | License: GNU AGPLv3 or later 10 | """ 11 | 12 | from __future__ import unicode_literals 13 | 14 | from aqt.qt import * 15 | from anki.hooks import addHook 16 | 17 | def createFilteredDeck(self, from_selected=False): 18 | col = self.mw.col 19 | if from_selected: 20 | cids = self.selectedCards() 21 | search = " OR ".join("cid:{}".format(cid) for cid in cids) 22 | else: 23 | search = self.form.searchEdit.lineEdit().text() 24 | if 'deck:current' in search: 25 | did = col.conf['curDeck'] 26 | curDeck = col.decks.get(did)['name'] 27 | search = search.replace('deck:current', '"deck:' + curDeck + '"') 28 | self.mw.onCram(search) 29 | 30 | def setupMenu(self): 31 | menu = self.form.menuEdit 32 | menu.addSeparator() 33 | a = menu.addAction('Filtered Deck from Search') 34 | a.setShortcut(QKeySequence("Ctrl+Shift+D")) 35 | a.triggered.connect(lambda _, b=self: createFilteredDeck(b)) 36 | a = menu.addAction('Filtered Deck with Selected Cards') 37 | a.setShortcut(QKeySequence("Ctrl+Shift+Alt+D")) 38 | a.triggered.connect(lambda _, 39 | b=self: createFilteredDeck(b, from_selected=True)) 40 | 41 | addHook("browser.setupMenus", setupMenu) 42 | -------------------------------------------------------------------------------- /src/browser_field_to_tags/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import browser_field_to_tags -------------------------------------------------------------------------------- /src/browser_field_to_tags/browser_field_to_tags.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Convert Field to Tags 5 | 6 | Provides a new Edit menu entry in the Browser that allows you 7 | to convert the contents of a specific field to tags. 8 | 9 | Copyright: (c) Glutanimate 2017 10 | License: GNU AGPLv3 or later 11 | """ 12 | 13 | from __future__ import unicode_literals 14 | 15 | ############## USER CONFIGURATION START ############## 16 | 17 | HOTKEY = "Ctrl+Alt+M" 18 | SEPARATOR = "_" # whitespace will be replaced with this character 19 | 20 | ############## USER CONFIGURATION END ############## 21 | 22 | from aqt.qt import * 23 | from aqt.browser import Browser 24 | from aqt.utils import askUser, tooltip, restoreGeom, saveGeom 25 | 26 | from anki import find 27 | from anki.hooks import addHook 28 | from anki.utils import stripHTML 29 | 30 | 31 | def getField(browser, fields): 32 | """Invoke field selection dialog and return field""" 33 | d = QDialog(browser) 34 | l = QVBoxLayout(d) 35 | d.label = QLabel( 36 | "Please select the field you would like to convert to tags", d) 37 | d.fieldSel = QComboBox(d) 38 | d.fieldSel.addItems(fields) 39 | d.buttonBox = QDialogButtonBox(d) 40 | d.buttonBox.setOrientation(Qt.Horizontal) 41 | d.buttonBox.setStandardButtons( 42 | QDialogButtonBox.Cancel|QDialogButtonBox.Ok) 43 | d.buttonBox.accepted.connect(d.accept) 44 | d.buttonBox.rejected.connect(d.reject) 45 | l.addWidget(d.label) 46 | l.addWidget(d.fieldSel) 47 | l.addWidget(d.buttonBox) 48 | d.setWindowModality(Qt.WindowModal) 49 | d.setWindowTitle("Field to Tags") 50 | restoreGeom(d, "fieldtotags") 51 | r = d.exec_() 52 | saveGeom(d, "fieldtotags") 53 | if not r: 54 | return None 55 | 56 | idx = d.fieldSel.currentIndex() 57 | field = fields[idx] 58 | 59 | return field 60 | 61 | 62 | def fieldToTags(self, nids, field): 63 | """Add field contents to to note tags""" 64 | edited = 0 65 | for nid in nids: 66 | note = self.mw.col.getNote(nid) 67 | if field not in note: 68 | continue 69 | html = note[field] 70 | text = stripHTML(html).strip() 71 | if not text: 72 | continue 73 | tag = SEPARATOR.join(text.split()) 74 | if note.hasTag(tag): 75 | continue 76 | note.addTag(tag) 77 | note.flush() 78 | edited += 1 79 | return edited 80 | 81 | 82 | def onFieldToTags(self, _): 83 | """Main function""" 84 | nids = self.selectedNotes() 85 | count = len(nids) 86 | if not nids: 87 | tooltip("Please select some cards.") 88 | return 89 | fields = sorted(find.fieldNames(self.col, downcase=False)) 90 | if not fields: 91 | tooltip("No fields found." 92 | "Something might be wrong with your collection") 93 | return 94 | 95 | field = getField(self, fields) 96 | 97 | if not field: 98 | return 99 | 100 | q = ("Are you sure you want to convert the '{}' field " 101 | "to tags in {} selected notes?".format(field, count)) 102 | ret = askUser(q, parent=self, title="Please confirm your choice") 103 | if not ret: 104 | return 105 | 106 | self.mw.checkpoint("Find and Replace") 107 | self.mw.progress.start() 108 | self.model.beginReset() 109 | 110 | edited = self.fieldToTags(nids, field) 111 | 112 | self.model.endReset() 113 | self.mw.progress.finish() 114 | tooltip("{} out of {} notes updated.".format(edited, count)) 115 | 116 | 117 | # Hooks 118 | 119 | def setupMenu(self): 120 | menu = self.form.menuEdit 121 | menu.addSeparator() 122 | a = menu.addAction('Convert Field to Tags...') 123 | a.setShortcut(QKeySequence(HOTKEY)) 124 | a.triggered.connect(self.onFieldToTags) 125 | 126 | addHook("browser.setupMenus", setupMenu) 127 | Browser.onFieldToTags = onFieldToTags 128 | Browser.fieldToTags = fieldToTags -------------------------------------------------------------------------------- /src/browser_more_hotkeys/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import browser_more_hotkeys -------------------------------------------------------------------------------- /src/browser_more_hotkeys/browser_more_hotkeys.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | """ 4 | Simple Anki addon that adds more hotkeys to the card browser 5 | 6 | Copyright: (c) Glutanimate 2016-2017 7 | License: GNU AGPLv3 or later 8 | """ 9 | 10 | from aqt.qt import * 11 | from anki.hooks import addHook 12 | 13 | def onBrowserSetupMenus(self): 14 | c = self.connect; f = self.form; s = SIGNAL("triggered()") 15 | self.invCut = QShortcut(QKeySequence("Ctrl+Alt+I"), self) 16 | c(self.invCut, SIGNAL("activated()"), self.invertSelection) 17 | 18 | c = self.connect; f = self.form; s = SIGNAL("triggered()") 19 | self.schedCut = QShortcut(QKeySequence("Ctrl+Alt+Shift+R"), self) 20 | c(self.schedCut, SIGNAL("activated()"), self.reschedule) 21 | 22 | addHook("browser.setupMenus", onBrowserSetupMenus) -------------------------------------------------------------------------------- /src/browser_refresh/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import browser_refresh -------------------------------------------------------------------------------- /src/browser_refresh/browser_refresh.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Refresh Browser List 5 | 6 | Refreshes browser view and optionally changes the sorting column 7 | (e.g. to show newly added cards since last search) 8 | 9 | Copyright: (c) Glutanimate 2016-2017 10 | 2018 Arthur Milchior (porting to 2.1) 11 | License: GNU AGPLv3 or later 12 | """ 13 | 14 | # Do not modify the following line 15 | from __future__ import unicode_literals 16 | from anki import version as anki_version 17 | anki21 = anki_version.startswith("2.1.") 18 | 19 | ######## USER CONFIGURATION START ######## 20 | 21 | SORTING_COLUMN = "noteCrt" 22 | # Custom column sorting applied on hotkey toggle 23 | # - only works if that column is active in the first place 24 | # - set to note creation time by default ("noteCrt") 25 | # - can be disabled by setting SORTING_COLUM = "" 26 | # 27 | # Valid Values (regular browser): 28 | # 29 | # 'question' 'answer' 'template' 'deck' 'noteFld' 'noteCrt' 'noteMod' 30 | # 'cardMod' 'cardDue' 'cardIvl' 'cardEase' 'cardReps' 'cardLapses' 31 | # 'noteTags' 'note' 32 | # 33 | # Additional values (advanced browser): 34 | # 35 | # 'cfirst' 'clast' 'cavgtime' 'ctottime' 'ntags' 'coverdueivl' 'cprevivl' 36 | 37 | 38 | ######## USER CONFIGURATION END ######## 39 | 40 | from aqt.qt import * 41 | from aqt.browser import Browser 42 | from anki.hooks import addHook 43 | def debug(t): 44 | #print(t) 45 | pass 46 | 47 | def refreshView(self): 48 | debug("Calling refreshView()") 49 | if anki21: 50 | self.onSearchActivated() 51 | else: 52 | self.onSearch(reset=True) 53 | if SORTING_COLUMN: 54 | try: 55 | col_index = self.model.activeCols.index(SORTING_COLUMN) 56 | self.onSortChanged(col_index, True) 57 | self.form.tableView.selectRow(0) 58 | except ValueError: 59 | pass 60 | 61 | def setupMenu(self): 62 | menu = self.form.menuEdit 63 | menu.addSeparator() 64 | a = menu.addAction('Refresh View') 65 | a.setShortcut(QKeySequence("CTRL+F5" if anki21 else "F5")) 66 | a.triggered.connect(self.refreshView) 67 | 68 | Browser.refreshView = refreshView 69 | addHook("browser.setupMenus", setupMenu) 70 | -------------------------------------------------------------------------------- /src/browser_replace_tag/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import browser_replace_tag -------------------------------------------------------------------------------- /src/browser_replace_tag/browser_replace_tag.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Replace tag 5 | 6 | Replace tag in selected notes. Combines tag 'add' and 'remove' dialogs 7 | in one workflow. 8 | 9 | Copyright: (c) Glutanimate 2016-2017 10 | License: GNU AGPLv3 or later 11 | """ 12 | 13 | from aqt.qt import * 14 | from aqt.utils import getText, tooltip 15 | from aqt.tagedit import TagEdit 16 | from anki.hooks import addHook 17 | from anki.lang import _ 18 | 19 | 20 | def myGetTag(parent, deck, question, tags="user", taglist=None, **kwargs): 21 | te = TagEdit(parent) 22 | te.setCol(deck) 23 | if taglist is not None: 24 | # set tag list manually 25 | te.model.setStringList(taglist) 26 | ret = getText(question, parent, edit=te, **kwargs) 27 | te.hideCompleter() 28 | return ret 29 | 30 | def replaceTag(self): 31 | mw = self.mw 32 | selected = self.selectedNotes() 33 | if not selected: 34 | tooltip("No cards selected.", period=2000) 35 | return 36 | 37 | firstNote = mw.col.getNote(selected[0]) 38 | msg = "Which tag would you like to replace?
Please select just one." 39 | (oldTag, r) = myGetTag(self, mw.col, msg, taglist=firstNote.tags, title="Choose tag") 40 | if not r or not oldTag.strip(): 41 | return 42 | oldTag = oldTag.split()[0] 43 | 44 | msg = "Which tag would you like to replace %s with?" % oldTag 45 | (newTag, r) = myGetTag(self, mw.col, msg, title="Replace Tag", default=oldTag) 46 | if not r or not newTag.strip(): 47 | return 48 | 49 | mw.checkpoint("replace tag") 50 | mw.progress.start() 51 | self.model.beginReset() 52 | for nid in selected: 53 | note = mw.col.getNote(nid) 54 | if note.hasTag(oldTag): 55 | note.delTag(oldTag) 56 | note.addTag(newTag) 57 | note.flush() 58 | self.model.endReset() 59 | mw.requireReset() 60 | mw.progress.finish() 61 | mw.reset() 62 | tooltip("Tag replaced.
Use 'Check Database' to remove unused tags.") 63 | 64 | def setupMenu(self): 65 | try: 66 | # used by multiple add-ons, so we check for its existence first 67 | menu = self.menuTags 68 | except: 69 | self.menuTags = QMenu(_("Tags")) 70 | action = self.menuBar().insertMenu(self.mw.form.menuTools.menuAction(), self.menuTags) 71 | menu = self.menuTags 72 | menu.addSeparator() 73 | a = menu.addAction('Replace Tag...') 74 | a.setShortcut(QKeySequence("Ctrl+Alt+Shift+T")) 75 | a.triggered.connect(lambda _, b=self: replaceTag(b)) 76 | 77 | 78 | addHook("browser.setupMenus", setupMenu) 79 | -------------------------------------------------------------------------------- /src/browser_search_hotkeys/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import browser_search_hotkeys -------------------------------------------------------------------------------- /src/browser_search_hotkeys/browser_search_hotkeys.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Browser search hotkeys 5 | 6 | Set up hotkeys for searches in the browser. 7 | 8 | Hotkeys follow this scheme: Ctrl+S –> (_modifier_) + _key_ 9 | (hit Ctrl+S to start the key sequence, then the key assigned to 10 | your search, plus an optional modifier.) 11 | 12 | You can use keyboard modifiers to control whether to add a term to the 13 | search, negate it, remove it, or do something else. This follows the same 14 | logic as the default behaviour in Anki when clicking on a search term in 15 | the sidebar. 16 | 17 | For instance, line 2 in the default search_shortcuts dict assigns the search 18 | 'added 1' (cards added today) to 'T'. This defines the following key sequences 19 | in the browser: 20 | 21 | Ctrl+S -> T replace search field with 'added:1' 22 | Ctrl+S -> Ctrl+T add 'added:1' to existing search 23 | Ctrl+S -> Alt+T: replace search field with '-added:1' 24 | Ctrl+S -> Ctrl+Alt+T: add '-added:1' to existing search 25 | Ctrl+S -> Shift+T: add 'or added:1' to existing search 26 | 27 | Copyright: (c) Glutanimate 2016-2017 28 | License: GNU AGPLv3 or later 29 | """ 30 | 31 | from __future__ import unicode_literals 32 | 33 | #============USER CONFIGURATION START=============== 34 | 35 | # assign hotkeys to searches 36 | search_shortcuts = { 37 | 'A': {'search': ''}, # All together now 38 | 'T': {'search': 'added:1'}, # Today 39 | 'V': {'search': 'rated:1'}, # Viewed 40 | 'G': {'search': 'rated:1:1'}, # aGain today 41 | 'F': {'search': 'card:1'}, # First 42 | 'C': {'search': 'deck:current'},# Current 43 | 'N': {'search': 'is:new'}, # New 44 | 'L': {'search': 'is:learn'}, # Learn 45 | 'R': {'search': 'is:review'}, # Review 46 | 'D': {'search': 'is:due'}, # Due 47 | 'S': {'search': 'is:suspended'},# Suspended 48 | 'B': {'search': 'is:buried'}, # Buried 49 | 'M': {'search': 'tag:marked'}, # Marked 50 | 'E': {'search': 'tag:leech'}, # lEech 51 | } 52 | 53 | # define the sequence starter hotkey 54 | sequence_starter = "Ctrl+S" 55 | 56 | #==============USER CONFIGURATION END============== 57 | 58 | from aqt.qt import * 59 | from aqt.browser import Browser 60 | from anki.hooks import addHook 61 | 62 | search_modifiers = { 63 | '': 'replace', 64 | 'Ctrl+': 'add', 65 | 'Alt+': 'negate', 66 | 'Ctrl+Alt+': 'add-negate', 67 | 'Shift+': 'add-or', 68 | } 69 | 70 | def setSearchField(self, search, action): 71 | cur = self.form.searchEdit.lineEdit().text() 72 | if action == "replace": 73 | pass 74 | elif action == "add": 75 | search = cur + " " + search 76 | elif action == "negate": 77 | search = "-" + search 78 | elif action == "add-negate": 79 | search = cur + " " + "-" + search 80 | elif action == "add-or": 81 | search = cur + " or " + search 82 | self.form.searchEdit.lineEdit().setText(search) 83 | self.onSearch() 84 | 85 | def onSetupMenus(self): 86 | for key, binding in search_shortcuts.items(): 87 | search = binding["search"] 88 | for modifier, action in search_modifiers.items(): 89 | key_sequence = modifier + key 90 | a = QShortcut(QKeySequence(sequence_starter + ', ' + key_sequence), self) 91 | a.activated.connect(lambda c=search,d=action: self.setSearchField(c,d)) 92 | 93 | addHook("browser.setupMenus", onSetupMenus) 94 | Browser.setSearchField = setSearchField -------------------------------------------------------------------------------- /src/browser_sidebar_tweaks/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import browser_sidebar_tweaks -------------------------------------------------------------------------------- /src/common_context_search/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import common_context_search -------------------------------------------------------------------------------- /src/common_context_search/common_context_search.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Context Menu Search Add-on for Anki 4 | # 5 | # Copyright (C) 2015-2019 Aristotelis P. 6 | # (C) 2015 Eddie Blundell 7 | # (C) 2013 Steve AW 8 | # 9 | # This program is free software: you can redistribute it and/or modify 10 | # it under the terms of the GNU Affero General Public License as 11 | # published by the Free Software Foundation, either version 3 of the 12 | # License, or (at your option) any later version, with the additions 13 | # listed at the end of the accompanied license file. 14 | # 15 | # This program is distributed in the hope that it will be useful, 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | # GNU Affero General Public License for more details. 19 | # 20 | # Any modifications to this file must keep this entire header intact. 21 | 22 | """ 23 | Adds context menu entries for searching the card browser and 24 | various online search providers. 25 | 26 | Based on 'OSX Dictionary Lookup' by Eddie Blundell 27 | and 'Search Google Images' by Steve AW. 28 | """ 29 | 30 | from __future__ import (absolute_import, division, 31 | print_function, unicode_literals) 32 | 33 | import aqt 34 | from aqt.qt import * 35 | from aqt.utils import openLink 36 | from anki.hooks import addHook 37 | 38 | from .config import config 39 | 40 | # Local search 41 | 42 | def lookupLocal(text): 43 | browser = aqt.dialogs.open("Browser", aqt.mw) 44 | browser.form.searchEdit.lineEdit().setText('"{}"'.format(text)) 45 | if ANKI20: 46 | browser.onSearch() 47 | else: 48 | browser.onSearchActivated() 49 | 50 | # Online search 51 | 52 | def lookupOnline(text, idx): 53 | text = " ".join(text.strip().split()) 54 | for url in SEARCH_PROVIDERS[idx][1]: 55 | # opt for using custom formatter to avoid errors with user URLs: 56 | url = url.replace("%s", "text") 57 | openLink(url) 58 | 59 | 60 | # Context menu 61 | 62 | def addToContextMenu(view, menu): 63 | """Add 'lookup' action to context menu""" 64 | if USE_CUSTOM_STYLESHEET: 65 | menu.setStyleSheet(stylesheet) 66 | selected = view.page().selectedText() 67 | if not selected: 68 | return 69 | 70 | suffix = (selected[:20] + '..') if len(selected) > 20 else selected 71 | label = u'Search for "%s" in Card &Browser' % suffix 72 | menu.addSeparator() 73 | a = menu.addAction(label) 74 | a.triggered.connect(lambda _, t=selected: lookup_browser(t)) 75 | 76 | search_menu = None 77 | if len(SEARCH_PROVIDERS) > 10: 78 | search_menu = menu.addMenu(u'&Search for "%s" with...' % suffix) 79 | 80 | for idx, provider in enumerate(SEARCH_PROVIDERS): 81 | if search_menu: 82 | label = provider[0] 83 | menu = search_menu 84 | else: 85 | label = u'Search for "%s" on %s' % (suffix, provider[0]) 86 | a = menu.addAction(label) 87 | a.triggered.connect(lambda _, i=idx,t=selected: lookup_online(t, i)) 88 | 89 | # Hooks / patches 90 | 91 | addHook("AnkiWebView.contextMenuEvent", addToContextMenu) 92 | addHook("EditorWebView.contextMenuEvent", addToContextMenu) 93 | -------------------------------------------------------------------------------- /src/common_context_search/config.md: -------------------------------------------------------------------------------- 1 | **Important**: These settings do not sync and require a restart to apply. 2 | 3 | - `onlineSearchProviders` (dict): Dictionary of search providers to add to the context menu. Each entry consists of a key, the name of the entry, and a value. 4 | 5 | The value may either be: 6 | - a single string, denoting the URL to open (e.g. `"name": "url"`), 7 | - a list of multiple URLs to open at once (e.g. `"name": ["url1", "url2"]`), 8 | - a subdictionary to group multiple menu entries under a separate submenu (e.g. `"submenu": {"name1": "url1", "name2": "url2"}`) 9 | 10 | Any occurences of the placeholder `%s` in your URLs will be replaced with the actual search term. 11 | 12 | For examples on all of the variations above, please refer to the default configuration of the add-on. 13 | 14 | - `localSearchEnabled` (true/false): Whether to show menu entry for searching in card browser. Default: `true`. 15 | - `useCustomStylesheet` (true/false): Whether to use alternate, more compact, menu styling. Might be buggy, so better left off. Default: `false`. 16 | 17 | Created with ❤️ by [Glutanimate](https://glutanimate.com/). If you enjoy this add-on please consider **[supporting me on Patreon](https://www.patreon.com/bePatron?u=7522179)**. Thanks! -------------------------------------------------------------------------------- /src/common_context_search/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Context Menu Search Add-on for Anki 4 | # 5 | # Copyright (C) 2015-2019 Aristotelis P. 6 | # 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as 9 | # published by the Free Software Foundation, either version 3 of the 10 | # License, or (at your option) any later version, with the additions 11 | # listed at the end of the accompanied license file. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU Affero General Public License for more details. 17 | # 18 | # Any modifications to this file must keep this entire header intact. 19 | 20 | """ 21 | Configuration shim between Anki 2.0 and Anki 2.1 22 | """ 23 | 24 | from __future__ import (absolute_import, division, 25 | print_function, unicode_literals) 26 | 27 | import os 28 | import io 29 | 30 | from aqt import mw 31 | from anki.utils import json 32 | 33 | from .platform import ANKI20, PATH_ADDON 34 | 35 | defaults_path = os.path.join(PATH_ADDON, "config.json") 36 | meta_path = os.path.join(PATH_ADDON, "meta.json") 37 | 38 | if not ANKI20: 39 | def getConfig(): 40 | return mw.addonManager.getConfig(__name__) 41 | 42 | def writeConfig(config): 43 | mw.addonManager.writeConfig(__name__, config) 44 | 45 | else: 46 | def _addonMeta(): 47 | """Get meta dictionary 48 | 49 | Reads in meta.json in add-on folder and returns 50 | resulting dictionary of user-defined metadata values. 51 | 52 | Note: 53 | Anki 2.1 stores both add-on meta data and customized 54 | settings in meta.json. In this module we are only dealing 55 | with the settings part. 56 | 57 | Returns: 58 | dict: config dictionary 59 | 60 | """ 61 | 62 | try: 63 | meta = json.load(io.open(meta_path, encoding="utf-8")) 64 | except (IOError, OSError): 65 | meta = None 66 | except ValueError as e: 67 | print("Could not read meta.json: " + str(e)) 68 | meta = None 69 | 70 | if not meta: 71 | meta = {"config": _addonConfigDefaults()} 72 | _writeAddonMeta(meta) 73 | 74 | return meta 75 | 76 | def _writeAddonMeta(meta): 77 | """Write meta dictionary 78 | 79 | Writes meta dictionary to meta.json in add-on folder. 80 | 81 | Args: 82 | meta (dict): meta dictionary 83 | 84 | """ 85 | 86 | with io.open(meta_path, 'w', encoding="utf-8") as f: 87 | f.write(unicode(json.dumps(meta, indent=4, 88 | sort_keys=True, 89 | ensure_ascii=False))) 90 | 91 | def _addonConfigDefaults(): 92 | """Get default config dictionary 93 | 94 | Reads in config.json in add-on folder and returns 95 | resulting dictionary of default config values. 96 | 97 | Returns: 98 | dict: config dictionary 99 | 100 | Raises: 101 | Exception: If config.json cannot be parsed correctly. 102 | (The assumption being that we would end up in an 103 | inconsistent state if we were to return an empty 104 | config dictionary. This should never happen.) 105 | 106 | """ 107 | 108 | try: 109 | return json.load(io.open(defaults_path, encoding="utf-8")) 110 | except (IOError, OSError, ValueError) as e: 111 | print("Could not read config.json: " + str(e)) 112 | raise Exception("Config file could not be read: " + str(e)) 113 | 114 | def getConfig(): 115 | """Get user config dictionary 116 | 117 | Merges user's keys into default config dictionary 118 | and returns the result. 119 | 120 | Returns: 121 | dict: config dictionary 122 | 123 | """ 124 | 125 | config = _addonConfigDefaults() 126 | meta = _addonMeta() 127 | userConf = meta.get("config", {}) 128 | config.update(userConf) 129 | return config 130 | 131 | def writeConfig(config): 132 | """Write user config dictionary 133 | 134 | Saves user's config dictionary via meta.json. 135 | 136 | Args: 137 | config (dict): user config dictionary 138 | 139 | """ 140 | 141 | _writeAddonMeta({"config": config}) 142 | 143 | # Only read in config once at app start for now 144 | config = getConfig() 145 | -------------------------------------------------------------------------------- /src/common_context_search/platform.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Libaddon for Anki 4 | # 5 | # Copyright (C) 2018-2019 Aristotelis P. 6 | # 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as 9 | # published by the Free Software Foundation, either version 3 of the 10 | # License, or (at your option) any later version, with the additions 11 | # listed at the end of the accompanied license file. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU Affero General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU Affero General Public License 19 | # along with this program. If not, see . 20 | # 21 | # NOTE: This program is subject to certain additional terms pursuant to 22 | # Section 7 of the GNU Affero General Public License. You should have 23 | # received a copy of these additional terms immediately following the 24 | # terms and conditions of the GNU Affero General Public License which 25 | # accompanied this program. 26 | # 27 | # If not, please request a copy through one of the means of contact 28 | # listed here: . 29 | # 30 | # Any modifications to this file must keep this entire header intact. 31 | 32 | """ 33 | Constants providing information on current system and Anki platform 34 | """ 35 | 36 | from __future__ import (absolute_import, division, 37 | print_function, unicode_literals) 38 | 39 | import sys 40 | import os 41 | from aqt import mw 42 | from anki import version 43 | from anki.utils import isMac, isWin 44 | 45 | __all__ = ["PYTHON3", "ANKI20", "SYS_ENCODING", "MODULE_ADDON", 46 | "MODULE_LIBADDON", "DIRECTORY_ADDONS", "JSPY_BRIDGE", 47 | "PATH_ADDON", "PATH_USERFILES", "PLATFORM"] 48 | 49 | PYTHON3 = sys.version_info[0] == 3 50 | ANKI20 = version.startswith("2.0.") 51 | SYS_ENCODING = sys.getfilesystemencoding() 52 | 53 | name_components = __name__.split(".") 54 | 55 | MODULE_ADDON = name_components[0] 56 | MODULE_LIBADDON = name_components[1] 57 | 58 | if ANKI20: 59 | DIRECTORY_ADDONS = mw.pm.addonFolder() 60 | JSPY_BRIDGE = "py.link" 61 | else: 62 | DIRECTORY_ADDONS = mw.addonManager.addonsFolder() 63 | JSPY_BRIDGE = "pycmd" 64 | 65 | PATH_ADDON = os.path.join(DIRECTORY_ADDONS, MODULE_ADDON) 66 | PATH_USERFILES = os.path.join(PATH_ADDON, "user_files") 67 | 68 | if isMac: 69 | PLATFORM = "mac" 70 | elif isWin: 71 | PLATFORM = "win" 72 | else: 73 | PLATFORM = "lin" 74 | -------------------------------------------------------------------------------- /src/common_ctrlf_search/common ctrlf search.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Ctrl+F Search 5 | Entry point for the add-on into Anki 6 | 7 | Copyright: (c) 2017-2019 Glutanimate 8 | 9 | License: GNU AGPLv3 10 | """ 11 | 12 | # IMPORTANT: 13 | # 14 | # If you are reading this and wondering where to find the 15 | # configuration section for Anki 2.0: 16 | # 17 | # As part of porting this add-on to Anki 2.1 I had to move the 18 | # main add-on module to a separate folder. It is now located under 19 | # common_ctrlf_search/common_ctrlf_search.py. 20 | # All configuration options are located 21 | # at the very top of that file. 22 | # 23 | # Please use NotePad++ or another text editor that supports python 24 | # source code files to edit it 25 | 26 | import common_ctrlf_search # noqa: F401 27 | -------------------------------------------------------------------------------- /src/common_ctrlf_search/common_ctrlf_search/__init__.py: -------------------------------------------------------------------------------- 1 | from . import common_ctrlf_search 2 | -------------------------------------------------------------------------------- /src/common_ctrlf_search/common_ctrlf_search/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "HOTKEY_SEARCH":"Ctrl+F", 3 | "HOTKEY_SEARCH_BROWSER":"Ctrl+Alt+Shift+F", 4 | "HOTKEY_NEXT":"F3", 5 | "HOTKEY_PREVIOUS":"Shift+F3", 6 | "Show_in_Browser_Context_menu":false 7 | } 8 | -------------------------------------------------------------------------------- /src/common_ctrlf_search/common_ctrlf_search/config.md: -------------------------------------------------------------------------------- 1 | ### Hotkeys: 2 | 3 | If you use Anki 2.1: By default Anki uses "F3" for adding media. If you want to use the default keybinding of this add-on for "Next result" which is also "F3" in Anki 2.1 you need the add-on [Customize Keyboard Shortcuts](https://ankiweb.net/shared/info/24411424). In the config of this add-on change "editor add media" to a different key. 4 | 5 | You have to restart Anki so that changes take effect. 6 | 7 | - `HOTKEY_SEARCH` (default is `CTRL + F`). This opens a search bar at the bottom of the window which allows you to search the editor. This shortcut works in the Add window and the EditCurrent window (you see the latter if you press "e" during reviews). 8 | - `HOTKEY_SEARCH_BROWSER` (default is `CTRL + Alt + Shift + F`): There is a separate shortcut for the browser because in the browser window there are fewer easy-to-use shortcuts unused. 9 | - `Close search` (default is `Esc`). 10 | - `Next result` (default is `F3`). 11 | - `Previous result` (default is `Shift+F3`). 12 | -------------------------------------------------------------------------------- /src/common_ctrlf_search/common_ctrlf_search/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Ctrl+F Search", 3 | "package": "common_ctrlf_search" 4 | } 5 | -------------------------------------------------------------------------------- /src/editor_autocomplete_whitelist/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_autocomplete_whitelist -------------------------------------------------------------------------------- /src/editor_autocomplete_whitelist/editor_autocomplete_whitelist.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | """ 3 | Anki Add-on: Editor Autocomplete Whitelist 4 | 5 | This is a slightly modified versions of Editor Autocomplete by 6 | Sartak that switches out the field blacklist with a whitelist. 7 | 8 | Copyright: (c) Sartak 2013 9 | (c) Glutanimate 2016-2017 10 | License: GNU AGPLv3 or later 11 | """ 12 | 13 | from aqt import mw, editor 14 | from aqt.qt import * 15 | from anki.hooks import wrap, addHook 16 | from anki.utils import splitFields, stripHTMLMedia, json 17 | import urllib2 18 | 19 | # Modify the following line to add the fields you would like to 20 | # enable autocomplete on (e.g. [ "field1", "field2" ] ) 21 | AutocompleteFields = [ ] 22 | 23 | def mySetup(self, note, hide=True, focus=False): 24 | self.prevAutocomplete = "" 25 | 26 | # only initialize the autocompleter on Add Cards not in browser 27 | if self.note and self.addMode: 28 | self.web.eval(""" 29 | document.styleSheets[0].addRule('.autocomplete', 'margin: 0.3em 0 1.0em 0; color: blue; text-decoration: underline; cursor: pointer;'); 30 | 31 | // every second send the current state over 32 | setInterval(function () { 33 | if (currentField) { 34 | var r = { 35 | text: currentField.innerHTML, 36 | }; 37 | 38 | py.run("autocomplete:" + JSON.stringify(r)); 39 | } 40 | }, 1000); 41 | """) 42 | 43 | def myBridge(self, str, _old=None): 44 | if str.startswith("autocomplete"): 45 | (type, jsonText) = str.split(":", 1) 46 | result = json.loads(jsonText) 47 | text = self.mungeHTML(result['text']) 48 | 49 | # bail out if the user hasn't actually changed the field 50 | previous = "%d:%s" % (self.currentField, text) 51 | if self.prevAutocomplete == previous: 52 | return 53 | self.prevAutocomplete = previous 54 | 55 | if text == "" or len(text) > 500 or self.note is None: 56 | self.web.eval("$('.autocomplete').remove();"); 57 | return 58 | 59 | field = self.note.model()['flds'][self.currentField] 60 | 61 | if not field['name'] in AutocompleteFields: 62 | field['no_autocomplete'] = True 63 | return 64 | 65 | # find a value from the same model and field whose 66 | # prefix is what the user typed so far 67 | query = "'note:%s' '%s:%s*'" % ( 68 | self.note.model()['name'], 69 | field['name'], 70 | text) 71 | 72 | col = self.note.col 73 | res = col.findCards(query, order=True) 74 | 75 | if len(res) == 0: 76 | self.web.eval("$('.autocomplete').remove();"); 77 | return 78 | 79 | # pull out the full value 80 | value = col.getCard(res[0]).note().fields[self.currentField] 81 | 82 | escaped = json.dumps(value) 83 | 84 | self.web.eval(""" 85 | $('.autocomplete').remove(); 86 | 87 | if (currentField) { 88 | $('
' + %s + '
').click(function () { 89 | currentField.focus(); 90 | currentField.innerHTML = %s; 91 | saveField("key"); 92 | }).insertAfter(currentField) 93 | } 94 | """ 95 | % (escaped, escaped)) 96 | else: 97 | _old(self, str) 98 | 99 | # XXX must figure out how to add noAutocomplete checkbox to form 100 | def myLoadField(self, idx): 101 | fld = self.model['flds'][idx] 102 | f = self.form 103 | if 'no_autocomplete' in fld.keys(): 104 | f.noAutocomplete.setChecked(fld['no_autocomplete']) 105 | 106 | def mySaveField(self): 107 | # not initialized yet? 108 | if self.currentIdx is None: 109 | return 110 | idx = self.currentIdx 111 | fld = self.model['flds'][idx] 112 | f = self.form 113 | fld['no_autocomplete'] = f.noAutocomplete.isChecked() 114 | 115 | # apply autocomplete on hotkey toggle 116 | def applyAutocomplete(editor): 117 | editor.web.eval(""" 118 | if ($('.autocomplete').text()) { 119 | currentField.focus(); 120 | currentField.innerHTML = $('.autocomplete').text(); 121 | saveField("key"); 122 | } 123 | """) 124 | 125 | # assign hotkey 126 | def onSetupButtons(editor): 127 | # default: Alt + Return 128 | # insert custom key sequences here: 129 | t = QShortcut(QKeySequence(Qt.ALT + Qt.Key_Return), editor.parentWindow) 130 | t.connect(t, SIGNAL("activated()"), 131 | lambda : applyAutocomplete(editor)) 132 | 133 | addHook("setupEditorButtons", onSetupButtons) 134 | 135 | editor.Editor.bridge = wrap(editor.Editor.bridge, myBridge, 'around') 136 | editor.Editor.setNote = wrap(editor.Editor.setNote, mySetup, 'after') 137 | -------------------------------------------------------------------------------- /src/editor_clear_all/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_clear_all -------------------------------------------------------------------------------- /src/editor_clear_all/editor_clear_all.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Reset Editor fields 5 | 6 | Adds hotkeys to reset the editor by various degrees. 7 | 8 | Based on Clear All Editor Fields add-on by Mirco Kraenz 9 | (https://github.com/proSingularity/anki2-addons) 10 | 11 | Copyright: (c) Glutanimate 2016-2017 12 | License: GNU AGPLv3 or later 13 | """ 14 | 15 | 16 | from anki.hooks import addHook 17 | from aqt.qt import * 18 | from aqt import mw, browser 19 | 20 | excluded_from_clearing = ["Quellen"] 21 | 22 | clear_all_shortcut = "Ctrl+Alt+Shift+R" 23 | clear_most_shortcut = "Ctrl+Shift+R" 24 | 25 | def clear_all_editor_fields(self, mode): 26 | u'''Remove text from fields in editor. ''' 27 | note = self.note 28 | # enumerate all fieldNames of the current note 29 | for c, field_name in enumerate(self.mw.col.models.fieldNames(note.model())): 30 | if mode == "most" and field_name in excluded_from_clearing: 31 | continue 32 | note[field_name] = '' 33 | self.loadNote() 34 | self.web.setFocus() 35 | self.web.eval("focusField(%d);" % self.currentField) 36 | self.web.eval('saveField("key");') 37 | 38 | 39 | def onSetupButtons(self): 40 | if not isinstance(self.parentWindow, browser.Browser): 41 | # avoid shortcut conflicts in browser 42 | t = QShortcut(QKeySequence(clear_all_shortcut), self.parentWindow) 43 | t.connect(t, SIGNAL("activated()"), 44 | lambda a=self: clear_all_editor_fields(a, "all")) 45 | t = QShortcut(QKeySequence(clear_most_shortcut), self.parentWindow) 46 | t.connect(t, SIGNAL("activated()"), 47 | lambda a=self: clear_all_editor_fields(a, "most")) 48 | 49 | addHook("setupEditorButtons", onSetupButtons) 50 | -------------------------------------------------------------------------------- /src/editor_cloze_highlighter/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_cloze_highlighter -------------------------------------------------------------------------------- /src/editor_cloze_highlighter/editor_cloze_highlighter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Highlight All Cloze Sequences 5 | 6 | Modifies Anki's default cloze function in order to provide the ability 7 | to target all clozed sequences through user-provided CSS 8 | 9 | Usage: 10 | 11 | - Use Anki's regular cloze shortcuts as usual 12 | - Update your stylesheet to target the new cloze elements, e.g.: 13 | 14 | ```CSS 15 | /* target all clozed elements: */ 16 | .clozed { 17 | color: grey; 18 | } 19 | /* target first clozed element: */ 20 | .clozed.c1 { 21 | background: yellow; 22 | } 23 | ``` 24 | 25 | Note: 26 | 27 | This add-on is incompatible with Cloze Overlapper and any other add-ons 28 | that overwrite the Editor.onCloze method. 29 | 30 | Copyright: (c) Glutanimate 2017 31 | License: GNU AGPLv3 or later 32 | """ 33 | 34 | import re 35 | 36 | from aqt.qt import * 37 | from aqt.editor import Editor 38 | 39 | from aqt.utils import tooltip, showInfo 40 | 41 | 42 | def onCloze(self): 43 | # check that the model is set up for cloze deletion 44 | if not re.search('{{(.*:)*cloze:',self.note.model()['tmpls'][0]['qfmt']): 45 | if self.addMode: 46 | tooltip(_("Warning, cloze deletions will not work until " 47 | "you switch the type at the top to Cloze.")) 48 | else: 49 | showInfo(_("""\ 50 | To make a cloze deletion on an existing note, you need to change it \ 51 | to a cloze type first, via Edit>Change Note Type.""")) 52 | return 53 | # find the highest existing cloze 54 | highest = 0 55 | for name, val in self.note.items(): 56 | m = re.findall("\{\{c(\d+)::", val) 57 | if m: 58 | highest = max(highest, sorted([int(x) for x in m])[-1]) 59 | # reuse last? 60 | if not self.mw.app.keyboardModifiers() & Qt.AltModifier: 61 | highest += 1 62 | # must start at 1 63 | highest = max(1, highest) 64 | # wrap cloze items in span 65 | js_cloze = """wrap('{{c%d::', '}}');""" 66 | self.web.eval(js_cloze % (highest, highest)) 67 | 68 | # Hooks 69 | 70 | Editor.onCloze = onCloze -------------------------------------------------------------------------------- /src/editor_custom_stylesheet/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_custom_stylesheet -------------------------------------------------------------------------------- /src/editor_custom_stylesheet/editor_custom_stylesheet.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Customize Editor CSS 5 | 6 | Allows you to customize the stylesheet of the Editor widget in Anki. 7 | 8 | Copyright: (c) Glutanimate 2017 9 | License: GNU AGPLv3 or later 10 | """ 11 | 12 | ######### USER CONFIGURATION START ########## 13 | 14 | # Whether to style editor window by default 15 | DEFAULT_STATE = True # default: True 16 | 17 | # Set a custom tag field background color 18 | # If this is set to an empty string no changes are applied 19 | # (e.g. "#F5F6CE" or "") 20 | TAGS_BACKGROUND = "" # default: "" 21 | 22 | # Disable custom CSS adjustments when Night Mode add-on active 23 | DISABLE_FOR_NIGHTMODE = True # default: True 24 | 25 | ######### USER CONFIGURATION END ########## 26 | 27 | import os 28 | 29 | from aqt.qt import * 30 | from aqt import mw 31 | from aqt import editor 32 | 33 | from anki.hooks import addHook, wrap 34 | 35 | old_html = editor._html 36 | new_html = old_html 37 | 38 | def updateTagsBackground(self): 39 | """Modify tagEdit background color""" 40 | nm_state_on = False 41 | if DISABLE_FOR_NIGHTMODE: 42 | try: 43 | from Night_Mode import nm_state_on 44 | except ImportError: 45 | pass 46 | 47 | if not mw._styleEditor or nm_state_on: 48 | return 49 | 50 | if TAGS_BACKGROUND: 51 | self.tags.setStyleSheet( 52 | """QLineEdit {{ background: {}; }}""".format( 53 | TAGS_BACKGROUND)) 54 | 55 | 56 | def profileLoaded(): 57 | """Import modified CSS code into editor""" 58 | global old_html 59 | global new_html 60 | media_dir = mw.col.media.dir() 61 | css_path = os.path.join(media_dir, "_editor.css") 62 | if not os.path.isfile(css_path): 63 | return False 64 | with open(css_path, "r") as css_file: 65 | css = css_file.read() 66 | if not css: 67 | return False 68 | editor_style = "".format(css.replace("%","%%")) 69 | old_html = editor._html 70 | editor._html = editor._html + editor_style 71 | new_html = editor._html 72 | 73 | 74 | def onEditorInit(self, *args, **kwargs): 75 | """Apply modified Editor HTML""" 76 | nm_state_on = False 77 | if DISABLE_FOR_NIGHTMODE: 78 | try: 79 | from Night_Mode import nm_state_on 80 | except ImportError: 81 | pass 82 | if not mw._styleEditor or nm_state_on: 83 | editor._html = old_html 84 | else: 85 | editor._html = new_html 86 | 87 | 88 | def onStylingToggle(checked): 89 | """Set mw variable that controls styling state""" 90 | mw._styleEditor = checked 91 | 92 | 93 | # Menu toggle: 94 | 95 | mw._styleEditor = DEFAULT_STATE 96 | action = QAction(mw) 97 | action.setText("Custom Editor Styling") 98 | action.setCheckable(True) 99 | action.setChecked(DEFAULT_STATE) 100 | action.setShortcut(QKeySequence("Shift+E")) 101 | mw.form.menuTools.addAction(action) 102 | action.toggled.connect(onStylingToggle) 103 | 104 | # Hooks: 105 | 106 | addHook("profileLoaded", profileLoaded) 107 | 108 | editor.Editor.__init__ = wrap(editor.Editor.__init__, onEditorInit, "after") 109 | editor.Editor.setupTags = wrap(editor.Editor.setupTags, 110 | updateTagsBackground, "after") 111 | -------------------------------------------------------------------------------- /src/editor_field_history/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017-2019 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_field_history -------------------------------------------------------------------------------- /src/editor_field_history/config.md: -------------------------------------------------------------------------------- 1 | **Important**: These settings do not sync and require a restart to apply. 2 | 3 | - `historyWindowShortcut` (string): Hotkey that invokes the history window. Default: `"Ctrl+Alt+H"`. 4 | - `fieldRestoreShortcut` (string): Hotkey that restores current field to last state. Default: `"Alt+Z"`. 5 | - `partialRestoreShortcut` (string): Hotkey that restores fields listed in `partialRestoreFields`. Default: `"Alt+Shift+Z"`. 6 | - `fullRestoreShortcut` (string): Hotkey that restores all fields at once, tags included: `"Ctrl+Alt+Shift+Z"`. 7 | - `partialRestoreFields` (list): List of fields to restore when using `partialRestoreShortcut` (e.g. `["Front", "Sources"]`. Default: `[]`. 8 | - `maxNotes` (int): Maximum number of notes to query when checking field history. Default: `100` 9 | -------------------------------------------------------------------------------- /src/editor_field_history/config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "title": "CTRLF5 to Refresh the Browser", 5 | "properties": { 6 | "historyWindowShortcut": { 7 | "type": "string", 8 | "title": "history window shortcut", 9 | "pattern": "^((([Cc][Tt][Rr][Ll]|[Aa][Ll][Tt]|[Ss][Hh][Ii][Ff][Tt])\\+){0,3}([a-zA-Z0-9]|F[1-9]|F1[0-2]))?$", 10 | "default": "Ctrl+Alt+H" 11 | }, 12 | "fieldRestoreShortcut": { 13 | "type": "string", 14 | "title": "field restore shortcut", 15 | "pattern": "^((([Cc][Tt][Rr][Ll]|[Aa][Ll][Tt]|[Ss][Hh][Ii][Ff][Tt])\\+){0,3}([a-zA-Z0-9]|F[1-9]|F1[0-2]))?$", 16 | "default": "Alt+Z" 17 | }, 18 | "partialRestoreShortcut": { 19 | "title": "partial restore shortcut", 20 | "type": "string", 21 | "pattern": "^((([Cc][Tt][Rr][Ll]|[Aa][Ll][Tt]|[Ss][Hh][Ii][Ff][Tt])\\+){0,3}([a-zA-Z0-9]|F[1-9]|F1[0-2]))?$", 22 | "default": "Ctrl+Alt+Shift+Z" 23 | }, 24 | "fullRestoreShortcut": { 25 | "type": "string", 26 | "title": "Full restore shortcut", 27 | "pattern": "^((([Cc][Tt][Rr][Ll]|[Aa][Ll][Tt]|[Ss][Hh][Ii][Ff][Tt])\\+){0,3}([a-zA-Z0-9]|F[1-9]|F1[0-2]))?$", 28 | "default": "Ctrl+Alt+Shift+Z" 29 | }, 30 | "partialRestoreFields": { 31 | "title": "Partial restore fields", 32 | "description": "List of fields to restore when using `partialRestoreShortcut`", 33 | "type": "array", 34 | "default": [], 35 | "items": { 36 | "title": "field", 37 | "type": "string" 38 | } 39 | }, 40 | "maxNotes": { 41 | "type": "number", 42 | "title": "Max notes", 43 | "description": "Maximum number of notes to query when checking field history.", 44 | "default": 100 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/editor_field_navigation/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_field_navigation -------------------------------------------------------------------------------- /src/editor_field_navigation/editor_field_navigation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Quick Field Navigation 5 | 6 | Implements shortcuts that allow you to navigate 7 | through your fields in the card editor. 8 | 9 | Copyright: Glutanimate 2015-2020 10 | License: GNU AGPLv3 or later 11 | """ 12 | 13 | from anki.hooks import addHook 14 | 15 | def changeFocusTo(self, fldnr): 16 | fldnr = fldnr - 1 17 | fldstot = len(self.note.fields) -1 18 | if fldnr >= fldstot or fldnr < 0: 19 | # ignore onid field (Note Organizer add-on) 20 | if self.note.model()['flds'][-1]['name'] == "onid": 21 | fldnr = fldstot - 1 22 | else: 23 | fldnr = fldstot 24 | elif self.note.model()['flds'][0]['name'] == "ID (hidden)": 25 | # ignore hidden ID field (Image Occlusion Enhanced) 26 | fldnr += 1 27 | self.web.setFocus() 28 | self.web.eval("focusField(%d);" % int(fldnr)) 29 | 30 | def onSetupShortcuts(cuts, editor): 31 | cuts.extend( 32 | [(f"Ctrl+{i}", lambda f=i: changeFocusTo(editor, f), True) for i in range(10)] 33 | ) 34 | return cuts 35 | 36 | addHook("setupEditorShortcuts", onSetupShortcuts) -------------------------------------------------------------------------------- /src/editor_indentation_formatter/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_indentation_formatter -------------------------------------------------------------------------------- /src/editor_indentation_formatter/editor_indentation_formatter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Indentation Formatter 5 | 6 | Extends Anki's note editing toolbar with an "indent" and "outdent" button 7 | that insert indented paragraphs. 8 | 9 | Copyright: (c) Glutanimate 2017 10 | License: GNU AGPLv3 or later 11 | """ 12 | 13 | # USER CONFIGURATION START 14 | 15 | # Default html tag to apply when there is no 16 | # pre-existing formatting (e.g. div, p, blockquote) 17 | HTML_TAG = "p" 18 | 19 | # Whether or not to indent existing tags instead of applying 20 | # the tag above 21 | USE_EXISTING_TAGS = False 22 | 23 | # Hotkeys 24 | HOTKEY_INDENT = "Alt+L" 25 | HOTKEY_OUTDENT = "Alt+J" 26 | 27 | # Indentation steps in px (default: 40) 28 | INDENTATION_STEP = 40 29 | 30 | # USER CONFIGURATION END 31 | 32 | from aqt.editor import Editor 33 | from anki.hooks import wrap 34 | 35 | 36 | js_indent = """ 37 | function indent(mode){ 38 | var elm = window.getSelection().focusNode; 39 | var parent = window.getSelection().focusNode.parentNode; 40 | if (%(exst)s){ 41 | var isElm = parent.isContentEditable && elm.toString() !== "[object Text]"; 42 | var isParElm = parent.isContentEditable && parent.parentNode.isContentEditable; 43 | } else { 44 | var isElm = elm.tagName == "%(tag)s" 45 | && parent.isContentEditable; 46 | var isParElm = parent.tagName == "%(tag)s" 47 | && parent.parentNode.isContentEditable; 48 | } 49 | var newNode = false 50 | 51 | if (mode == "in" && !isElm && !isParElm){ 52 | document.execCommand("formatBlock", false, "%(tag)s"); 53 | var elm = window.getSelection().focusNode; 54 | if (elm.tagName !== "%(tag)s") { 55 | elm = elm.parentNode; 56 | } 57 | var marginL = %(step)i 58 | var newNode = true 59 | } else if (isElm || isParElm) { 60 | if (!isElm) { 61 | elm = parent; 62 | } 63 | mleft = parseInt(elm.style.marginLeft) 64 | if (isNaN(mleft)){ 65 | mleft = 0; 66 | } 67 | if (mode == "in"){ 68 | var marginL = mleft + %(step)i 69 | } else { 70 | var marginL = Math.max(mleft-%(step)i, 0) 71 | } 72 | } else { 73 | return 74 | } 75 | 76 | if (newNode) { 77 | elm.style.margin = "0px"; 78 | } 79 | elm.style.marginLeft = marginL + "px"; 80 | 81 | } 82 | indent("%%s"); 83 | saveField('key'); 84 | """ % dict(exst=str(USE_EXISTING_TAGS).lower(), tag=HTML_TAG.upper(), 85 | step=INDENTATION_STEP) 86 | 87 | 88 | def onIndent(self, mode): 89 | self.web.eval(js_indent % mode) 90 | 91 | def setupButtons(self): 92 | self._addButton("OutdentBtn", lambda: self.onIndent("out"), 93 | text=u"←", tip="Outdent ({})".format(HOTKEY_OUTDENT), 94 | key=HOTKEY_OUTDENT) 95 | self._addButton("IndentBtn", lambda: self.onIndent("in"), 96 | text=u"→", tip="Indent ({})".format(HOTKEY_INDENT), 97 | key=HOTKEY_INDENT) 98 | 99 | Editor.onIndent = onIndent 100 | Editor.setupButtons = wrap(Editor.setupButtons, setupButtons) 101 | -------------------------------------------------------------------------------- /src/editor_paste_sources/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_paste_sources -------------------------------------------------------------------------------- /src/editor_paste_sources/editor_paste_sources.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Paste Sources into Editor 5 | 6 | Use a hotkey to replace / add to the Sources field from your clipboard 7 | 8 | Based in part on paste_to_my_field by Mirco Kraenz 9 | (https://github.com/proSingularity/anki2-addons) 10 | 11 | Copyright: (c) Glutanimate 2017 12 | License: GNU AGPLv3 or later 13 | """ 14 | 15 | from __future__ import unicode_literals 16 | 17 | ############## USER CONFIGURATION START ############## 18 | 19 | SHORTCUT_REPLACE_PASTE = "Alt+Shift+V" 20 | SHORTCUT_ADD_PASTE = "Ctrl+Alt+Shift+V" 21 | SOURCE_FIELD = "Quellen" 22 | 23 | ############## USER CONFIGURATION END ############## 24 | 25 | from PyQt4.QtGui import QClipboard 26 | 27 | from aqt.qt import * 28 | from anki.hooks import addHook 29 | 30 | def pasteIntoField(self, action): 31 | '''Paste clipboard text to field specified by constant SOURCE_FIELD ''' 32 | mode = QClipboard.Clipboard 33 | note = self.note 34 | cb = self.mw.app.clipboard().mimeData(mode=mode) 35 | if cb.hasText(): 36 | cb_text = cb.text() 37 | if cb_text and SOURCE_FIELD in note: 38 | if action == "replace": 39 | # replace existing contents 40 | note[SOURCE_FIELD] = cb_text 41 | elif action == "add": 42 | # add to existing contents 43 | curr = note[SOURCE_FIELD] 44 | new = curr + "
" + cb_text 45 | note[SOURCE_FIELD] = new 46 | self.web.eval(""" 47 | if (currentField) { 48 | saveField("key"); 49 | } 50 | """) 51 | self.loadNote() 52 | 53 | # assign hotkey 54 | def onSetupButtons(self): 55 | t = QShortcut(QKeySequence(SHORTCUT_REPLACE_PASTE), self.widget) 56 | t.activated.connect(lambda a=self: pasteIntoField(a, "replace")) 57 | t = QShortcut(QKeySequence(SHORTCUT_ADD_PASTE), self.widget) 58 | t.activated.connect(lambda a=self: pasteIntoField(a, "add")) 59 | 60 | addHook("setupEditorButtons", onSetupButtons) -------------------------------------------------------------------------------- /src/editor_preserve_fields_on_switch/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_preserve_fields_on_switch -------------------------------------------------------------------------------- /src/editor_preserve_fields_on_switch/editor_preserve_fields_on_switch.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Preserve editor fields on note model switch 5 | 6 | Copies values of identically named fields over to corresponding fields 7 | in new note type. 8 | 9 | Should be implemented by default in the next Anki release. 10 | 11 | Copyright: (c) Glutanimate 2016-2017 12 | License: GNU AGPLv3 or later 13 | """ 14 | 15 | from anki.hooks import wrap 16 | from aqt.addcards import AddCards 17 | 18 | def newOnReset(self, _old, model=None, keep=False): 19 | # return _old(self, model=None, keep=False) 20 | oldNote = self.editor.note 21 | note = self.setupNewNote(set=False) 22 | modelName = note.model()['name'] 23 | flds = note.model()['flds'] 24 | if oldNote: 25 | oldModelName = oldNote.model()['name'] 26 | if oldModelName != modelName: 27 | # model changed 28 | oldFields = oldNote.keys() 29 | newFields = note.keys() 30 | for n, f in enumerate(note.model()['flds']): 31 | fieldName = f['name'] 32 | try: 33 | oldFieldName = oldNote.model()['flds'][n]['name'] 34 | except IndexError: 35 | oldFieldName = None 36 | # copy identical fields 37 | if fieldName in oldFields: 38 | note[fieldName] = oldNote[fieldName] 39 | # set non-identical fields by field index 40 | elif oldFieldName and oldFieldName not in newFields: 41 | try: 42 | note.fields[n] = oldNote.fields[n] 43 | except IndexError: 44 | pass 45 | else: 46 | # model identical 47 | if not keep: 48 | self.removeTempNote(oldNote) 49 | for n in range(len(note.fields)): 50 | try: 51 | if not keep or flds[n]['sticky']: 52 | note.fields[n] = oldNote.fields[n] 53 | else: 54 | note.fields[n] = "" 55 | except IndexError: 56 | break 57 | self.editor.currentField = 0 58 | self.editor.setNote(note, focus=True) 59 | 60 | 61 | AddCards.onReset = wrap(AddCards.onReset, newOnReset, "around") 62 | -------------------------------------------------------------------------------- /src/editor_random_list/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017-2018 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_random_list -------------------------------------------------------------------------------- /src/editor_random_list/config.md: -------------------------------------------------------------------------------- 1 | **Important**: Please restart Anki to apply changes to these settings 2 | 3 | - `hotkeyToggleList` (string): Hotkey for the "Insert Randomized List" button. Default: `"Alt+Shift+L"`. -------------------------------------------------------------------------------- /src/editor_random_list/editor_random_list.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Insert Randomized List 5 | 6 | Inserts an unordered list with the 'shuffle' CSS class. 7 | 8 | This can be used to randomize list items when coupled with 9 | a special card template. 10 | 11 | Copyright: (c) Glutanimate 2017 12 | License: GNU AGPLv3 or later 13 | """ 14 | 15 | ############## USER CONFIGURATION START ############## 16 | 17 | # These settings only apply to Anki 2.0. For Anki 2.1 please use 18 | # Anki's built-in add-on configuration menu 19 | 20 | HOTKEY_TOGGLE_LIST = "Alt+Shift+L" 21 | 22 | ############## USER CONFIGURATION END ############## 23 | 24 | from aqt import editor, mw 25 | from anki.hooks import wrap, addHook 26 | 27 | from anki import version 28 | 29 | ANKI21 = version.startswith("2.1") 30 | 31 | if ANKI21: 32 | config = mw.addonManager.getConfig(__name__) 33 | HOTKEY_TOGGLE_LIST = config["hotkeyToggleList"] 34 | 35 | editor_style = """ 36 | 41 | """ 42 | 43 | 44 | def toggleRandUl(self): 45 | self.web.eval(""" 46 | document.execCommand('insertUnorderedList'); 47 | var ulElem = window.getSelection().focusNode.parentNode; 48 | if (ulElem !== null) { 49 | var setAttrs = true; 50 | while (ulElem.toString() !== "[object HTMLUListElement]") { 51 | ulElem = ulElem.parentNode; 52 | if (ulElem === null) { 53 | setAttrs = false; 54 | break; 55 | } 56 | } 57 | if (setAttrs) { 58 | ulElem.style.marginLeft = "20px"; 59 | ulElem.className = "shuffle" 60 | } 61 | } 62 | """) 63 | 64 | 65 | def setupButtons20(self): 66 | self._addButton("randUlBtn", self.toggleRandUl, 67 | text="RL", 68 | tip="Insert randomized unordered list ({})".format( 69 | HOTKEY_TOGGLE_LIST), 70 | key=HOTKEY_TOGGLE_LIST) 71 | 72 | 73 | def setupButtons21(btns, editor): 74 | btn = editor.addButton(None, "randUlBtn", toggleRandUl, 75 | label="RL", keys=HOTKEY_TOGGLE_LIST, 76 | tip="Insert randomized unordered list ({})".format( 77 | HOTKEY_TOGGLE_LIST)) 78 | btns.append(btn) 79 | return btns 80 | 81 | editor._html = editor._html + editor_style 82 | 83 | 84 | if not ANKI21: 85 | editor.Editor.toggleRandUl = toggleRandUl 86 | editor.Editor.setupButtons = wrap( 87 | editor.Editor.setupButtons, setupButtons20) 88 | else: 89 | addHook("setupEditorButtons", setupButtons21) 90 | -------------------------------------------------------------------------------- /src/editor_replace_linebreaks/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_replace_linebreaks -------------------------------------------------------------------------------- /src/editor_replace_linebreaks/editor_replace_linebreaks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Replace Linebreaks in Clipboard or Selection 5 | 6 | Adds hotkeys that remove linebreaks in the clipboard or 7 | currently selected text. 8 | 9 | Copyright: (c) Glutanimate 2017 10 | License: GNU AGPLv3 or later 11 | """ 12 | 13 | # Do not modify the following line: 14 | from __future__ import unicode_literals 15 | 16 | ############## USER CONFIGURATION START ############## 17 | 18 | # KEY ASSIGNMENTS 19 | 20 | PASTE_HOTKEY = "Alt+P" # Default: Alt+P 21 | EDIT_SELECTION_HOTKEY = "Alt+Shift+P" # Default: Alt+Shift+P 22 | 23 | # OPTIONS 24 | 25 | # try to preserve paragraphs (not 100% exact): 26 | PRESERVE_PARAGRAPHS = True # Default: True 27 | # confirm actions with tooltips 28 | TOOLTIP = True # Default: True 29 | 30 | ############## USER CONFIGURATION END ############## 31 | 32 | import re 33 | 34 | from aqt.qt import * 35 | from aqt.editor import Editor 36 | from aqt.utils import tooltip 37 | 38 | from anki.hooks import wrap 39 | from anki import json 40 | 41 | if PRESERVE_PARAGRAPHS: 42 | re_lb_open = "(
|
||
)" 43 | re_lb_close = "(<\/div>)" 44 | else: 45 | re_lb_open = "(
|

|
||
)" 46 | re_lb_close = "(<\/div>|<\/p>)" 47 | 48 | js_linebreak_remove = """ 49 | function getSelectionHtml() { 50 | // Based on an SO answer by Tim Down 51 | var html = ""; 52 | if (typeof window.getSelection != "undefined") { 53 | var sel = window.getSelection(); 54 | if (sel.rangeCount) { 55 | var container = document.createElement("div"); 56 | for (var i = 0, len = sel.rangeCount; i < len; ++i) { 57 | container.appendChild(sel.getRangeAt(i).cloneContents()); 58 | } 59 | html = container.innerHTML; 60 | } 61 | } else if (typeof document.selection != "undefined") { 62 | if (document.selection.type == "Text") { 63 | html = document.selection.createRange().htmlText; 64 | } 65 | } 66 | return html; 67 | } 68 | if (typeof window.getSelection != "undefined") { 69 | // get selected HTML 70 | var sel = getSelectionHtml(); 71 | // replace linebreak tags 72 | var sel = sel.replace(/%s/g, " ") 73 | var sel = sel.replace(/%s/g, "") 74 | document.execCommand('insertHTML', false, sel); 75 | saveField('key'); 76 | } 77 | """ % (re_lb_open, re_lb_close) 78 | 79 | 80 | def pasteWithoutLinebreaks(self): 81 | """Remove linebreaks from clipboard and paste""" 82 | mime = self.web.mungeClip(mode=QClipboard.Clipboard) 83 | html = mime.html() 84 | text = mime.text() 85 | 86 | if not(text or html): 87 | return 88 | 89 | if html: 90 | content = re.sub(re_lb_open, " ", html) 91 | content = re.sub(re_lb_close, "", content) 92 | else: 93 | if PRESERVE_PARAGRAPHS: 94 | # remove EOL hyphens: 95 | content = re.sub(r"-\n(?!\n)", "", text) 96 | # skip ends of sentences: 97 | content = re.sub(r"(? tags 99 | content = "
".join(i.strip() for i in content.split("\n")) 100 | else: 101 | content = content.replace("\n", " ") 102 | 103 | self.web.eval(""" 104 | var pasteHTML = function(html) { 105 | setFormat("inserthtml", html); 106 | }; 107 | var filterHTML = function(html) { 108 | // wrap it in as we aren't allowed to change top level elements 109 | var top = $.parseHTML("" + html + "")[0]; 110 | filterNode(top); 111 | var outHtml = top.innerHTML; 112 | // get rid of nbsp 113 | outHtml = outHtml.replace(/ /ig, " "); 114 | return outHtml; 115 | }; 116 | pasteHTML(%s); 117 | """ % json.dumps(content)) 118 | 119 | if TOOLTIP: 120 | tooltip("Pasting without linebreaks", period=500) 121 | 122 | 123 | def removeLinebreaksInSelection(self): 124 | """Remove linebreaks from selected text""" 125 | # here we have to operate on HTML tags instead of newlines 126 | self.web.eval(js_linebreak_remove) 127 | if TOOLTIP: 128 | tooltip("Removing linebreaks", period=500) 129 | 130 | 131 | def setupButtons(self): 132 | """Add hotkeys to editor""" 133 | t = QShortcut(QKeySequence(PASTE_HOTKEY), self.parentWindow) 134 | t.activated.connect(self.pasteWithoutLinebreaks) 135 | t = QShortcut(QKeySequence(EDIT_SELECTION_HOTKEY), self.parentWindow) 136 | t.activated.connect(self.removeLinebreaksInSelection) 137 | 138 | # Hooks 139 | 140 | Editor.removeLinebreaksInSelection = removeLinebreaksInSelection 141 | Editor.pasteWithoutLinebreaks = pasteWithoutLinebreaks 142 | Editor.setupButtons = wrap(Editor.setupButtons, setupButtons) 143 | -------------------------------------------------------------------------------- /src/editor_reverse_toggle/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_reverse_toggle -------------------------------------------------------------------------------- /src/editor_reverse_toggle/editor_reverse_toggle.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Editor Reverse Toggle 5 | 6 | Simple addon to quickly toggle the reverse card for a given note 7 | 8 | (requires setting up the reverse field name below) 9 | 10 | Hotkeys: 11 | 12 | Alt+Shift+B toggle reverse field 13 | Ctrl+Alt+Shift+B toggle reverse field and control its frozen state 14 | (meant to be used with the Frozen Fields add-on) 15 | 16 | Copyright: (c) Glutanimate 2015-2017 17 | License: GNU AGPLv3 or later 18 | """ 19 | 20 | from __future__ import unicode_literals 21 | 22 | ############## USER CONFIGURATION START ############## 23 | 24 | # set reverse field name here 25 | rev_field_name = "Bidirektional" 26 | 27 | # set up different field content toggles 28 | key_toggles = { 29 | "b": "y", # paste "y" into field 30 | "a": "a" # paste "a" into field 31 | } 32 | 33 | ############## USER CONFIGURATION END ############## 34 | 35 | from aqt.qt import * 36 | from anki.hooks import addHook 37 | 38 | def toggleFrozenState(self, state): 39 | model = self.note.model() 40 | for n, f in enumerate(model['flds']): 41 | fieldName = f['name'] 42 | if fieldName == rev_field_name: 43 | fieldNr = n 44 | break 45 | model['flds'][n]['sticky'] = state 46 | 47 | def toggleReverseField(self, toggle, freeze=False): 48 | if rev_field_name not in self.note: 49 | return 50 | if self.note[rev_field_name] == toggle: 51 | self.note[rev_field_name] = "" 52 | if freeze: 53 | toggleFrozenState(self, False) 54 | else: 55 | self.note[rev_field_name] = toggle 56 | if freeze: 57 | toggleFrozenState(self, True) 58 | self.web.eval(""" 59 | if (currentField) { 60 | saveField("key"); 61 | } 62 | """) 63 | self.loadNote() 64 | self.web.setFocus() 65 | self.web.eval("focusField(%d);" % self.currentField) 66 | self.web.eval('saveField("key");') 67 | 68 | 69 | def onSetupButtons(self): 70 | for key, toggle in list(key_toggles.items()): 71 | 72 | t = QShortcut(QKeySequence("Alt+Shift+" + key), self.parentWindow) 73 | t.activated.connect( 74 | lambda x=toggle: toggleReverseField(self, x)) 75 | 76 | t = QShortcut(QKeySequence("Ctrl+Alt+Shift+" + key), self.parentWindow) 77 | t.activated.connect( 78 | lambda x=toggle: toggleReverseField(self, x, freeze=True)) 79 | 80 | addHook("setupEditorButtons", onSetupButtons) -------------------------------------------------------------------------------- /src/editor_second_addcards_dialog/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_second_addcards_dialog -------------------------------------------------------------------------------- /src/editor_second_addcards_dialog/editor_second_addcards_dialog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Second Add Cards Dialog 5 | 6 | Hotkey that launches a second AddCards dialog from an existing 7 | AddCards instance. Pressing the hotkey after launching the second 8 | dialog will toggle between the two. 9 | 10 | Hotkey: Ctrl+Shift+A 11 | 12 | TODO: support for an arbitrary number of AddCards dialogs 13 | 14 | Copyright: (c) Glutanimate 2016-2017 15 | License: GNU AGPLv3 or later 16 | """ 17 | 18 | #============USER CONFIGURATION START=============== 19 | 20 | open_editor_hotkey = "Ctrl+Shift+A" 21 | 22 | #==============USER CONFIGURATION END=============== 23 | 24 | from aqt import dialogs, addcards 25 | from aqt.addcards import AddCards 26 | from aqt.qt import * 27 | from anki.hooks import addHook, remHook, wrap 28 | from anki.sound import clearAudioQueue 29 | from aqt.utils import saveGeom, restoreGeom 30 | 31 | dialogs._dialogs["AddCards2"] = [addcards.AddCards, None] 32 | 33 | def myInit(self, mw): 34 | curDialogs = dialogs._dialogs 35 | self.dialogName = "AddCards" 36 | # existing instance → open second instance 37 | if curDialogs[self.dialogName][1] != None: 38 | self.dialogName = "AddCards2" 39 | self.setWindowTitle(_("Add") + " 2") 40 | restoreGeom(self, "add2") 41 | 42 | def myReject(self): 43 | if not self.canClose(): 44 | return 45 | remHook('reset', self.onReset) 46 | clearAudioQueue() 47 | self.removeTempNote(self.editor.note) 48 | self.editor.setNote(None) 49 | self.modelChooser.cleanup() 50 | self.deckChooser.cleanup() 51 | self.mw.maybeReset() 52 | # save geometry of current dialog 53 | if self.dialogName == "AddCards": 54 | saveGeom(self, "add") 55 | else: 56 | saveGeom(self, "add2") 57 | # close dialog 58 | dialogs.close(self.dialogName) 59 | QDialog.reject(self) 60 | 61 | def switchAddWindow(self): 62 | curDialogs = dialogs._dialogs 63 | # toggle between windows 64 | if curDialogs["AddCards"][1] == self.parentWindow: 65 | dialogs.open("AddCards2", self.mw) 66 | else: 67 | dialogs.open("AddCards", self.mw) 68 | 69 | def onSetupButtons(self): 70 | t = QShortcut(QKeySequence(open_editor_hotkey), self.parentWindow) 71 | t.connect(t, SIGNAL("activated()"), 72 | lambda a=self: switchAddWindow(a)) 73 | 74 | # -----------------HOOKS-------------------- # 75 | 76 | AddCards.__init__ = wrap(AddCards.__init__, myInit, "after") 77 | AddCards.reject = myReject 78 | addHook("setupEditorButtons", onSetupButtons) -------------------------------------------------------------------------------- /src/editor_sync_html_cursor/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_sync_html_cursor -------------------------------------------------------------------------------- /src/editor_tag_hotkeys/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import editor_tag_hotkeys -------------------------------------------------------------------------------- /src/editor_tag_hotkeys/editor_tag_hotkeys.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Editor Tag Hotkeys 5 | 6 | Extends Anki's note editor with hotkeys that toggle specific tags. 7 | 8 | Copyright: (c) Glutanimate 2016-2017 9 | License: GNU AGPLv3 or later 10 | """ 11 | 12 | from __future__ import unicode_literals 13 | 14 | ################## USER CONFIGURATION START ##################### 15 | 16 | # Dictionary of hotkey assignments to tags 17 | tags = { 18 | "Alt+Shift+1": u"tag1", 19 | "Alt+Shift+2": u"tag2", 20 | "Alt+Shift+3": u"tag3", 21 | "Alt+Shift+4": u"tag4", 22 | "Alt+Shift+5": u"tag5", 23 | "Alt+Shift+6": u"tag6", 24 | "Alt+Shift+7": u"tag7", 25 | "Alt+Shift+8": u"tag8", 26 | "Alt+Shift+9": u"tag9", 27 | } 28 | # syntax: {"Hotkey": u"tag1", "Hotkey": u"tag2"} 29 | 30 | # List of tags that can only be set one at a time: 31 | unique_tags = ["tag7", "tag8", "tag9"] 32 | # syntax: ["tag1", "tag2"] 33 | # 34 | # (toggling the hotkey for any of these will delete any 35 | # other unique tag found in the current tags) 36 | 37 | ################## USER CONFIGURATION End ##################### 38 | 39 | from aqt.qt import * 40 | 41 | from aqt.editor import Editor 42 | from anki.hooks import addHook 43 | 44 | def toggleTag(self, tag): 45 | current = self.tags.text().split() 46 | if tag in current: 47 | current.remove(tag) 48 | else: 49 | current.append(tag) 50 | if tag in unique_tags: 51 | intersectedTags = [val for val in unique_tags if val in current] 52 | if tag in intersectedTags: 53 | intersectedTags.remove(tag) 54 | for tag in intersectedTags: 55 | current.remove(tag) 56 | self.tags.setText(" ".join(current)) 57 | self.saveTags() 58 | 59 | def resetTags(self): 60 | self.tags.clear() 61 | 62 | def onSetupButtons(self): 63 | s = QShortcut(QKeySequence("Alt+Shift+R"), self.parentWindow) 64 | s.activated.connect(self.resetTags) 65 | 66 | for hotkey, tag in tags.items(): 67 | s = QShortcut(QKeySequence(hotkey), self.parentWindow) 68 | s.activated.connect(lambda t=tag: self.toggleTag(t)) 69 | 70 | 71 | addHook("setupEditorButtons", onSetupButtons) 72 | Editor.resetTags = resetTags 73 | Editor.toggleTag = toggleTag -------------------------------------------------------------------------------- /src/main_fullscreen/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import main_fullscreen -------------------------------------------------------------------------------- /src/main_fullscreen/main_fullscreen.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Toggle Full Screen Extended 5 | 6 | Adds the ability to toggle full-screen mode. Based on Damien's 7 | original add-on and extended with the following new features: 8 | 9 | - Hide menu bar when in full screen mode 10 | - Configurable shortcut 11 | 12 | Copyright: (c) Damien Elmes 2012 13 | (c) Glutanimate 2016-2017 14 | License: GNU AGPLv3 or later 15 | """ 16 | 17 | from __future__ import unicode_literals 18 | 19 | ############## USER CONFIGURATION START ############## 20 | 21 | KEY_FULLSCREEN_TOGGLE = "F11" 22 | HIDE_MENU_BAR = True 23 | 24 | ############## USER CONFIGURATION END ############## 25 | 26 | from aqt.qt import * 27 | from aqt import mw 28 | from anki.lang import _ 29 | 30 | 31 | def onFullScreen(): 32 | if not mw.isFullScreen(): 33 | mw.setWindowState(mw.windowState() | Qt.WindowFullScreen) 34 | if HIDE_MENU_BAR: 35 | mw.menuBar().hide() 36 | custom_undo.setEnabled(True) 37 | else: 38 | mw.setWindowState(mw.windowState() ^ Qt.WindowFullScreen) 39 | mw.menuBar().show() 40 | custom_undo.setEnabled(False) 41 | 42 | def myUndo(): 43 | try: 44 | mw.onUndo() 45 | except TypeError: # no more steps back 46 | pass 47 | 48 | 49 | # Restore undo shortcut that would otherwise be disabled 50 | # due to removing the menu 51 | custom_undo = QShortcut(QKeySequence(_("Ctrl+Z")), mw, 52 | activated=myUndo) 53 | custom_undo.setEnabled(False) 54 | 55 | 56 | QShortcut(QKeySequence(KEY_FULLSCREEN_TOGGLE), mw, 57 | activated=onFullScreen) 58 | -------------------------------------------------------------------------------- /src/main_ontop/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import main_ontop -------------------------------------------------------------------------------- /src/main_ontop/main_ontop.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: "Always on-top" for all windows 5 | 6 | Based on https://ankiweb.net/shared/info/1830523200 7 | 8 | Makes all important Anki windows stay on top 9 | 10 | Copyright: (c) Glutanimate 2017 11 | License: GNU AGPLv3 or later 12 | """ 13 | 14 | from anki.hooks import wrap 15 | 16 | from aqt import dialogs 17 | from aqt import mw, addcards, editcurrent, browser 18 | from aqt.qt import * 19 | 20 | def alwaysOnTop(triggered): 21 | mw._onTop = not mw._onTop 22 | windows = [mw] 23 | for dclass, instance in dialogs._dialogs.values(): 24 | if instance: 25 | windows.append(instance) 26 | for window in windows: 27 | windowFlags = window.windowFlags() 28 | windowFlags ^= Qt.WindowStaysOnTopHint 29 | window.setWindowFlags(windowFlags) 30 | window.show() 31 | 32 | def onWindowInit(self, *args, **kwargs): 33 | if mw._onTop: 34 | windowFlags = self.windowFlags() | Qt.WindowStaysOnTopHint 35 | self.setWindowFlags(windowFlags) 36 | self.show() 37 | 38 | mw._onTop = False 39 | action = QAction("Always on top", mw) 40 | action.setCheckable(True) 41 | action.triggered.connect(alwaysOnTop) 42 | mw.form.menuTools.addAction(action) 43 | 44 | addcards.AddCards.__init__ = wrap( 45 | addcards.AddCards.__init__, onWindowInit, "after") 46 | editcurrent.EditCurrent.__init__ = wrap( 47 | editcurrent.EditCurrent.__init__, onWindowInit, "after") 48 | browser.Browser.__init__ = wrap( 49 | browser.Browser.__init__, onWindowInit, "after") -------------------------------------------------------------------------------- /src/overview_browser_shortcuts/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import overview_browser_shortcuts -------------------------------------------------------------------------------- /src/overview_browser_shortcuts/overview_browser_shortcuts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Overview Browser Shortcuts 5 | 6 | Specify hotkeys that allow you to go directly from the deck overview 7 | to a specific search in the card browser (e.g. cards added today) 8 | 9 | Copyright: (c) Glutanimate 2017 10 | License: GNU AGPLv3 or later 11 | """ 12 | 13 | ############## USER CONFIGURATION START ############## 14 | 15 | menu_items = { 16 | 'Shift+T': {'search': 'added:1', 'label': _('Added Today')}, 17 | 'Shift+D': {'search': 'deck:current', 'label': _('Current Deck')}, 18 | } 19 | 20 | ############## USER CONFIGURATION END ############## 21 | 22 | from aqt.qt import * 23 | import aqt 24 | from aqt import mw 25 | from anki.lang import ngettext 26 | 27 | def openBrowserWithSearch(mw, search): 28 | browser = aqt.dialogs.open("Browser", mw) 29 | browser.form.searchEdit.lineEdit().setText(search) 30 | browser.onSearch() 31 | 32 | go_menu = QMenu(_("&Go")) 33 | action = mw.menuBar().insertMenu(mw.form.menuTools.menuAction(), go_menu) 34 | 35 | for key_sequence, binding in menu_items.iteritems(): 36 | search = binding["search"] 37 | label = binding["label"] 38 | a = go_menu.addAction(label) 39 | a.setShortcut(QKeySequence(key_sequence)) 40 | a.connect(a, SIGNAL("triggered()"), lambda c=search: openBrowserWithSearch(mw, c)) 41 | -------------------------------------------------------------------------------- /src/overview_deck_switcher/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import overview_deck_switcher -------------------------------------------------------------------------------- /src/overview_deck_switcher/overview_deck_switcher.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Overview Deck Switcher 5 | 6 | Switch between decks in the deck overview screen. 7 | 8 | Hotkeys: 9 | Ctrl + (Shift) + Tab : Switch to next/previous deck 10 | 11 | Options: 12 | See below under USER CONFIGURATION 13 | 14 | Copyright: (c) Glutanimate 2016-2017 15 | License: GNU AGPLv3 or later 16 | """ 17 | 18 | ############## USER CONFIGURATION START ############## 19 | 20 | # possible values: True False 21 | 22 | deck_switcher_skip_filtered = True # skip filtered decks 23 | deck_switcher_skip_empty = True # skip empty decks 24 | 25 | # possible values: valid key sequences 26 | 27 | deck_switcher_key_forward = "Ctrl+Tab" 28 | deck_switcher_key_backward = "Ctrl+Shift+Tab" 29 | 30 | ############## USER CONFIGURATION END ############## 31 | 32 | from aqt.qt import * 33 | from aqt import mw 34 | 35 | def quickSwitchDeck(drc): 36 | # get info on current decks 37 | oldid = mw.col.decks.current()["id"] 38 | dl = mw.col.sched.deckDueList() 39 | cnt = mw.col.decks.count() 40 | # find index of current deck in duelist 41 | for i, deck in enumerate(dl): 42 | if deck[1] == oldid: 43 | idx = i 44 | break 45 | i = idx + drc 46 | newid = oldid 47 | # iterate through decks and skip based on configuration 48 | while (i != idx): 49 | if i == cnt: 50 | # reached end of list 51 | i = 0 52 | did = dl[i][1] 53 | crds = dl[i][2] + dl[i][3] + dl[i][4] 54 | i += drc 55 | if deck_switcher_skip_filtered and mw.col.decks.isDyn(did): 56 | continue 57 | if crds > 0 or not deck_switcher_skip_empty: 58 | newid = did 59 | break 60 | # set new did 61 | mw.col.decks.select(newid) 62 | # uncollapse parent decks if applicable 63 | parents = mw.col.decks.parents(newid) 64 | for parent in parents: 65 | if parent["collapsed"]: 66 | mw.col.decks.collapse(parent["id"]) 67 | # refresh view 68 | if mw.state == "deckBrowser": 69 | mw.deckBrowser.refresh() 70 | else: 71 | mw.onOverview() 72 | 73 | # Set up menu entries and hotkeys 74 | action = QAction(mw) 75 | action.setText("Next deck") 76 | action.setShortcut(QKeySequence(deck_switcher_key_forward)) 77 | mw.form.menuEdit.addAction(action) 78 | action.triggered.connect(lambda _: quickSwitchDeck(1)) 79 | 80 | action = QAction(mw) 81 | action.setText("Previous deck") 82 | action.setShortcut(QKeySequence(deck_switcher_key_backward)) 83 | mw.form.menuEdit.addAction(action) 84 | action.triggered.connect(lambda _: quickSwitchDeck(-1)) -------------------------------------------------------------------------------- /src/overview_deck_tooltip/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import overview_deck_tooltip -------------------------------------------------------------------------------- /src/overview_refresh_media/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import overview_refresh_media -------------------------------------------------------------------------------- /src/overview_refresh_media/overview_refresh_media.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Refresh Media References 5 | 6 | Adds an entry in the Tools menu that clears the webview cache. 7 | This will effectively refresh all media files used by your cards 8 | and templates, allowing you to display changes to external files 9 | without having to restart Anki. 10 | 11 | The add-on will also update the modification time of your media 12 | collection which will force an upload of any updated files 13 | on the next synchronization with AnkiWeb. 14 | 15 | Note: Might lead to increased memory consumption if used excessively 16 | 17 | Copyright: (c) Glutanimate 2017 18 | License: GNU AGPLv3 or later 19 | """ 20 | 21 | import os 22 | 23 | from aqt import mw 24 | from aqt.utils import tooltip 25 | from aqt.qt import * 26 | 27 | def refresh_media(): 28 | # clear QWebView cache 29 | QWebSettings.clearMemoryCaches() 30 | # write a dummy file to update collection.media modtime and force sync 31 | media_dir = mw.col.media.dir() 32 | fpath = os.path.join(media_dir, "syncdummy.txt") 33 | if not os.path.isfile(fpath): 34 | with open(fpath, "w") as f: 35 | f.write("anki sync dummy") 36 | os.remove(fpath) 37 | # reset Anki 38 | mw.reset() 39 | tooltip("Media References Updated") 40 | 41 | # Set up menus and hooks 42 | refresh_media_action = QAction("Refresh &Media", mw) 43 | refresh_media_action.setShortcut(QKeySequence("Ctrl+Alt+M")) 44 | refresh_media_action.triggered.connect(refresh_media) 45 | mw.form.menuTools.addAction(refresh_media_action) -------------------------------------------------------------------------------- /src/previewer_tag_browser/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import previewer_tag_browser -------------------------------------------------------------------------------- /src/previewer_tag_browser/previewer_tag_browser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Previewer Tag Browser 5 | 6 | This is an extension to the Advanced Previewer add-on. 7 | 8 | It modified the previewer window to act as a tag browser, allowing 9 | you to go through the items in the tag sidebar by using Up and Down. 10 | 11 | Clicking on any of the tags in the sidebar will also automatically 12 | launch a new previewer window. 13 | 14 | Dependencies: Advanced Previewer 15 | 16 | Copyright: (c) Glutanimate 2017 17 | License: GNU AGPLv3 or later 18 | """ 19 | 20 | ############## USER CONFIGURATION START ############## 21 | 22 | TAG_BROWSER_MODE = True 23 | 24 | ############## USER CONFIGURATION END ############## 25 | 26 | from aqt.qt import * 27 | from aqt.browser import Browser 28 | from anki.hooks import wrap, addHook 29 | 30 | try: 31 | # requires the add-on folder to follow the default naming 32 | from advanced_previewer.previewer import Previewer 33 | except ImportError: 34 | Previewer = None 35 | 36 | 37 | def onSetupHotkeys(self): 38 | """Add additional hotkeys to the Previewer window""" 39 | nextTagCut = QShortcut(QKeySequence(_("Down")), 40 | self, activated=lambda: self.b.onTagMove("n")) 41 | prevTagCut = QShortcut(QKeySequence(_("Up")), 42 | self, activated=lambda: self.b.onTagMove("p")) 43 | 44 | 45 | def onTagMove(self, target): 46 | """Navigate between tag entries""" 47 | tree = self.form.tree 48 | cur = tree.currentItem() 49 | if target == "n": 50 | item = tree.itemBelow(cur) 51 | elif target == "p": 52 | item = tree.itemAbove(cur) 53 | self.switchToSidebarItem(item) 54 | 55 | 56 | def switchToSidebarItem(self, item): 57 | """Move to entry and select all cards""" 58 | if not item: 59 | return 60 | self.form.tree.setCurrentItem(item) 61 | item.onclick() 62 | self.form.tableView.selectAll() 63 | 64 | 65 | def onSetFilter(self, *args, **kwargs): 66 | """Invoke Previewer window on tag entry click""" 67 | _old = kwargs["_old"] 68 | ret = _old(self, *args) 69 | if args[0] != "tag": 70 | return ret 71 | self.form.tableView.selectAll() 72 | if not self._previewWindow: 73 | self._openPreview() 74 | return ret 75 | 76 | 77 | def onProfileLoaded(): 78 | """ 79 | Apply modifications to Browser and Previewer 80 | Needs to be run after all other add-ons have been loaded 81 | """ 82 | Browser.onTagMove = onTagMove 83 | Browser.switchToSidebarItem = switchToSidebarItem 84 | Browser.setFilter = wrap(Browser.setFilter, onSetFilter, "around") 85 | Previewer.setupHotkeys = wrap(Previewer.setupHotkeys, onSetupHotkeys, "after") 86 | 87 | 88 | if Previewer and TAG_BROWSER_MODE: 89 | addHook("profileLoaded", onProfileLoaded) -------------------------------------------------------------------------------- /src/reviewer_auto_rate_hotkey/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Hotkey to Auto-rate Based on Elapsed Time 5 | 6 | Module-level entry point for the add-on into Anki 2.1.x 7 | 8 | Please do not edit this file if you are not sure what you are doing. 9 | 10 | Copyright: (c) 2018 Glutanimate 11 | License: GNU AGPLv3 12 | """ 13 | 14 | from . import reviewer_auto_rate_hotkey 15 | -------------------------------------------------------------------------------- /src/reviewer_auto_rate_hotkey/reviewer_auto_rate_hotkey.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Hotkey to Auto-rate Based on Elapsed Time 5 | 6 | Main module, hooks add-on methods into Anki. 7 | 8 | Copyright: (c) 2018 Glutanimate 9 | License: GNU AGPLv3 10 | """ 11 | 12 | from __future__ import unicode_literals 13 | from anki.lang import _ 14 | 15 | ############## USER CONFIGURATION START ############## 16 | 17 | # BASIC CONFIGURATION 18 | 19 | HOTKEY_AUTORATE = "k" # Anki 2.0 only supports single-key 20 | # assignments in the reviewer 21 | 22 | # ADVANCED CONFIGURATION 23 | 24 | # only touch this if you are know what you are doing 25 | 26 | # Default time limits. There's usually no need to adjust these. 27 | DEFAULT_LIMITS = (30, 10, 2) 28 | 29 | # answer interval mapping to ease. 30 | # key: button count, val: list of ease indexes 31 | REMAP = {2: [None, 1, 2, 2, 2], # nil Again Good Good Good 32 | 3: [None, 1, 2, 2, 3], # nil Again Good Good Easy 33 | 4: [None, 1, 2, 3, 4]} # nil Again Hard Good Easy 34 | 35 | # color assignment to tooltips 36 | COLORS = { 37 | _("Again"): "#D32F2F", 38 | _("Hard"): "#455A64", 39 | _("Good"): "#4CAF50", 40 | _("Easy"): "#03A9F4" 41 | } 42 | 43 | ############## USER CONFIGURATION END ############## 44 | 45 | import time 46 | 47 | from aqt.qt import * 48 | from aqt import mw 49 | from aqt.reviewer import Reviewer 50 | from aqt.deckconf import DeckConf 51 | from aqt.forms import dconf 52 | from aqt.utils import tooltip 53 | 54 | from anki.hooks import wrap, addHook 55 | 56 | from anki import version as anki_version 57 | anki21 = anki_version.startswith("2.1.") 58 | 59 | 60 | # Config related methods and hooks 61 | 62 | def setupUi(self, Dialog): 63 | 64 | # Create spinbox grid: 65 | 66 | grid = QGridLayout() 67 | 68 | idx = 0 69 | for rating in ("Hard", "Good", "Easy"): 70 | lb_rating = QLabel(self.tab_5) 71 | lb_rating.setText("{}:".format(rating)) 72 | sb_rating = QSpinBox(self.tab_5) 73 | sb_rating.setMinimum(0) 74 | sb_rating.setMaximum(3600) 75 | setattr(self, "autoRate{}".format(rating), sb_rating) 76 | grid.addWidget(lb_rating, 0, idx, 1, 1) 77 | grid.addWidget(sb_rating, 0, idx+1, 1, 1) 78 | idx += 2 79 | 80 | spacerItem = QSpacerItem(10, 10, QSizePolicy.Expanding, QSizePolicy.Minimum) 81 | grid.addItem(spacerItem, 0, idx+1, 1, 1) 82 | 83 | # Add widgets to tab layout: 84 | 85 | self.line = QFrame(self.tab_5) 86 | self.line.setFrameShape(QFrame.HLine) 87 | self.line.setFrameShadow(QFrame.Sunken) 88 | lb_main = QLabel(self.tab_5) 89 | lb_main.setText("Upper answer time limits for auto rate hotkey (sec):") 90 | self.line2 = QFrame(self.tab_5) 91 | self.line2.setFrameShape(QFrame.HLine) 92 | self.line2.setFrameShadow(QFrame.Sunken) 93 | 94 | self.verticalLayout_6.insertWidget(1, self.line) 95 | self.verticalLayout_6.insertWidget(2, lb_main) 96 | self.verticalLayout_6.insertLayout(3, grid) 97 | self.verticalLayout_6.insertWidget(4, self.line2) 98 | 99 | 100 | def loadConf(self): 101 | limits = self.conf.get('autoRate', DEFAULT_LIMITS) 102 | for idx, rating in enumerate(("Hard", "Good", "Easy")): 103 | sb_rating = getattr(self.form, "autoRate{}".format(rating), None) 104 | if not sb_rating: 105 | # should never happen 106 | continue 107 | sb_rating.setValue(limits[idx]) 108 | 109 | 110 | def saveConf(self): 111 | new_limits = [] 112 | for idx, rating in enumerate(("Hard", "Good", "Easy")): 113 | sb_rating = getattr(self.form, "autoRate{}".format(rating), None) 114 | if not sb_rating: 115 | # should never happen 116 | continue 117 | new_limits.append(sb_rating.value()) 118 | 119 | self.conf["autoRate"] = tuple(new_limits) 120 | 121 | 122 | dconf.Ui_Dialog.setupUi = wrap(dconf.Ui_Dialog.setupUi, setupUi) 123 | DeckConf.loadConf = wrap(DeckConf.loadConf, loadConf) 124 | DeckConf.saveConf = wrap(DeckConf.saveConf, saveConf, 'before') 125 | 126 | # Rating related methods and hooks 127 | 128 | def saveAnswerTime(self): 129 | """Record answer time for later use""" 130 | elapsed = round(time.time() - self.card.timerStarted, 1) 131 | self._autoRateElapsed = elapsed 132 | 133 | 134 | Reviewer.saveAnswerTime = saveAnswerTime 135 | addHook("showAnswer", mw.reviewer.saveAnswerTime) 136 | 137 | 138 | 139 | def autoRate(self): 140 | """Rate card based on recorded answer time""" 141 | if self.state == "question": # reveal answer if on question side 142 | if anki21: 143 | self._showAnswer() 144 | else: 145 | self._showAnswerHack() 146 | return 147 | 148 | conf = self.mw.col.decks.confForDid(self.card.odid or self.card.did) 149 | limits = conf.get('autoRate', DEFAULT_LIMITS) 150 | 151 | elapsed = getattr(mw.reviewer, "_autoRateElapsed", None) 152 | if elapsed is None: 153 | tooltip("Error: Elapsed time not registered. This should not have happened.") 154 | return False 155 | 156 | key = 0 157 | for upper_limit in limits: 158 | key += 1 159 | if elapsed > upper_limit: 160 | break 161 | else: # easy 162 | key += 1 163 | 164 | # determine ease based on button count 165 | # (will become obsolete with new Scheduler in Anki 2.1) 166 | count = self.mw.col.sched.answerButtons(self.card) 167 | ease = REMAP[count][key] 168 | 169 | answer_buttons = self._answerButtonList() 170 | rating = answer_buttons[ease - 1][1] 171 | 172 | color = COLORS.get(rating, None) 173 | if color: 174 | rating_string = "{}".format(color, rating) 175 | else: # support for add-ons that modify the answer button label 176 | rating_string = rating 177 | 178 | tooltip("""Answer time: {} s
Rating: {}""".format(elapsed, rating_string)) 179 | 180 | self._answerCard(ease) 181 | 182 | 183 | Reviewer.autoRate = autoRate 184 | 185 | 186 | # Key handler related methods and hooks 187 | 188 | if anki21: 189 | def _addShortcuts(shortcuts): 190 | """Add shortcuts on Anki 2.1.x""" 191 | shortcuts.insert(0, (HOTKEY_AUTORATE, mw.reviewer.autoRate)) 192 | 193 | addHook("reviewStateShortcuts", _addShortcuts) 194 | 195 | else: 196 | def _newKeyHandler(self, evt, _old): 197 | """Add shortcuts on Anki 2.0.x""" 198 | if evt.key() == QKeySequence(HOTKEY_AUTORATE)[0]: 199 | self.autoRate() 200 | return 201 | return _old(self, evt) 202 | 203 | Reviewer._keyHandler = wrap( 204 | Reviewer._keyHandler, _newKeyHandler, "around") 205 | -------------------------------------------------------------------------------- /src/reviewer_browse_creation/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import reviewer_browse_creation -------------------------------------------------------------------------------- /src/reviewer_browse_creation/reviewer_browse_creation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Browse Card creation. 5 | 6 | Adds commands to the Reviewer "More" menu to open the browser on the selected card. 7 | The browser is configured to sort based on creation date, and select the card. 8 | Enables the card to be viewed in its "creation context" ie notes that were created 9 | before/after in the same deck 10 | 11 | Copyright: (c) Steve AW 2013 12 | (c) Glutanimate 2016-2017 13 | License: GNU AGPLv3 or later 14 | """ 15 | 16 | from __future__ import unicode_literals 17 | 18 | ############## USER CONFIGURATION START ############## 19 | 20 | # also show options in right-click context menu? 21 | SHOW_IN_CONTEXT_MENU = False 22 | 23 | ############## USER CONFIGURATION END ############## 24 | 25 | from aqt.qt import * 26 | 27 | import aqt 28 | from aqt import mw 29 | from aqt.reviewer import Reviewer 30 | from aqt.utils import tooltip 31 | 32 | from anki.lang import _ 33 | from anki.hooks import wrap, runHook, addHook 34 | 35 | 36 | def insert_reviewer_more_action(self, m): 37 | #self is Reviewer 38 | if mw.state != "review": 39 | return 40 | a = m.addAction('Browse Creation of This Card') 41 | a.setShortcut(QKeySequence("c")) 42 | a.triggered.connect(lambda _, s=mw.reviewer: browse_this_card(s)) 43 | a = m.addAction('Browse Creation of Last Card') 44 | a.triggered.connect(lambda _, s=mw.reviewer: browse_last_card(s)) 45 | 46 | def browse_last_card(self): 47 | #self is Reviewer 48 | if self.lastCard(): 49 | browse_creation_of_card(self, self.lastCard()) 50 | else: 51 | tooltip("Last card not available yet.") 52 | 53 | 54 | def browse_this_card(self): 55 | #self is Reviewer 56 | browse_creation_of_card(self, self.card) 57 | 58 | 59 | def browse_creation_of_card(self, target_card): 60 | #self is Reviewer 61 | #Follow pattern in AddCards.editHistory() 62 | browser = aqt.dialogs.open("Browser", self.mw) 63 | deck_name = self.card.col.decks.get(target_card.did)['name'] 64 | browser.form.searchEdit.lineEdit().setText("deck:'%s'" % deck_name) 65 | browser.onSearch() 66 | if u'noteCrt' in browser.model.activeCols: 67 | col_index = browser.model.activeCols.index(u'noteCrt') 68 | browser.onSortChanged(col_index, False) 69 | browser.focusCid(target_card.id) 70 | 71 | def keyHandler(self, evt, _old): 72 | key = unicode(evt.text()) 73 | if key == "c": 74 | browse_this_card(self) 75 | else: 76 | return _old(self, evt) 77 | 78 | if SHOW_IN_CONTEXT_MENU: 79 | addHook("AnkiWebView.contextMenuEvent", insert_reviewer_more_action) 80 | addHook("Reviewer.contextMenuEvent", insert_reviewer_more_action) 81 | Reviewer._keyHandler = wrap(Reviewer._keyHandler, keyHandler, "around") -------------------------------------------------------------------------------- /src/reviewer_browse_today/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import reviewer_browse_today -------------------------------------------------------------------------------- /src/reviewer_browse_today/reviewer_browse_today.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Open 'Added Today' from Reviewer. 5 | 6 | Adds a menu item into the "History" menu of the "Add" notes dialog that 7 | opens a Browser on the 'Added Today' view. 8 | 9 | Copyright: (c) Steve AW 2013 10 | (c) Glutanimate 2016-2017 11 | License: GNU AGPLv3 or later 12 | """ 13 | 14 | from anki.lang import _ 15 | 16 | from aqt.qt import * 17 | from aqt.addcards import AddCards 18 | from anki.hooks import wrap, runHook, addHook 19 | import aqt 20 | from anki import version as anki_version 21 | anki21 = anki_version.startswith("2.1.") 22 | 23 | def insert_open_browser_action(self, m): 24 | """self -- AddCards object 25 | m -- QMenu objet""" 26 | m.addSeparator() 27 | a = m.addAction("Open Browser on 'Added &Today'") 28 | if anki21: 29 | a.triggered.connect(lambda: show_browser_on_added_today(self)) 30 | else: 31 | a.connect(a, SIGNAL("triggered()"), 32 | lambda self=self: show_browser_on_added_today(self)) 33 | 34 | 35 | def show_browser_on_added_today(self): 36 | """AddCards objects""" 37 | browser = aqt.dialogs.open("Browser", self.mw) 38 | browser.form.searchEdit.lineEdit().setText("added:1") 39 | if anki21: 40 | browser.onSearchActivated() 41 | else: 42 | browser.onSearch() 43 | if u'noteCrt' in browser.model.activeCols: 44 | col_index = browser.model.activeCols.index(u'noteCrt') 45 | browser.onSortChanged(col_index, True) 46 | browser.form.tableView.selectRow(0) 47 | 48 | 49 | def mySetupButtons(self): 50 | self.historyButton.setEnabled(True) 51 | 52 | AddCards.showBrowserOnAddedToday = show_browser_on_added_today 53 | AddCards.setupButtons = wrap(AddCards.setupButtons, mySetupButtons, "after") 54 | addHook("AddCards.onHistory", insert_open_browser_action) 55 | -------------------------------------------------------------------------------- /src/reviewer_card_stats/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import reviewer_card_stats -------------------------------------------------------------------------------- /src/reviewer_card_stats/reviewer_card_stats.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Card Stats 5 | 6 | Displays stats in a sidebar while reviewing. 7 | 8 | For the most part based on the following add-ons: 9 | 10 | - Card Info During Review by Damien Elmes (https://ankiweb.net/shared/info/2179254157) 11 | - reviewer_show_cardinfo by Steve AW (https://github.com/steveaw/anki_addons/) 12 | 13 | This version of Card Stats combines the sidebar in Damien's add-on with the extra 14 | review log info found in Steve AW's add-on. 15 | 16 | Copyright: (c) Glutanimate 2016-2017 17 | License: GNU AGPLv3 or later 18 | """ 19 | 20 | from anki.hooks import addHook 21 | from aqt import mw 22 | from aqt.qt import * 23 | from aqt.webview import AnkiWebView 24 | import aqt.stats 25 | import time 26 | import datetime 27 | from anki.lang import _ 28 | from anki.utils import fmtTimeSpan 29 | from anki.stats import CardStats 30 | 31 | 32 | class StatsSidebar(object): 33 | def __init__(self, mw): 34 | self.mw = mw 35 | self.shown = False 36 | addHook("showQuestion", self._update) 37 | addHook("deckClosing", self.hide) 38 | addHook("reviewCleanup", self.hide) 39 | 40 | def _addDockable(self, title, w): 41 | class DockableWithClose(QDockWidget): 42 | closed = pyqtSignal() 43 | def closeEvent(self, evt): 44 | self.closed.emit() 45 | QDockWidget.closeEvent(self, evt) 46 | dock = DockableWithClose(title, mw) 47 | dock.setObjectName(title) 48 | dock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) 49 | dock.setFeatures(QDockWidget.DockWidgetClosable) 50 | dock.setWidget(w) 51 | if mw.width() < 600: 52 | mw.resize(QSize(600, mw.height())) 53 | mw.addDockWidget(Qt.RightDockWidgetArea, dock) 54 | return dock 55 | 56 | def _remDockable(self, dock): 57 | mw.removeDockWidget(dock) 58 | 59 | def show(self): 60 | if not self.shown: 61 | class ThinAnkiWebView(AnkiWebView): 62 | def sizeHint(self): 63 | return QSize(200, 100) 64 | self.web = ThinAnkiWebView() 65 | self.shown = self._addDockable(_("Card Info"), self.web) 66 | self.shown.closed.connect(self._onClosed) 67 | 68 | self._update() 69 | 70 | def hide(self): 71 | if self.shown: 72 | self._remDockable(self.shown) 73 | self.shown = None 74 | #actionself.mw.form.actionCstats.setChecked(False) 75 | 76 | def toggle(self): 77 | if self.shown: 78 | self.hide() 79 | else: 80 | self.show() 81 | 82 | def _onClosed(self): 83 | # schedule removal for after evt has finished 84 | self.mw.progress.timer(100, self.hide, False) 85 | 86 | #copy and paste from Browser 87 | #Added IntDate column 88 | def _revlogData(self, card, cs): 89 | entries = self.mw.col.db.all( 90 | "select id/1000.0, ease, ivl, factor, time/1000.0, type " 91 | "from revlog where cid = ?", card.id) 92 | if not entries: 93 | return "" 94 | s = "" % _("Date") 95 | s += ("" * 6) % ( 96 | _("Type"), _("Rating"), _("Interval"), "IntDate", _("Ease"), _("Time")) 97 | cnt = 0 98 | for (date, ease, ivl, factor, taken, type) in reversed(entries): 99 | cnt += 1 100 | s += "" % time.strftime(_("%Y-%m-%d @ %H:%M"), 101 | time.localtime(date)) 102 | tstr = [_("Learn"), _("Review"), _("Relearn"), _("Filtered"), 103 | _("Resched")][type] 104 | import anki.stats as st 105 | 106 | fmt = "%s" 107 | if type == 0: 108 | tstr = fmt % (st.colLearn, tstr) 109 | elif type == 1: 110 | tstr = fmt % (st.colMature, tstr) 111 | elif type == 2: 112 | tstr = fmt % (st.colRelearn, tstr) 113 | elif type == 3: 114 | tstr = fmt % (st.colCram, tstr) 115 | else: 116 | tstr = fmt % ("#000", tstr) 117 | if ease == 1: 118 | ease = fmt % (st.colRelearn, ease) 119 | #################### 120 | int_due = "na" 121 | if ivl > 0: 122 | int_due_date = time.localtime(date + (ivl * 24 * 60 * 60)) 123 | int_due = time.strftime(_("%Y-%m-%d"), int_due_date) 124 | #################### 125 | if ivl == 0: 126 | ivl = _("0d") 127 | elif ivl > 0: 128 | ivl = fmtTimeSpan(ivl * 86400, short=True) 129 | else: 130 | ivl = cs.time(-ivl) 131 | 132 | s += ("" * 6) % ( 133 | tstr, 134 | ease, ivl, 135 | int_due 136 | , 137 | "%d%%" % (factor / 10) if factor else "", 138 | cs.time(taken)) + "" 139 | s += "
%s%s
%s%s
" 140 | if cnt < card.reps: 141 | s += _("""\ 142 | Note: Some of the history is missing. For more information, \ 143 | please see the browser documentation.""") 144 | return s 145 | 146 | def _update(self): 147 | if not self.shown: 148 | return 149 | txt = "" 150 | r = self.mw.reviewer 151 | d = self.mw.col 152 | cs = CardStats(d, r.card) 153 | cc = r.card 154 | if cc: 155 | txt += _("

Current

") 156 | txt += d.cardStats(cc) 157 | txt += "

" 158 | txt += self._revlogData(cc, cs) 159 | lc = r.lastCard() 160 | if lc: 161 | txt += _("

Last

") 162 | txt += d.cardStats(lc) 163 | txt += "

" 164 | txt += self._revlogData(lc, cs) 165 | if not txt: 166 | txt = _("No current card or last card.") 167 | style = self._style() 168 | self.web.setHtml(""" 169 | 170 | 171 |

%s
"""% (style, txt)) 172 | 173 | def _style(self): 174 | from anki import version 175 | if version.startswith("2.0."): 176 | return "" 177 | return "td { font-size: 80%; }" 178 | 179 | _cs = StatsSidebar(mw) 180 | 181 | def cardStats(on): 182 | _cs.toggle() 183 | 184 | action = QAction(mw) 185 | action.setText("Card Stats") 186 | action.setCheckable(True) 187 | action.setShortcut(QKeySequence("Shift+C")) 188 | mw.form.menuTools.addAction(action) 189 | action.toggled.connect(cardStats) 190 | -------------------------------------------------------------------------------- /src/reviewer_file_hyperlinks/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import reviewer_file_hyperlinks -------------------------------------------------------------------------------- /src/reviewer_file_hyperlinks/reviewer_file_hyperlinks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: File Hyperlinks in the Reviewer 5 | 6 | Parses the Reviewer for a custom URL scheme and inserts links 7 | that invoke external programs. 8 | 9 | Copyright: (c) Glutanimate 2016-2017 10 | License: GNU AGPLv3 or later 11 | """ 12 | 13 | ############## USER CONFIGURATION START ############## 14 | 15 | # path to external script or app that handles files 16 | 17 | # Windows 18 | external_handler_win = r"C:\Users\AnkiUser\script.exe" 19 | 20 | # Unix (Linux/macOS) 21 | external_handler_unix = r"notify-send" 22 | 23 | ############## USER CONFIGURATION END ############## 24 | 25 | import subprocess 26 | import re 27 | 28 | from aqt.utils import tooltip 29 | from aqt.reviewer import Reviewer 30 | from anki.hooks import wrap, addHook 31 | 32 | from anki.utils import isWin 33 | 34 | from anki import version as anki_version 35 | anki21 = anki_version.startswith("2.1.") 36 | 37 | pycmd = "pycmd" if anki21 else "py.link" 38 | 39 | regex_link = r"(qv.+?\..+?\b(#\b.+?\b)?)" 40 | replacement = r"""\1""".format(pycmd) 41 | 42 | 43 | def openFileHandler(file): 44 | try: 45 | if isWin: 46 | external_handler = external_handler_win 47 | else: 48 | external_handler = external_handler_unix 49 | subprocess.Popen([external_handler, file]) 50 | except OSError: 51 | tooltip("External handler produced an error.
" 52 | "Please confirm that it is assigned correctly.") 53 | 54 | 55 | def linkHandler(self, url, _old): 56 | if not url.startswith("open"): 57 | return _old(self, url) 58 | (cmd, arg) = url.split(":", 1) 59 | openFileHandler(arg) 60 | 61 | 62 | def linkInserter(html): 63 | return re.sub(regex_link, replacement, html) 64 | 65 | 66 | def onMungeQA(self, buf, _old): 67 | buf = _old(self, buf) 68 | return linkInserter(buf) 69 | 70 | 71 | def profileLoaded(): 72 | """Support for Advanced Previewer""" 73 | try: 74 | from advanced_previewer.previewer import Previewer 75 | except ImportError: 76 | return 77 | Previewer.linkHandler = wrap( 78 | Previewer.linkHandler, linkHandler, "around") 79 | addHook("previewerMungeQA", linkInserter) 80 | 81 | 82 | Reviewer._linkHandler = wrap(Reviewer._linkHandler, linkHandler, "around") 83 | Reviewer._mungeQA = wrap(Reviewer._mungeQA, onMungeQA, "around") 84 | addHook("profileLoaded", profileLoaded) -------------------------------------------------------------------------------- /src/reviewer_hide_toolbar/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import reviewer_hide_toolbar -------------------------------------------------------------------------------- /src/reviewer_hide_toolbar/reviewer_hide_toolbar.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Hides the main window's toolbar when reviewing. 4 | 5 | The aim of the addon is to free up some vertical screen space while reviewing 6 | cards. For me this is useful as I have many cards that contain diagrams which can 7 | be fairly large. In addition. modern wide screen displays seem to feel more 8 | cramped vertically than horizontally. 9 | 10 | The toolbar is made visible again when viewing decks and the overview. 11 | 12 | The commands that are usually found on the toolbar are added to a new 13 | main window "Toolbar" menu. Shortcut keys should continue to function 14 | 15 | Copyright: Steve AW 16 | License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 17 | 18 | Support: Use at your own risk. If you do find a problem please email me 19 | or use one the following forums, however there are certain periods 20 | throughout the year when I will not have time to do any work on 21 | these addons. 22 | 23 | Github page: https://github.com/steveaw/anki_addons 24 | Anki addons: https://groups.google.com/forum/?hl=en#!forum/anki-addons 25 | """ 26 | from PyQt4.QtCore import SIGNAL 27 | from PyQt4.QtGui import QMenu 28 | from anki.hooks import wrap 29 | from aqt import mw 30 | from aqt.main import AnkiQt 31 | 32 | __author__ = 'Steve' 33 | 34 | 35 | def hide_toolbar_reviewing(self, oldState): 36 | self.toolbar.web.hide() 37 | 38 | 39 | def show_toolbar_not_reviewing(self, state, *args): 40 | #Rather than tracking the state of the toolbar's visibility 41 | #leave it to Qt to handle. m 42 | self.toolbar.web.show() 43 | 44 | 45 | AnkiQt._reviewState = wrap(AnkiQt._reviewState, hide_toolbar_reviewing, "after") 46 | AnkiQt.moveToState = wrap(AnkiQt.moveToState, show_toolbar_not_reviewing, "before") 47 | 48 | toolbar_menu = QMenu("Tool&bar") 49 | action = mw.menuBar().insertMenu(mw.form.menuTools.menuAction(), toolbar_menu) 50 | #Not really the intended purpose of link_handlers, but good enough 51 | for key, value in iter(sorted(mw.toolbar.link_handlers.items())): 52 | a = toolbar_menu.addAction(_(key.title())) 53 | a.connect(a, SIGNAL("triggered()"), value) 54 | -------------------------------------------------------------------------------- /src/reviewer_hint_hotkeys/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import reviewer_hint_hotkeys -------------------------------------------------------------------------------- /src/reviewer_hint_hotkeys/reviewer_hint_hotkeys.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Anki Add-on: Hint Hotkeys 4 | 5 | Adds two hotkeys to the reviewer: 'H' to reveal hints one by one, 6 | 'G' to reveal all hints at once. 7 | 8 | Based on "Hint-peeking Keyboard Bindings" by Ben Lickly 9 | 10 | Copyright: (c) Ben Lickly 2012 11 | (c) Glutanimate 2016-2017 12 | License: GNU AGPLv3 or later 13 | """ 14 | 15 | from __future__ import unicode_literals 16 | 17 | ############## USER CONFIGURATION START ############## 18 | 19 | # Shortcuts need to be single keys on Anki 2.0.x 20 | # Key combinations are supported on Anki 2.1.x 21 | 22 | # Shortcut that will reveal the hint fields one by one: 23 | SHORTCUT_INCREMENTAL = "H" 24 | # Shortcut that will reveal all hint fields at once: 25 | SHORTCUT_ALL = "G" 26 | 27 | ############## USER CONFIGURATION END ############## 28 | 29 | from aqt.qt import * 30 | from aqt import mw 31 | from aqt.reviewer import Reviewer 32 | 33 | from anki.hooks import wrap, addHook 34 | from anki import version as ankiversion 35 | 36 | 37 | def _newKeyHandler(self, evt, _old): 38 | """Add shortcuts on Anki 2.0.x""" 39 | if evt.key() == QKeySequence(SHORTCUT_INCREMENTAL)[0]: 40 | _showHint(incremental=True) 41 | elif evt.key() == QKeySequence(SHORTCUT_ALL)[0]: 42 | _showHint() 43 | return _old(self, evt) 44 | 45 | 46 | def _addShortcuts(shortcuts): 47 | """Add shortcuts on Anki 2.1.x""" 48 | additions = ( 49 | (SHORTCUT_INCREMENTAL, lambda: _showHint(incremental=True)), 50 | (SHORTCUT_ALL, _showHint) 51 | ) 52 | shortcuts += additions 53 | 54 | 55 | def _showHint(incremental=False): 56 | """Show hint by activating corresponding links.""" 57 | mw.web.eval(""" 58 | var customEvent = document.createEvent('MouseEvents'); 59 | customEvent.initEvent('click', false, true); 60 | var arr = document.getElementsByTagName('a'); 61 | // Cloze Overlapper support 62 | if (typeof olToggle === "function") { 63 | olToggle(); 64 | } 65 | // Image Occlusion Enhanced support 66 | var ioBtn = document.getElementById("io-revl-btn"); 67 | if (!(typeof ioBtn === 'undefined' || !ioBtn)) { 68 | ioBtn.click(); 69 | } 70 | for (var i=0; i 7 | # License: GNU AGPLv3 8 | 9 | from . import reviewer_letitsnow -------------------------------------------------------------------------------- /src/reviewer_letitsnow/reviewer_letitsnow.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | This file is part of the Let It Snow! add-on for Anki 5 | 6 | Copyright: (c) 2017 Glutanimate 7 | License: GNU AGPLv3 8 | 9 | Uses snowfall.js by loktar00 (https://github.com/loktar00/JQuery-Snowfall), 10 | licensed under the Apache license 2.0 11 | """ 12 | 13 | from aqt.reviewer import Reviewer 14 | from anki.hooks import wrap 15 | 16 | from anki import version as anki_version 17 | anki21 = anki_version.startswith("2.1.") 18 | 19 | js_snow = r""" 20 | if(!Date.now) 21 | Date.now=function(){return new Date().getTime()};(function(){'use strict';var vendors=['webkit','moz'];for(var i=0;i(elHeight)-(this.size+6)){this.reset()} 26 | this.element.style.top=this.y+'px';this.element.style.left=this.x+'px';this.step+=this.stepSize;if(doRatio===!1){this.x+=Math.cos(this.step)}else{this.x+=(doRatio+Math.cos(this.step))} 27 | if(options.collection){if(this.x>this.target.x&&this.xthis.target.y&&this.ythis.target.height){if(curY+this.speed+this.size>this.target.height){while(curY+this.speed+this.size>this.target.height&&this.speed>0){this.speed*=.5} 28 | ctx.fillStyle=defaults.flakeColor;if(colData[parseInt(curX)][parseInt(curY+this.speed+this.size)]==undefined){colData[parseInt(curX)][parseInt(curY+this.speed+this.size)]=1;ctx.fillRect(curX,(curY)+this.speed+this.size,this.size,this.size)}else{colData[parseInt(curX)][parseInt(curY+this.speed)]=1;ctx.fillRect(curX,curY+this.speed,this.size,this.size)} 29 | this.reset()}else{this.speed=1;this.stepSize=0;if(parseInt(curX)+10&&colData[parseInt(curX)-1][parseInt(curY)+1]==undefined){this.x--}else{ctx.fillStyle=defaults.flakeColor;ctx.fillRect(curX,curY,this.size,this.size);colData[parseInt(curX)][parseInt(curY)]=1;this.reset()}}}}} 30 | if(this.x+this.size>(elWidth)-widthOffset||this.x',{'class':'snowfall-canvas'}),collisionData=[];if(bounds.top-collectionHeight>0){$('body').append($canvas);$canvas.css({'position':options.flakePosition,'left':bounds.left+'px','top':bounds.top-collectionHeight+'px'}).prop({width:bounds.width,height:collectionHeight});for(var w=0;w" + js_snow + "" 63 | 64 | if not anki21: 65 | Reviewer._revHtml += "" 66 | else: 67 | Reviewer.revHtml = wrap(Reviewer.revHtml, onRevHtml, pos="around") 68 | -------------------------------------------------------------------------------- /src/reviewer_more_answer_buttons/More Answer Buttons.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: More Answer Buttons for New Cards 5 | 6 | Entry point for the add-on into Anki 7 | Please do not edit this if you do not know what you are doing. 8 | 9 | Copyright: (c) 2016-2019 Glutanimate 10 | (c) ijgnd 2019 11 | 12 | License: GNU AGPLv3 13 | """ 14 | 15 | # IMPORTANT: 16 | # 17 | # If you are reading this and wondering where to find the 18 | # configuration section for Anki 2.0: 19 | # 20 | # As part of porting this add-on to Anki 2.1 I had to move the 21 | # main add-on module to a separate folder. It is now located under 22 | # more_answer_buttons/reviewer_more_answer_buttons_for_20.py. 23 | # All configuration options are located 24 | # at the very top of that file. 25 | # 26 | # Please use NotePad++ or another text editor that supports python 27 | # source code files to edit it 28 | 29 | import more_answer_buttons # noqa: F401 30 | -------------------------------------------------------------------------------- /src/reviewer_more_answer_buttons/more_answer_buttons/README.md: -------------------------------------------------------------------------------- 1 | ## Configuring the add-on on Anki 2.0 2 | 3 | The configuration options may be found at the top of the file reviewer_more_answer_buttons_for_20.py. -------------------------------------------------------------------------------- /src/reviewer_more_answer_buttons/more_answer_buttons/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017-2019 Glutanimate 7 | # (c) 2019 ijgnd 8 | # License: GNU AGPLv3 9 | 10 | from anki import version 11 | 12 | anki20 = version.startswith("2.0") 13 | 14 | if anki20: 15 | from . import reviewer_more_answer_buttons_for_20 # noqa: F401 16 | else: 17 | from . import reviewer_more_answer_buttons_for_21 # noqa: F401 18 | -------------------------------------------------------------------------------- /src/reviewer_more_answer_buttons/more_answer_buttons/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extra_buttons": [ 3 | { 4 | "Description": "3-4d", 5 | "Label": "3-4", 6 | "ShortCut": "5", 7 | "ReschedMin": 3, 8 | "ReschedMax": 5 9 | }, 10 | { 11 | "Description": "5-7d", 12 | "Label": "5-7", 13 | "ShortCut": "6", 14 | "ReschedMin": 5, 15 | "ReschedMax": 7 16 | }, 17 | { 18 | "Description": "8-15d", 19 | "Label": "8-15", 20 | "ShortCut": "7", 21 | "ReschedMin": 8, 22 | "ReschedMax": 15 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /src/reviewer_more_answer_buttons/more_answer_buttons/config.md: -------------------------------------------------------------------------------- 1 | ### Answer button properties 2 | 3 | You can add **up to four buttons at maximum**, each of which should have the following properties defined (please refer to the default config for the exact syntax and formatting): 4 | 5 | - `Description` [text]: appears above the button 6 | - `Label` [text]: the label of the button 7 | - `ShortCut` [text]: the shortcut key for the button 8 | - `ReschedMin` [number]: same as the lower number in the Browser's "Edit/Rescedule" command 9 | - `ReschedMax` [number]: same as the higher number in the Browser's "Edit/Rescedule" command 10 | -------------------------------------------------------------------------------- /src/reviewer_more_answer_buttons/more_answer_buttons/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "More Answer Buttons for New Cards", 3 | "package": "more_answer_buttons" 4 | } 5 | -------------------------------------------------------------------------------- /src/reviewer_more_answer_buttons/more_answer_buttons/reviewer_more_answer_buttons_for_20.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: More Answer Buttons for New Cards 5 | 6 | Adds extra buttons to the answer button area for new cards 7 | 8 | Copyright: (c) Steve AW 2013 9 | (c) Glutanimate 2016-2017 10 | License: GNU AGPLv3 or later 11 | """ 12 | 13 | ############## USER CONFIGURATION START ############## 14 | 15 | # Setup data: 16 | # 17 | # List of dicts, where each item of the list (a dict) is the data for a new button. 18 | # This can be edited to suit, but there can not be more than 4 buttons. 19 | # Description ... appears above the button 20 | # Label ... the label of the button 21 | # ShortCut ... the shortcut key for the button 22 | # ReschedMin ... same as the lower number in the Browser's "Edit/Rescedule" command 23 | # ReschedMax ... same as the higher number in the Browser's "Edit/Rescedule" command 24 | 25 | extra_buttons = [{"Description": "3-4d", "Label": "3-4", "ShortCut": "5", 26 | "ReschedMin": 3, "ReschedMax": 4}, 27 | {"Description": "5-7d", "Label": "5-7", "ShortCut": "6", 28 | "ReschedMin": 5, "ReschedMax": 7}, 29 | {"Description": "8-15d", "Label": "8-15", "ShortCut": "7", 30 | "ReschedMin": 8, "ReschedMax": 15}] 31 | 32 | ############## USER CONFIGURATION END ############## 33 | 34 | #Must be four or less 35 | assert len(extra_buttons) <= 4 36 | 37 | from aqt.reviewer import Reviewer 38 | from anki.hooks import wrap 39 | from aqt.utils import tooltip 40 | 41 | #Anki uses a single digit to track which button has been clicked. 42 | #We will use 6 and above to track the extra buttons. 43 | INTERCEPT_EASE_BASE = 6 44 | 45 | #todo: brittle. Replaces existing function 46 | def _answerButtons(self): 47 | times = [] 48 | default = self._defaultEase() 49 | 50 | def but(i, label): 51 | if i == default: 52 | extra = "id=defease" 53 | else: 54 | extra = "" 55 | due = self._buttonTime(i) 56 | return ''' 57 | %s''' % (due, extra, _("Shortcut key: %s") % i, i, label) 59 | 60 | buf = "
" 61 | for ease, label in self._answerButtonList(): 62 | buf += but(ease, label) 63 | #swAdded start ====> 64 | #Only for cards in the new queue 65 | if self.card.type in (0, 1, 3): # New, Learn, Day learning 66 | #Check that the number of answer buttons is as expected. 67 | assert self.mw.col.sched.answerButtons(self.card) == 3 68 | #python lists are 0 based 69 | for i, buttonItem in enumerate(extra_buttons): 70 | buf += ''' 71 | ''' % (buttonItem["Description"], buttonItem["ShortCut"], i + INTERCEPT_EASE_BASE, buttonItem["Label"]) 73 | #swAdded end 74 | buf += "
%s
" 75 | script = """ 76 | """ 77 | return buf + script 78 | 79 | #This wraps existing Reviewer._answerCard function. 80 | def answer_card_intercepting(self, actual_ease, _old): 81 | ease = actual_ease 82 | was_new_card = self.card.type in (0, 1, 3) 83 | is_extra_button = was_new_card and actual_ease >= INTERCEPT_EASE_BASE 84 | if is_extra_button: 85 | #Make sure this is as expected. 86 | assert self.mw.col.sched.answerButtons(self.card) == 3 87 | #So this is one of our buttons. First answer the card as if "Easy" clicked. 88 | ease = 3 89 | #We will need this to reschedule it. 90 | prev_card_id = self.card.id 91 | # 92 | ret = _old(self, ease) 93 | 94 | if is_extra_button: 95 | buttonItem = extra_buttons[actual_ease - INTERCEPT_EASE_BASE] 96 | #Do the reschedule. 97 | self.mw.col.sched.reschedCards([prev_card_id], buttonItem["ReschedMin"], buttonItem["ReschedMax"]) 98 | tooltip("
Rescheduled:" + "
" + buttonItem["Description"] + "
") 99 | return ret 100 | 101 | #Handle the shortcut. Used changekeys.py addon as a guide 102 | def keyHandler(self, evt, _old): 103 | key = unicode(evt.text()) 104 | if self.state == "answer": 105 | for i, buttonItem in enumerate(extra_buttons): 106 | if key == buttonItem["ShortCut"]: 107 | #early exit ok in python? 108 | return self._answerCard(i + INTERCEPT_EASE_BASE) 109 | return _old(self, evt) 110 | 111 | 112 | Reviewer._keyHandler = wrap(Reviewer._keyHandler, keyHandler, "around") 113 | Reviewer._answerButtons = _answerButtons 114 | Reviewer._answerCard = wrap(Reviewer._answerCard, answer_card_intercepting, "around") 115 | -------------------------------------------------------------------------------- /src/reviewer_more_answer_buttons/more_answer_buttons/reviewer_more_answer_buttons_for_21.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: More Answer Buttons for New Cards 5 | 6 | Adds extra buttons to the answer button area for new cards 7 | 8 | Copyright: (c) 2013 Steve AW 9 | (c) 2016-2019 Glutanimate 10 | (c) 2019 ijgnd 11 | License: GNU AGPLv3 or later 12 | """ 13 | 14 | from aqt.reviewer import Reviewer 15 | from anki.hooks import wrap, addHook 16 | from aqt.utils import tooltip 17 | from anki.lang import _ 18 | from aqt import mw 19 | 20 | 21 | def getConfig(): 22 | return mw.addonManager.getConfig(__name__) 23 | 24 | 25 | #Must be four or less 26 | extra_buttons = getConfig().get("extra_buttons", None) 27 | assert len(extra_buttons) <= 4 28 | 29 | #Anki uses a single digit to track which button has been clicked. 30 | #We will use 6 and above to track the extra buttons. 31 | INTERCEPT_EASE_BASE = 6 32 | 33 | 34 | #todo: brittle. Replaces existing function 35 | def _answerButtons21(self): 36 | default = self._defaultEase() 37 | 38 | def but(i, label): 39 | if i == default: 40 | extra = "id=defease" 41 | else: 42 | extra = "" 43 | due = self._buttonTime(i) 44 | return ''' 45 | %s''' % (due, extra, _("Shortcut key: %s") % i, i, i, label) 47 | 48 | buf = "
" 49 | for ease, label in self._answerButtonList(): 50 | buf += but(ease, label) 51 | 52 | #################mod start 53 | #Only for cards in the new queue 54 | if self.card.type in (0, 1, 3): # New, Learn, Day learning 55 | #Check that the number of answer buttons is as expected. 56 | if self.mw.col.sched.name == "std": 57 | assert self.mw.col.sched.answerButtons(self.card) == 3 58 | if self.mw.col.sched.name == "std2": 59 | assert self.mw.col.sched.answerButtons(self.card) == 4 60 | 61 | def my_buttonTime(text): 62 | if not mw.col.conf['estTimes']: 63 | return "
" 64 | return '%s
' % text 65 | 66 | #python lists are 0 based 67 | extra_buttons = getConfig().get("extra_buttons", None) 68 | for i, buttonItem in enumerate(extra_buttons): 69 | j = i + INTERCEPT_EASE_BASE 70 | due = my_buttonTime(buttonItem["Description"]) 71 | buf +='''''' % (due, "", _("Shortcut key: %s") % buttonItem["ShortCut"], j, j, buttonItem["Label"]) 73 | 74 | ###################mod end 75 | 76 | buf += "
%s
" 77 | script = """ 78 | """ 79 | return buf + script 80 | 81 | 82 | 83 | #This wraps existing Reviewer._answerCard function. 84 | def answer_card_intercepting21(self, actual_ease, _old): 85 | ease = actual_ease 86 | #in 2.1 this function is also called when you are in the deck overview screen where self.card does not exit 87 | if hasattr(self,"card"): 88 | was_new_card = self.card.type in (0, 1, 3) 89 | is_extra_button = was_new_card and actual_ease >= INTERCEPT_EASE_BASE 90 | if is_extra_button: 91 | #Make sure this is as expected. 92 | if self.mw.col.sched.name == "std": 93 | assert self.mw.col.sched.answerButtons(self.card) == 3 94 | #So this is one of our buttons. First answer the card as if "Easy" clicked. 95 | ease = 3 96 | if self.mw.col.sched.name == "std2": 97 | assert self.mw.col.sched.answerButtons(self.card) == 4 98 | #So this is one of our buttons. First answer the card as if "Easy" clicked. 99 | ease = 4 100 | #We will need this to reschedule it. 101 | prev_card_id = self.card.id 102 | # 103 | ret = _old(self, ease) 104 | 105 | if is_extra_button: 106 | extra_buttons = getConfig().get("extra_buttons", None) 107 | buttonItem = extra_buttons[actual_ease - INTERCEPT_EASE_BASE] 108 | #Do the reschedule. 109 | self.mw.col.sched.reschedCards([prev_card_id], buttonItem["ReschedMin"], buttonItem["ReschedMax"]) 110 | tooltip("
Rescheduled:" + "
" + buttonItem["Description"] + "
") 111 | return ret 112 | 113 | 114 | def addShortcuts21(shortcuts): 115 | additions = [] 116 | extra_buttons = getConfig().get("extra_buttons", None) 117 | for i, buttonItem in enumerate(extra_buttons): 118 | key = str( buttonItem["ShortCut"] ) 119 | additions.append([key , lambda a=i: mw.reviewer._answerCard(a + INTERCEPT_EASE_BASE)]) 120 | shortcuts += additions 121 | addHook("reviewStateShortcuts", addShortcuts21) 122 | 123 | 124 | Reviewer._answerButtons = _answerButtons21 125 | Reviewer._answerCard = wrap(Reviewer._answerCard, answer_card_intercepting21, "around") 126 | -------------------------------------------------------------------------------- /src/reviewer_progress_bar/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import reviewer_progress_bar -------------------------------------------------------------------------------- /src/reviewer_refocus_card/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2018 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import reviewer_refocus_card -------------------------------------------------------------------------------- /src/reviewer_refocus_card/reviewer_refocus_card.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Refocus Card when Reviewing 5 | 6 | Refocuses card during reviews, so that you can use controls like 7 | Page up / Page down. 8 | 9 | Copyright: (c) 2013 Edgar Simo-Serra 10 | (c) 2018 Glutanimate 11 | License: GNU AGPLv3 or later 12 | """ 13 | 14 | from anki.hooks import addHook 15 | from aqt import mw 16 | 17 | def refocusInterface(): 18 | mw.web.setFocus() 19 | 20 | addHook("showQuestion", refocusInterface) 21 | addHook("showAnswer", refocusInterface) 22 | -------------------------------------------------------------------------------- /src/reviewer_track_unseen/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import reviewer_track_unseen -------------------------------------------------------------------------------- /src/reviewer_track_unseen/reviewer_track_unseen.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Track Unseen Cards 5 | 6 | Original Description: 7 | 8 | I am using this in conjunction with cramming. It uses tags (nsN,ns1,ns2,nsX where X is 9 | the card's ordinal number) to identify a set of cards, and then as the cards are viewed, 10 | it automatically removes that card's tag from its note. The idea behind it is: Say I have 11 | an exam in a week. My goal is to review all my cards that relate to the exam over the 12 | coming week, doing bits and pieces wherever I can. In reality, I often don't get the time 13 | to do all of them, but if each day I can do a random smattering of 50-100 extra cards, 14 | that is generally good enough. 15 | 16 | I never found a good way, with the inbuilt cramming/study more tools, 17 | to reliably keep track of what cards had been crammed when cramming across 18 | multiple sessions (the changed field/ "Last reviewed" gets updated when cards are added 19 | to dynamic decks which makes it useless for my needs). This addon allows me to add the 20 | tags to those cards I want to see. I can then create dynamic decks for those tags 21 | (tag:ns*), and the tags only get removed from cards that are seen. It gives me confidence 22 | that I am not re-cramming a card, and also allows me to see how many are still "unseen". 23 | I will see how it goes this semester, but I think it is going to finally give me a good 24 | way to systematically cram. 25 | 26 | Copyright: (c) Steve AW 2013 27 | (c) Glutanimate 2016-2017 28 | License: GNU AGPLv3 or later 29 | """ 30 | 31 | from PyQt4.QtCore import SIGNAL 32 | from PyQt4.QtGui import QMenu, QKeySequence 33 | from anki.hooks import addHook, wrap 34 | from anki.sched import Scheduler 35 | from aqt.reviewer import Reviewer 36 | from aqt.utils import tooltip 37 | 38 | 39 | __author__ = 'Steve' 40 | 41 | unseen_tag = ".unseen" 42 | 43 | def add_unseen_tags_to_selected(self): 44 | #self is browser 45 | selected_cids = self.selectedCards() 46 | if not selected_cids: 47 | tooltip(_("No cards selected."), period=2000) 48 | return 49 | self.mw.progress.start() 50 | self.mw.checkpoint("Add Unseen Tags") 51 | self.model.beginReset() 52 | for cid in selected_cids: 53 | unseen_card = self.col.getCard(cid) 54 | unseen_note = unseen_card.note() 55 | unseen_note.addTag(unseen_tag) 56 | unseen_note.flush() 57 | self.model.endReset() 58 | self.mw.requireReset() 59 | self.mw.progress.finish() 60 | tooltip("Added " + unseen_tag + " to notes") 61 | 62 | 63 | def remove_unseen_tags_from_selected(self): 64 | #self is browser 65 | selected_cids = self.selectedCards() 66 | if not selected_cids: 67 | tooltip(_("No cards selected."), period=2000) 68 | return 69 | self.mw.progress.start() 70 | self.mw.checkpoint("Remove Unseen Tags") 71 | self.model.beginReset() 72 | for cid in selected_cids: 73 | unseen_card = self.col.getCard(cid) 74 | unseen_note = unseen_card.note() 75 | unseen_note.delTag(unseen_tag) 76 | unseen_note.flush() 77 | self.model.endReset() 78 | self.mw.requireReset() 79 | self.mw.progress.finish() 80 | tooltip("Removed " + unseen_tag + " from notes") 81 | 82 | 83 | def change_background_color(self): 84 | pot_unseen_card = self.card 85 | pot_unseen_note = pot_unseen_card.note() 86 | if pot_unseen_note.hasTag(unseen_tag): 87 | #We have to remove this, and play nicely with other addons that may fiddle with background 88 | self.web.eval('document.body.style.backgroundColor = "#FFFBBF"') 89 | 90 | def wipe_background_for_nextCard(self): 91 | #do this early enough so that any addon applying colour changes 92 | #does it after 93 | self.web.eval('document.body.style.backgroundColor = "#FFFFFF"') 94 | 95 | def _remove_unseen_tags_for_card_and_note(pot_unseen_card, pot_unseen_note): 96 | if pot_unseen_note.hasTag(unseen_tag): 97 | pot_unseen_note.delTag(unseen_tag) 98 | pot_unseen_note.flush() 99 | 100 | 101 | def _remove_unseen_tags(self): 102 | pot_unseen_card = self.card 103 | pot_unseen_note = pot_unseen_card.note() 104 | _remove_unseen_tags_for_card_and_note(pot_unseen_card, pot_unseen_note) 105 | 106 | 107 | def answer_card_removing_unseen_tags(self, ease): 108 | #self is reviewer 109 | #Keep these guards in place ... not sure why? 110 | if self.mw.state != "review": 111 | return 112 | if self.state != "answer": 113 | return 114 | _remove_unseen_tags(self) 115 | 116 | 117 | def suspend_cards_removing_unseen_tags(self, ids): 118 | #self is Scheduler 119 | for cid in ids: 120 | sus_card = self.col.getCard(cid) 121 | sus_note = sus_card.note() 122 | _remove_unseen_tags_for_card_and_note(sus_card, sus_note) 123 | 124 | 125 | def show_all_unseen_cards(self): 126 | #self is browser 127 | self.form.searchEdit.lineEdit().setText("tag:" + unseen_tag) 128 | self.onSearch() 129 | 130 | 131 | def setup_browser_menu(self): 132 | #self is browser 133 | try: 134 | # used by multiple add-ons, so we check for its existence first 135 | menu = self.menuTags 136 | except: 137 | self.menuTags = QMenu(_("Tags")) 138 | action = self.menuBar().insertMenu(self.mw.form.menuTools.menuAction(), self.menuTags) 139 | menu = self.menuTags 140 | menu.addSeparator() 141 | unseen_menu = menu.addMenu("Unseen Card Tracking") 142 | a = unseen_menu.addAction('Add "Unseen" Tags to Selected Cards') 143 | a.setShortcut(QKeySequence("Ctrl+U")) 144 | self.connect(a, SIGNAL("triggered()"), lambda b=self: add_unseen_tags_to_selected(b)) 145 | a = unseen_menu.addAction('Remove "Unseen" Tags from Selected Cards') 146 | a.setShortcut(QKeySequence("Ctrl+Shift+U")) 147 | self.connect(a, SIGNAL("triggered()"), lambda b=self: remove_unseen_tags_from_selected(b)) 148 | a = unseen_menu.addAction('Show all Unseen Cards') 149 | a.setShortcut(QKeySequence("Ctrl+Alt+U")) 150 | self.connect(a, SIGNAL("triggered()"), lambda b=self: show_all_unseen_cards(b)) 151 | 152 | #todo: menu action to set search string to show all 153 | addHook("browser.setupMenus", setup_browser_menu) 154 | Reviewer._answerCard = wrap(Reviewer._answerCard, answer_card_removing_unseen_tags, "before") 155 | Reviewer._showQuestion = wrap(Reviewer._showQuestion, change_background_color, "after") 156 | # have to call this again on the answer side because of JS Booster add-on 157 | Reviewer._showAnswer = wrap(Reviewer._showAnswer, change_background_color, "after") 158 | 159 | Reviewer.nextCard = wrap(Reviewer.nextCard, wipe_background_for_nextCard, "before") 160 | 161 | #By wrapping this, we cover suspending cards/notes etc 162 | Scheduler.suspendCards = wrap(Scheduler.suspendCards, suspend_cards_removing_unseen_tags, "before") 163 | -------------------------------------------------------------------------------- /src/sched_advanced_newcard_limits/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import sched_advanced_newcard_limits -------------------------------------------------------------------------------- /src/sched_advanced_newcard_limits/sched_advanced_newcard_limits.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Anki Add-on: Advanced New Card Limits 4 | 5 | Allows you to restrict new cards to less than one per day. 6 | 7 | The deck options need to be set to 1 new card per day for 8 | the advanced limits to apply. 9 | 10 | Otherwise, the deck can be given to every deck using the same deck. 11 | 12 | Copyright: (c) Glutanimate 2017 and Arthur Milchior arthur@milchior.fr 13 | License: GNU AGPLv3 or later 14 | """ 15 | 16 | ############## USER CONFIGURATION START ############## 17 | 18 | # Syntax: "deck_name": days_between_new_cards 19 | deck_limits = { 20 | "Default": 2, # show 1 new card every 2 days in "Default" deck 21 | u"Very Slöw": 7 # deck names with unicode need to be prepended with u 22 | } 23 | 24 | option_limits={ 25 | "hard": 5, 26 | "Chapter": 2, 27 | } 28 | #In ANKI21, you can edit the options in the configuration manager, 29 | #instead of here. Thus, the configurations will be kept when the 30 | #add-on is updated. 31 | 32 | ############## USER CONFIGURATION END ############## 33 | from anki import version as anki_version 34 | import aqt 35 | anki21 = anki_version.startswith("2.1.") 36 | if anki21: 37 | userOption = aqt.mw.addonManager.getConfig(__name__) 38 | deck_limits.update(userOption["deck limits"]) 39 | option_limits.update(userOption["option limits"]) 40 | 41 | from anki.sched import Scheduler 42 | from anki.schedv2 import Scheduler as SchedulerV2 43 | 44 | def debug(t): 45 | print(t) 46 | pass 47 | def myDeckNewLimitSingle(self, g): 48 | """Limit for deck without parent limits, 49 | modified to only show cards every n days""" 50 | debug(f"Calling myDeckNewLimitSingle({g})") 51 | if g['dyn']: 52 | return self.reportLimit 53 | did = g['id'] 54 | c = self.col.decks.confForDid(did) 55 | cname = c['name'] 56 | deck = self.col.decks.nameOrNone(did) 57 | per_day = c['new']['perDay'] 58 | lim = max(0, per_day - g['newToday'][1]) 59 | if cname in option_limits: 60 | our_limit = option_limits[cname] 61 | elif per_day <= 1 and deck in deck_limits and deck_limits[deck] != 1: 62 | our_limit = deck_limits[deck] 63 | else: 64 | return lim 65 | dsel = "cid in (select id from cards where did = %s)" % did 66 | last = self.col.db.scalar( 67 | "select id from revlog where type = 0 and ivl > 0 and %s order by id desc limit 1" % dsel) 68 | if not last: 69 | return lim 70 | last_new = last/1000 71 | last_cutoff = self.dayCutoff - 86400 72 | # days between last graduation and yesterday's cutoff: 73 | ddays = -(-(last_cutoff - last_new) // 86400.0) # ceil 74 | if ddays < our_limit: 75 | lim = 0 76 | # print ddays 77 | return lim 78 | 79 | Scheduler._deckNewLimitSingle = myDeckNewLimitSingle 80 | SchedulerV2._deckNewLimitSingle = myDeckNewLimitSingle 81 | -------------------------------------------------------------------------------- /src/sched_deck_orgactions/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import sched_deck_orgactions -------------------------------------------------------------------------------- /src/sched_deck_orgactions/sched_deck_orgactions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Deck Organization Actions 5 | 6 | Allows users to assign various deck organization tasks 7 | to a simple Tools menu entry that can be invoked via Shift+O. 8 | 9 | Copyright: (c) 2017 Glutanimate 10 | License: GNU AGPLv3 11 | """ 12 | 13 | from __future__ import unicode_literals 14 | 15 | ############## USER CONFIGURATION START ############## 16 | 17 | # Tuple of task dictionaries describing organization tasks 18 | # to be performed on toggling menu entry 19 | org_tasks = ( 20 | {"action": "move", "orig": "source deck 1", "dest": "destination deck", 21 | "count": 2, "order": "added", "invert": True}, 22 | {"action": "move", "orig": "source deck 2", "dest": "destination deck", 23 | "count": 2, "order": "due", "invert": False} 24 | ) 25 | # dictinary keys and possible values: 26 | # "action": "move" (default) # = move new cards, only action so far 27 | # "orig": string, name of old deck 28 | # "dest": string, name of new deck 29 | # "count": integer, number of cards 30 | # "order": "due" (default) / "added" / "random" 31 | # "invert": True / False (default) # whether to invert card order 32 | 33 | # Whether or not to warn when executing task more than once a day 34 | WARN_ON_MULTIPLE_EXECUTIONS = True 35 | 36 | ############## USER CONFIGURATION END ############## 37 | 38 | import time 39 | import random 40 | 41 | from aqt.qt import * 42 | from aqt.utils import tooltip, askUser 43 | from aqt import mw 44 | 45 | VALID_ACTIONS = ("move") 46 | VALID_ORDERS = ("due", "added", "random") 47 | 48 | 49 | def moveCardsAction(task): 50 | """Move cards between decks""" 51 | orig = task.get("orig", None) 52 | dest = task.get("dest", None) 53 | count = task.get("count", None) 54 | order = task.get("order", "due") 55 | invert = task.get("invert", False) 56 | 57 | try: 58 | assert orig and dest and count 59 | assert order in VALID_ORDERS 60 | except AssertionError: 61 | return "invalid" 62 | 63 | orig_did = mw.col.decks.id(orig, create=False) 64 | if not orig_did: 65 | return "invalid" 66 | 67 | dest_did = mw.col.decks.id(dest, create=False) 68 | 69 | if dest_did and mw.col.decks.isDyn(dest_did): 70 | # cards can't be moved into filtered deck 71 | return "dynamic" 72 | 73 | 74 | cids = mw.col.decks.cids(orig_did) 75 | if not cids: 76 | return "nocids" 77 | 78 | if order in ("random", "added"): 79 | cmd = "select id from cards where did = ? and type=0 order by id" 80 | elif order == "due": 81 | cmd = "select id from cards where did = ? and type=0 order by due" 82 | 83 | scids = mw.col.db.list(cmd, orig_did) 84 | 85 | if order == "random": 86 | scids = random.sample(scids, count) 87 | elif invert: 88 | scids = scids[-count:] 89 | else: 90 | scids = scids[:count] 91 | 92 | if not scids: 93 | return "nocids" 94 | 95 | if not dest_did: 96 | # create destination deck now 97 | dest_did = mw.col.decks.id(dest) 98 | 99 | # remove any cards from filtered deck first 100 | mw.col.sched.remFromDyn(scids) 101 | # then move into new deck 102 | mw.col.decks.setDeck(scids, dest_did) 103 | 104 | 105 | def performDeckOrgActions(): 106 | """Parse org_tasks dictionary for tasks and perform them""" 107 | returncodes = [] 108 | 109 | mw.checkpoint("Deck Organization Tasks") 110 | 111 | for task in org_tasks: 112 | 113 | action = task.get("action", "move") 114 | 115 | try: 116 | assert action in VALID_ACTIONS 117 | except AssertionError: 118 | print("Invalid task. Skipping") 119 | returncodes.append("invalid") 120 | continue 121 | 122 | ret = moveCardsAction(task) 123 | if ret: 124 | returncodes.append(ret) 125 | 126 | mw.reset() 127 | 128 | msg = ["Deck organization tasks complete."] 129 | if "invalid" in returncodes: 130 | msg.append("Warning: Your task dictionary contained invalid" 131 | " tasks that had to be skipped.") 132 | if "dynamic" in returncodes: 133 | msg.append("Warning: Some of your decks are filtered decks." 134 | "
Moving cards into filtered decks is not supported") 135 | if "nocids" in returncodes: 136 | msg.append("Warning: Some of the origin decks did not contain" 137 | "any transferable cards.
Actions were skipped in those" 138 | " instances.") 139 | 140 | info = "

".join(msg) 141 | tooltip(info) 142 | 143 | 144 | def onOrganizeTask(): 145 | """Main function. Invoked on clicking menu entry""" 146 | conf = mw.col.conf 147 | today = mw.col.sched.today # today in days since col creation time 148 | 149 | if WARN_ON_MULTIPLE_EXECUTIONS: 150 | # check if we've already performed tasks today: 151 | last = conf.get("deckOrgLast", None) 152 | if last and last == today: 153 | q = ("You have already performed this task today." 154 | " Are you sure you want to proceed?") 155 | ret = askUser(q) 156 | if not ret: 157 | return False 158 | 159 | performDeckOrgActions() 160 | 161 | conf["deckOrgLast"] = today 162 | mw.col.setMod() 163 | 164 | 165 | # Menu / Hooks 166 | 167 | action = QAction(mw) 168 | action.setText("Perform Deck Organization Tasks") 169 | action.setShortcut(QKeySequence("Shift+O")) 170 | mw.form.menuTools.addAction(action) 171 | action.triggered.connect(onOrganizeTask) -------------------------------------------------------------------------------- /src/sched_filter_dailydue/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import sched_filter_dailydue -------------------------------------------------------------------------------- /src/sched_filter_dailydue/sched_filter_dailydue.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Create Filtered Deck of All Cards Scheduled for Today 5 | 6 | Adds the search term "is:today" to the filtered deck creation dialog 7 | which includes the following cards: 8 | 9 | - all cards due for today, according to each deck's review limit 10 | - all new cards "due for today", according to each deck's new card limit 11 | 12 | Also supports limiting to / excluding specific decks by combining 13 | "is:today" with "deck:" phrases. 14 | 15 | Copyright: (c) 2017 Glutanimate 16 | License: GNU AGPLv3 17 | """ 18 | 19 | import random 20 | 21 | from anki.sched import Scheduler 22 | from anki.hooks import wrap 23 | 24 | def onUpdateStats(self, card, type, cnt=1): 25 | """Increment card counts for original decks""" 26 | # only applies to filtered decks using our special syntax 27 | key = type+"Today" 28 | if card.odid: 29 | conf = self.col.decks.confForDid(card.did) 30 | if "is:today" not in conf["terms"][0][0]: 31 | return 32 | for g in ([self.col.decks.get(card.odid)] + 33 | self.col.decks.parents(card.odid)): 34 | # add 35 | g[key][1] += cnt 36 | self.col.decks.save(g) 37 | 38 | def myFillDyn(self, deck, _old): 39 | """Fill filtered deck using custom filter""" 40 | search, limit, order = deck['terms'][0] 41 | if "is:today" in search: 42 | ids = self.dynToday(search, order=order) 43 | # move the cards over 44 | self.col.log(deck['id'], ids) 45 | self._moveToDyn(deck['id'], ids) 46 | return ids 47 | else: 48 | return _old(self, deck) 49 | 50 | def dynToday(self, search, order=False): 51 | """Find all cards that are scheduled for today""" 52 | tokens = search.split() 53 | dids = [int(i) for i in self.col.decks.allIds()] 54 | idids = [] 55 | sdids = [] 56 | for t in tokens: 57 | # set decks according to search phrase 58 | if t.startswith("-deck:"): 59 | l = idids 60 | elif t.startswith("deck:"): 61 | l = sdids 62 | else: 63 | continue 64 | name = ":".join(t.split(":")[1:]).replace('"', '').replace("'", "") 65 | deck = self.col.decks.byName(name) 66 | if not deck: 67 | continue 68 | did = deck["id"] 69 | if did not in l: 70 | l += [did] + [a[1] for a in self.col.decks.children(did)] 71 | if sdids: 72 | dids = sdids 73 | if idids: 74 | dids = [did for did in dids if did not in idids] 75 | ids = [] 76 | for did in dids: 77 | newlim = self._deckNewLimit(did) 78 | revlim = self._deckRevLimit(did) 79 | # new cards according to deck limits 80 | ids += self.col.db.list(""" 81 | select id from cards where did = ? and 82 | queue = 0 order by due limit ?""", did, newlim) 83 | # due cards according to deck limits 84 | ids += self.col.db.list(""" 85 | select id from cards where did = %s and 86 | (queue in (2,3) and due <= %d) or 87 | (queue = 1 and due <= %d) 88 | order by due limit %d""" % (did, self.today, self.dayCutoff, revlim)) 89 | # randomize if option set 90 | if order == 1: 91 | random.shuffle(ids) 92 | return ids 93 | 94 | Scheduler.dynToday = dynToday 95 | Scheduler._fillDyn = wrap(Scheduler._fillDyn, myFillDyn, "around") 96 | Scheduler._updateStats = wrap(Scheduler._updateStats, onUpdateStats, "after") -------------------------------------------------------------------------------- /src/sched_ignore_lapses_below_ivl/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import sched_ignore_lapses_below_ivl -------------------------------------------------------------------------------- /src/sched_ignore_lapses_below_ivl/sched_ignore_lapses_below_ivl.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Ignore Lapses Below Interval 5 | 6 | Copyright: (c) 2018 Glutanimate 7 | License: GNU AGPLv3 8 | """ 9 | 10 | ############## USER CONFIGURATION START ############## 11 | 12 | IVL_THRESHOLD = 4 # Interval threshold in days [integer]. 13 | # Only lapses above this interval will 14 | # be registered as such. 15 | 16 | ############## USER CONFIGURATION END ############## 17 | 18 | import time 19 | from heapq import * 20 | from anki.sched import Scheduler 21 | 22 | 23 | def myRescheduleLapse(self, card): 24 | conf = self._lapseConf(card) 25 | card.lastIvl = card.ivl 26 | if self._resched(card): 27 | # ==== MODIFICATIONS START ==== 28 | if card.lastIvl > IVL_THRESHOLD: 29 | card.lapses += 1 30 | # ==== MODIFICATIONS END ==== 31 | card.ivl = self._nextLapseIvl(card, conf) 32 | card.factor = max(1300, card.factor-200) 33 | card.due = self.today + card.ivl 34 | # if it's a filtered deck, update odue as well 35 | if card.odid: 36 | card.odue = card.due 37 | # if suspended as a leech, nothing to do 38 | delay = 0 39 | if self._checkLeech(card, conf) and card.queue == -1: 40 | return delay 41 | # if no relearning steps, nothing to do 42 | if not conf['delays']: 43 | return delay 44 | # record rev due date for later 45 | if not card.odue: 46 | card.odue = card.due 47 | delay = self._delayForGrade(conf, 0) 48 | card.due = int(delay + time.time()) 49 | card.left = self._startingLeft(card) 50 | # queue 1 51 | if card.due < self.dayCutoff: 52 | self.lrnCount += card.left // 1000 53 | card.queue = 1 54 | heappush(self._lrnQueue, (card.due, card.id)) 55 | else: 56 | # day learn queue 57 | ahead = ((card.due - self.dayCutoff) // 86400) + 1 58 | card.due = self.today + ahead 59 | card.queue = 3 60 | return delay 61 | 62 | # Hooks 63 | 64 | Scheduler._rescheduleLapse = myRescheduleLapse -------------------------------------------------------------------------------- /src/sched_sibling_spacing_whitelist/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import sched_sibling_spacing_whitelist -------------------------------------------------------------------------------- /src/sched_sibling_spacing_whitelist/sched_sibling_spacing_whitelist.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | #-*- coding: utf-8 -*- 3 | # --------------------------------------------------------------------------- 4 | # Sibling Spacing is an addon for Anki 2 - http://ankisrs.net 5 | # --------------------------------------------------------------------------- 6 | # Original Author: Andreas Klauer (Andreas Klauer@metamorpher.de) 7 | # Modified by: Glutanimate 2016 (https://github.com/Glutanimate) 8 | # Version: 0.02 (2016-03-17) 9 | # License: GPL 10 | # --------------------------------------------------------------------------- 11 | # 12 | # This modified version of the Sibling Spacing add-on follows a whitelist 13 | # approach when adjusting scheduling of siblings. Only note models defined in 14 | # the 'enabledModels' list will be processed. 15 | 16 | # --- Imports: --- 17 | 18 | from anki.hooks import addHook, wrap 19 | from aqt import * 20 | from aqt.utils import showInfo, tooltip 21 | 22 | # --- Globals: --- 23 | 24 | enabled = True 25 | debug = True 26 | 27 | # edit this list with the note models you want to enable 28 | # sibling spacing on 29 | enabledModels = [ "Erweitert - Mehrzweck", "Kunst - Bildende Kunst" ] 30 | 31 | # --- Functions: --- 32 | 33 | def siblingIvl(self, card, idealIvl, _old): 34 | origIvl = _old(self, card, idealIvl) 35 | 36 | if not enabled: 37 | return origIvl 38 | 39 | modelName = card.model()["name"] 40 | 41 | if modelName not in enabledModels: 42 | # if debug: 43 | # print "Sibling Spacing not enabled for %s. No adjustment." % modelName 44 | return origIvl 45 | 46 | ivl = origIvl 47 | 48 | # Penalty 49 | minIvl = self.col.db.scalar('''SELECT MIN(ivl) FROM cards WHERE ivl > 0 AND nid = ? AND id != ? AND queue = 2''', 50 | card.nid, card.id) 51 | 52 | while minIvl and minIvl > 0 and ivl > minIvl*4: 53 | ivl = max(1, int(ivl/2.0)) 54 | 55 | # Boost 56 | delta = max(1, int(ivl * 0.15)) 57 | boost = max(1, int(ivl * 0.5)) 58 | 59 | siblings = True 60 | 61 | while siblings: 62 | siblings = self.col.db.scalar('''SELECT count() FROM cards WHERE due >= ? AND due <= ? AND nid = ? AND id != ? AND queue = 2''', 63 | self.today + ivl - delta, self.today + ivl + delta, card.nid, card.id) 64 | if siblings: 65 | ivl += boost 66 | 67 | if debug: 68 | if minIvl and minIvl > 0: 69 | print "Sibling Spacing %d%+d = %d days for card %d (sibling has %d days)" % (origIvl,ivl-origIvl,ivl,card.id,minIvl,) 70 | tooltip("Sibling Spacing:
%d%+d days
" % (origIvl,ivl-origIvl)) 71 | else: 72 | print "Sibling Spacing == %d (orig:%d) days for card %d without visible siblings" % (ivl,origIvl,card.id,) 73 | 74 | return ivl 75 | 76 | def toggle(): 77 | global enabled 78 | 79 | enabled = not enabled 80 | 81 | if enabled: 82 | showInfo("Sibling Spacing is now ON") 83 | else: 84 | showInfo("Sibling Spacing is now OFF") 85 | 86 | def toggle_debug(): 87 | global debug 88 | 89 | debug = not debug 90 | 91 | if debug: 92 | showInfo("Sibling Spacing Debug is now ON") 93 | else: 94 | showInfo("Sibling Spacing Debug is now OFF") 95 | 96 | def siblingMenu(): 97 | '''Extend the addon menu with toggle.''' 98 | m = None 99 | 100 | for action in mw.form.menuPlugins.actions(): 101 | menu = action.menu() 102 | if menu and menu.title() == "anki-sibling-spacing-whitelist": 103 | m = menu 104 | break 105 | 106 | if not m: 107 | return 108 | 109 | a = QAction(_("Toggle ON/OFF..."), mw) 110 | mw.connect(a, SIGNAL("triggered()"), toggle) 111 | m.addAction(a) 112 | a = QAction(_("Debug ON/OFF..."), mw) 113 | mw.connect(a, SIGNAL("triggered()"), toggle_debug) 114 | m.addAction(a) 115 | 116 | def profileLoaded(): 117 | # add menu entry 118 | mw.addonManager.rebuildAddonsMenu = wrap(mw.addonManager.rebuildAddonsMenu, 119 | siblingMenu) 120 | mw.addonManager.rebuildAddonsMenu() 121 | 122 | # add scheduler 123 | anki.sched.Scheduler._adjRevIvl = wrap(anki.sched.Scheduler._adjRevIvl, 124 | siblingIvl, "around") 125 | 126 | # --- Hooks: --- 127 | 128 | addHook("profileLoaded", profileLoaded) 129 | 130 | # --- End of file. --- 131 | -------------------------------------------------------------------------------- /src/search_last_edited/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import search_last_edited -------------------------------------------------------------------------------- /src/search_last_edited/search_last_edited.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Search by Edit Date 5 | 6 | Adds two new properties to Anki's search module: 7 | 8 | "edited:x" – will list all cards edited over the last x days 9 | "editedon:x" – will list all cards edited x days ago 10 | 11 | These can be used across all search interfaces that Anki provides 12 | (Browser, Filtered Decks Creation, etc.) 13 | 14 | Copyright: (c) Glutanimate 2017 15 | License: GNU AGPLv3 or later 16 | """ 17 | 18 | from anki.find import Finder 19 | from anki.utils import ids2str 20 | from anki.hooks import wrap 21 | 22 | 23 | def findLastEdited(self, val, exact=False): 24 | """Find cards by edit date""" 25 | # self is find.Finder 26 | try: 27 | days = int(val[0]) 28 | except ValueError: 29 | return 30 | days = max(days, 0) 31 | 32 | # first cutoff at x days ago 33 | cutoff1 = (self.col.sched.dayCutoff - 86400*days) 34 | 35 | if exact: 36 | # second cutoff at x-1 days ago 37 | cutoff2 = cutoff1 + 86400 38 | # select notes that were edited on that day 39 | nids = self.col.db.list( 40 | "select id from notes where mod between {} and {}".format( 41 | cutoff1, cutoff2)) 42 | else: 43 | # select notes that were edited since then 44 | nids = self.col.db.list( 45 | "select id from notes where mod > {}".format(cutoff1)) 46 | 47 | return ("c.nid in {}".format(ids2str(nids))) 48 | 49 | 50 | def addFinder(self, col): 51 | """Add custom finders to search dictionary""" 52 | self.search["edited"] = self.findLastEdited 53 | self.search["editedon"] = lambda *x: self.findLastEdited(*x, exact=True) 54 | 55 | 56 | # Hooks 57 | 58 | Finder.findLastEdited = findLastEdited 59 | Finder.__init__ = wrap(Finder.__init__, addFinder, "after") 60 | -------------------------------------------------------------------------------- /src/stats_true_retention_extended/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import stats_true_retention_extended -------------------------------------------------------------------------------- /src/stats_true_retention_extended/stats_true_retention_extended.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | True Retention Add-on for Anki (extended) 5 | 6 | Based on True Retention by Strider (?) 7 | (https://ankiweb.net/shared/info/613684242) 8 | 9 | Copyright: (c) 2016 Strider (?) 10 | (c) 2017 Glutanimate (https://github.com/Glutanimate) 11 | License: GNU AGPLv3 or later 12 | """ 13 | 14 | from __future__ import unicode_literals 15 | 16 | ############## USER CONFIGURATION START ############## 17 | 18 | MATURE_IVL = 21 # mature card interval in days 19 | 20 | ############## USER CONFIGURATION END ############## 21 | 22 | import anki.stats 23 | 24 | from anki.utils import fmtTimeSpan 25 | from anki.lang import _, ngettext 26 | from anki import version as anki_version 27 | 28 | 29 | # Types: 0 - new today; 1 - review; 2 - relearn; 3 - (cram?) [before the answer was pressed] 30 | # "Learning" corresponds to New|Relearn. "Review" corresponds to Young|Mature. 31 | # Ease: 1 - flunk button; 2 - second; 3 - third; 4 - fourth (easy) [which button was pressed] 32 | # Intervals: -60 <1m -600 10m etc; otherwise days 33 | def _line_now(self, i, a, b, bold=True): 34 | colon = _(":") 35 | if bold: 36 | i.append(("%s%s%s") % (a,colon,b)) 37 | else: 38 | i.append(("%s%s%s") % (a,colon,b)) 39 | 40 | def _lineTbl_now(self, i): 41 | return "" + "".join(i) + "
" 42 | 43 | def statList(self, lim, span): 44 | yflunked, ypassed, mflunked, mpassed, learned, relearned = self.col.db.first(""" 45 | select 46 | sum(case when lastIvl < %(i)d and ease = 1 and type == 1 then 1 else 0 end), /* flunked young */ 47 | sum(case when lastIvl < %(i)d and ease > 1 and type == 1 then 1 else 0 end), /* passed young */ 48 | sum(case when lastIvl >= %(i)d and ease = 1 and type == 1 then 1 else 0 end), /* flunked mature */ 49 | sum(case when lastIvl >= %(i)d and ease > 1 and type == 1 then 1 else 0 end), /* passed mature */ 50 | sum(case when ivl > 0 and type == 0 then 1 else 0 end), /* learned */ 51 | sum(case when ivl > 0 and type == 2 then 1 else 0 end) /* relearned */ 52 | from revlog where id > ? """ % dict(i=MATURE_IVL) +lim, span) 53 | yflunked, mflunked = yflunked or 0, mflunked or 0 54 | ypassed, mpassed = ypassed or 0, mpassed or 0 55 | learned, relearned = learned or 0, relearned or 0 56 | 57 | # True retention 58 | # young 59 | try: 60 | yret = "%0.1f%%" %(ypassed/float(ypassed+yflunked)*100) 61 | except ZeroDivisionError: 62 | yret = "N/A" 63 | # mature 64 | try: 65 | mret = "%0.1f%%" %(mpassed/float(mpassed+mflunked)*100) 66 | except ZeroDivisionError: 67 | mret = "N/A" 68 | # total 69 | try: 70 | tret = "%0.1f%%" %((ypassed+mpassed)/float(ypassed+mpassed+yflunked+mflunked)*100) 71 | except ZeroDivisionError: 72 | tret = "N/A" 73 | 74 | i = [] 75 | i.append(u"""""") 77 | i.append(u"Young cards
") 78 | _line_now(self, i, u"True retention", yret) 79 | _line_now(self, i, u"Passed reviews", ypassed) 80 | _line_now(self, i, u"Flunked reviews", yflunked) 81 | i.append(u"Mature cards (ivl≥%d)" % MATURE_IVL) 82 | _line_now(self, i, u"True retention", mret) 83 | _line_now(self, i, u"Passed reviews", mpassed) 84 | _line_now(self, i, u"Flunked reviews", mflunked) 85 | i.append(u"Total
") 86 | _line_now(self, i, u"True retention", tret) 87 | _line_now(self, i, u"Passed reviews", ypassed+mpassed) 88 | _line_now(self, i, u"Flunked reviews", yflunked+mflunked) 89 | _line_now(self, i, u"New cards learned", learned) 90 | _line_now(self, i, u"Cards relearned", relearned) 91 | return _lineTbl_now(self, i) 92 | 93 | def todayStats_new(self): 94 | lim = self._revlogLimit() 95 | if lim: 96 | lim = u" and " + lim 97 | 98 | pastDay = statList(self, lim, (self.col.sched.dayCutoff-86400)*1000) 99 | pastWeek = statList(self, lim, (self.col.sched.dayCutoff-86400*7)*1000) 100 | 101 | if self.type == 0: 102 | period = 31; name = u"Past month" 103 | elif self.type == 1: 104 | period = 365; name = u"Past year" 105 | elif self.type == 2: 106 | period = float('inf'); name = u"All time" 107 | 108 | pastPeriod = statList(self, lim, (self.col.sched.dayCutoff-86400*period)*1000) 109 | 110 | return todayStats_old(self) + u"

" \ 111 | + u"Past day" + pastDay + u"" \ 112 | + u"Past week" + pastWeek + u"" \ 113 | + u"" + name + u"" + pastPeriod + u"
" 114 | 115 | def todayStats_old(self): 116 | """We need to overwrite the entire method to change the mature ivl""" 117 | b = self._title(_("Today")) 118 | # studied today 119 | lim = self._revlogLimit() 120 | if lim: 121 | lim = " and " + lim 122 | cards, thetime, failed, lrn, rev, relrn, filt = self.col.db.first(""" 123 | select count(), sum(time)/1000, 124 | sum(case when ease = 1 then 1 else 0 end), /* failed */ 125 | sum(case when type = 0 then 1 else 0 end), /* learning */ 126 | sum(case when type = 1 then 1 else 0 end), /* review */ 127 | sum(case when type = 2 then 1 else 0 end), /* relearn */ 128 | sum(case when type = 3 then 1 else 0 end) /* filter */ 129 | from revlog where id > ? """+lim, (self.col.sched.dayCutoff-86400)*1000) 130 | cards = cards or 0 131 | thetime = thetime or 0 132 | failed = failed or 0 133 | lrn = lrn or 0 134 | rev = rev or 0 135 | relrn = relrn or 0 136 | filt = filt or 0 137 | # studied 138 | if anki_version.startswith("2.0."): 139 | def bold(s): 140 | return ""+unicode(s)+"" 141 | else: 142 | def bold(s): 143 | return ""+str(s)+"" 144 | msgp1 = ngettext("%d card", "%d cards", cards) % cards 145 | b += _("Studied %(a)s in %(b)s today.") % dict( 146 | a=bold(msgp1), b=bold(fmtTimeSpan(thetime, unit=1))) 147 | # again/pass count 148 | b += "
" + _("Again count: %s") % bold(failed) 149 | if cards: 150 | b += " " + _("(%s correct)") % bold( 151 | "%0.1f%%" %((1-failed/float(cards))*100)) 152 | # type breakdown 153 | b += "
" 154 | b += (_("Learn: %(a)s, Review: %(b)s, Relearn: %(c)s, Filtered: %(d)s") 155 | % dict(a=bold(lrn), b=bold(rev), c=bold(relrn), d=bold(filt))) 156 | # mature today 157 | mcnt, msum = self.col.db.first(""" 158 | select count(), sum(case when ease = 1 then 0 else 1 end) from revlog 159 | where lastIvl >= %d and id > ?""" % MATURE_IVL +lim, (self.col.sched.dayCutoff-86400)*1000) 160 | b += "
" 161 | if mcnt: 162 | b += _("Correct answers on mature cards: %(a)d/%(b)d (%(c).1f%%)") % dict( 163 | a=msum, b=mcnt, c=(msum / float(mcnt) * 100)) 164 | else: 165 | b += _("No mature cards were studied today.") 166 | return b 167 | 168 | anki.stats.CollectionStats.todayStats = todayStats_new 169 | -------------------------------------------------------------------------------- /src/tagedit_enhancements/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import tagedit_enhancements -------------------------------------------------------------------------------- /src/tagedit_enhancements/tagedit_enhancements.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: Custom Tag Editor 5 | 6 | Various modifications to the tag editor: 7 | 8 | - disable initial popup when entering tag editor 9 | - apply current completion with Enter/Return 10 | - go to next completion with Ctrl+Tab 11 | - show completer with Up/Down arrows 12 | 13 | Copyright: (c) Glutanimate 2016-2017 14 | License: GNU AGPLv3 or later 15 | """ 16 | 17 | from aqt.qt import * 18 | from aqt.tagedit import TagEdit 19 | 20 | def myFocusInEvent(self, evt): 21 | QLineEdit.focusInEvent(self, evt) 22 | if self.type == 1: # only show completer for decks 23 | self.showCompleter() 24 | 25 | def myKeyPressEvent(self, evt): 26 | if evt.key() in (Qt.Key_Up, Qt.Key_Down): 27 | # show completer on up/down 28 | if not self.completer.popup().isVisible(): 29 | self.showCompleter() 30 | return 31 | if (evt.key() == Qt.Key_Tab and evt.modifiers() == Qt.ControlModifier 32 | and self.completer.popup().isVisible()): 33 | # select next completion 34 | index = self.completer.currentIndex() 35 | self.completer.popup().setCurrentIndex(index) 36 | start = self.completer.currentRow() 37 | if not self.completer.setCurrentRow(start + 1): 38 | self.completer.setCurrentRow(0) 39 | return 40 | if evt.key() in (Qt.Key_Enter, Qt.Key_Return): 41 | # set current completion 42 | popidx = self.completer.popup().currentIndex() 43 | selected = QCompleter.pathFromIndex(self.completer, popidx) 44 | if not selected: 45 | # only apply completion if no list item selected: 46 | self.applyCompletion() 47 | self.hideCompleter() 48 | QWidget.keyPressEvent(self, evt) 49 | return 50 | QLineEdit.keyPressEvent(self, evt) 51 | if not evt.text(): 52 | # if it's a modifier, don't show 53 | return 54 | if evt.key() not in ( 55 | Qt.Key_Enter, Qt.Key_Return, Qt.Key_Escape, Qt.Key_Space, 56 | Qt.Key_Tab, Qt.Key_Backspace, Qt.Key_Delete): 57 | self.showCompleter() 58 | 59 | def applyCompletion(self): 60 | txt = self.text() 61 | pos = self.cursorPosition() 62 | tags = txt.split() 63 | after = txt[pos:].split() 64 | before = txt[:pos].split() 65 | try: 66 | cur = txt[pos] 67 | if cur == " ": # cur is at tag boundary 68 | after = None 69 | except IndexError: 70 | pass 71 | if not before and after: 72 | pfx = after[0] 73 | elif not after and before: 74 | pfx = before[-1] 75 | elif before != after: 76 | pfx = before[-1] + after[0] 77 | elif not before and not after: 78 | return False 79 | else: 80 | pfx = before[0] 81 | try: 82 | tidx = tags.index(pfx) 83 | except ValueError: 84 | return False 85 | self.completer.setCompletionPrefix(pfx) 86 | completion = self.completer.currentCompletion() 87 | if not completion: 88 | return False 89 | tags[tidx] = completion + " " 90 | self.setText(" ".join(tags)) 91 | newpos = len(" ".join(tags[:tidx+1])) 92 | self.setCursorPosition(newpos) 93 | return True 94 | 95 | TagEdit.applyCompletion = applyCompletion 96 | TagEdit.focusInEvent = myFocusInEvent 97 | TagEdit.keyPressEvent = myKeyPressEvent 98 | -------------------------------------------------------------------------------- /src/tagedit_subtag_completer/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Entry point for the add-on into Anki 4 | # Please do not edit this if you do not know what you are doing. 5 | # 6 | # Copyright: (c) 2017 Glutanimate 7 | # License: GNU AGPLv3 8 | 9 | from . import tagedit_subtag_completer -------------------------------------------------------------------------------- /src/tagedit_subtag_completer/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Subtag Completer", 3 | "package": "tagedit_subtag_completer" 4 | } 5 | -------------------------------------------------------------------------------- /src/tagedit_subtag_completer/tagedit_subtag_completer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Anki Add-on: TagEdit Subtag Completion 5 | 6 | Modifies the tag entry's autocompletion behaviour. 7 | 8 | Allows for substring matching (either free-form or limited 9 | to tag boundaries as defined by the delimiters used in the 10 | Hierarchical tags add-on). 11 | 12 | Copyright: (c) Glutanimate 2017 13 | License: GNU AGPLv3 14 | """ 15 | 16 | from __future__ import unicode_literals 17 | 18 | ############## USER CONFIGURATION START ############## 19 | 20 | # Limit matches to tag hierarchy instead of arbitrary substrings 21 | LIMIT_TO_HIERARCHY = False # Default: False 22 | # Hierarchical tag delimiter 23 | HIERARCHICHAL_DELIMITER = "::" # Default: "::" 24 | # Use substring highlight workaround 25 | # - needed for autocompleter entries to show up properly on some 26 | # Linux systems that seem to suffer from a Qt bug 27 | # - enabled by default for now. Disable this if you'd like your 28 | # autocompleter to look a bit nicer (if it works for you) 29 | HIGHLIGHT_WORKAROUND = True # Default: True 30 | 31 | ############## USER CONFIGURATION END ############## 32 | 33 | import re 34 | 35 | from aqt.qt import * 36 | from aqt import tagedit 37 | from anki import version as anki_version 38 | 39 | OldTagEdit = tagedit.TagEdit 40 | OldTagCompleter = tagedit.TagCompleter 41 | 42 | if anki_version.startswith("2.0."): 43 | QSOViewItem = QStyleOptionViewItemV4 44 | else: 45 | QSOViewItem = QStyleOptionViewItem 46 | 47 | 48 | class HTMLDelegate(QStyledItemDelegate): 49 | """ 50 | Custom item delegate for QCompleter popup that 51 | allows us to render rich text 52 | """ 53 | 54 | def __init__(self, *args): 55 | QStyledItemDelegate.__init__(self, *args) 56 | self.prefix = None 57 | 58 | def paint(self, painter, option, index): 59 | options = QSOViewItem(option) 60 | self.initStyleOption(options, index) 61 | if options.widget is None: 62 | style = QApplication.style() 63 | else: 64 | style = options.widget.style() 65 | 66 | # highlight search term 67 | prefix = self.prefix 68 | if prefix: 69 | text = options.text 70 | pfx = re.escape(prefix.lower()) 71 | 72 | if not LIMIT_TO_HIERARCHY: 73 | re_match = r"({})".format(pfx) 74 | re_replace = r"\1" 75 | text = re.sub(re_match, re_replace, text, flags=re.I) 76 | else: 77 | re_match = r"({1})({0})".format(pfx, HIERARCHICHAL_DELIMITER) 78 | re_replace = r"{0}\2".format(HIERARCHICHAL_DELIMITER) 79 | text = re.sub(re_match, re_replace, text, flags=re.I) 80 | 81 | re_match = "^({0})".format(pfx) 82 | re_replace = r"\1" 83 | text = re.sub(re_match, re_replace, text, flags=re.I) 84 | 85 | options.text = text 86 | 87 | doc = QTextDocument() 88 | doc.setHtml(options.text) 89 | doc.setTextWidth(option.rect.width()) 90 | doc.setDocumentMargin(0) # fix lines being cut off 91 | 92 | options.text = "" 93 | style.drawControl(QStyle.CE_ItemViewItem, options, painter) 94 | 95 | ctx = QAbstractTextDocumentLayout.PaintContext() 96 | 97 | # Highlighting text if item is selected 98 | if options.state & QStyle.State_Selected: 99 | ctx.palette.setColor(QPalette.Text, 100 | options.palette.color(QPalette.Active, 101 | QPalette.HighlightedText)) 102 | 103 | textRect = style.subElementRect(QStyle.SE_ItemViewItemText, 104 | options) 105 | painter.save() 106 | painter.translate(textRect.topLeft()) 107 | if not HIGHLIGHT_WORKAROUND: 108 | painter.setClipRect(textRect.translated(-textRect.topLeft())) 109 | doc.documentLayout().draw(painter, ctx) 110 | painter.restore() 111 | 112 | 113 | class CustomTagEdit(OldTagEdit): 114 | 115 | """ 116 | Custom Tag Edit Widget with support 117 | for custom Tag Completer 118 | """ 119 | 120 | def __init__(self, parent, type=0): 121 | OldTagEdit.__init__(self, parent, type=type) 122 | self.type = type 123 | self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) 124 | # TODO: find a way to use filtered PopupCompletion 125 | # (cf. https://stackoverflow.com/q/5129211/1708932) 126 | 127 | def setCol(self, col): 128 | "Set the current col, updating list of available tags." 129 | self.col = col 130 | if self.type == 0: 131 | l = sorted(self.col.tags.all()) 132 | else: 133 | l = sorted(self.col.decks.allNames()) 134 | self.model.setStringList(l) 135 | self.completer.strings = l 136 | 137 | def showCompleter(self): 138 | if self.type == 0: # tag selection 139 | self.completer.update(self.text()) 140 | else: # deck selection 141 | self.completer.setCompletionPrefix(self.text()) 142 | self.completer.complete() 143 | 144 | 145 | class CustomTagCompleter(OldTagCompleter): 146 | 147 | """ 148 | Custom Tag Completer that performs substring matches 149 | and highlights results 150 | """ 151 | 152 | def __init__(self, model, parent, edit, *args): 153 | OldTagCompleter.__init__(self, model, parent, edit, *args) 154 | self.strings = [] 155 | self.delegate = HTMLDelegate() 156 | 157 | 158 | def update(self, prefix): 159 | if not self.tags: 160 | return 161 | 162 | prefix = [self.tags[self.cursor or 0]][0] 163 | pfx = prefix.lower() 164 | hpfx = "{}{}".format(HIERARCHICHAL_DELIMITER, pfx) 165 | strings = self.strings 166 | 167 | if not pfx: 168 | filtered = strings 169 | else: 170 | if not LIMIT_TO_HIERARCHY: 171 | filtered = [s for s in strings if pfx in s.lower()] 172 | else: 173 | filtered = [s for s in strings 174 | if hpfx in s.lower() or s.lower().startswith(pfx)] 175 | self.model().setStringList(filtered) 176 | 177 | self.delegate.prefix = prefix 178 | self.popup().setItemDelegate(self.delegate) 179 | 180 | 181 | # Hooks 182 | tagedit.TagEdit = CustomTagEdit 183 | tagedit.TagCompleter = CustomTagCompleter -------------------------------------------------------------------------------- /tools/build_ankiaddon.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Buildscript for add-ons in glutanimate/anki-addons-misc 3 | # 4 | # Builds and prepares files for upload to AnkiWeb 5 | # 6 | # Compatible add-on structures: 7 | # 8 | # 1.) single-file add-on 9 | # 10 | # folder: addon_basename 11 | # → file: __init__.py 12 | # → file: addon_basename.py 13 | # → ?folder: docs 14 | # 15 | # 2.) multi-file 2.1-only add-on 16 | # 17 | # folder: addon_basename 18 | # → file: __init__.py 19 | # → ?file(s): *.py 20 | # → ?folder: docs 21 | # 22 | # 3.) multi-file add-on 23 | # 24 | # folder: addon_basename 25 | # → file: "Display Name.py" 26 | # → folder: module_name 27 | # → file: __init__.py 28 | # → ... 29 | # → ... 30 | # 31 | # Copyright: (c) 2017-2018 Glutanimate 32 | # (c) 2019 zjosua 33 | # License: GNU AGPLv3 34 | 35 | 36 | set -ue 37 | 38 | # Options 39 | excluded_patterns=("*__pycache__/*" "meta.json" "*.pyc" "*.pyo" "*.html" 40 | ".python-version" ".hidden" ".directory" "*.vscode/*" "*docs/*") 41 | ALL="true" # default: process all add-ons 42 | 43 | # Global variables 44 | wd_path="$PWD" 45 | src_path="${wd_path}/src" 46 | build_path="${wd_path}/build" 47 | ankiaddon_path="${build_path}/ankiaddon" 48 | 49 | 50 | USAGE=" 51 | ${0} [-afh] [] 52 | 53 | Options: 54 | -a: Build all add-ons [default if no other option provided] 55 | -f : Build add-ons that are passed in a space-separated list 56 | directory name inside the src folder 57 | -h: show this help section 58 | " 59 | 60 | 61 | # evaluate options 62 | while getopts "afh" OPTIONS; do 63 | case $OPTIONS in 64 | a ) ALL="true" 65 | ;; 66 | f ) ALL="false" 67 | ;; 68 | h ) echo "Usage: $USAGE" 69 | exit 0 70 | ;; 71 | \? ) echo "$USAGE" 72 | exit 1 73 | ;; 74 | esac 75 | done 76 | # remove options from arguments after processing is done 77 | shift $((OPTIND-1)) 78 | 79 | 80 | build_all () { 81 | echo -e "Building all 'anki-addons-misc' add-ons...\n" 82 | # Safely iterate over src directories and call build_addon() 83 | while IFS= read -d $'\0' -r addon_dir ; do 84 | base=$(basename "${addon_dir}") 85 | build_addon "${base}" 86 | done < <(find "${src_path}" -mindepth 1 -maxdepth 1 -type d -print0) 87 | } 88 | 89 | build_specific () { 90 | echo -e "Building the following add-on(s): $@\n" 91 | for addon in "$@"; do 92 | build_addon "$addon" 93 | done 94 | } 95 | 96 | 97 | build_addon () { 98 | addon="$1" 99 | 100 | if [[ -z "${addon}" || -z "${src_path}" ]]; then 101 | echo "No add-on or source path supplied. Aborting." 102 | exit 1 103 | fi 104 | 105 | cd "${src_path}/${addon}" 106 | 107 | echo "Building .ankiaddon file for ${addon}." 108 | # Determine whether we're dealing with a single-file or multi-file add-on 109 | if [[ -f "__init__.py" ]]; then # single-file add-on 110 | if [[ ! -f "manifest.json" ]]; then 111 | echo "Manifest not found. Skipping ${addon}." 112 | ERRORS+=("${addon}") 113 | return 0 114 | fi 115 | # Anki 2.1: 116 | zip -FS -r $exclude_string "${ankiaddon_path}/${addon}.ankiaddon" * 117 | else 118 | # find module directory by looking at dirs containing initfile 119 | module_dir=$(find . -mindepth 2 -maxdepth 2 -name __init__.py -print0 | \ 120 | xargs -0 -n1 dirname | sort --unique) 121 | manifest=$(find . -mindepth 2 -maxdepth 2 -type f -name manifest.json) 122 | if [[ -z "$module_dir" || $(echo "module_dir" | wc -l) != 1 ]]; then 123 | echo "Unrecognized add-on directory format. Skipping ${addon}." 124 | ERRORS+=("${addon}") 125 | return 0 126 | fi 127 | if [[ -z "$manifest" ]]; then 128 | echo "Manifest not found. Skipping ${addon}." 129 | ERRORS+=("${addon}") 130 | return 0 131 | fi 132 | # Anki 2.1: 133 | cd "$module_dir" 134 | zip -FS -r $exclude_string "${ankiaddon_path}/${addon}.ankiaddon" * 135 | fi 136 | 137 | cd "${wd_path}" 138 | 139 | return 0 140 | } 141 | 142 | # Main 143 | 144 | if [[ "$ALL" == "false" && "$#" = "0" ]]; then 145 | echo "Error: no add-ons specified for '-f' option." 146 | exit 1 147 | fi 148 | 149 | if [[ ! -d "$src_path" ]]; then 150 | echo "Error: src directory not found. Exiting." 151 | exit 1 152 | fi 153 | 154 | # Compile exclude patterns 155 | exclude_string="" 156 | for pattern in "${excluded_patterns[@]}"; do 157 | exclude_string+="--exclude=${pattern} " 158 | done 159 | echo -e "Zip exclusion options: ${exclude_string}\n" 160 | 161 | # Create build and dist directories 162 | mkdir -p "${ankiaddon_path}" 163 | 164 | ERRORS=("") 165 | 166 | if [[ "$ALL" == "true" ]]; then 167 | build_all 168 | else 169 | build_specific "$@" 170 | fi 171 | 172 | echo -e "\nBuild complete." 173 | if [[ -n "${ERRORS[@]}" ]]; then 174 | echo "Errors where encountered while processing the following add-ons: ${ERRORS[@]}" 175 | fi 176 | -------------------------------------------------------------------------------- /tools/build_zips.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Buildscript for add-ons in glutanimate/anki-addons-misc 3 | # 4 | # Builds and prepares files for upload to AnkiWeb 5 | # 6 | # Compatible add-on structures: 7 | # 8 | # 1.) single-file add-on 9 | # 10 | # folder: addon_basename 11 | # → file: __init__.py 12 | # → file: addon_basename.py 13 | # → ?folder: docs 14 | # 15 | # 2.) multi-file 2.1-only add-on 16 | # 17 | # folder: addon_basename 18 | # → file: __init__.py 19 | # → ?file(s): *.py 20 | # → ?folder: docs 21 | # 22 | # 3.) multi-file add-on 23 | # 24 | # folder: addon_basename 25 | # → file: "Display Name.py" 26 | # → folder: module_name 27 | # → file: __init__.py 28 | # → ... 29 | # → ... 30 | # 31 | # Copyright: (c) 2017-2018 Glutanimate 32 | # License: GNU AGPLv3 33 | 34 | 35 | set -ue 36 | 37 | # Options 38 | excluded_patterns=("*__pycache__/*" "meta.json" "*.pyc" "*.pyo" "*.html" 39 | ".python-version" ".hidden" ".directory" "*.vscode/*" "*docs/*") 40 | ALL="true" # default: process all add-ons 41 | 42 | # Global variables 43 | wd_path="$PWD" 44 | src_path="${wd_path}/src" 45 | build_path="${wd_path}/build" 46 | dist20_path="${build_path}/dist20" 47 | dist21_path="${build_path}/dist21" 48 | 49 | 50 | USAGE=" 51 | ${0} [-afh] [] 52 | 53 | Options: 54 | -a: Build all add-ons [default if no other option provided] 55 | -f : Build add-ons that are passed in a space-separated list 56 | directory name inside the src folder 57 | -h: show this help section 58 | " 59 | 60 | 61 | # evaluate options 62 | while getopts "afh" OPTIONS; do 63 | case $OPTIONS in 64 | a ) ALL="true" 65 | ;; 66 | f ) ALL="false" 67 | ;; 68 | h ) echo "Usage: $USAGE" 69 | exit 0 70 | ;; 71 | \? ) echo "$USAGE" 72 | exit 1 73 | ;; 74 | esac 75 | done 76 | # remove options from arguments after processing is done 77 | shift $((OPTIND-1)) 78 | 79 | 80 | build_all () { 81 | echo -e "Building all 'anki-addons-misc' add-ons...\n" 82 | # Safely iterate over src directories and call build_addon() 83 | while IFS= read -d $'\0' -r addon_dir ; do 84 | base=$(basename "${addon_dir}") 85 | build_addon "${base}" 86 | done < <(find "${src_path}" -mindepth 1 -maxdepth 1 -type d -print0) 87 | } 88 | 89 | build_specific () { 90 | echo -e "Building the following add-on(s): $@\n" 91 | for addon in "$@"; do 92 | build_addon "$addon" 93 | done 94 | } 95 | 96 | 97 | build_addon () { 98 | addon="$1" 99 | 100 | if [[ -z "${addon}" || -z "${src_path}" ]]; then 101 | echo "No add-on or source path supplied. Aborting." 102 | exit 1 103 | fi 104 | 105 | cd "${src_path}/${addon}" 106 | 107 | echo "Building ${addon} for Anki 2.0 and 2.1..." 108 | # Determine whether we're dealing with a single-file or multi-file add-on 109 | if [[ -f "__init__.py" ]]; then # single-file add-on 110 | if [[ -f "${addon}.py" ]]; then 111 | # Anki 2.0: 112 | cp "${addon}.py" "${dist20_path}/" 113 | fi 114 | # Anki 2.1: 115 | zip -FS -r $exclude_string "${dist21_path}/${addon}.zip" * 116 | else 117 | # find entry file for importing module into Anki 2.0 118 | entry_file=$(find . -mindepth 1 -maxdepth 1 -type f -name "*.py") 119 | # find module directory by looking at dirs containing initfile 120 | module_dir=$(find . -mindepth 2 -maxdepth 2 -name __init__.py -print0 | \ 121 | xargs -0 -n1 dirname | sort --unique) 122 | if [[ -z "$entry_file" || -z "$module_dir" || $(echo "$entry_file" | wc -l) != 1 || $(echo "module_dir" | wc -l) != 1 ]]; then 123 | echo "Unrecognized add-on directory format. Skipping ${addon}." 124 | ERRORS+=("${addon}") 125 | return 0 126 | fi 127 | # Anki 2.0: 128 | zip -FS -r $exclude_string "${dist20_path}/${addon}-anki20.zip" * 129 | # Anki 2.1: 130 | cd "$module_dir" 131 | zip -FS -r $exclude_string "${dist21_path}/${addon}-anki21.zip" * 132 | fi 133 | 134 | cd "${wd_path}" 135 | 136 | return 0 137 | } 138 | 139 | # Main 140 | 141 | if [[ "$ALL" == "false" && "$#" = "0" ]]; then 142 | echo "Error: no add-ons specified for '-f' option." 143 | exit 1 144 | fi 145 | 146 | if [[ ! -d "$src_path" ]]; then 147 | echo "Error: src directory not found. Exiting." 148 | exit 1 149 | fi 150 | 151 | # Compile exclude patterns 152 | exclude_string="" 153 | for pattern in "${excluded_patterns[@]}"; do 154 | exclude_string+="--exclude=${pattern} " 155 | done 156 | echo -e "Zip exclusion options: ${exclude_string}\n" 157 | 158 | # Create build and dist directories 159 | mkdir -p "${dist20_path}" 160 | mkdir -p "${dist21_path}" 161 | 162 | ERRORS=("") 163 | 164 | if [[ "$ALL" == "true" ]]; then 165 | build_all 166 | else 167 | build_specific "$@" 168 | fi 169 | 170 | echo -e "\nBuild complete." 171 | if [[ -n "$ERRORS" ]]; then 172 | echo "Errors where encountered while processing the following add-ons: ${ERRORS[@]}" 173 | fi 174 | --------------------------------------------------------------------------------