diff --git a/src/__init__.py b/src/__init__.py index e4a1192..9688b87 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,75 +1,128 @@ +from dataclasses import dataclass from platform import platform import sys import os -import platform import aqt from aqt.qt import * import anki +from .config import get +from .sys_libraries import init_sys_libs -# insert librairies into sys.path - - -def add_sys_path(*path_parts): - sys.path.insert( - 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib", *path_parts) - ) - - -add_sys_path("shared") -if anki.utils.is_lin: - add_sys_path("linux") -elif anki.utils.is_mac and sys.version_info.major >= 3 and sys.version_info.minor >= 11: - add_sys_path("macos_311") -elif anki.utils.is_mac and sys.version_info.major >= 3 and sys.version_info.minor >= 10: - add_sys_path("macos_310") -elif anki.utils.is_mac: - add_sys_path("macos_39") -elif anki.utils.is_win: - add_sys_path("windows") - - -# Allow webviews to access necessary resources -aqt.mw.addonManager.setWebExports(__name__, r"(languages/.*?\.svg|inplace_editor.css)") +init_sys_libs() # Initialize sub modules from . import ( - note_type_dialogs, + anki_version, + browser, + card_layout, card_type_selector, - note_type_mgr, - reviewer, + click_play_audio, editor, inplace_editor, - click_play_audio, - browser, - card_layout, - welcome_wizard, + migaku_connection, + note_type_dialogs, + note_type_mgr, + reviewer, + toolbar, webview_contextmenu, - settings_window, - ease_reset, - retirement, - balance_scheduler, - balance_scheduler_vacation_window, - balance_scheduler_dayoff_window, - anki_version, + welcome_wizard, + menu, ) -from . import migaku_connection +def setup_hooks(): + # Allow webviews to access necessary resources + aqt.mw.addonManager.setWebExports( + __name__, r"(languages/.*?\.svg|inplace_editor.css)" + ) + + aqt.gui_hooks.models_did_init_buttons.append(note_type_dialogs.setup_note_editor) + aqt.editor.Editor.onBridgeCmd = anki.hooks.wrap( + aqt.editor.Editor.onBridgeCmd, editor.on_migaku_bridge_cmds, "around" + ) + aqt.gui_hooks.editor_did_init.append(editor.editor_did_init) + aqt.gui_hooks.editor_did_load_note.append(editor.editor_did_load_note) + aqt.gui_hooks.editor_did_init_buttons.append(editor.setup_editor_buttons) + + # DECK CHANGE + def deck_change(id): + editor.current_editor.on_addcards_did_change_deck(id) + toolbar.refresh_migaku_toolbar() + + if getattr(aqt.gui_hooks, "add_cards_did_change_deck", None): + aqt.gui_hooks.add_cards_did_change_deck.append(deck_change) + else: + aqt.addcards.AddCards.on_deck_changed = anki.hooks.wrap( + aqt.addcards.AddCards.on_deck_changed, + lambda _, id: deck_change(id), + "before", + ) + + ### MODEL CHANGE + def notetype_change(id): + editor.current_editor.on_addcards_did_change_note_type(id) + toolbar.refresh_migaku_toolbar() + + if getattr(aqt.gui_hooks, "add_cards_did_change_note_type", None): + aqt.gui_hooks.add_cards_did_change_note_type.append( + lambda _, id: notetype_change(id) + ) + elif getattr(aqt.addcards.AddCards, "on_notetype_change", None): + aqt.addcards.AddCards.on_notetype_change = anki.hooks.wrap( + aqt.addcards.AddCards.on_notetype_change, + lambda _, id: notetype_change(id), + ) + else: + aqt.addcards.AddCards.onModelChange = anki.hooks.wrap( + aqt.addcards.AddCards.onModelChange, + notetype_change, + ) + + aqt.gui_hooks.editor_did_init.append(editor.current_editor.set_current_editor) + aqt.editor.Editor.cleanup = anki.hooks.wrap( + aqt.editor.Editor.cleanup, editor.current_editor.remove_editor, "before" + ) + + aqt.gui_hooks.profile_did_open.append(note_type_mgr.update_all_installed) + + aqt.gui_hooks.collection_did_load.append(toolbar.inject_migaku_toolbar) + + @dataclass + class Defaults: + notetype_id: int + deck_id: int + + def set_current_deck_model_to_migaku(current_review_card): + toolbar.set_deck_type_to_migaku() + return Defaults( + get("migakuNotetypeId"), + get("migakuDeckId"), + ) + + def overwrite_defaults_for_adding(col): + col.defaults_for_adding = set_current_deck_model_to_migaku + aqt.gui_hooks.collection_did_load.append(overwrite_defaults_for_adding) + + aqt.addcards.AddCards.on_notetype_change = anki.hooks.wrap( + aqt.addcards.AddCards.on_notetype_change, + lambda addcards, _1: editor.reset_migaku_mode(addcards.editor), + "before", + ) + + aqt.gui_hooks.add_cards_did_init.append( + lambda _: toolbar.refresh_migaku_toolbar_opened_addcards() + ) -def setup_menu(): - menu = QMenu("Migaku", aqt.mw) - menu.addAction(settings_window.action) - menu.addSeparator() - menu.addAction(ease_reset.action) - menu.addAction(retirement.action) - menu.addAction(balance_scheduler.action) - menu.addAction(balance_scheduler_vacation_window.action) - menu.addAction(balance_scheduler_dayoff_window.action) - aqt.mw.form.menubar.insertMenu(aqt.mw.form.menuHelp.menuAction(), menu) + # aqt.deckbrowser.DeckBrowser.set_current_deck = anki.hooks.wrap( + # aqt.deckbrowser.DeckBrowser.set_current_deck, + # lambda self, deck_id: toolbar.refresh_migaku_toolbar(), + # "after", + # ) -setup_menu() +menu.setup_menu() +setup_hooks() anki_version.check_anki_version_dialog() diff --git a/src/card_types.py b/src/card_types.py new file mode 100644 index 0000000..427edf4 --- /dev/null +++ b/src/card_types.py @@ -0,0 +1,94 @@ +from base64 import b64decode +from dataclasses import dataclass, field +from typing import Optional +import requests + +from .migaku_connection.handle_files import move_file_to_media_dir + + +@dataclass +class AudioAsset: + id: str + title: str + input: str + r2Url: Optional[str] = None + + +@dataclass +class ImageAsset: + id: str + title: str + src: str + alt: str + r2Url: Optional[str] = None + + +@dataclass +class CardFields: + targetWord: str = "" + sentence: str = "" + translation: str = "" + definitions: str = "" + sentenceAudio: str = "" + wordAudio: str = "" + images: str = "" + exampleSentences: str = "" + notes: str = "" + + +def process_image_asset(image: ImageAsset): + name = f"{image.id}.webp" + + if image.src.startswith("data:image/webp;base64,"): + data = image.src.split(",", 1)[1] + move_file_to_media_dir(b64decode(data), name) + elif image.input.startswith("http"): + data = requests.get(image.src, allow_redirects=True) + move_file_to_media_dir(data.content, name) + + return f"" + + +def process_audio_asset(audio: AudioAsset): + name = f"{audio.id}.m4a" + + if audio.input.startswith("data:audio/mp4;base64,"): + data = audio.input.split(",", 1)[1] + move_file_to_media_dir(b64decode(data), name) + elif audio.input.startswith("http"): + data = requests.get(audio.input, allow_redirects=True) + move_file_to_media_dir(data.content, name) + + return f"[sound:{name}]" + + +def card_fields_from_dict(data: dict[str, any]): + br = "\n
\n" + + sentenceAudios = br.join( + [ + process_audio_asset(AudioAsset(**audio)) + for audio in data.get("sentenceAudio", []) + ] + ) + wordAudios = br.join( + [ + process_audio_asset(AudioAsset(**audio)) + for audio in data.get("wordAudio", []) + ] + ) + imagess = br.join( + [process_image_asset(ImageAsset(**img)) for img in data.get("images", [])] + ) + + return CardFields( + targetWord=data.get("targetWord", ""), + sentence=data.get("sentence", ""), + translation=data.get("translation", ""), + definitions=data.get("definitions", ""), + sentenceAudio=sentenceAudios, + wordAudio=wordAudios, + images=imagess, + exampleSentences=data.get("exampleSentences", ""), + notes=data.get("notes", ""), + ) diff --git a/src/editor.py b/src/editor/__init__.py similarity index 50% rename from src/editor.py rename to src/editor/__init__.py index 1a4a5b5..98b026c 100644 --- a/src/editor.py +++ b/src/editor/__init__.py @@ -1,20 +1,24 @@ import os -from typing import List +import re import json +from typing import List import aqt +import anki from aqt.editor import Editor -from . import note_type_mgr -from . import util -from .util import addon_path +from ..config import get, set +from ..languages import Language +from ..note_type_mgr import nt_get_lang +from ..util import addon_path, show_critical +from ..migaku_fields import get_migaku_fields def editor_get_lang(editor: Editor): if editor.note: nt = editor.note.note_type() if nt: - return note_type_mgr.nt_get_lang(nt) + return nt_get_lang(nt) return None @@ -27,7 +31,7 @@ def editor_generate_syntax(editor: Editor): return if not aqt.mw.migaku_connection.is_connected(): - util.show_critical("Anki is not connected to the Browser Extension.") + show_critical("Anki is not connected to the Browser Extension.") return note_id = editor.note.id @@ -54,7 +58,7 @@ def handle_syntax(syntax_data): [{note_id_key: text}], lang.code, on_done=handle_syntax, - on_error=lambda msg: util.show_critical(msg, parent=editor.parentWindow), + on_error=lambda msg: show_critical(msg, parent=editor.parentWindow), callback_on_main_thread=True, timeout=10, ) @@ -86,6 +90,11 @@ def do_edit(): editor.call_after_note_saved(callback=do_edit, keepFocus=True) +def toggle_migaku_mode(editor: Editor): + data = get_migaku_fields(editor.note.note_type()) + editor.web.eval(f"MigakuEditor.toggleMode({json.dumps(data)});") + + def setup_editor_buttons(buttons: List[str], editor: Editor): added_buttons = [ editor.addButton( @@ -106,35 +115,71 @@ def setup_editor_buttons(buttons: List[str], editor: Editor): ), ] + if editor.addMode: + added_buttons.append( + editor.addButton( + label="Field Maps", + icon=None, + id="migaku_btn_toggle_mode", + cmd="migaku_toggle_mode", + toggleable=True, + disables=False, + func=toggle_migaku_mode, + ) + ) + buttons[0:0] = added_buttons return buttons -aqt.gui_hooks.editor_did_init_buttons.append(setup_editor_buttons) +def editor_get_js_by_lang(lang: Language): + add_icon_path = lang.web_uri("icons", "generate.svg") + remove_icon_path = lang.web_uri("icons", "remove.svg") + no_icon_invert = os.path.exists(lang.file_path("icons", "no_invert")) + img_filter = "invert(0)" if no_icon_invert else "" + return f"MigakuEditor.initButtons('{add_icon_path}', '{remove_icon_path}', '{img_filter}');" -def editor_note_changed(editor: Editor): + +def editor_did_load_note(editor: Editor): lang = editor_get_lang(editor) - if lang is None: - js = """ - document.getElementById('migaku_btn_syntax_generate').style.display = 'none'; - document.getElementById('migaku_btn_syntax_remove').style.display = 'none'; - """ - else: - add_icon_path = lang.web_uri("icons", "generate.svg") - remove_icon_path = lang.web_uri("icons", "remove.svg") - no_icon_invert = os.path.exists(lang.file_path("icons", "no_invert")) - img_filter = "invert(0)" if no_icon_invert else "" - - js = f""" - document.querySelector('#migaku_btn_syntax_generate img').src = '{add_icon_path}'; - document.querySelector('#migaku_btn_syntax_generate img').style.filter = '{img_filter}'; - document.querySelector('#migaku_btn_syntax_remove img').src = '{remove_icon_path}'; - document.querySelector('#migaku_btn_syntax_remove img').style.filter = '{img_filter}'; - document.getElementById('migaku_btn_syntax_generate').style.display = ''; - document.getElementById('migaku_btn_syntax_remove').style.display = ''; - """ + js = "MigakuEditor.hideButtons();" if lang is None else editor_get_js_by_lang(lang) + editor.web.eval(js) -aqt.gui_hooks.editor_did_load_note.append(editor_note_changed) +def on_migaku_bridge_cmds(self: Editor, cmd: str, _old): + if cmd.startswith("migakuIntercept"): + (_, value) = cmd.split(":", 1) + set("migakuIntercept", True if value == "true" else False, do_write=True) + + elif cmd.startswith("migakuSelectChange"): + (_, migaku_type, field_name) = cmd.split(":", 2) + migakuFields = get("migakuFields", {}) + + set( + "migakuFields", + { + **migakuFields, + self.note.mid: { + **( + migakuFields[self.note.mid] + if self.note.mid in migakuFields + else {} + ), + field_name: migaku_type, + }, + }, + do_write=True, + ) + else: + _old(self, cmd) + + +def editor_did_init(editor: Editor): + with open(addon_path("editor/editor.js"), "r", encoding="utf-8") as editor_file: + editor.web.eval(editor_file.read()) + + +def reset_migaku_mode(editor: Editor): + editor.web.eval(f"MigakuEditor.resetMigakuEditor();") diff --git a/src/editor/current_editor.py b/src/editor/current_editor.py new file mode 100644 index 0000000..4d84a80 --- /dev/null +++ b/src/editor/current_editor.py @@ -0,0 +1,161 @@ +from anki.hooks import wrap +from anki.notes import Note +from aqt.editor import Editor +import aqt +from ..config import get +from ..card_types import CardFields +from ..migaku_fields import get_migaku_fields + +current_editors = [] + + +def set_current_editor(editor: Editor): + global current_editors + remove_editor(editor) + current_editors.append(editor) + + +def remove_editor(editor: Editor): + global current_editors + current_editors = [e for e in current_editors if e != editor] + + +def get_current_editor() -> Editor: + if len(current_editors) > 0: + return current_editors[-1] + return None + + +def get_current_note_info() -> Note: + for editor in reversed(current_editors): + if editor.note: + return { + "note": editor.note, + "editor": editor, + } + + if aqt.mw.reviewer and aqt.mw.reviewer.card: + note = aqt.mw.reviewer.card.note() + if note: + return { + "note": note, + "reviewer": aqt.mw.reviewer, + } + return None + + +def get_add_cards() -> Note: + for editor in reversed(current_editors): + if editor.addMode: + return { + "addcards": editor.parentWindow, + "note": editor.note, + "editor": editor, + } + + return None + + +current_note_type_id = 0 +current_deck_id = 0 + + +def add_cards_add_to_history(note): + addcards = get_add_cards() + + if not addcards: + return + + addcards["addcards"].addHistory(note) + + +def get_add_cards_info(defaults=None): + addcards = get_add_cards() + + if addcards and defaults: + note = addcards["note"] + tags = note.tags + + notetype_id = defaults.notetype_id + notetype = aqt.mw.col.models.get(notetype_id) + notetype_name = notetype["name"] + + deck_id = defaults.deck_id + deck = aqt.mw.col.decks.get(deck_id) + deck_name = deck["name"] + + fields = get_migaku_fields(notetype) + + elif addcards: + note = addcards["note"] + tags = note.tags + notetype = note.note_type() + + fields = get_migaku_fields(notetype) + notetype_name = notetype["name"] + notetype_id = notetype["id"] + + deck_id = get_current_deck_id() + deck = aqt.mw.col.decks.get(deck_id) + deck_name = deck["name"] + + else: + notetype_id = int(get("migakuNotetypeId", aqt.mw.col.get_config("curModel"))) + notetype = aqt.mw.col.models.get(notetype_id) + fields = get_migaku_fields(notetype) + notetype_name = notetype["name"] + + deck_id = int(get("migakuDeckId", aqt.mw.col.get_config("curDeck"))) + deck = aqt.mw.col.decks.get(deck_id) + deck_name = deck["name"] + tags = [] + + return { + "fields": fields, + "notetype": notetype, + "notetype_name": notetype_name, + "notetype_id": notetype_id, + "deck": deck, + "deck_name": deck_name, + "deck_id": deck_id, + "tags": tags, + } + + +def map_to_add_cards(card: CardFields): + addcards = get_add_cards() + + if not addcards: + return False + + info = get_add_cards_info() + note = addcards["note"] + fields = info["fields"] + + for fieldname, type in fields.items(): + if type == "none": + continue + + note[fieldname] = str(getattr(card, type)) + + aqt.mw.taskman.run_on_main(addcards["editor"].loadNoteKeepingFocus) + + return True + + +def on_addcards_did_change_note_type(new_id): + global current_note_type_id + current_note_type_id = new_id + + +def on_addcards_did_change_deck(new_id): + global current_deck_id + current_deck_id = new_id + + +def get_current_note_type_id(): + return current_note_type_id + + +def get_current_deck_id(): + return current_deck_id diff --git a/src/editor/editor.js b/src/editor/editor.js new file mode 100644 index 0000000..9ce2073 --- /dev/null +++ b/src/editor/editor.js @@ -0,0 +1,141 @@ +function MigakuEditor() { } + +/** + * @param {string} add_icon_path + * @param {string} remove_icon_path + * @param {string} img_filter + */ +MigakuEditor.initButtons = function (add_icon_path, remove_icon_path, img_filter) { + document.querySelector('#migaku_btn_syntax_generate img').src = add_icon_path; + document.querySelector('#migaku_btn_syntax_generate img').style.filter = img_filter; + document.querySelector('#migaku_btn_syntax_remove img').src = remove_icon_path; + document.querySelector('#migaku_btn_syntax_remove img').style.filter = img_filter; + document.getElementById('migaku_btn_syntax_generate').style.display = ''; + document.getElementById('migaku_btn_syntax_remove').style.display = ''; +} + +MigakuEditor.hideButtons = function () { + if (!document.getElementById('migaku_btn_syntax_generate')) setTimeout(() => { + document.getElementById('migaku_btn_syntax_generate').style.display = 'none'; + document.getElementById('migaku_btn_syntax_remove').style.display = 'none'; + }, 100) + else { + document.getElementById('migaku_btn_syntax_generate').style.display = 'none'; + document.getElementById('migaku_btn_syntax_remove').style.display = 'none'; + } +} + +/** These are the values on CardFields */ +const selectorOptions = [ + { value: 'none', text: '(None)' }, + { value: 'sentence', text: 'Sentence' }, + { value: 'targetWord', text: 'Word' }, + { value: 'translation', text: 'Sentence Translation' }, + { value: 'sentenceAudio', text: 'Sentence Audio' }, + { value: 'wordAudio', text: 'Word Audio' }, + { value: 'images', text: 'Image' }, + { value: 'definitions', text: 'Definitions' }, + { value: 'exampleSentences', text: 'Example sentences' }, + { value: 'notes', text: 'Notes' }, +] + +function getSelectorField(editorField, settings) { + const field = document.createElement('div'); + field.classList.add('migaku-field-selector'); + + const select = document.createElement('select'); + select.style.margin = '2px'; + field.append(select); + + for (const option of selectorOptions) { + const optionElement = document.createElement('option'); + optionElement.value = option.value; + optionElement.text = option.text; + select.append(optionElement); + } + + const fieldContainer = editorField.parentElement.parentElement + const labelName = fieldContainer.querySelector('.label-name').innerText + select.value = settings[labelName] ?? 'none' + + select.addEventListener('change', (selectTarget) => { + const cmd = `migakuSelectChange:${selectTarget.currentTarget.value}:${labelName}` + bridgeCommand(cmd) + }) + + return field +} + +const hiddenButtonCategories = [ + 'settings', + 'inlineFormatting', + 'blockFormatting', + 'template', + 'cloze', + 'image-occlusion-button', +] + +// New Migaku Editor +function setupMigakuEditor(settings) { + document.querySelectorAll('.editing-area').forEach((field) => field.style.display = 'none'); + document.querySelectorAll('.plain-text-badge').forEach((field) => field.style.display = 'none'); + document.querySelectorAll('svg#mdi-pin-outline').forEach((field) => field.parentElement.parentElement.parentElement.style.display = 'none'); + + hiddenButtonCategories.forEach((category) => { + const button = document.querySelector(`.item#${category}`) + if (button) button.style.display = 'none' + }); + + for (const field of document.querySelectorAll('.editor-field')) { + field.append(getSelectorField(field, settings)) + } +} + +MigakuEditor.resetMigakuEditor = function () { + document.querySelectorAll('.editing-area').forEach((field) => field.style.display = ''); + document.querySelectorAll('.plain-text-badge').forEach((field) => field.style.display = ''); + document.querySelectorAll('svg#mdi-pin-outline').forEach((field) => field.parentElement.parentElement.parentElement.style.display = ''); + + hiddenButtonCategories.forEach((category) => { + const button = document.querySelector(`.item#${category}`) + if (button) button.style.display = '' + }); + + document.querySelectorAll('.migaku-field-selector').forEach((selector) => selector.remove()); +} + +MigakuEditor.toggleMode = function (settings) { + if (document.querySelector('.migaku-field-selector')) { + MigakuEditor.resetMigakuEditor(); + } else { + setupMigakuEditor(settings); + } +} + + +require('anki/ui') + .loaded + .then(() => new Promise((resolve) => setTimeout(resolve, 400))) + .then(() => { + const migakuMode = document.getElementById('migaku_btn_toggle_mode') + if (!migakuMode) return + + const input = document.createElement('input') + input.type = 'checkbox' + input.id = 'migaku_btn_intercept_fields' + input.style.margin = '0 3px' + + const label = document.createElement('label') + label.htmlFor = 'migaku_btn_intercept_fields' + label.textContent = 'Intercept Fields' + label.style.userSelect = 'none' + label.style.padding = '0 3px' + + input.addEventListener('change', (e) => { + const cmd = `migakuIntercept:${e.currentTarget.checked}` + bridgeCommand(cmd) + }) + + migakuMode.parentElement.append(input) + migakuMode.parentElement.append(label) + }) diff --git a/src/inplace_editor.py b/src/inplace_editor.py index 1ccb711..b3276cb 100644 --- a/src/inplace_editor.py +++ b/src/inplace_editor.py @@ -216,8 +216,6 @@ def reviewer_reshow(reviewer: Reviewer, mute=False, reload_card=True) -> None: # Copied from aqt.editor.Editor and aqt.editor.EditorWebView with minimal changes - - class PasteHandler: pics = ("jpg", "jpeg", "png", "tif", "tiff", "gif", "svg", "webp", "ico") diff --git a/src/languages/ja/card/support.html b/src/languages/ja/card/support.html index 240628f..08dec47 100644 --- a/src/languages/ja/card/support.html +++ b/src/languages/ja/card/support.html @@ -1,697 +1,780 @@ diff --git a/src/menu/__init__.py b/src/menu/__init__.py new file mode 100644 index 0000000..f3163fb --- /dev/null +++ b/src/menu/__init__.py @@ -0,0 +1,80 @@ +import aqt +from aqt.qt import * +from ..editor.current_editor import get_add_cards +from ..config import set + +from . import ( + settings_window, + ease_reset, + retirement, + balance_scheduler, + dayoff_window, + vacation_window, +) + +menu = QMenu("Migaku", aqt.mw) + +titleItem = QAction("Map Fields…", aqt.mw) +titleItem.triggered.connect(lambda: aqt.mw.onAddCard()) + +typeItem = QAction("", aqt.mw) +typeItem.setEnabled(False) + + +def notetypeTrigger(): + aqt.mw.onAddCard() + addcards = get_add_cards()["addcards"] + addcards.show_notetype_selector() + + +typeItem.triggered.connect(notetypeTrigger) + + +deckItem = QAction("", aqt.mw) +deckItem.setEnabled(False) + + +def deckTrigger(): + aqt.mw.onAddCard() + addcards = get_add_cards()["addcards"] + addcards.deck_chooser.choose_deck() + + +deckItem.triggered.connect(deckTrigger) + + +def setup_menu(): + menu.addAction(settings_window.action) + + menu.addSeparator() + menu.addAction(ease_reset.action) + menu.addAction(retirement.action) + menu.addAction(balance_scheduler.action) + menu.addAction(vacation_window.action) + menu.addAction(dayoff_window.action) + + menu.addSeparator() + menu.addAction(titleItem) + menu.addAction(typeItem) + menu.addAction(deckItem) + + aqt.mw.form.menubar.insertMenu(aqt.mw.form.menuHelp.menuAction(), menu) + + +def activate_deck_type(): + pass + + +def deactivate_deck_type(): + pass + + +def set_type_name(name, id): + set("migakuNotetypeId", id, do_write=True) + typeItem.setText(f"Type: {name}") + print("foo type", name, id) + + +def set_deck_name(name, id): + set("migakuDeckId", id, do_write=True) + deckItem.setText(f"Deck: {name}") diff --git a/src/balance_scheduler.py b/src/menu/balance_scheduler.py similarity index 98% rename from src/balance_scheduler.py rename to src/menu/balance_scheduler.py index 3133ded..8920c56 100644 --- a/src/balance_scheduler.py +++ b/src/menu/balance_scheduler.py @@ -6,7 +6,7 @@ from aqt.qt import QAction from anki.decks import DeckConfigId -from .balance_scheduler_func import balance, Card, Vacation +from .scheduler_func import balance, Card, Vacation SECOND_MS = 1000 diff --git a/src/balance_scheduler_dayoff_window.py b/src/menu/dayoff_window.py similarity index 97% rename from src/balance_scheduler_dayoff_window.py rename to src/menu/dayoff_window.py index 29e616b..04500b8 100644 --- a/src/balance_scheduler_dayoff_window.py +++ b/src/menu/dayoff_window.py @@ -2,7 +2,7 @@ from aqt.qt import * from .balance_scheduler import BalanceScheduler -from . import util +from .. import util class BalanceSchedulerDayOffWindow(QDialog): @@ -81,5 +81,5 @@ def accept(self): super().accept() -action = QAction("Day Off", aqt.mw) +action = QAction("Day Off…", aqt.mw) action.triggered.connect(lambda: BalanceSchedulerDayOffWindow().exec()) diff --git a/src/ease_reset.py b/src/menu/ease_reset.py similarity index 95% rename from src/ease_reset.py rename to src/menu/ease_reset.py index 8daa523..66339cf 100644 --- a/src/ease_reset.py +++ b/src/menu/ease_reset.py @@ -1,7 +1,7 @@ import aqt from aqt.qt import * -from . import config +from .. import config def reset_ease(sync=True, force_sync=True): diff --git a/src/retirement.html b/src/menu/retirement.html similarity index 100% rename from src/retirement.html rename to src/menu/retirement.html diff --git a/src/retirement.js b/src/menu/retirement.js similarity index 100% rename from src/retirement.js rename to src/menu/retirement.js diff --git a/src/retirement.py b/src/menu/retirement.py similarity index 98% rename from src/retirement.py rename to src/menu/retirement.py index 558bec0..3bea708 100644 --- a/src/retirement.py +++ b/src/menu/retirement.py @@ -8,8 +8,8 @@ from aqt.reviewer import Reviewer from aqt.operations import CollectionOp -from . import config -from . import util +from .. import config +from .. import util class RetirementHandler: @@ -285,10 +285,10 @@ def answer_hook(rev: Reviewer, card: Card, ease: Literal[1, 2, 3, 4]) -> None: aqt.gui_hooks.reviewer_did_answer_card.append(answer_hook) -with open(util.addon_path("retirement.html")) as f: +with open(util.addon_path("menu/retirement.html")) as f: retirement_html = f.read() -with open(util.addon_path("retirement.js")) as f: +with open(util.addon_path("menu/retirement.js")) as f: retirement_js = f.read() diff --git a/src/balance_scheduler_func.py b/src/menu/scheduler_func.py similarity index 100% rename from src/balance_scheduler_func.py rename to src/menu/scheduler_func.py diff --git a/src/settings_window.py b/src/menu/settings_window.py similarity index 94% rename from src/settings_window.py rename to src/menu/settings_window.py index a60018f..bae41ef 100644 --- a/src/settings_window.py +++ b/src/menu/settings_window.py @@ -2,9 +2,9 @@ import aqt from aqt.qt import * -from . import util -from . import config -from .settings_widgets import SETTINGS_WIDGETS +from .. import util +from .. import config +from ..settings_widgets import SETTINGS_WIDGETS class SettingsWindow(QDialog): diff --git a/src/balance_scheduler_vacation_window.py b/src/menu/vacation_window.py similarity index 99% rename from src/balance_scheduler_vacation_window.py rename to src/menu/vacation_window.py index 6a88c6b..5bb143f 100644 --- a/src/balance_scheduler_vacation_window.py +++ b/src/menu/vacation_window.py @@ -222,5 +222,5 @@ def date_to_col_day(self, date): return max(0, date.toJulianDay() - self.col_date.toJulianDay()) -action = QAction("Manage Vacations", aqt.mw) +action = QAction("Manage Vacations…", aqt.mw) action.triggered.connect(lambda: BalanceSchedulerVacationWindow().exec()) diff --git a/src/migaku_connection/__init__.py b/src/migaku_connection/__init__.py index b6d903c..9261979 100644 --- a/src/migaku_connection/__init__.py +++ b/src/migaku_connection/__init__.py @@ -10,6 +10,7 @@ from .hello import MigakuHello from .migaku_connector import MigakuConnector from .card_creator import CardCreator +from .card_receiver import CardReceiver from .audio_condenser import AudioCondenser from .learning_status_handler import LearningStatusHandler from .profile_data_provider import ProfileDataProvider @@ -141,8 +142,8 @@ def timeout(self, *args, **kwargs): ("/condense", AudioCondenser), ("/learning-statuses", LearningStatusHandler), ("/create", CardCreator), + ("/receive", CardReceiver), ("/profile-data", ProfileDataProvider), - ("/create", CardCreator), ("/info", InfoProvider), ("/sendcard", CardSender), ("/search", SearchHandler), diff --git a/src/migaku_connection/card_creator.py b/src/migaku_connection/card_creator.py index 510f8a5..2874297 100644 --- a/src/migaku_connection/card_creator.py +++ b/src/migaku_connection/card_creator.py @@ -1,24 +1,20 @@ -from pydub import AudioSegment -from pydub import effects -import time -import subprocess import json -import os import re import aqt from anki.notes import Note -from .migaku_http_handler import MigakuHTTPHandler + from .. import config from .. import util from ..inplace_editor import reviewer_reshow +from ..editor.current_editor import get_current_note_info +from .migaku_http_handler import MigakuHTTPHandler +from .handle_files import handle_files -class CardCreator(MigakuHTTPHandler): - image_formats = ["jpg", "gif", "png"] - audio_formats = ["mp3", "ogg", "wav"] +class CardCreator(MigakuHTTPHandler): TO_MP3_RE = re.compile(r"\[sound:(.*?)\.(wav|ogg)\]") BR_RE = re.compile(r"") @@ -93,86 +89,18 @@ def create_card(self, card_data_json): aqt.mw.col.save() aqt.mw.taskman.run_on_main(aqt.mw.reset) - self.handle_files(self.request.files) + handle_files(self.request.files) self.finish(json.dumps({"id": note.id})) def handle_definitions(self, msg_id, definition_data): definitions = json.loads(definition_data) - self.handle_files(self.request.files) + handle_files(self.request.files) self.connection._recv_data( {"id": msg_id, "msg": "Migaku-Deliver-Definitions", "data": definitions} ) self.finish("Received defintions from card creator.") - def move_file_to_media_dir(self, file_body, filename): - file_path = util.col_media_path(filename) - with open(file_path, "wb") as file_handle: - file_handle.write(file_body) - - def move_file_to_tmp_dir(self, file_body, filename): - file_path = util.tmp_path(filename) - with open(file_path, "wb") as file_handle: - file_handle.write(file_body) - - def handle_files(self, file_dict): - if file_dict: - for name_internal, sub_file_dicts in file_dict.items(): - sub_file_dict = sub_file_dicts[0] - file_name = sub_file_dict["filename"] - file_body = sub_file_dict["body"] - - suffix = file_name[-3:] - - if suffix in self.image_formats: - self.move_file_to_media_dir(file_body, file_name) - - elif suffix in self.audio_formats: - self.handleAudioFile(file_body, file_name, suffix) - - def handleAudioFile(self, file, filename, suffix): - if config.get("normalize_audio", True) or ( - config.get("convert_audio_mp3", True) and suffix != "mp3" - ): - self.move_file_to_tmp_dir(file, filename) - audio_temp_path = util.tmp_path(filename) - if not self.checkFileExists(audio_temp_path): - alert(filename + " could not be converted to an mp3.") - return - filename = filename[0:-3] + "mp3" - if config.get("normalize_audio", True): - self.moveExtensionMp3NormalizeToMediaFolder(audio_temp_path, filename) - # self.moveExtensionMp3ToMediaFolder(audio_temp_path, filename) - else: - self.moveExtensionMp3ToMediaFolder(audio_temp_path, filename) - else: - print("moving audio file") - self.move_file_to_media_dir(file, filename) - - def checkFileExists(self, source): - now = time.time() - while True: - if os.path.exists(source): - return True - if time.time() - now > 15: - return False - - def moveExtensionMp3NormalizeToMediaFolder(self, source, filename): - path = util.col_media_path(filename) - - def match_target_amplitude(sound, target_dBFS): - change_in_dBFS = target_dBFS - sound.dBFS - return sound.apply_gain(change_in_dBFS) - - sound = AudioSegment.from_file(source) - normalized_sound = match_target_amplitude(sound, -25.0) - with open(path, "wb") as file: - normalized_sound.export(file, format="mp3") - - def moveExtensionMp3ToMediaFolder(self, source, filename): - path = util.col_media_path(filename) - self.connection.ffmpeg.call("-i", source, path) - def handle_data_from_card_creator(self, jsonData): r = self._handle_data_from_card_creator(jsonData) self.finish(r) @@ -188,12 +116,12 @@ def _handle_data_from_card_creator(self, jsonData): templates = data["templates"] default_templates = data["defaultTemplates"] - current_note_info = get_current_note_info() + info = get_current_note_info() - if not current_note_info: + if not info: return "No current note." - note = current_note_info["note"] + note = info["note"] note_type = note.note_type() @@ -225,7 +153,7 @@ def _handle_data_from_card_creator(self, jsonData): if not field_name: return "No field for data_type found for current note" - self.handle_files(self.request.files) + handle_files(self.request.files) field_contents = note[field_name].rstrip() if field_contents: @@ -236,23 +164,23 @@ def _handle_data_from_card_creator(self, jsonData): def update(): # runs GUI code, so it needs to run on main thread - if "editor" in current_note_info: - editor = current_note_info["editor"] + if "editor" in info: + editor = info["editor"] editor.loadNote() if not editor.addMode: editor._save_current_note() - if "reviewer" in current_note_info: + if "reviewer" in info: # NOTE: cannot use aqt.operations.update_note as it invalidates mw note.flush() aqt.mw.col.save() - reviewer_reshow(current_note_info["reviewer"], mute=True) + reviewer_reshow(info["reviewer"], mute=True) aqt.mw.taskman.run_on_main(update) return "Added data to note." def handle_audio_delivery(self, text): - self.handle_files(self.request.files) + handle_files(self.request.files) print("Audio was received by anki.") print(text) self.finish("Audio was received by anki.") @@ -288,10 +216,6 @@ def conv_mp3(match): return text -def alert(msg: str): - aqt.mw.taskman.run_on_main(util.show_info(msg, "Condensed Audio Export")) - - def template_find_field_name_and_syntax_for_data_type(template, data_type): for field_name, field_data in template.items(): if isinstance(field_data, dict): @@ -300,37 +224,3 @@ def template_find_field_name_and_syntax_for_data_type(template, data_type): syntax = field_data.get("syntax", False) return field_name, syntax return None, None - - -# this is dirty... - -from anki.hooks import wrap -from aqt.editor import Editor - -current_editors = [] - - -def set_current_editor(editor: aqt.editor.Editor): - global current_editors - remove_editor(editor) - current_editors.append(editor) - - -def remove_editor(editor: aqt.editor.Editor): - global current_editors - current_editors = [e for e in current_editors if e != editor] - - -aqt.gui_hooks.editor_did_init.append(set_current_editor) -Editor.cleanup = wrap(Editor.cleanup, remove_editor, "before") - - -def get_current_note_info() -> Note: - for editor in reversed(current_editors): - if editor.note: - return {"note": editor.note, "editor": editor} - if aqt.mw.reviewer and aqt.mw.reviewer.card: - note = aqt.mw.reviewer.card.note() - if note: - return {"note": note, "reviewer": aqt.mw.reviewer} - return None diff --git a/src/migaku_connection/card_receiver.py b/src/migaku_connection/card_receiver.py new file mode 100644 index 0000000..f135ca2 --- /dev/null +++ b/src/migaku_connection/card_receiver.py @@ -0,0 +1,89 @@ +import json +import re + +import aqt +from anki.notes import Note +from ..config import get +from ..card_types import CardFields, card_fields_from_dict +from ..editor.current_editor import ( + add_cards_add_to_history, + get_add_cards_info, + map_to_add_cards, +) +from tornado.web import RequestHandler + +from .migaku_http_handler import MigakuHTTPHandler + + +class CardReceiver(MigakuHTTPHandler): + def post(self: RequestHandler): + try: + body = json.loads(self.request.body) + card = card_fields_from_dict(body) + self.create_card(card) + except Exception as e: + self.finish({"success": False, "error": f"Invalid request: {str(e)}."}) + + return + + def create_card(self, card: CardFields): + if get("migakuIntercept", False) and map_to_add_cards(card): + print("Tryied to map to add cards.") + aqt.mw.taskman.run_on_main( + lambda: aqt.utils.tooltip("Mapped Migaku fields to Add cards window.") + ) + self.finish( + json.dumps( + { + "success": True, + "created": False, + } + ) + ) + return + + info = get_add_cards_info() + + note = Note(aqt.mw.col, info["notetype"]) + fields = info["fields"] + + if not any([type != "none" for (fieldname, type) in fields.items()]): + print("No fields to map to.") + aqt.mw.taskman.run_on_main( + lambda: aqt.utils.tooltip( + "Could not create Migaku Card: No fields to map to." + ) + ) + self.finish( + { + "success": False, + "error": "No fields to map to.", + } + ) + return + + for fieldname, type in fields.items(): + if type == "none": + continue + + note[fieldname] = str(getattr(card, type)) + + note.tags = info["tags"] + note.model()["did"] = int(info["deck_id"]) + + aqt.mw.col.addNote(note) + aqt.mw.col.save() + aqt.mw.taskman.run_on_main(aqt.mw.reset) + aqt.mw.taskman.run_on_main(lambda: aqt.utils.tooltip("Migaku Card created")) + aqt.mw.taskman.run_on_main(lambda: add_cards_add_to_history(note)) + print(f"Card created. ID: {note.id}.") + + self.finish( + json.dumps( + { + "success": True, + "created": True, + "id": note.id, + } + ) + ) diff --git a/src/migaku_connection/handle_files.py b/src/migaku_connection/handle_files.py new file mode 100644 index 0000000..b7618fd --- /dev/null +++ b/src/migaku_connection/handle_files.py @@ -0,0 +1,90 @@ +from pydub import AudioSegment +import time +import os + +import aqt + +from .. import config +from .. import util + +image_formats = ["jpg", "gif", "png"] +audio_formats = ["mp3", "ogg", "wav"] + + +def move_file_to_media_dir(file_body, filename): + file_path = util.col_media_path(filename) + with open(file_path, "wb") as file_handle: + file_handle.write(file_body) + + +def move_file_to_tmp_dir(file_body, filename): + file_path = util.tmp_path(filename) + with open(file_path, "wb") as file_handle: + file_handle.write(file_body) + + +def check_file_exists(source): + now = time.time() + while True: + if os.path.exists(source): + return True + if time.time() - now > 15: + return False + + +def move_extension_mp3_to_media_folder(source, filename): + path = util.col_media_path(filename) + aqt.mw.migaku_connection.ffmpeg.call("-i", source, path) + + +def move_extension_mp3_normalize_to_media_folder(source, filename): + path = util.col_media_path(filename) + + def match_target_amplitude(sound, target_dBFS): + change_in_dBFS = target_dBFS - sound.dBFS + return sound.apply_gain(change_in_dBFS) + + sound = AudioSegment.from_file(source) + normalized_sound = match_target_amplitude(sound, -25.0) + with open(path, "wb") as file: + normalized_sound.export(file, format="mp3") + + +def alert(msg: str): + aqt.mw.taskman.run_on_main(util.show_info(msg, "Condensed Audio Export")) + + +def handle_audio_file(file, filename, suffix): + if config.get("normalize_audio", True) or ( + config.get("convert_audio_mp3", True) and suffix != "mp3" + ): + move_file_to_tmp_dir(file, filename) + audio_temp_path = util.tmp_path(filename) + if not check_file_exists(audio_temp_path): + alert(filename + " could not be converted to an mp3.") + return + filename = filename[0:-3] + "mp3" + if config.get("normalize_audio", True): + move_extension_mp3_normalize_to_media_folder(audio_temp_path, filename) + else: + move_extension_mp3_to_media_folder(audio_temp_path, filename) + else: + print("moving audio file") + move_file_to_media_dir(file, filename) + + +def handle_files(file_dict, only_move=False): + if not file_dict: + return + + for _, sub_file_dicts in file_dict.items(): + sub_file_dict = sub_file_dicts[0] + file_name = sub_file_dict["filename"] + file_body = sub_file_dict["body"] + + suffix = file_name[-3:] + + if suffix in image_formats or only_move: + move_file_to_media_dir(file_body, file_name) + elif suffix in audio_formats: + handle_audio_file(file_body, file_name, suffix) diff --git a/src/migaku_connection/migaku_http_handler.py b/src/migaku_connection/migaku_http_handler.py index 6d29629..4e5fe69 100644 --- a/src/migaku_connection/migaku_http_handler.py +++ b/src/migaku_connection/migaku_http_handler.py @@ -14,7 +14,8 @@ def initialize(self): self.connection = self.application.settings["connection"] def check_version(self): - version = int(self.get_body_argument("version", default=False)) + version_string = self.get_body_argument("version", default=False) + version = int(version_string) version_match = self.connection.PROTOCOL_VERSION == version return version_match diff --git a/src/migaku_connection/program_manager.py b/src/migaku_connection/program_manager.py index e34d8f2..fdc5bb6 100644 --- a/src/migaku_connection/program_manager.py +++ b/src/migaku_connection/program_manager.py @@ -1,4 +1,5 @@ import os +from pathlib import Path import subprocess import requests import zipfile diff --git a/src/migaku_connection/srs_import.py b/src/migaku_connection/srs_import.py index f9db403..f08db8a 100644 --- a/src/migaku_connection/srs_import.py +++ b/src/migaku_connection/srs_import.py @@ -19,7 +19,7 @@ def get(self): self.write( { "ok": aqt.mw.col is not None and self.connection.is_connected(), - "version": "2", + "version": "3", } ) diff --git a/src/migaku_connection/srs_util.py b/src/migaku_connection/srs_util.py index ec1e8fd..8774ff0 100644 --- a/src/migaku_connection/srs_util.py +++ b/src/migaku_connection/srs_util.py @@ -229,7 +229,13 @@ def _upload_media_single_attempt(fname, user_token, is_audio=False): out_path = tmp_path(fname) # The arguments to ffmpeg are the same as in MM r = aqt.mw.migaku_connection.ffmpeg.call( - "-y", "-i", in_path, "-vn", "-b:a", "128k", out_path, + "-y", + "-i", + in_path, + "-vn", + "-b:a", + "128k", + out_path, ) if r != 0: # ignore failed conversions, most likely bad audio @@ -245,7 +251,12 @@ def _upload_media_single_attempt(fname, user_token, is_audio=False): # The arguments to ffmpeg are the same as in MM r = aqt.mw.migaku_connection.ffmpeg.call( - "-y", "-i", in_path, "-vf", "scale='min(800,iw)':-1", out_path, + "-y", + "-i", + in_path, + "-vf", + "scale='min(800,iw)':-1", + out_path, ) if r != 0: # ignore failed conversions, most likely bad audio diff --git a/src/migaku_fields.py b/src/migaku_fields.py new file mode 100644 index 0000000..0df9966 --- /dev/null +++ b/src/migaku_fields.py @@ -0,0 +1,102 @@ +import re +from typing import Literal + +from .config import get +import anki + + +def infer_migaku_type( + name: str, +) -> Literal[ + "none", + "sentence", + "targetWord", + "translation", + "sentenceAudio", + "wordAudio", + "images", + "definitions", + "exampleSentences", + "notes", +]: + if re.search( + r"(audio|音声|音频|오디오|audio|áudio|audio|audio|áudio)", name, re.IGNORECASE + ): + if re.search( + r"(Is Audio Card|音声カード|音频卡|오디오 카드|tarjeta de audio|cartão de áudio|carte audio|audio karte|cartão de áudio)", + name, + re.IGNORECASE, + ): + return "none" + elif re.search( + r"(sentence|文|句|문장|frase|phrase|satz|frase)", name, re.IGNORECASE + ): + return "sentenceAudio" + else: + return "wordAudio" + + if re.search( + r"(word|単語|单词|단어|palabra|palavra|mot|wort|palavra)", name, re.IGNORECASE + ): + return "targetWord" + if ( + re.search( + r"(picture|画像|图片|이미지|imagen|imagem|image|bild|imagem)", name, re.IGNORECASE + ) + or re.search(r"(photo|写真|照片|사진|foto|foto|photo|foto|foto)", name, re.IGNORECASE) + or re.search( + r"(drawing|絵|图画|그림|dibujo|desenho|dessin|zeichnung|desenho)", + name, + re.IGNORECASE, + ) + or re.search( + r"(screenshot|スクリーンショット|截图|스크린샷|capturas de pantalla|capturas de tela|captures d'écran|bildschirmfotos|capturas de tela)", + name, + re.IGNORECASE, + ) + ): + return "images" + + if re.search( + r"(example|例|例句|例子|예|ejemplo|exemplo|exemple|beispiel|exemplo)", + name, + re.IGNORECASE, + ): + return "exampleSentences" + + if re.search(r"(sentence|文|句|문장|frase|phrase|satz|frase)", name, re.IGNORECASE): + return "sentence" + + if re.search( + r"(translation|訳|译|번역|traducción|traduction|übersetzung|tradução)", + name, + re.IGNORECASE, + ): + return "translation" + + if re.search( + r"(definition|定義|定义|정의|definición|definição|définition|definition|definição)", + name, + re.IGNORECASE, + ): + return "definitions" + if re.search(r"(note|ノート|笔记|노트|nota|nota|note|notiz|nota)", name, re.IGNORECASE): + return "notes" + return "none" + + +def get_migaku_fields(nt: anki.models.NoteType): + migaku_fields = get("migakuFields", {}) + data = migaku_fields.get(str(nt["id"]), {}) + + field_names = [field["name"] for field in nt["flds"]] + + for field_name in field_names: + if field_name not in data: + data[field_name] = infer_migaku_type(field_name) + + for field_name in data.keys(): + if field_name not in field_names: + del data[field_name] + + return data diff --git a/src/note_type_dialogs.py b/src/note_type_dialogs.py index 152d049..8d908d2 100644 --- a/src/note_type_dialogs.py +++ b/src/note_type_dialogs.py @@ -1,5 +1,4 @@ from typing import Optional -import re import anki import aqt @@ -301,6 +300,3 @@ def setup_note_editor(buttons, notes_editor: aqt.models.Models): buttons.append(("Migaku Options", lambda: on_manage_migaku(notes_editor))) buttons.append(("Add Migaku\nNote Type", lambda: on_add_migaku(notes_editor))) return buttons - - -aqt.gui_hooks.models_did_init_buttons.append(setup_note_editor) diff --git a/src/note_type_mgr.py b/src/note_type_mgr.py index a2b81a5..012d99a 100644 --- a/src/note_type_mgr.py +++ b/src/note_type_mgr.py @@ -67,58 +67,67 @@ def install(lang: Language) -> None: def nt_update(nt: NotetypeDict, lang: Language, commit=True) -> None: + # It is the reserved, read-only note type, e.g. "Migaku Japanese". + # Custom notetypes are cloned from this one. + is_base_tmpl = nt["name"] == NOTE_TYPE_PREFIX + lang.name_en nt_mgr = aqt.mw.col.models - nt["name"] = NOTE_TYPE_PREFIX + lang.name_en - # Assure required fields exist def field_exists(name): return any([fld["name"] == name for fld in nt["flds"]]) - for field_name in lang.fields: - if not field_exists(field_name): + if is_base_tmpl: + # Assure fields + for field_name in lang.fields: + if field_exists(field_name): + continue + field = nt_mgr.new_field(field_name) nt_mgr.add_field(nt, field) - css_path = lang.file_path("card", "styles.css") - with open(css_path, "r", encoding="utf-8") as file: - css_data = file.read() + # Set CSS + css_path = lang.file_path("card", "styles.css") + with open(css_path, "r", encoding="utf-8") as file: + css_data = file.read() - # Set CSS - nt["css"] = NOTE_TYPE_MARK_CSS + "\n\n" + css_data + nt["css"] = NOTE_TYPE_MARK_CSS + "\n\n" + css_data - # Get or create template + # Assure standard template template_name = "Standard" template = None template_idx = -1 - for i, t in enumerate(nt["tmpls"]): - if t["name"] == template_name: - template = t - template_idx = i + for idx, tmpl in enumerate(nt["tmpls"]): + if tmpl["name"] == template_name: + template = tmpl + template_idx = idx break - if template is None: + + if is_base_tmpl and not template: template = nt_mgr.new_template(template_name) nt["tmpls"].append(template) template_idx = len(nt["tmpls"]) - 1 # Set template html - for fmt, html_name in [("qfmt", "front.html"), ("afmt", "back.html")]: - html_path = lang.file_path("card", html_name) - with open(html_path, "r", encoding="utf-8") as file: - html = file.read() - - fields_settings = nt_get_tmpl_fields_settings(nt, template_idx, fmt) - nt["tmpls"][template_idx][fmt] = html - nt_set_tmpl_lang( - nt, - lang, - template_idx, - fmt, - fields_settings, - settings_mismatch_ignore=True, - commit=False, - ) + if template: + for fmt, html_name in [("qfmt", "front.html"), ("afmt", "back.html")]: + if nt["name"] == NOTE_TYPE_PREFIX + lang.name_en: + html_path = lang.file_path("card", html_name) + with open(html_path, "r", encoding="utf-8") as file: + html = file.read() + nt["tmpls"][template_idx][fmt] = html + + fields_settings = nt_get_tmpl_fields_settings(nt, template_idx, fmt) + + nt_set_tmpl_lang( + nt, + lang, + template_idx, + fmt, + fields_settings, + settings_mismatch_ignore=True, + commit=False, + ) # Set template css nt_set_css_lang(nt, lang, commit=False) @@ -148,7 +157,7 @@ def nt_get_lang(nt: NotetypeDict) -> Optional[Language]: def nt_set_css_lang(nt: NotetypeDict, lang: Optional[Language], commit=True) -> None: - # Remove CSS + # User CSS css_data = STYLE_RE.sub("", nt["css"]).rstrip() if lang: @@ -283,11 +292,12 @@ def nt_was_installed(nt: NotetypeDict) -> bool: def update_all_installed() -> None: - nt_mgr = aqt.mw.col.models - for lang in Languages: - if is_installed(lang): - nt = nt_mgr.by_name(NOTE_TYPE_PREFIX + lang.name_en) - nt_update(nt, lang) + notetypes = aqt.mw.col.models.all() + + for nt in notetypes: + lang = nt_get_lang(nt) + if not lang or not is_installed(lang): + continue -aqt.gui_hooks.profile_did_open.append(update_all_installed) + nt_update(nt, lang) diff --git a/src/settings_widget.py b/src/settings_widget.py new file mode 100644 index 0000000..e519a7f --- /dev/null +++ b/src/settings_widget.py @@ -0,0 +1,72 @@ +import aqt +from aqt.qt import * +from . import util + + +class SettingsWidget(QWidget): + class WizardPage(QWizardPage): + def __init__(self, widget_cls, parent=None, is_tutorial=True): + super().__init__(parent) + self.widget = widget_cls(is_tutorial=is_tutorial) + if self.widget.TITLE: + self.setTitle(self.widget.TITLE) + if self.widget.SUBTITLE: + self.setSubTitle(self.widget.SUBTITLE) + if self.widget.PIXMAP: + if hasattr(QWizard, "WizardPixmap"): + QWizard_WatermarkPixmap = QWizard.WizardPixmap.WatermarkPixmap + else: + QWizard_WatermarkPixmap = QWizard.WatermarkPixmap + self.setPixmap( + QWizard_WatermarkPixmap, util.make_pixmap(self.widget.PIXMAP) + ) + self.lyt = QVBoxLayout() + self.lyt.setContentsMargins(0, 0, 0, 0) + self.lyt.addWidget(self.widget) + self.setLayout(self.lyt) + + def save(self): + self.widget.save() + + TITLE = None + SUBTITLE = None + PIXMAP = None + BOTTOM_STRETCH = True + + def __init__(self, parent=None, is_tutorial=False): + super().__init__(parent) + + self.is_tutorial = is_tutorial + + self.lyt = QVBoxLayout() + self.setLayout(self.lyt) + self.init_ui() + self.toggle_advanced(False) + if self.BOTTOM_STRETCH: + self.lyt.addStretch() + + def init_ui(self) -> None: + pass + + def toggle_advanced(self, state: bool) -> None: + pass + + def save(self) -> None: + pass + + @classmethod + def wizard_page(cls, parent=None, is_tutorial=True) -> WizardPage: + return cls.WizardPage(cls, parent, is_tutorial) + + @classmethod + def make_label(cls, text: str) -> QLabel: + lbl = QLabel(text) + lbl.setWordWrap(True) + lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) + lbl.linkActivated.connect(aqt.utils.openLink) + return lbl + + def add_label(self, text: str) -> QLabel: + lbl = self.make_label(text) + self.lyt.addWidget(lbl) + return lbl diff --git a/src/settings_widgets.py b/src/settings_widgets.py index 4b3f836..9389c00 100644 --- a/src/settings_widgets.py +++ b/src/settings_widgets.py @@ -1,83 +1,19 @@ import aqt from aqt.qt import * +from .settings_widget import SettingsWidget +from .tutorial_widgets import ( + ExtensionWidget, + GlobalHotkeysWidget, + InplaceEditorWidget, + LanguageWidget, + ReviewWidget, + SyntaxAddRemoveWidget, + SyntaxWidget, +) + from .version import VERSION_STRING -from . import config -from . import util -from .languages import Languages -from . import note_type_mgr -from .migaku_connection import ConnectionStatusLabel -from .global_hotkeys import HotkeyConfigWidget, hotkey_handler -from .balance_scheduler_vacation_window import BalanceSchedulerVacationWindow - - -class SettingsWidget(QWidget): - class WizardPage(QWizardPage): - def __init__(self, widget_cls, parent=None, is_tutorial=True): - super().__init__(parent) - self.widget = widget_cls(is_tutorial=is_tutorial) - if self.widget.TITLE: - self.setTitle(self.widget.TITLE) - if self.widget.SUBTITLE: - self.setSubTitle(self.widget.SUBTITLE) - if self.widget.PIXMAP: - if hasattr(QWizard, "WizardPixmap"): - QWizard_WatermarkPixmap = QWizard.WizardPixmap.WatermarkPixmap - else: - QWizard_WatermarkPixmap = QWizard.WatermarkPixmap - self.setPixmap( - QWizard_WatermarkPixmap, util.make_pixmap(self.widget.PIXMAP) - ) - self.lyt = QVBoxLayout() - self.lyt.setContentsMargins(0, 0, 0, 0) - self.lyt.addWidget(self.widget) - self.setLayout(self.lyt) - - def save(self): - self.widget.save() - - TITLE = None - SUBTITLE = None - PIXMAP = None - BOTTOM_STRETCH = True - - def __init__(self, parent=None, is_tutorial=False): - super().__init__(parent) - - self.is_tutorial = is_tutorial - - self.lyt = QVBoxLayout() - self.setLayout(self.lyt) - self.init_ui() - self.toggle_advanced(False) - if self.BOTTOM_STRETCH: - self.lyt.addStretch() - - def init_ui(self) -> None: - pass - - def toggle_advanced(self, state: bool) -> None: - pass - - def save(self) -> None: - pass - - @classmethod - def wizard_page(cls, parent=None, is_tutorial=True) -> WizardPage: - return cls.WizardPage(cls, parent, is_tutorial) - - @classmethod - def make_label(cls, text: str) -> QLabel: - lbl = QLabel(text) - lbl.setWordWrap(True) - lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) - lbl.linkActivated.connect(aqt.utils.openLink) - return lbl - - def add_label(self, text: str) -> QLabel: - lbl = self.make_label(text) - self.lyt.addWidget(lbl) - return lbl +from . import config, util class AboutWidget(SettingsWidget): @@ -109,246 +45,6 @@ def on_toggle_advanced(self) -> None: self.settings_window.toggle_advanced(state) -class WelcomeWidget(SettingsWidget): - TITLE = "Welcome!" - SUBTITLE = "This is the first time you are using the Migaku Anki add-on." - PIXMAP = "migaku_side.png" - - def init_ui(self): - welcome_lbl = QLabel( - "Migaku Anki provides all features you need to optimally learn languages with Anki and Migaku.

" - "This setup will give you a quick overview over the feature-set and explain how to use it.

" - "Let's go!

" - "You can always later see this guide and the settings by clicking Migaku > Settings/Help." - ) - welcome_lbl.setWordWrap(True) - self.lyt.addWidget(welcome_lbl) - - -class LanguageWidget(SettingsWidget): - TITLE = "Language Selection" - - def init_ui(self): - lbl1 = QLabel( - "Migaku allows you to learn all of the following languages. Please select all the ones you want to learn:" - ) - lbl1.setWordWrap(True) - self.lyt.addWidget(lbl1) - - self.lang_list = QListWidget() - self.lang_list.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.lyt.addWidget(self.lang_list) - - lbl2 = QLabel( - "Note: This will create a note type for every selected language. " - "If you want to uninstall a language, remove the correspending note type with Tools > Manage Note Types." - ) - lbl2.setWordWrap(True) - self.lyt.addWidget(lbl2) - - self.setup_langs() - - def setup_langs(self): - self.lang_list.clear() - - for lang in Languages: - is_installed = note_type_mgr.is_installed(lang) - text = lang.name_en - if lang.name_native and lang.name_native != lang.name_en: - text += f" ({lang.name_native})" - item = QListWidgetItem(text) - item.setData(Qt.ItemDataRole.UserRole, lang.code) - item.setCheckState( - Qt.CheckState.Checked if is_installed else Qt.CheckState.Unchecked - ) - if is_installed: - item.setFlags( - item.flags() - & ~(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) - ) - self.lang_list.addItem(item) - - def save(self): - for i in range(self.lang_list.count()): - item = self.lang_list.item(i) - lang_code = item.data(Qt.ItemDataRole.UserRole) - if item.checkState() == Qt.CheckState.Checked: - lang = Languages[lang_code] - note_type_mgr.install(lang) - self.setup_langs() - - -class ExtensionWidget(SettingsWidget): - TITLE = "Browser Extension" - - EXTENSION_URL = "https://www.migaku.io/todo" - - BOTTOM_STRETCH = False - - def init_ui(self, parent=None): - lbl1 = QLabel( - "Migaku Anki uses the Migaku Browser Extension as a dictionary, to add syntax to your cards and several other features.

" - f'Make sure to install the extension for your browser to use this functionality. See here for instructions.

' - "If the browser extension is installed and running, the status below will reflect so." - ) - lbl1.setWordWrap(True) - lbl1.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) - lbl1.linkActivated.connect(aqt.utils.openLink) - self.lyt.addWidget(lbl1) - - # Information on changing port - self.custom_port_hr = self.add_label("
") - self.custom_port_info = self.add_label( - "In some cases, you might want to have Anki and the browser extension communicate on a custom port, because another application is already using the default port. You need to set the same port in the browser extension settings! You need to restart Anki after changing the port. " - ) - - self.custom_port = QLineEdit(config.get("port", "")) - self.custom_port.setPlaceholderText(str(util.DEFAULT_PORT)) - self.custom_port.textChanged.connect( - lambda text: config.set("port", text if len(text) else None) - ) - - self.lyt.addWidget(self.custom_port) - - self.lyt.addStretch() - self.lyt.addWidget(ConnectionStatusLabel()) - - def toggle_advanced(self, state: bool) -> None: - if not state: - self.custom_port_hr.hide() - self.custom_port_info.hide() - self.custom_port.hide() - - else: - self.custom_port_hr.show() - self.custom_port_info.show() - self.custom_port.show() - - -class GlobalHotkeysWidget(SettingsWidget): - TITLE = "Global Hotkeys" - - BOTTOM_STRETCH = False - - def init_ui(self, parent=None): - if not hotkey_handler.is_available(): - self.add_label( - "For Migaku global hotkeys to work, you must allow Anki to monitor keyboard inputs.\n\n" - 'To do this, go to System Preferences > Security & Privacy > Privacy. Then for both "Accessibility" and "Input Monitoring" check the box for "Anki".\n\n' - "These permissions are required to detect when the specified shortcuts are pressend and are required to copy the selected text. " - "Finally restart Anki." - ) - return - - self.add_label( - "You can use the following hotkeys to interact with the browser extension while it is connected:" - ) - - self.hotkey_config = HotkeyConfigWidget(hotkey_handler) - self.lyt.addWidget(self.hotkey_config) - - self.add_label( - "To set new key combinations click the buttons on the right and press a new key combination. " - "To disable a hotkey click the button again without pressing a new key combination." - ) - - self.lyt.addStretch() - self.lyt.addWidget(ConnectionStatusLabel()) - - def save(self): - config.write() - - -class SyntaxWidget(SettingsWidget): - TITLE = "Language Syntax" - - def init_ui(self, parent=None): - lbl1 = QLabel( - "Migaku Anki offers card syntax that can be used in fields to add information to your flashcard fields. " - "This information includes (depending on the language): Readings, tone/pitch/gender coloring, " - "part of speech and more.

" - "Generally the syntax is added after words, enclosed in square brackets like this:" - ) - lbl1.setWordWrap(True) - self.lyt.addWidget(lbl1) - - lbl2 = QLabel( - "This[this,pron,ðɪs;ðɪs] is[be,aux,ɪz;ɪz] a[a,x,ʌ;eɪ] test[test,noun,test]." - ) - lbl2.setStyleSheet("background-color:#202020; color:#F8F8F8;") - lbl2.setWordWrap(True) - self.lyt.addWidget(lbl2) - - lbl3 = QLabel( - "
On your cards the information in the brackets is displayed in a popup over the word, as ruby text or changes the color of the word, depending on the language:" - ) - lbl3.setWordWrap(True) - self.lyt.addWidget(lbl3) - - lbl4 = QLabel() - lbl4.setPixmap(util.make_pixmap("syntax_displayed_small_example.png")) - self.lyt.addWidget(lbl4) - - -class SyntaxAddRemoveWidget(SettingsWidget): - TITLE = "Add/Remove Language Syntax" - - def init_ui(self): - lbl1 = QLabel( - "To add or update syntax of your cards go to any editor window in Anki, select a field and press F2 or press this button (icons vary depending on language:" - ) - lbl1.setWordWrap(True) - self.lyt.addWidget(lbl1) - - lbl2 = QLabel() - lbl2.setPixmap(util.make_pixmap("syntax_buttons_example.png")) - self.lyt.addWidget(lbl2) - - lbl3 = QLabel( - "
Make sure the browser extension is connected when using these features.

" - "To remove syntax either press F4 or press the right button." - ) - lbl3.setWordWrap(True) - self.lyt.addWidget(lbl3) - - -class InplaceEditorWidget(SettingsWidget): - TITLE = "Inplace Editor" - - def init_ui(self): - self.add_label( - "With Migaku Anki you can edit your flash cards during your reviews.

" - "To do so simply double click any field on your card. A cursor will appear and you can freely edit the field and even paste images and audio files. " - "Simply click out of the field to finish editing.

" - 'If you want to add editing support to your own note types add the "editable" filter before the field names:' - ) - - lbl = QLabel("{{ExampleField}} -> {{editable:ExampleField}}") - lbl.setStyleSheet("background-color:#202020; color:#F8F8F8;") - lbl.setWordWrap(True) - self.lyt.addWidget(lbl) - - self.add_label("") - - self.add_label( - "By enabling the following option you can also edit empty fields on your cards:" - ) - - show_empty_fields = QCheckBox("Show empty fields") - show_empty_fields.setChecked( - config.get("inplace_editor_show_empty_fields", False) - ) - show_empty_fields.toggled.connect( - lambda checked: config.set("inplace_editor_show_empty_fields", checked) - ) - self.lyt.addWidget(show_empty_fields) - - def save(self): - from .inplace_editor import update_show_empty_fields - - update_show_empty_fields() - - class CardTypeWidget(SettingsWidget): TITLE = "Card Type Changing" @@ -378,53 +74,6 @@ def init_ui(self): self.lyt.addWidget(tag) -class ReviewWidget(SettingsWidget): - TITLE = "Review Settings" - - def init_ui(self): - self.add_label( - "By default Anki allows grading cards as Again, Hard, Good and Easy.
" - "The hard and easy buttons can lead to unnecessarily long grading decision times as well as permanently seeing cards too often/little.
" - "Enabling Pass/Fail will remove those buttons." - ) - - pass_fail = QCheckBox("Enable Pass/Fail (Recommended)") - pass_fail.setChecked(config.get("reviewer_pass_fail", True)) - pass_fail.toggled.connect( - lambda checked: config.set("reviewer_pass_fail", checked) - ) - self.lyt.addWidget(pass_fail) - - self.add_label("
") - - self.add_label( - "The negative effects of the ease factor can be prevented by fixing the ease factor (how difficult a card is considered) in place.
" - "This option will prevent those effects if you want to use the Easy/Hard buttons and fixes negative effects caused in the past." - ) - - pass_fail = QCheckBox("Maintain Ease Factor (Recommended)") - pass_fail.setChecked(config.get("maintain_ease", True)) - pass_fail.toggled.connect(lambda checked: config.set("maintain_ease", checked)) - self.lyt.addWidget(pass_fail) - - self.add_label( - "If you review cards on mobile devices your ease factor will not be maintained. " - 'To reset it press "Reset Ease Factor" from the Migaku menu. Note that this action will force a full sync.' - ) - - self.add_label("
") - - colored_buttons = QCheckBox("Colored Grading Buttons") - colored_buttons.setChecked(config.get("reviewer_button_coloring", True)) - colored_buttons.toggled.connect( - lambda checked: config.set("reviewer_button_coloring", checked) - ) - self.lyt.addWidget(colored_buttons) - - def save(self): - config.write() - - class SchedulingWidget(SettingsWidget): TITLE = "Review Scheduling (Beta)" @@ -1031,14 +680,3 @@ def change_dir(self): FieldSettingsWidget, CondensedAudioWidget, ] - -TUTORIAL_WIDGETS = [ - WelcomeWidget, - LanguageWidget, - ExtensionWidget, - GlobalHotkeysWidget, - SyntaxWidget, - SyntaxAddRemoveWidget, - InplaceEditorWidget, - ReviewWidget, -] diff --git a/src/sys_libraries.py b/src/sys_libraries.py new file mode 100644 index 0000000..bf02b97 --- /dev/null +++ b/src/sys_libraries.py @@ -0,0 +1,32 @@ +# insert librairies into sys.path +import anki +import os +import sys + + +def add_sys_path(*path_parts): + sys.path.insert( + 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib", *path_parts) + ) + + +def init_sys_libs(): + add_sys_path("shared") + if anki.utils.is_lin: + add_sys_path("linux") + elif ( + anki.utils.is_mac + and sys.version_info.major >= 3 + and sys.version_info.minor >= 11 + ): + add_sys_path("macos_311") + elif ( + anki.utils.is_mac + and sys.version_info.major >= 3 + and sys.version_info.minor >= 10 + ): + add_sys_path("macos_310") + elif anki.utils.is_mac: + add_sys_path("macos_39") + elif anki.utils.is_win: + add_sys_path("windows") diff --git a/src/toolbar/__init__.py b/src/toolbar/__init__.py new file mode 100644 index 0000000..053d334 --- /dev/null +++ b/src/toolbar/__init__.py @@ -0,0 +1,51 @@ +import json +import aqt +from ..config import get +from .. import menu + +from ..editor.current_editor import ( + get_add_cards_info, + on_addcards_did_change_deck, + on_addcards_did_change_note_type, +) + + +def activate_migaku_toolbar(): + info = get_add_cards_info() + menu.set_deck_name(info["deck_name"], info["deck_id"]) + menu.set_type_name(info["notetype_name"], info["notetype_id"]) + menu.activate_deck_type() + + +def deactivate_migaku_toolbar(): + menu.deactivate_deck_type() + + +def refresh_migaku_toolbar(): + info = get_add_cards_info() + menu.set_deck_name(info["deck_name"], info["deck_id"]) + menu.set_type_name(info["notetype_name"], info["notetype_id"]) + + +def refresh_migaku_toolbar_opened_addcards(): + defaults = aqt.mw.col.defaults_for_adding(current_review_card=aqt.mw.reviewer.card) + info = get_add_cards_info(defaults) + + on_addcards_did_change_deck(defaults.deck_id) + on_addcards_did_change_note_type(defaults.notetype_id) + + menu.set_deck_name(info["deck_name"], info["deck_id"]) + menu.set_type_name(info["notetype_name"], info["notetype_id"]) + + +def inject_migaku_toolbar(col): + activate_migaku_toolbar() + + +def set_deck_type_to_migaku(): + aqt.mw.col.set_config( + "curDeck", get("migakuDeckId", aqt.mw.col.get_config("curDeck")) + ) + aqt.mw.col.set_config( + "curModel", get("migakuNotetypeId", aqt.mw.col.get_config("curModel")) + ) diff --git a/src/toolbar/toolbar.html b/src/toolbar/toolbar.html new file mode 100644 index 0000000..61228f9 --- /dev/null +++ b/src/toolbar/toolbar.html @@ -0,0 +1,47 @@ + + + diff --git a/src/tutorial_widgets.py b/src/tutorial_widgets.py new file mode 100644 index 0000000..9fe7a5c --- /dev/null +++ b/src/tutorial_widgets.py @@ -0,0 +1,312 @@ +import aqt +from aqt.qt import * + +from .settings_widget import SettingsWidget +from .languages import Languages +from .migaku_connection import ConnectionStatusLabel +from .global_hotkeys import HotkeyConfigWidget, hotkey_handler + +from . import config, util, note_type_mgr + + +class WelcomeWidget(SettingsWidget): + TITLE = "Welcome!" + SUBTITLE = "This is the first time you are using the Migaku Anki add-on." + PIXMAP = "migaku_side.png" + + def init_ui(self): + welcome_lbl = QLabel( + "Migaku Anki provides all features you need to optimally learn languages with Anki and Migaku.

" + "This setup will give you a quick overview over the feature-set and explain how to use it.

" + "Let's go!

" + "You can always later see this guide and the settings by clicking Migaku > Settings/Help." + ) + welcome_lbl.setWordWrap(True) + self.lyt.addWidget(welcome_lbl) + + +class LanguageWidget(SettingsWidget): + TITLE = "Language Selection" + + def init_ui(self): + lbl1 = QLabel( + "Migaku allows you to learn all of the following languages. Please select all the ones you want to learn:" + ) + lbl1.setWordWrap(True) + self.lyt.addWidget(lbl1) + + self.lang_list = QListWidget() + self.lang_list.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.lyt.addWidget(self.lang_list) + + lbl2 = QLabel( + "Note: This will create a note type for every selected language. " + "If you want to uninstall a language, remove the correspending note type with Tools > Manage Note Types." + ) + lbl2.setWordWrap(True) + self.lyt.addWidget(lbl2) + + self.setup_langs() + + def setup_langs(self): + self.lang_list.clear() + + for lang in Languages: + is_installed = note_type_mgr.is_installed(lang) + text = lang.name_en + if lang.name_native and lang.name_native != lang.name_en: + text += f" ({lang.name_native})" + item = QListWidgetItem(text) + item.setData(Qt.ItemDataRole.UserRole, lang.code) + item.setCheckState( + Qt.CheckState.Checked if is_installed else Qt.CheckState.Unchecked + ) + if is_installed: + item.setFlags( + item.flags() + & ~(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) + ) + self.lang_list.addItem(item) + + def save(self): + for i in range(self.lang_list.count()): + item = self.lang_list.item(i) + lang_code = item.data(Qt.ItemDataRole.UserRole) + if item.checkState() == Qt.CheckState.Checked: + lang = Languages[lang_code] + note_type_mgr.install(lang) + self.setup_langs() + + +class ExtensionWidget(SettingsWidget): + TITLE = "Browser Extension" + + EXTENSION_URL = "https://www.migaku.io/todo" + + BOTTOM_STRETCH = False + + def init_ui(self, parent=None): + lbl1 = QLabel( + "Migaku Anki uses the Migaku Browser Extension as a dictionary, to add syntax to your cards and several other features.

" + f'Make sure to install the extension for your browser to use this functionality. See here for instructions.

' + "If the browser extension is installed and running, the status below will reflect so." + ) + lbl1.setWordWrap(True) + lbl1.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) + lbl1.linkActivated.connect(aqt.utils.openLink) + self.lyt.addWidget(lbl1) + + # Information on changing port + self.custom_port_hr = self.add_label("
") + self.custom_port_info = self.add_label( + "In some cases, you might want to have Anki and the browser extension communicate on a custom port, because another application is already using the default port. You need to set the same port in the browser extension settings! You need to restart Anki after changing the port. " + ) + + self.custom_port = QLineEdit(config.get("port", "")) + self.custom_port.setPlaceholderText(str(util.DEFAULT_PORT)) + self.custom_port.textChanged.connect( + lambda text: config.set("port", text if len(text) else None) + ) + + self.lyt.addWidget(self.custom_port) + + self.lyt.addStretch() + self.lyt.addWidget(ConnectionStatusLabel()) + + def toggle_advanced(self, state: bool) -> None: + if not state: + self.custom_port_hr.hide() + self.custom_port_info.hide() + self.custom_port.hide() + + else: + self.custom_port_hr.show() + self.custom_port_info.show() + self.custom_port.show() + + +class GlobalHotkeysWidget(SettingsWidget): + TITLE = "Global Hotkeys" + + BOTTOM_STRETCH = False + + def init_ui(self, parent=None): + if not hotkey_handler.is_available(): + self.add_label( + "For Migaku global hotkeys to work, you must allow Anki to monitor keyboard inputs.\n\n" + 'To do this, go to System Preferences > Security & Privacy > Privacy. Then for both "Accessibility" and "Input Monitoring" check the box for "Anki".\n\n' + "These permissions are required to detect when the specified shortcuts are pressend and are required to copy the selected text. " + "Finally restart Anki." + ) + return + + self.add_label( + "You can use the following hotkeys to interact with the browser extension while it is connected:" + ) + + self.hotkey_config = HotkeyConfigWidget(hotkey_handler) + self.lyt.addWidget(self.hotkey_config) + + self.add_label( + "To set new key combinations click the buttons on the right and press a new key combination. " + "To disable a hotkey click the button again without pressing a new key combination." + ) + self.add_label("
") + self.add_label( + "If you are using the new extension, hotkeys require the Migaku App window to be open." + ) + + self.lyt.addStretch() + self.lyt.addWidget(ConnectionStatusLabel()) + + def save(self): + config.write() + + +class SyntaxWidget(SettingsWidget): + TITLE = "Language Syntax" + + def init_ui(self, parent=None): + lbl1 = QLabel( + "Migaku Anki offers card syntax that can be used in fields to add information to your flashcard fields. " + "This information includes (depending on the language): Readings, tone/pitch/gender coloring, " + "part of speech and more.

" + "Generally the syntax is added after words, enclosed in square brackets like this:" + ) + lbl1.setWordWrap(True) + self.lyt.addWidget(lbl1) + + lbl2 = QLabel( + "This[this,pron,ðɪs;ðɪs] is[be,aux,ɪz;ɪz] a[a,x,ʌ;eɪ] test[test,noun,test]." + ) + lbl2.setStyleSheet("background-color:#202020; color:#F8F8F8;") + lbl2.setWordWrap(True) + self.lyt.addWidget(lbl2) + + lbl3 = QLabel( + "
On your cards the information in the brackets is displayed in a popup over the word, as ruby text or changes the color of the word, depending on the language:" + ) + lbl3.setWordWrap(True) + self.lyt.addWidget(lbl3) + + lbl4 = QLabel() + lbl4.setPixmap(util.make_pixmap("syntax_displayed_small_example.png")) + self.lyt.addWidget(lbl4) + + +class SyntaxAddRemoveWidget(SettingsWidget): + TITLE = "Add/Remove Language Syntax" + + def init_ui(self): + lbl1 = QLabel( + "To add or update syntax of your cards go to any editor window in Anki, select a field and press F2 or press this button (icons vary depending on language:" + ) + lbl1.setWordWrap(True) + self.lyt.addWidget(lbl1) + + lbl2 = QLabel() + lbl2.setPixmap(util.make_pixmap("syntax_buttons_example.png")) + self.lyt.addWidget(lbl2) + + lbl3 = QLabel( + "
Make sure the browser extension is connected when using these features.

" + "To remove syntax either press F4 or press the right button." + ) + lbl3.setWordWrap(True) + self.lyt.addWidget(lbl3) + + +class InplaceEditorWidget(SettingsWidget): + TITLE = "Inplace Editor" + + def init_ui(self): + self.add_label( + "With Migaku Anki you can edit your flash cards during your reviews.

" + "To do so simply double click any field on your card. A cursor will appear and you can freely edit the field and even paste images and audio files. " + "Simply click out of the field to finish editing.

" + 'If you want to add editing support to your own note types add the "editable" filter before the field names:' + ) + + lbl = QLabel("{{ExampleField}} -> {{editable:ExampleField}}") + lbl.setStyleSheet("background-color:#202020; color:#F8F8F8;") + lbl.setWordWrap(True) + self.lyt.addWidget(lbl) + + self.add_label("") + + self.add_label( + "By enabling the following option you can also edit empty fields on your cards:" + ) + + show_empty_fields = QCheckBox("Show empty fields") + show_empty_fields.setChecked( + config.get("inplace_editor_show_empty_fields", False) + ) + show_empty_fields.toggled.connect( + lambda checked: config.set("inplace_editor_show_empty_fields", checked) + ) + self.lyt.addWidget(show_empty_fields) + + def save(self): + from .inplace_editor import update_show_empty_fields + + update_show_empty_fields() + + +class ReviewWidget(SettingsWidget): + TITLE = "Review Settings" + + def init_ui(self): + self.add_label( + "By default Anki allows grading cards as Again, Hard, Good and Easy.
" + "The hard and easy buttons can lead to unnecessarily long grading decision times as well as permanently seeing cards too often/little.
" + "Enabling Pass/Fail will remove those buttons." + ) + + pass_fail = QCheckBox("Enable Pass/Fail (Recommended)") + pass_fail.setChecked(config.get("reviewer_pass_fail", True)) + pass_fail.toggled.connect( + lambda checked: config.set("reviewer_pass_fail", checked) + ) + self.lyt.addWidget(pass_fail) + + self.add_label("
") + + self.add_label( + "The negative effects of the ease factor can be prevented by fixing the ease factor (how difficult a card is considered) in place.
" + "This option will prevent those effects if you want to use the Easy/Hard buttons and fixes negative effects caused in the past." + ) + + pass_fail = QCheckBox("Maintain Ease Factor (Recommended)") + pass_fail.setChecked(config.get("maintain_ease", True)) + pass_fail.toggled.connect(lambda checked: config.set("maintain_ease", checked)) + self.lyt.addWidget(pass_fail) + + self.add_label( + "If you review cards on mobile devices your ease factor will not be maintained. " + 'To reset it press "Reset Ease Factor" from the Migaku menu. Note that this action will force a full sync.' + ) + + self.add_label("
") + + colored_buttons = QCheckBox("Colored Grading Buttons") + colored_buttons.setChecked(config.get("reviewer_button_coloring", True)) + colored_buttons.toggled.connect( + lambda checked: config.set("reviewer_button_coloring", checked) + ) + self.lyt.addWidget(colored_buttons) + + def save(self): + config.write() + + +TUTORIAL_WIDGETS = [ + WelcomeWidget, + LanguageWidget, + ExtensionWidget, + GlobalHotkeysWidget, + SyntaxWidget, + SyntaxAddRemoveWidget, + InplaceEditorWidget, + ReviewWidget, +] diff --git a/src/welcome_wizard.py b/src/welcome_wizard.py index ad2863d..5219d26 100644 --- a/src/welcome_wizard.py +++ b/src/welcome_wizard.py @@ -3,7 +3,9 @@ from . import util from . import config -from .settings_widgets import TUTORIAL_WIDGETS + +from .tutorial_widgets import TUTORIAL_WIDGETS + class WelcomeWizard(QWizard): INITIAL_SIZE = (625, 440)