# -*- coding: utf-8 -*- # AwesomeTTS text-to-speech add-on for Anki # Copyright (C) 2010-Present Anki AwesomeTTS Development Team # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. """ Add-on package initialization """ from os.path import join import os import sys from time import time from PyQt5.QtCore import PYQT_VERSION_STR, Qt from PyQt5.QtGui import QKeySequence import anki import aqt from . import conversion as to, gui, paths, service from .bundle import Bundle from .config import Config from .player import Player from .router import Router from .text import Sanitizer __all__ = ['browser_menus', 'cards_button', 'config_menu', 'editor_button', 'reviewer_hooks', 'sound_tag_delays', 'window_shortcuts'] def get_platform_info(): """Exception-tolerant platform information for use with AGENT.""" implementation = system_description = "???" python_version = "?.?.?" try: import platform except: # catch-all, pylint:disable=bare-except pass else: try: implementation = platform.python_implementation() except: # catch-all, pylint:disable=bare-except pass try: python_version = platform.python_version() except: # catch-all, pylint:disable=bare-except pass try: system_description = platform.platform().replace('-', ' ') except: # catch-all, pylint:disable=bare-except pass return "%s %s; %s" % (implementation, python_version, system_description) VERSION = '1.18.0' WEB = 'https://github.com/AwesomeTTS/awesometts-anki-addon' AGENT = 'AwesomeTTS/%s (Anki %s; PyQt %s; %s)' % (VERSION, anki.version, PYQT_VERSION_STR, get_platform_info()) # Begin core class initialization and dependency setup, pylint:disable=C0103 if 'AWESOMETTS_DEBUG_LOGGING' in os.environ and os.environ['AWESOMETTS_DEBUG_LOGGING'] == 'enable': import logging logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger('awesometts') logger.setLevel(logging.DEBUG) else: logger = Bundle(debug=lambda *a, **k: None, error=lambda *a, **k: None, info=lambda *a, **k: None, warn=lambda *a, **k: None) sequences = {key: QKeySequence() for key in ['browser_generator', 'browser_stripper', 'configurator', 'editor_generator', 'templater']} config = Config( db=Bundle(path=paths.CONFIG, table='general', normalize=to.normalized_ascii), cols=[ ('automaticAnswers', 'integer', True, to.lax_bool, int), ('automatic_answers_errors', 'integer', True, to.lax_bool, int), ('automaticQuestions', 'integer', True, to.lax_bool, int), ('automatic_questions_errors', 'integer', True, to.lax_bool, int), ('cache_days', 'integer', 365, int, int), ('delay_answers_onthefly', 'integer', 0, int, int), ('delay_answers_stored_ours', 'integer', 0, int, int), ('delay_answers_stored_theirs', 'integer', 0, int, int), ('delay_questions_onthefly', 'integer', 0, int, int), ('delay_questions_stored_ours', 'integer', 0, int, int), ('delay_questions_stored_theirs', 'integer', 0, int, int), ('ellip_note_newlines', 'integer', False, to.lax_bool, int), ('ellip_template_newlines', 'integer', False, to.lax_bool, int), ('extras', 'text', {}, to.deserialized_dict, to.compact_json), ('filenames', 'text', 'hash', str, str), ('filenames_human', 'text', '{{text}} ({{service}} {{voice}})', str, str), ('groups', 'text', {}, to.deserialized_dict, to.compact_json), ('lame_flags', 'text', '--quiet -q 2', str, str), ('last_mass_append', 'integer', True, to.lax_bool, int), ('last_mass_behavior', 'integer', True, to.lax_bool, int), ('last_mass_dest', 'text', 'Back', str, str), ('last_mass_source', 'text', 'Front', str, str), ('last_options', 'text', {}, to.deserialized_dict, to.compact_json), ('last_service', 'text', ('sapi5js' if 'win32' in sys.platform else 'say' if 'darwin' in sys.platform else 'yandex'), str, str), ('last_strip_mode', 'text', 'ours', str, str), ('launch_browser_generator', 'integer', Qt.ControlModifier | Qt.Key_T, to.nullable_key, to.nullable_int), ('launch_browser_stripper', 'integer', None, to.nullable_key, to.nullable_int), ('launch_configurator', 'integer', Qt.ControlModifier | Qt.Key_T, to.nullable_key, to.nullable_int), ('launch_editor_generator', 'integer', Qt.ControlModifier | Qt.Key_T, to.nullable_key, to.nullable_int), ('launch_templater', 'integer', Qt.ControlModifier | Qt.Key_T, to.nullable_key, to.nullable_int), ('otf_only_revealed_cloze', 'integer', False, to.lax_bool, int), ('otf_remove_hints', 'integer', False, to.lax_bool, int), ('presets', 'text', {}, to.deserialized_dict, to.compact_json), ('spec_note_count', 'text', '', str, str), ('spec_note_count_wrap', 'integer', True, to.lax_bool, int), ('spec_note_ellipsize', 'text', '', str, str), ('spec_note_strip', 'text', '', str, str), ('spec_template_count', 'text', '', str, str), ('spec_template_count_wrap', 'integer', True, to.lax_bool, int), ('spec_template_ellipsize', 'text', '', str, str), ('spec_template_strip', 'text', '', str, str), ('strip_note_braces', 'integer', False, to.lax_bool, int), ('strip_note_brackets', 'integer', False, to.lax_bool, int), ('strip_note_parens', 'integer', False, to.lax_bool, int), ('strip_template_braces', 'integer', False, to.lax_bool, int), ('strip_template_brackets', 'integer', False, to.lax_bool, int), ('strip_template_parens', 'integer', False, to.lax_bool, int), ('sub_note_cloze', 'text', 'anki', str, str), ('sub_template_cloze', 'text', 'anki', str, str), ('sul_note', 'text', [], to.substitution_list, to.substitution_json), ('sul_template', 'text', [], to.substitution_list, to.substitution_json), ('templater_cloze', 'integer', True, to.lax_bool, int), ('templater_field', 'text', 'Front', str, str), ('templater_hide', 'text', 'normal', str, str), ('templater_target', 'text', 'front', str, str), ('throttle_sleep', 'integer', 30, int, int), ('throttle_threshold', 'integer', 10, int, int), ('TTS_KEY_A', 'integer', Qt.Key_F4, to.nullable_key, to.nullable_int), ('TTS_KEY_Q', 'integer', Qt.Key_F3, to.nullable_key, to.nullable_int), ], logger=logger, events=[ ], ) try: from aqt.sound import av_player from anki.sound import SoundOrVideoTag def append_file(self, filename: str) -> None: self._enqueued.append(SoundOrVideoTag(filename=filename)) self._play_next_if_idle() anki.sound.play = lambda filename: append_file(av_player, filename) except ImportError: pass player = Player( anki=Bundle( mw=aqt.mw, native=anki.sound.play, # need direct reference, as this gets wrapped sound=anki.sound, # for accessing queue member, which is not wrapped ), blank=paths.BLANK, config=config, logger=logger, ) router = Router( services=Bundle( mappings=[ ('abair', service.Abair), ('azure', service.Azure), ('baidu', service.Baidu), ('cambridge', service.Cambridge), ('collins', service.Collins), ('duden', service.Duden), ('ekho', service.Ekho), ('espeak', service.ESpeak), ('festival', service.Festival), ('fluencynl', service.FluencyNl), ('google', service.Google), ('googletts', service.GoogleTTS), ('howjsay', service.Howjsay), ('imtranslator', service.ImTranslator), ('ispeech', service.ISpeech), ('naver', service.Naver), ('neospeech', service.NeoSpeech), ('oddcast', service.Oddcast), ('oxford', service.Oxford), ('pico2wave', service.Pico2Wave), ('rhvoice', service.RHVoice), ('sapi5com', service.SAPI5COM), ('sapi5js', service.SAPI5JS), ('say', service.Say), ('spanishdict', service.SpanishDict), ('voicetext', service.VoiceText), ('wiktionary', service.Wiktionary), ('yandex', service.Yandex), ('youdao', service.Youdao), ('forvo', service.Forvo), ], dead=dict( ttsapicom="TTS-API.com has gone offline and can no longer be " "used. Please switch to another service with English.", ), aliases=[('b', 'baidu'), ('g', 'google'), ('macosx', 'say'), ('microsoft', 'sapi5js'), ('microsoftjs', 'sapi5js'), ('microsoftjscript', 'sapi5js'), ('oed', 'oxford'), ('osx', 'say'), ('sapi', 'sapi5js'), ('sapi5', 'sapi5js'), ('sapi5jscript', 'sapi5js'), ('sapijs', 'sapi5js'), ('sapijscript', 'sapi5js'), ('svox', 'pico2wave'), ('svoxpico', 'pico2wave'), ('ttsapi', 'ttsapicom'), ('windows', 'sapi5js'), ('windowsjs', 'sapi5js'), ('windowsjscript', 'sapi5js'), ('y', 'yandex')], normalize=to.normalized_ascii, args=(), kwargs=dict(temp_dir=paths.TEMP, lame_flags=lambda: config['lame_flags'], normalize=to.normalized_ascii, logger=logger, ecosystem=Bundle(web=WEB, agent=AGENT)), ), cache_dir=paths.CACHE, temp_dir=join(paths.TEMP, '_awesometts_scratch_' + str(int(time()))), logger=logger, config=config, ) STRIP_TEMPLATE_POSTHTML = [ 'whitespace', 'sounds_univ', 'filenames', ('within_parens', 'strip_template_parens'), ('within_brackets', 'strip_template_brackets'), ('within_braces', 'strip_template_braces'), ('char_remove', 'spec_template_strip'), ('counter', 'spec_template_count', 'spec_template_count_wrap'), ('char_ellipsize', 'spec_template_ellipsize'), ('custom_sub', 'sul_template'), 'ellipses', 'whitespace', ] def bundlefail(message, text="Not available by addon.Bundle.downloader.fail"): aqt.utils.showCritical(message, aqt.mw) addon = Bundle( config=config, downloader=Bundle( base=aqt.addons.GetAddons, superbase=aqt.addons.GetAddons.__bases__[0], args=[aqt.mw], kwargs=dict(), attrs=dict( form=Bundle( code=Bundle(text=lambda: '301952613'), ), mw=aqt.mw, ), fail=bundlefail, ), logger=logger, paths=Bundle(cache=paths.CACHE, is_link=paths.ADDON_IS_LINKED), player=player, router=router, strip=Bundle( # n.b. cloze substitution logic happens first in both modes because: # - we need the <span>...</span> markup in on-the-fly to identify it # - Anki won't recognize cloze w/ HTML beginning/ending within braces # - the following 'html' rule will cleanse the HTML out anyway # for content directly from a note field (e.g. BrowserGenerator runs, # prepopulating a modal input based on some note field, where cloze # placeholders are still in their unprocessed state) from_note=Sanitizer([ ('clozes_braced', 'sub_note_cloze'), ('newline_ellipsize', 'ellip_note_newlines'), 'html', 'whitespace', 'sounds_univ', 'filenames', ('within_parens', 'strip_note_parens'), ('within_brackets', 'strip_note_brackets'), ('within_braces', 'strip_note_braces'), ('char_remove', 'spec_note_strip'), ('counter', 'spec_note_count', 'spec_note_count_wrap'), ('char_ellipsize', 'spec_note_ellipsize'), ('custom_sub', 'sul_note'), 'ellipses', 'whitespace', ], config=config, logger=logger), # for cleaning up already-processed HTML templates (e.g. on-the-fly, # where cloze is marked with <span class=cloze></span> tags) from_template_front=Sanitizer([ ('clozes_rendered', 'sub_template_cloze'), 'hint_links', ('hint_content', 'otf_remove_hints'), ('newline_ellipsize', 'ellip_template_newlines'), 'html', ] + STRIP_TEMPLATE_POSTHTML, config=config, logger=logger), # like the previous, but for the back sides of cards from_template_back=Sanitizer([ ('clozes_revealed', 'otf_only_revealed_cloze'), 'hint_links', ('hint_content', 'otf_remove_hints'), ('newline_ellipsize', 'ellip_template_newlines'), 'html', ] + STRIP_TEMPLATE_POSTHTML, config=config, logger=logger), # for cleaning up text from unknown sources (e.g. system clipboard); # n.b. clozes_revealed is not used here without the card context and # it would be a weird thing to apply to the clipboard content anyway from_unknown=Sanitizer([ ('clozes_braced', 'sub_note_cloze'), ('clozes_rendered', 'sub_template_cloze'), 'hint_links', ('hint_content', 'otf_remove_hints'), ('newline_ellipsize', 'ellip_note_newlines'), ('newline_ellipsize', 'ellip_template_newlines'), 'html', 'html', # clipboards often have escaped HTML, so we run twice 'whitespace', 'sounds_univ', 'filenames', ('within_parens', ['strip_note_parens', 'strip_template_parens']), ('within_brackets', ['strip_note_brackets', 'strip_template_brackets']), ('within_braces', ['strip_note_braces', 'strip_template_braces']), ('char_remove', 'spec_note_strip'), ('char_remove', 'spec_template_strip'), ('counter', 'spec_note_count', 'spec_note_count_wrap'), ('counter', 'spec_template_count', 'spec_template_count_wrap'), ('char_ellipsize', 'spec_note_ellipsize'), ('char_ellipsize', 'spec_template_ellipsize'), ('custom_sub', 'sul_note'), ('custom_sub', 'sul_template'), 'ellipses', 'whitespace', ], config=config, logger=logger), # for direct user input (e.g. previews, EditorGenerator insertion) from_user=Sanitizer(rules=['ellipses', 'whitespace'], logger=logger), # target sounds specifically sounds=Bundle( # using Anki's method (used if we need to reproduce how Anki does # something, e.g. when Reviewer emulates {{FrontSide}}) anki=anki.sound.stripSounds, # using AwesomeTTS's methods (which have access to precompiled re # objects, usable for everything else, e.g. when BrowserGenerator # or BrowserStripper need to remove old sounds) ours=Sanitizer(rules=['sounds_ours', 'filenames'], logger=logger), theirs=Sanitizer(rules=['sounds_theirs'], logger=logger), univ=Sanitizer(rules=['sounds_univ', 'filenames'], logger=logger), ), ), version=VERSION, web=WEB, ) # End core class initialization and dependency setup, pylint:enable=C0103 # GUI interaction with Anki # n.b. be careful wrapping methods that have return values (see anki.hooks); # in general, only the 'before' mode absolves us of responsibility # These are all called manually from the __init__.py loader so that if there # is some sort of breakage with a specific component, it could be possibly # disabled easily by users who are not utilizing that functionality. def browser_menus(): """ Gives user access to mass generator, MP3 stripper, and the hook that disables and enables it upon selection of items. """ from PyQt5 import QtWidgets def on_setup_menus(browser): """Create an AwesomeTTS menu and add browser actions to it.""" menu = QtWidgets.QMenu("Awesome&TTS", browser.form.menubar) browser.form.menubar.addMenu(menu) gui.Action( target=Bundle( constructor=gui.BrowserGenerator, args=(), kwargs=dict(browser=browser, addon=addon, alerts=aqt.utils.showWarning, ask=aqt.utils.getText, parent=browser), ), text="&Add Audio to Selected...", sequence=sequences['browser_generator'], parent=menu, ) gui.Action( target=Bundle( constructor=gui.BrowserStripper, args=(), kwargs=dict(browser=browser, addon=addon, alerts=aqt.utils.showWarning, parent=browser), ), text="&Remove Audio from Selected...", sequence=sequences['browser_stripper'], parent=menu, ) def update_title_wrapper(browser): """Enable/disable AwesomeTTS menu items upon selection.""" enabled = bool(browser.form.tableView.selectionModel().selectedRows()) for action in browser.findChildren(gui.Action): action.setEnabled(enabled) anki.hooks.addHook( 'browser.setupMenus', on_setup_menus, ) aqt.browser.Browser.updateTitle = anki.hooks.wrap( aqt.browser.Browser.updateTitle, update_title_wrapper, 'before', ) def cache_control(): """Registers a hook to handle cache control on session exits.""" def on_unload_profile(): """ Finds MP3s in the cache directory older than the user's configured cache limit and attempts to remove them. """ from os import listdir, unlink cache = paths.CACHE try: filenames = listdir(cache) except: # allow silent failure, pylint:disable=bare-except return if not filenames: return prospects = (join(cache, filename) for filename in filenames) if config['cache_days']: from os.path import getmtime limit = time() - 86400 * config['cache_days'] targets = (prospect for prospect in prospects if getmtime(prospect) < limit) else: targets = prospects for target in targets: try: unlink(target) except: # skip broken files, pylint:disable=bare-except pass anki.hooks.addHook('unloadProfile', on_unload_profile) def cards_button(): """Provides access to the templater helper.""" from aqt import clayout clayout.CardLayout.setupButtons = anki.hooks.wrap( clayout.CardLayout.setupButtons, lambda card_layout: card_layout.buttons.insertWidget( # Now, the card layout for regular notes has 7 buttons/stretchers # and the one for cloze notes has 6 (as it lacks a "Flip" button); # position 3 puts our button after "Add Field", but in the event # that the form suddenly has a different number of buttons, let's # just fallback to the far left position 3 if card_layout.buttons.count() in [6, 7] else 0, gui.Button( text="Add &TTS", tooltip="Insert a tag for on-the-fly playback w/ AwesomeTTS", sequence=sequences['templater'], target=Bundle( constructor=gui.Templater, args=(), kwargs=dict(card_layout=card_layout, addon=addon, alerts=aqt.utils.showWarning, ask=aqt.utils.getText, parent=card_layout), ), ), ), 'after', # must use 'after' so that 'buttons' attribute is set ) def config_menu(): """ Adds a menu item to the Tools menu in Anki's main window for launching the configuration dialog. """ gui.Action( target=Bundle( constructor=gui.Configurator, args=(), kwargs=dict(addon=addon, sul_compiler=to.substitution_compiled, alerts=aqt.utils.showWarning, ask=aqt.utils.getText, parent=aqt.mw), ), text="Awesome&TTS...", sequence=sequences['configurator'], parent=aqt.mw.form.menuTools, ) def editor_button(): """ Enable the generation of a single audio clip through the editor, which is present in the "Add" and browser windows. """ anki.hooks.addHook( 'setupEditorButtons', lambda buttons, editor: gui.HTMLButton( buttons, editor, link_id='awesometts_btn', tooltip="Record and insert an audio clip here w/ AwesomeTTS", sequence=sequences['editor_generator'], target=Bundle( constructor=gui.EditorGenerator, args=(), kwargs=dict(editor=editor, addon=addon, alerts=aqt.utils.showWarning, ask=aqt.utils.getText, parent=editor.parentWindow), ) ).buttons ) anki.hooks.addHook( 'setupEditorShortcuts', lambda shortcuts, editor: shortcuts.append( ( sequences['editor_generator'].toString(), editor._links['awesometts_btn'] ) ) ) # TODO: Editor buttons are now in the WebView, not sure how (and if) # we should implement muzzling. Please see: # https://github.com/dae/anki/commit/a001553f66efe75e660eb0702cd29a9d62503fc4 """ aqt.editor.Editor.enableButtons = anki.hooks.wrap( aqt.editor.Editor.enableButtons, lambda editor, val=True: ( editor.widget.findChild(gui.Button).setEnabled(val), # Temporarily disable any AwesomeTTS menu shortcuts in the Browser # window so that if a shortcut combination has been re-used # between the editor button and those, the "local" shortcut works. # Has no effect on "Add" window (the child list will be empty). [action.muzzle(val) for action in editor.parentWindow.findChildren(gui.Action)], ), 'before', ) """ def reviewer_hooks(): """ Enables support for AwesomeTTS to automatically play text-to-speech tags and to also do playback on-demand via shortcut keys and the context menu. """ from PyQt5.QtCore import QEvent from PyQt5.QtWidgets import QMenu reviewer = gui.Reviewer(addon=addon, alerts=aqt.utils.showWarning, mw=aqt.mw) # automatic playback anki.hooks.addHook( 'showQuestion', lambda: reviewer.card_handler('question', aqt.mw.reviewer.card), ) anki.hooks.addHook( 'showAnswer', lambda: reviewer.card_handler('answer', aqt.mw.reviewer.card), ) # shortcut-triggered playback reviewer_filter = gui.Filter( relay=lambda event: reviewer.key_handler( key_event=event, state=aqt.mw.reviewer.state, card=aqt.mw.reviewer.card, replay_audio=aqt.mw.reviewer.replayAudio, ), when=lambda event: (aqt.mw.state == 'review' and event.type() == QEvent.KeyPress and not event.isAutoRepeat() and not event.spontaneous()), parent=aqt.mw, # prevents filter from being garbage collected ) aqt.mw.installEventFilter(reviewer_filter) # context menu playback strip = Sanitizer([('newline_ellipsize', 'ellip_template_newlines')] + STRIP_TEMPLATE_POSTHTML, config=config, logger=logger) def on_context_menu(web_view, menu): """Populate context menu, given the context/configuration.""" window = web_view.window() try: # this works for web views embedded in editor windows atts_button = web_view.editor.widget.findChild(gui.Button) except AttributeError: atts_button = None say_text = config['presets'] and strip(web_view.selectedText()) tts_card = tts_side = None tts_shortcuts = False try: # this works for web views in the reviewer and template dialog if window is aqt.mw and aqt.mw.state == 'review': tts_card = aqt.mw.reviewer.card tts_side = aqt.mw.reviewer.state tts_shortcuts = True elif web_view.objectName() == 'mainText': # card template dialog parent_name = web_view.parentWidget().objectName() tts_card = window.card tts_side = ('question' if parent_name == 'groupBox' else 'answer' if parent_name == 'groupBox_2' else None) except Exception: # just in case, pylint:disable=broad-except pass tts_question = tts_card and tts_side and \ reviewer.has_tts('question', tts_card) tts_answer = tts_card and tts_side == 'answer' and \ reviewer.has_tts('answer', tts_card) if not (atts_button or say_text or tts_question or tts_answer): return submenu = QMenu("Awesome&TTS", menu) submenu.setIcon(gui.ICON) needs_separator = False if atts_button: submenu.addAction( "Add MP3 to the Note", lambda: atts_button.click() if atts_button.isEnabled() else aqt.utils.showWarning( "Select the note field to which you want to add an MP3.", window, ) ) needs_separator = True if say_text: say_display = (say_text if len(say_text) < 25 else say_text[0:20].rstrip(' .') + "...") if config['presets']: if needs_separator: submenu.addSeparator() else: needs_separator = True def preset_glue(xxx_todo_changeme): """Closure for callback handler to access `preset`.""" (name, preset) = xxx_todo_changeme submenu.addAction( 'Say "%s" w/ %s' % (say_display, name), lambda: reviewer.selection_handler(say_text, preset, window), ) for item in sorted(config['presets'].items(), key=lambda item: item[0].lower()): preset_glue(item) if config['groups']: if needs_separator: submenu.addSeparator() else: needs_separator = True def group_glue(xxx_todo_changeme1): """Closure for callback handler to access `group`.""" (name, group) = xxx_todo_changeme1 submenu.addAction( 'Say "%s" w/ %s' % (say_display, name), lambda: reviewer.selection_handler_group(say_text, group, window), ) for item in sorted(config['groups'].items(), key=lambda item: item[0].lower()): group_glue(item) if tts_question or tts_answer: if needs_separator: submenu.addSeparator() if tts_question: submenu.addAction( "Play On-the-Fly TTS from Question Side", lambda: reviewer.nonselection_handler('question', tts_card, window), tts_shortcuts and config['tts_key_q'] or 0, ) if tts_answer: submenu.addAction( "Play On-the-Fly TTS from Answer Side", lambda: reviewer.nonselection_handler('answer', tts_card, window), tts_shortcuts and config['tts_key_a'] or 0, ) menu.addMenu(submenu) anki.hooks.addHook('AnkiWebView.contextMenuEvent', on_context_menu) anki.hooks.addHook('EditorWebView.contextMenuEvent', on_context_menu) anki.hooks.addHook('Reviewer.contextMenuEvent', lambda reviewer, menu: on_context_menu(reviewer.web, menu)) def sound_tag_delays(): """ Enables support for the following sound delay configuration options: - delay_questions_stored_ours (AwesomeTTS MP3s on questions) - delay_questions_stored_theirs (non-AwesomeTTS MP3s on questions) - delay_answers_stored_ours (AwesomeTTS MP3s on answers) - delay_answers_stored_theirs (non-AwesomeTTS MP3s on answers) """ anki.sound.play = player.native_wrapper def temp_files(): """Remove temporary files upon session exit.""" def on_unload_profile(): """ Finds scratch directories in the temporary path, removes their files, then removes the directories themselves. """ from os import listdir, unlink, rmdir from os.path import isdir temp = paths.TEMP try: subdirs = [join(temp, filename) for filename in listdir(temp) if filename.startswith('_awesometts_scratch')] except: # allow silent failure, pylint:disable=bare-except return if not subdirs: return for subdir in subdirs: if isdir(subdir): for filename in listdir(subdir): try: unlink(join(subdir, filename)) except: # skip busy files, pylint:disable=bare-except pass try: rmdir(subdir) except: # allow silent failure, pylint:disable=bare-except pass anki.hooks.addHook('unloadProfile', on_unload_profile) def window_shortcuts(): """Enables shortcuts to launch windows.""" def on_sequence_change(new_config): """Update sequences on configuration changes.""" for key, sequence in sequences.items(): new_sequence = QKeySequence(new_config['launch_' + key] or None) sequence.swap(new_sequence) try: aqt.mw.form.menuTools.findChild(gui.Action). \ setShortcut(sequences['configurator']) except AttributeError: # we do not have a config menu pass on_sequence_change(config) # set config menu if created before we ran config.bind(['launch_' + key for key in sequences.keys()], on_sequence_change)