├── .gitignore ├── .isort.cfg ├── .pylintrc ├── Makefile ├── build.py ├── code ├── altgrfix │ ├── __init__.py │ └── manifest.json ├── cardstats │ ├── __init__.py │ ├── config.json │ ├── config.md │ └── manifest.json ├── changecreationtimes │ ├── __init__.py │ └── manifest.json ├── fixinvalidhtml │ ├── __init__.py │ └── manifest.json ├── gtts_player │ ├── __init__.py │ ├── add_vendor.sh │ ├── manifest.json │ └── vendor │ │ └── gtts │ │ ├── LICENSE │ │ ├── __init__.py │ │ ├── accents.py │ │ ├── cli.py │ │ ├── lang.py │ │ ├── langs.py │ │ ├── tests │ │ ├── __init__.py │ │ ├── input_files │ │ │ ├── test_cli_test_ascii.txt │ │ │ └── test_cli_test_utf8.txt │ │ ├── test_cli.py │ │ ├── test_lang.py │ │ ├── test_tts.py │ │ └── test_utils.py │ │ ├── tokenizer │ │ ├── __init__.py │ │ ├── core.py │ │ ├── pre_processors.py │ │ ├── symbols.py │ │ ├── tests │ │ │ ├── test_core.py │ │ │ ├── test_pre_processors.py │ │ │ └── test_tokenizer_cases.py │ │ └── tokenizer_cases.py │ │ ├── tts.py │ │ ├── utils.py │ │ └── version.py ├── japanese │ ├── __init__.py │ ├── bulkreading.py │ ├── config.json │ ├── config.md │ ├── license.mecab-ipadic.txt │ ├── license.txt │ ├── lookup.py │ ├── manifest.json │ ├── model.py │ ├── notetypes.py │ ├── reading.py │ ├── stats.py │ ├── support │ │ ├── char.bin │ │ ├── dicrc │ │ ├── itaijidict │ │ ├── kakasi │ │ ├── kakasi.exe │ │ ├── kakasi.lin │ │ ├── kanwadict │ │ ├── libmecab.2.dylib │ │ ├── libmecab.dll │ │ ├── libmecab.so.1 │ │ ├── matrix.bin │ │ ├── mecab │ │ ├── mecab.exe │ │ ├── mecab.lin │ │ ├── mecabrc │ │ ├── sys.dic │ │ ├── unk.dic │ │ └── user_dic.dic │ └── tests │ │ ├── __init__.py │ │ └── test_stats.py ├── localizemedia │ ├── __init__.py │ └── manifest.json ├── mergechilddecks │ ├── __init__.py │ └── manifest.json ├── no_tts │ ├── __init__.py │ └── manifest.json ├── print │ ├── __init__.py │ ├── config.json │ └── manifest.json ├── quickcolours │ ├── __init__.py │ ├── config.json │ ├── config.md │ └── manifest.json └── removehistory │ ├── __init__.py │ └── manifest.json ├── demos ├── av_player │ └── __init__.py ├── card_did_render │ └── __init__.py ├── deckoptions_raw_html │ ├── __init__.py │ ├── raw.html │ └── raw.js └── field_filter │ └── __init__.py ├── mypy.ini ├── requirements-dev.txt ├── requirements.txt ├── setup-venv.sh └── update-anki.sh /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | *.pyc 3 | *.pyo 4 | .idea 5 | __pycache__ 6 | meta.json 7 | .*.swp 8 | 9 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile=black 3 | extend_skip=fastbar,archive,vendor 4 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore-patterns=.*_pb2.* 3 | persistent = no 4 | extension-pkg-whitelist=orjson 5 | 6 | [REPORTS] 7 | output-format=colorized 8 | 9 | [MESSAGES CONTROL] 10 | disable= 11 | R, 12 | line-too-long, 13 | too-many-lines, 14 | missing-function-docstring, 15 | missing-module-docstring, 16 | missing-class-docstring, 17 | import-outside-toplevel, 18 | wrong-import-position, 19 | wrong-import-order, 20 | fixme, 21 | unused-wildcard-import, 22 | attribute-defined-outside-init, 23 | redefined-builtin, 24 | wildcard-import, 25 | broad-except, 26 | bare-except, 27 | unused-argument, 28 | unused-variable, 29 | redefined-outer-name, 30 | global-statement, 31 | protected-access, 32 | arguments-differ, 33 | arguments-renamed, 34 | consider-using-f-string, 35 | invalid-name, 36 | broad-exception-raised 37 | 38 | [BASIC] 39 | good-names = 40 | id, 41 | tr, 42 | db, 43 | ok, 44 | ip, 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAKEFLAGS += -j 2 | 3 | all: build 4 | 5 | venv: 6 | @test -d ~/Local/python/addons || (./setup-venv.sh && ~/Local/python/addons/bin/pip install --pre aqt[qt6]) 7 | 8 | check: check_format mypy pylint 9 | 10 | check_format: venv 11 | ~/Local/python/addons/bin/black --exclude=vendor/ --check code demos 12 | ~/Local/python/addons/bin/isort --check code demos 13 | 14 | format: venv 15 | ~/Local/python/addons/bin/black --exclude=vendor/ code demos 16 | ~/Local/python/addons/bin/isort code demos 17 | 18 | mypy: venv 19 | MYPY_FORCE_COLOR=1 ~/Local/python/addons/bin/mypy code demos 20 | 21 | pylint: venv 22 | ~/Local/python/addons/bin/pylint -j 10 -f colorized \ 23 | --extension-pkg-whitelist=PyQt6 code/* demos/* 24 | 25 | fix: 26 | ~/Local/python/addons/bin/black --exclude=vendor/ code demos 27 | ~/Local/python/addons/bin/isort code demos 28 | 29 | build: check 30 | find . -name '*.pyc' -delete 31 | find . -name __pycache__ -delete 32 | (cd code && ~/Local/python/addons/bin/python ../build.py) 33 | open ~/.cache/anki-addons & 34 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import json 3 | import os 4 | from pathlib import Path 5 | import subprocess 6 | 7 | build_dir = Path("~/.cache/anki-addons").expanduser() 8 | if not build_dir.exists(): 9 | build_dir.mkdir() 10 | 11 | 12 | def addons(): 13 | dirs = [] 14 | for file in os.listdir("."): 15 | init = os.path.join(file, "__init__.py") 16 | if os.path.exists(init): 17 | dirs.append(file) 18 | return dirs 19 | 20 | 21 | def build_all(): 22 | needed = [] 23 | for dir in addons(): 24 | if needs_build(dir): 25 | needed.append(dir) 26 | 27 | if needed: 28 | for dir in needed: 29 | print("building", dir, "...") 30 | build(dir) 31 | 32 | 33 | def needs_build(dir): 34 | build_ts = last_build_time(dir) 35 | mod_ts = most_recent_change(dir) 36 | return mod_ts > build_ts 37 | 38 | 39 | def build(dir): 40 | out = target_file(dir) 41 | if os.path.exists(out): 42 | os.unlink(out) 43 | ensure_manifest(dir) 44 | subprocess.check_call( 45 | [ 46 | "7z", 47 | "a", 48 | "-tzip", 49 | "-x!meta.json", 50 | "-x!tests", 51 | "-bso0", # less verbose 52 | out, 53 | # package folder contents but not folder itself 54 | "-w", 55 | os.path.join(dir, "."), 56 | ] 57 | ) 58 | 59 | 60 | def run(cmd): 61 | subprocess.check_call(cmd, shell=True) 62 | 63 | 64 | def ensure_manifest(dir): 65 | manifest_path = os.path.join(dir, "manifest.json") 66 | if not os.path.exists(manifest_path): 67 | open(manifest_path, "w").write(json.dumps(dict(package=dir, name=dir))) 68 | 69 | 70 | def target_file(dir): 71 | return os.path.join(build_dir, dir + ".ankiaddon") 72 | 73 | 74 | def last_build_time(dir): 75 | out = target_file(dir) 76 | try: 77 | return os.stat(out).st_mtime 78 | except: 79 | return 0 80 | 81 | 82 | def most_recent_change(dir): 83 | newest = 0 84 | 85 | for dirpath, _, fnames in os.walk(dir): 86 | for fname in fnames: 87 | path = os.path.join(dirpath, fname) 88 | newest = max(newest, os.stat(path).st_mtime) 89 | 90 | return newest 91 | 92 | 93 | build_all() 94 | print("all done") 95 | -------------------------------------------------------------------------------- /code/altgrfix/__init__.py: -------------------------------------------------------------------------------- 1 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 2 | 3 | from typing import Any 4 | 5 | from aqt.qt import * 6 | from aqt.webview import AnkiWebView 7 | 8 | 9 | def _runJavaScriptSync(page: QWebEnginePage, js: str, timeout: int = 500) -> Any: 10 | result = None 11 | eventLoop = QEventLoop() 12 | called = False 13 | 14 | def callback(val: Any) -> None: 15 | nonlocal result, called 16 | result = val 17 | called = True 18 | eventLoop.quit() 19 | 20 | page.runJavaScript(js, callback) 21 | 22 | if not called: 23 | timer = QTimer() 24 | timer.setSingleShot(True) 25 | timer.timeout.connect(eventLoop.quit) 26 | timer.start(timeout) 27 | eventLoop.exec() 28 | 29 | if not called: 30 | print("runJavaScriptSync() timed out") 31 | return result 32 | 33 | 34 | def event(self: QWebEngineView, evt: QEvent) -> bool: 35 | if evt.type() == QEvent.Type.ShortcutOverride: 36 | # alt-gr bug workaround 37 | exceptChars = (str(num) for num in range(1, 10)) 38 | if evt.text() not in exceptChars: # type: ignore 39 | js = """ 40 | var e=document.activeElement; 41 | (e.tagName === "DIV" && e.contentEditable) || 42 | ["INPUT", "TEXTAREA"].indexOf(document.activeElement.tagName) !== -1""" 43 | if _runJavaScriptSync(self.page(), js, timeout=100): 44 | evt.accept() 45 | return True 46 | return QWebEngineView.event(self, evt) 47 | 48 | 49 | AnkiWebView.event = event # type: ignore 50 | -------------------------------------------------------------------------------- /code/altgrfix/manifest.json: -------------------------------------------------------------------------------- 1 | {"package": "altgrfix", "name": "altgrfix"} 2 | -------------------------------------------------------------------------------- /code/cardstats/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 3 | # 4 | # Show statistics about the current and previous card while reviewing. 5 | # Activate from the tools menu. 6 | # 7 | 8 | from __future__ import annotations 9 | 10 | from anki.cards import Card 11 | from aqt import mw 12 | from aqt.main import AnkiQt 13 | from aqt.mediasrv import PageContext 14 | from aqt.qt import * 15 | from aqt.webview import AnkiWebView, AnkiWebViewKind 16 | 17 | 18 | class DockableWithClose(QDockWidget): 19 | closed = pyqtSignal() 20 | 21 | def closeEvent(self, evt: QCloseEvent) -> None: 22 | self.closed.emit() 23 | QDockWidget.closeEvent(self, evt) 24 | 25 | 26 | class CardStats: 27 | def __init__(self, mw: AnkiQt): 28 | self.mw = mw 29 | self.shown: DockableWithClose | None = None 30 | from aqt import gui_hooks 31 | 32 | gui_hooks.reviewer_did_show_question.append(self._update) 33 | gui_hooks.reviewer_will_end.append(self.hide) 34 | 35 | def _addDockable(self, title: str, w: AnkiWebView) -> DockableWithClose: 36 | dock = DockableWithClose(title, mw) 37 | dock.setObjectName(title) 38 | dock.setAllowedAreas( 39 | Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea 40 | ) 41 | dock.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable) 42 | dock.setWidget(w) 43 | if mw.width() < 600: 44 | mw.resize(QSize(600, mw.height())) 45 | mw.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock) 46 | return dock 47 | 48 | def _remDockable(self, dock: QDockWidget) -> None: 49 | mw.removeDockWidget(dock) 50 | 51 | def show(self) -> None: 52 | if not self.shown: 53 | 54 | class ThinAnkiWebView(AnkiWebView): 55 | def sizeHint(self) -> QSize: 56 | return QSize(200, 100) 57 | 58 | self.web = ThinAnkiWebView(kind=AnkiWebViewKind.BROWSER_CARD_INFO) 59 | self.shown = self._addDockable(("Card Info"), self.web) 60 | self.shown.closed.connect(self._onClosed) 61 | self._load_html() 62 | else: 63 | self._update(None) 64 | 65 | def hide(self) -> None: 66 | if self.shown: 67 | self._remDockable(self.shown) 68 | self.shown = None 69 | 70 | def toggle(self) -> None: 71 | if self.shown: 72 | self.hide() 73 | else: 74 | self.show() 75 | 76 | def _onClosed(self) -> None: 77 | # schedule removal for after evt has finished 78 | self.mw.progress.single_shot(100, self.hide, False) 79 | 80 | def _update(self, card: Card | None) -> None: 81 | if not self.shown: 82 | return 83 | self.web.eval(f"anki.updateCardInfos('{self._get_ids()}');") 84 | 85 | def _get_ids(self) -> str: 86 | r = self.mw.reviewer 87 | current_id = r.card.id if r.card else "null" 88 | previous = r.lastCard() 89 | previous_id = previous.id if previous else "null" 90 | return f"{current_id}/{previous_id}" 91 | 92 | def _revlog_as_number(self) -> str: 93 | config = mw.addonManager.getConfig(__name__) 94 | return "1" if config.get("revlog") else "0" 95 | 96 | def _curve_as_number(self) -> str: 97 | config = mw.addonManager.getConfig(__name__) 98 | return "1" if config.get("curve") else "0" 99 | 100 | def _load_html(self) -> None: 101 | self.web.load_sveltekit_page( 102 | f"card-info/{self._get_ids()}?revlog={self._revlog_as_number()}&curve={self._curve_as_number()}" 103 | ) 104 | 105 | 106 | _cs = CardStats(mw) 107 | 108 | 109 | def cardStats(on: bool) -> None: 110 | _cs.toggle() 111 | 112 | 113 | action = QAction(mw) 114 | action.setText("Card Stats") 115 | action.setCheckable(True) 116 | action.setShortcut(QKeySequence("Ctrl+Alt+C")) 117 | mw.form.menuTools.addAction(action) 118 | action.toggled.connect(cardStats) # type: ignore 119 | -------------------------------------------------------------------------------- /code/cardstats/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "revlog": false, 3 | "curve": false 4 | } -------------------------------------------------------------------------------- /code/cardstats/config.md: -------------------------------------------------------------------------------- 1 | - **revlog**: set to true to show the review history 2 | - **curve**: set to true to show the forgetting curve -------------------------------------------------------------------------------- /code/cardstats/manifest.json: -------------------------------------------------------------------------------- 1 | {"package": "cardstats", "name": "cardstats"} -------------------------------------------------------------------------------- /code/changecreationtimes/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Matthew Duggan, mostly copied from bulk reading generator plugin 2 | # by Damien Elmes. 3 | # 4 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 5 | # 6 | # Reset creation times of a block of cards. 7 | # 8 | # For information about how to use, how to report a problem, see this addon's 9 | # download page: https://ankiweb.net/shared/info/1348853407 10 | # 11 | # History: 12 | # 2010/12/17 - New. 13 | # In Anki 1, creation time was at the card level, and kept in its own column. 14 | # 2013/03/19 - Updated for Anki 2.0.8 (by someguy) 15 | # In Anki 2, creation time is at the "note" level, inhereted by all the cards 16 | # created from a note, regardless of when those cards were created. Also, 17 | # creation time is no longer in its own column. It is the note's "id". 18 | # Changing the creation time is a bit trickier now because we have to execute 19 | # SQL to change the note table in a way Anki didn't intend. And, because 20 | # cards are foreign-keyed to the note's ID, we have to execute SQL to change 21 | # the card table too. 22 | 23 | from __future__ import annotations 24 | 25 | import random 26 | import time 27 | from collections.abc import Sequence 28 | 29 | from anki.cards import CardId 30 | from anki.lang import ngettext 31 | from anki.notes import NoteId 32 | from aqt import mw 33 | from aqt.browser.browser import Browser 34 | from aqt.qt import * 35 | from aqt.utils import getText, showWarning, tooltip 36 | 37 | # debug 38 | # from aqt.utils import showInfo 39 | 40 | # Bulk updates 41 | ########################################################################## 42 | 43 | 44 | def resetCreationTimes(note_ids: Sequence[NoteId], desttime: int) -> None: 45 | mw.progress.start(label="Reset Creation Times: updating...", max=len(note_ids)) 46 | 47 | # debug 48 | # showInfo(("Called reset with %s notes") % len(note_ids)) 49 | 50 | for note_cnt, note_id in enumerate(note_ids): 51 | # debug 52 | # showInfo(("Loop: Processing note id %s") % note_id) 53 | 54 | mw.progress.update( 55 | label=("Resetting Creation Times: updating note %s") % note_id, 56 | value=note_cnt, 57 | ) 58 | 59 | # Ensure timestamp doesn't already exist. (Copied from anki/utils.py 60 | # timestampID function). 61 | while mw.col.db.scalar("select id from notes where id = ?", desttime): 62 | desttime += 1 63 | 64 | # Update the note row 65 | mw.col.db.execute( 66 | """update notes 67 | set id=? 68 | where id = ?""", 69 | desttime, 70 | note_id, 71 | ) 72 | 73 | # Update the cards row(s) 74 | mw.col.db.execute( 75 | """update cards 76 | set nid=? 77 | where nid = ?""", 78 | desttime, 79 | note_id, 80 | ) 81 | 82 | desttime += 10 83 | 84 | mw.progress.finish() 85 | 86 | return 87 | 88 | 89 | # The browser displays cards. But, the creation date is on the note, and the 90 | # note can have multiple cards associated to it. This creates a challenge to 91 | # access the notes in the order of the displayed cards. To keep the processing 92 | # simple, this addon preprocesses the cards, gathering the unique note IDs, 93 | # and ensuring there are no surprises (such as the same note referenced by 94 | # cards in different sort locations in the browser). 95 | def identifyNotes(card_ids: Sequence[CardId]) -> tuple[int, list[NoteId]]: 96 | mw.progress.start( 97 | label="Reset Creation Times: collecting notes...", max=len(card_ids) 98 | ) 99 | last_nid: NoteId | None = None 100 | nids_ordered = [] 101 | nids_lookup = {} 102 | 103 | # debug 104 | # showInfo(("Called identifyNotes with %s cards") % len(card_ids)) 105 | 106 | # Loop through the selected cards, detecting unique notes and saving the 107 | # note IDs for driving the DB update. 108 | card_cnt = 0 109 | for card_cnt, card_id in enumerate(card_ids): 110 | # debug 111 | # showInfo(("Loop: Processing card id %s") % card_id) 112 | 113 | mw.progress.update( 114 | label=("Reset Creation Times: collecting note for card %s") % card_id, 115 | value=card_cnt, 116 | ) 117 | 118 | # Retrieve the selected card to get the note ID. 119 | card = mw.col.get_card(card_id) 120 | 121 | # debug 122 | # showInfo(("retrieved card_id %s with note_id %s") % (card_id, card.nid)) 123 | 124 | # We expect sibling cards (of a note) to be grouped together. When a new 125 | # note is encountered, save it for later processing. 126 | if card.nid != last_nid: 127 | # I don't think this could ever happen: 128 | # This is a precaution that a note's sibling cards are grouped 129 | # together. If it were possible for them to be sorted in the browser 130 | # in a way that they wouldn't be contiguous, this would cause the 131 | # underlying note (creation time) to be processed twice in a way the 132 | # user didn't intend. Anki's data model makes this logically 133 | # possible, but the browser may prevent it. This test is a way to be 134 | # absolutely certain. 135 | if card.nid in nids_lookup: 136 | showWarning( 137 | "A note found out of order. Your cards appear to be sorted in a way that siblings are not contiguous. It is not possible to reset the create time this way. Please report this to the addon discussion forum." 138 | ) 139 | 140 | # Add the nid to the end of an array which will be used to drive the 141 | # DB update. This maintains note ids in the same order which the 142 | # cards appear in the browser. 143 | nids_ordered.append(card.nid) 144 | 145 | # Add the nid to a dictionary so we can easily reference it (to see 146 | # if a nid was previously encountered. I.e., whether sibling cards 147 | # weren't grouped together in the browser.). 148 | nids_lookup.update({card.nid: 1}) 149 | 150 | # Save the new nid value so we can skip sibling cards and detect 151 | # when a card belonging to a different note is encountered. 152 | last_nid = card.nid 153 | 154 | mw.progress.finish() 155 | 156 | return card_cnt + 1, nids_ordered 157 | 158 | 159 | def setupMenu(browser: Browser) -> None: 160 | a = QAction("Reset Creation Times", browser) 161 | a.triggered.connect(lambda _, e=browser: onResetTimes(e)) 162 | browser.form.menuEdit.addSeparator() 163 | browser.form.menuEdit.addAction(a) 164 | 165 | 166 | def onResetTimes(browser: Browser) -> None: 167 | # Make sure user selected something. 168 | if not browser.form.tableView.selectionModel().hasSelection(): 169 | showWarning( 170 | "Please select at least one card to reset creation date.", parent=browser 171 | ) 172 | return 173 | 174 | # Preprocess cards, collecting note IDs. 175 | (card_cnt, nids) = identifyNotes(browser.selected_cards()) 176 | 177 | # debug 178 | # showInfo(("Processed %s cards leading to %s notes") % (card_cnt, len(nids))) 179 | 180 | # Prompt for date. 181 | todaystr = time.strftime("%Y/%m/%d", time.localtime()) 182 | (s, ret) = getText( 183 | "Enter a date as YYYY/MM/DD to set as the creation time, or 'today' for current date (%s):" 184 | % todaystr, 185 | parent=browser, 186 | ) 187 | 188 | if (not s) or (not ret): 189 | return 190 | 191 | # Generate a random MM:HH:SS. This will help prevent the same timestamp from 192 | # being used if this addon is executed multiple times with the same date. 193 | random_time = ("%s:%s:%s") % ( 194 | random.randint(0, 23), 195 | random.randint(0, 59), 196 | random.randint(0, 59), 197 | ) 198 | 199 | # Don't want random? Uncomment the following line and specify any time you 200 | # want in the format HH:MM:SS where HH is 00-24: 201 | # random_time = "15:01:01" 202 | 203 | if s == "today": 204 | desttime = time.mktime( 205 | time.strptime(("%s %s") % (todaystr, random_time), "%Y/%m/%d %H:%M:%S") 206 | ) 207 | else: 208 | try: 209 | desttime = time.mktime( 210 | time.strptime(("%s %s") % (s, random_time), "%Y/%m/%d %H:%M:%S") 211 | ) 212 | except ValueError: 213 | showWarning( 214 | "Sorry, I didn't understand that date. Please enter 'today' or a date in YYYY/MM/DD format", 215 | parent=browser, 216 | ) 217 | return 218 | 219 | # This mimics anki/utils.py timestampID function (which calls int_time for 220 | # seconds since epoch and multiplies those seconds by 1000). 221 | desttime = desttime * 1000 222 | 223 | # debug 224 | # showInfo(("desttime %s") % desttime) 225 | 226 | # Force a full sync if collection isn't already marked for one. This is 227 | # apparently because we are changing the key column of the table. 228 | # (Per Damien on 2013/01/07: http://groups.google.com/group/anki-users/msg/3c8910e10f6fd0ac?hl=en ) 229 | mw.col.mod_schema(check=True) 230 | 231 | # Do it. 232 | resetCreationTimes(nids, int(desttime)) 233 | 234 | # Done. 235 | mw.reset() 236 | tooltip( 237 | ngettext( 238 | "Creation time reset for %d note.", 239 | "Creation time reset for %d notes.", 240 | len(nids), 241 | ) 242 | % len(nids) 243 | ) 244 | 245 | 246 | from aqt import gui_hooks 247 | 248 | gui_hooks.browser_will_show.append(setupMenu) 249 | -------------------------------------------------------------------------------- /code/changecreationtimes/manifest.json: -------------------------------------------------------------------------------- 1 | {"package": "changecreationtimes", "name": "changecreationtimes"} -------------------------------------------------------------------------------- /code/fixinvalidhtml/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 3 | # 4 | # Feed field HTML through BeautifulSoup to fix things like unbalanced div tags. 5 | # 6 | 7 | import warnings 8 | from collections.abc import Sequence 9 | 10 | from anki.notes import Note, NoteId 11 | from aqt import gui_hooks, mw 12 | from aqt.browser.browser import Browser 13 | from aqt.qt import * 14 | from aqt.utils import showInfo 15 | from bs4 import BeautifulSoup 16 | 17 | 18 | def onFixHTML(browser: Browser) -> None: 19 | nids = browser.selected_notes() 20 | if not nids: 21 | showInfo("Please select some notes.") 22 | return 23 | 24 | mw.progress.start(immediate=True) 25 | try: 26 | changed = _onFixHTML(browser, nids) 27 | finally: 28 | mw.progress.finish() 29 | 30 | mw.reset() 31 | 32 | showInfo("Updated %d/%d notes." % (changed, len(nids)), parent=browser) 33 | 34 | 35 | def _onFixHTML(browser: Browser, nids: Sequence[NoteId]) -> int: 36 | changed = 0 37 | for c, nid in enumerate(nids): 38 | note = mw.col.get_note(nid) 39 | if _fixNoteHTML(note): 40 | changed += 1 41 | mw.progress.update(label="Processed %d/%d notes" % (c + 1, len(nids))) 42 | return changed 43 | 44 | 45 | # true on change 46 | def _fixNoteHTML(note: Note) -> bool: 47 | changed = False 48 | for fld, val in note.items(): 49 | with warnings.catch_warnings(): 50 | warnings.simplefilter("ignore", UserWarning) 51 | parsed = str(BeautifulSoup(val, "html.parser")) 52 | if parsed != val: 53 | note[fld] = parsed 54 | changed = True 55 | 56 | if changed: 57 | mw.col.update_note(note) 58 | 59 | return changed 60 | 61 | 62 | def onMenuSetup(browser: Browser) -> None: 63 | act = QAction(browser) 64 | act.setText("Fix Invalid HTML") 65 | mn = browser.form.menu_Notes 66 | mn.addSeparator() 67 | mn.addAction(act) 68 | act.triggered.connect(lambda b=browser: onFixHTML(browser)) 69 | 70 | 71 | gui_hooks.browser_will_show.append(onMenuSetup) 72 | -------------------------------------------------------------------------------- /code/fixinvalidhtml/manifest.json: -------------------------------------------------------------------------------- 1 | {"package": "fixinvalidhtml", "name": "fixinvalidhtml"} -------------------------------------------------------------------------------- /code/gtts_player/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | """ 5 | An example of how other TTS services can be added to Anki. 6 | 7 | This add-on registers all voices with the name 'gTTS', meaning if the user 8 | wishes to use it in preference to any other TTS drivers that are registered, 9 | they can use the following in their template, changing en_US to another language 10 | as necessary: 11 | 12 | {{tts en_US voices=gTTS:Field}} 13 | """ 14 | 15 | import os 16 | import sys 17 | from concurrent.futures import Future 18 | from dataclasses import dataclass 19 | from typing import List, cast 20 | 21 | from anki.lang import compatMap 22 | from anki.sound import AVTag, TTSTag 23 | from aqt import mw 24 | from aqt.sound import OnDoneCallback, av_player 25 | from aqt.tts import TTSProcessPlayer, TTSVoice 26 | 27 | sys.path.append(os.path.join(os.path.dirname(__file__), "vendor")) 28 | 29 | from gtts import gTTS # isort:skip pylint: disable=import-error 30 | from gtts.lang import tts_langs # pylint: disable=import-error 31 | 32 | 33 | # we subclass the default voice object to store the gtts language code 34 | @dataclass 35 | class GTTSVoice(TTSVoice): 36 | gtts_lang: str 37 | 38 | 39 | class GTTSPlayer(TTSProcessPlayer): 40 | # this is called the first time Anki tries to play a TTS file 41 | def get_available_voices(self) -> list[TTSVoice]: 42 | voices = [] 43 | for code, name in tts_langs().items(): 44 | if "-" in code: 45 | # get a standard code like en_US from the gtts code en-us 46 | head, tail = code.split("-") 47 | std_code = f"{head}_{tail.upper()}" 48 | else: 49 | # get a standard code like cs_CZ from gtts code cs 50 | std_code = compatMap.get(code) 51 | # skip languages we don't understand 52 | if not std_code: 53 | continue 54 | 55 | # add the voice using the name "gtts" 56 | voices.append(GTTSVoice(name="gTTS", lang=std_code, gtts_lang=code)) 57 | return voices # type: ignore 58 | 59 | # this is called on a background thread, and will not block the UI 60 | def _play(self, tag: AVTag) -> None: 61 | # get the avtag 62 | assert isinstance(tag, TTSTag) 63 | match = self.voice_for_tag(tag) 64 | assert match 65 | voice = cast(GTTSVoice, match.voice) 66 | 67 | # is the field blank? 68 | if not tag.field_text.strip(): 69 | return 70 | 71 | # get filename, and skip if already generated 72 | self._tmpfile = self.temp_file_for_tag_and_voice(tag, match.voice) + ".mp3" 73 | if os.path.exists(self._tmpfile): 74 | return 75 | 76 | # gtts only offers 'normal' and 'slow' 77 | slow = tag.speed < 1 78 | 79 | # call gtts to save an mp3 file to the path 80 | lang = voice.gtts_lang # pylint: disable=no-member 81 | tts = gTTS(text=tag.field_text, lang=lang, lang_check=False, slow=slow) 82 | tts.save(self._tmpfile) 83 | 84 | # this is called on the main thread, after _play finishes 85 | def _on_done(self, ret: Future, cb: OnDoneCallback) -> None: 86 | ret.result() 87 | 88 | # inject file into the top of the audio queue 89 | av_player.insert_file(self._tmpfile) 90 | 91 | # then tell player to advance, which will cause the file to be played 92 | cb() 93 | 94 | # we don't support stopping while the file is being downloaded 95 | # (but the user can interrupt playing after it has been downloaded) 96 | def stop(self): 97 | pass 98 | 99 | 100 | # register our handler 101 | av_player.players.append(GTTSPlayer(mw.taskman)) 102 | -------------------------------------------------------------------------------- /code/gtts_player/add_vendor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm -rf g vendor/gtts 4 | git clone --depth 1 https://github.com/pndurette/gTTS.git g 5 | mv g/gtts vendor 6 | mv g/LICENSE vendor/gtts 7 | 8 | rm -rf g 9 | -------------------------------------------------------------------------------- /code/gtts_player/manifest.json: -------------------------------------------------------------------------------- 1 | {"package": "gtts_player", "name": "gtts_player"} -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2014-2023 Pierre Nicolas Durette 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/__init__.py: -------------------------------------------------------------------------------- 1 | from .version import __version__ # noqa: F401 2 | from .tts import gTTS, gTTSError 3 | 4 | __all__ = ["gTTS", "gTTSError"] 5 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/accents.py: -------------------------------------------------------------------------------- 1 | # try adding these to the tld 2 | accents = [ 3 | "com", 4 | "ad", 5 | "ae", 6 | "com.af", 7 | "com.ag", 8 | "com.ai", 9 | "com.ar", 10 | "as", 11 | "at", 12 | "com.au", 13 | "az", 14 | "ba", 15 | "com.bd", 16 | "be", 17 | "bf", 18 | "bg", 19 | "bj", 20 | "br", 21 | "bs", 22 | "bt", 23 | "co.bw", 24 | "by", 25 | "com.bz", 26 | "ca", 27 | "cd", 28 | "ch", 29 | "ci", 30 | "co.ck", 31 | "cl", 32 | "cm", 33 | "cn", 34 | "com.co", 35 | "co.cr", 36 | "cv", 37 | "dj", 38 | "dm", 39 | "com.do", 40 | "dz", 41 | "com.ec", 42 | "ee", 43 | "com.eg", 44 | "es", 45 | "et", 46 | "fi", 47 | "com.fj", 48 | "fm", 49 | "fr", 50 | "ga", 51 | "ge", 52 | "gg", 53 | "com.gh", 54 | "com.gi", 55 | "gl", 56 | "gm", 57 | "gr", 58 | "com.gt", 59 | "gy", 60 | "com.hk", 61 | "hn", 62 | "ht", 63 | "hr", 64 | "hu", 65 | "co.id", 66 | "ie", 67 | "co.il", 68 | "im", 69 | "co.in", 70 | "iq", 71 | "is", 72 | "it", 73 | "iw", 74 | "je", 75 | "com.je", 76 | "jo", 77 | "co.jp", 78 | "co.ke", 79 | "com.kh", 80 | "ki", 81 | "kg", 82 | "co.kr", 83 | "com.kw", 84 | "kz", 85 | "la", 86 | "com.lb", 87 | "li", 88 | "lk", 89 | "co.ls", 90 | "lt", 91 | "lu", 92 | "lv", 93 | "com.ly", 94 | "com.ma", 95 | "md", 96 | "me", 97 | "mg", 98 | "mk", 99 | "ml", 100 | "mm", 101 | "mn", 102 | "ms", 103 | "com.mt", 104 | "mu", 105 | "mv", 106 | "mw", 107 | "com.mx", 108 | "com.my", 109 | "co.mz", 110 | "na", 111 | "ng", 112 | "ni", 113 | "ne", 114 | "nl", 115 | "no", 116 | "com.np", 117 | "nr", 118 | "nu", 119 | "co.nz", 120 | "com.om", 121 | "pa", 122 | "pe", 123 | "pg", 124 | "ph", 125 | "pk", 126 | "pl", 127 | "pn", 128 | "com.pr", 129 | "ps", 130 | "pt", 131 | "com.py", 132 | "com.qa", 133 | "ro", 134 | "ru", 135 | "rw", 136 | "com.sa", 137 | "com.sb", 138 | "sc", 139 | "se", 140 | "com.sg", 141 | "sh", 142 | "si", 143 | "sk", 144 | "com.sl", 145 | "sn", 146 | "so", 147 | "sm", 148 | "sr", 149 | "st", 150 | "com.sv", 151 | "td", 152 | "tg", 153 | "co.th", 154 | "com.tj", 155 | "tl", 156 | "tm", 157 | "tn", 158 | "to", 159 | "com.tr", 160 | "tt", 161 | "com.tw", 162 | "co.tz", 163 | "com.ua", 164 | "co.ug", 165 | "co.uk", 166 | "com,uy", 167 | "co.uz", 168 | "com.vc", 169 | "co.ve", 170 | "vg", 171 | "co.vi", 172 | "com.vn", 173 | "vu", 174 | "ws", 175 | "rs", 176 | "co.za", 177 | "co.zm", 178 | "co.zw", 179 | "cat", 180 | ] 181 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/cli.py: -------------------------------------------------------------------------------- 1 | from gtts import gTTS, gTTSError, __version__ 2 | from gtts.lang import tts_langs, _fallback_deprecated_lang 3 | import click 4 | import logging 5 | import logging.config 6 | 7 | # Click settings 8 | CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} 9 | 10 | # Logger settings 11 | LOGGER_SETTINGS = { 12 | "version": 1, 13 | "formatters": {"default": {"format": "%(name)s - %(levelname)s - %(message)s"}}, 14 | "handlers": {"console": {"class": "logging.StreamHandler", "formatter": "default"}}, 15 | "loggers": {"gtts": {"handlers": ["console"], "level": "WARNING"}}, 16 | } 17 | 18 | # Logger 19 | logging.config.dictConfig(LOGGER_SETTINGS) 20 | log = logging.getLogger("gtts") 21 | 22 | 23 | def sys_encoding(): 24 | """Charset to use for --file |- (stdin)""" 25 | return "utf8" 26 | 27 | 28 | def validate_text(ctx, param, text): 29 | """Validation callback for the argument. 30 | Ensures (arg) and (opt) are mutually exclusive 31 | """ 32 | if not text and "file" not in ctx.params: 33 | # No and no 34 | raise click.BadParameter(" or -f/--file required") 35 | if text and "file" in ctx.params: 36 | # Both and 37 | raise click.BadParameter(" and -f/--file can't be used together") 38 | return text 39 | 40 | 41 | def validate_lang(ctx, param, lang): 42 | """Validation callback for the option. 43 | Ensures is a supported language unless the flag is set 44 | """ 45 | if ctx.params["nocheck"]: 46 | return lang 47 | 48 | # Fallback from deprecated language if needed 49 | lang = _fallback_deprecated_lang(lang) 50 | 51 | try: 52 | if lang not in tts_langs(): 53 | raise click.UsageError( 54 | "'%s' not in list of supported languages.\n" 55 | "Use --all to list languages or " 56 | "add --nocheck to disable language check." % lang 57 | ) 58 | else: 59 | # The language is valid. 60 | # No need to let gTTS re-validate. 61 | ctx.params["nocheck"] = True 62 | except RuntimeError as e: 63 | # Only case where the flag can be False 64 | # Non-fatal. gTTS will try to re-validate. 65 | log.debug(str(e), exc_info=True) 66 | 67 | return lang 68 | 69 | 70 | def print_languages(ctx, param, value): 71 | """Callback for flag. 72 | Prints formatted sorted list of supported languages and exits 73 | """ 74 | if not value or ctx.resilient_parsing: 75 | return 76 | 77 | try: 78 | langs = tts_langs() 79 | langs_str_list = sorted("{}: {}".format(k, langs[k]) for k in langs) 80 | click.echo(" " + "\n ".join(langs_str_list)) 81 | except RuntimeError as e: # pragma: no cover 82 | log.debug(str(e), exc_info=True) 83 | raise click.ClickException("Couldn't fetch language list.") 84 | ctx.exit() 85 | 86 | 87 | def set_debug(ctx, param, debug): 88 | """Callback for flag. 89 | Sets logger level to DEBUG 90 | """ 91 | if debug: 92 | log.setLevel(logging.DEBUG) 93 | return 94 | 95 | 96 | @click.command(context_settings=CONTEXT_SETTINGS) 97 | @click.argument("text", metavar="", required=False, callback=validate_text) 98 | @click.option( 99 | "-f", 100 | "--file", 101 | metavar="", 102 | # For py2.7/unicode. If encoding not None Click uses io.open 103 | type=click.File(encoding=sys_encoding()), 104 | help="Read from instead of .", 105 | ) 106 | @click.option( 107 | "-o", 108 | "--output", 109 | metavar="", 110 | type=click.File(mode="wb"), 111 | help="Write to instead of stdout.", 112 | ) 113 | @click.option("-s", "--slow", default=False, is_flag=True, help="Read more slowly.") 114 | @click.option( 115 | "-l", 116 | "--lang", 117 | metavar="", 118 | default="en", 119 | show_default=True, 120 | callback=validate_lang, 121 | help="IETF language tag. Language to speak in. List documented tags with --all.", 122 | ) 123 | @click.option( 124 | "-t", 125 | "--tld", 126 | metavar="", 127 | default="com", 128 | show_default=True, 129 | is_eager=True, # Prioritize to ensure it gets set before 130 | help="Top-level domain for the Google host, i.e https://translate.google.", 131 | ) 132 | @click.option( 133 | "--nocheck", 134 | default=False, 135 | is_flag=True, 136 | is_eager=True, # Prioritize to ensure it gets set before 137 | help="Disable strict IETF language tag checking. Allow undocumented tags.", 138 | ) 139 | @click.option( 140 | "--all", 141 | default=False, 142 | is_flag=True, 143 | is_eager=True, 144 | expose_value=False, 145 | callback=print_languages, 146 | help="Print all documented available IETF language tags and exit.", 147 | ) 148 | @click.option( 149 | "--debug", 150 | default=False, 151 | is_flag=True, 152 | is_eager=True, # Prioritize to see debug logs of callbacks 153 | expose_value=False, 154 | callback=set_debug, 155 | help="Show debug information.", 156 | ) 157 | @click.version_option(version=__version__) 158 | def tts_cli(text, file, output, slow, tld, lang, nocheck): 159 | """Read to mp3 format using Google Translate's Text-to-Speech API 160 | (set or --file to - for standard input) 161 | """ 162 | 163 | # stdin for 164 | if text == "-": 165 | text = click.get_text_stream("stdin").read() 166 | 167 | # stdout (when no ) 168 | if not output: 169 | output = click.get_binary_stream("stdout") 170 | 171 | # input (stdin on '-' is handled by click.File) 172 | if file: 173 | try: 174 | text = file.read() 175 | except UnicodeDecodeError as e: # pragma: no cover 176 | log.debug(str(e), exc_info=True) 177 | raise click.FileError( 178 | file.name, " must be encoded using '%s'." % sys_encoding() 179 | ) 180 | 181 | # TTS 182 | try: 183 | tts = gTTS(text=text, lang=lang, slow=slow, tld=tld, lang_check=not nocheck) 184 | tts.write_to_fp(output) 185 | except (ValueError, AssertionError) as e: 186 | raise click.UsageError(str(e)) 187 | except gTTSError as e: 188 | raise click.ClickException(str(e)) 189 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/lang.py: -------------------------------------------------------------------------------- 1 | from gtts.langs import _main_langs 2 | from warnings import warn 3 | import logging 4 | 5 | __all__ = ["tts_langs"] 6 | 7 | # Logger 8 | log = logging.getLogger(__name__) 9 | log.addHandler(logging.NullHandler()) 10 | 11 | 12 | def tts_langs(): 13 | """Languages Google Text-to-Speech supports. 14 | 15 | Returns: 16 | dict: A dictionary of the type `{ '': ''}` 17 | 18 | Where `` is an IETF language tag such as `en` or `zh-TW`, 19 | and `` is the full English name of the language, such as 20 | `English` or `Chinese (Mandarin/Taiwan)`. 21 | 22 | The dictionary returned combines languages from two origins: 23 | 24 | - Languages fetched from Google Translate (pre-generated in :mod:`gtts.langs`) 25 | - Languages that are undocumented variations that were observed to work and 26 | present different dialects or accents. 27 | 28 | """ 29 | langs = dict() 30 | langs.update(_main_langs()) 31 | langs.update(_extra_langs()) 32 | log.debug("langs: {}".format(langs)) 33 | return langs 34 | 35 | 36 | def _extra_langs(): 37 | """Define extra languages. 38 | 39 | Returns: 40 | dict: A dictionnary of extra languages manually defined. 41 | 42 | Variations of the ones generated in `_main_langs`, 43 | observed to provide different dialects or accents or 44 | just simply accepted by the Google Translate Text-to-Speech API. 45 | 46 | """ 47 | return { 48 | # Chinese 49 | "zh-TW": "Chinese (Mandarin/Taiwan)", 50 | "zh": "Chinese (Mandarin)", 51 | } 52 | 53 | 54 | def _fallback_deprecated_lang(lang): 55 | """Languages Google Text-to-Speech used to support. 56 | 57 | Language tags that don't work anymore, but that can 58 | fallback to a more general language code to maintain 59 | compatibility. 60 | 61 | Args: 62 | lang (string): The language tag. 63 | 64 | Returns: 65 | string: The language tag, as-is if not deprecated, 66 | or a fallack if it exits. 67 | 68 | Example: 69 | ``en-GB`` returns ``en``. 70 | ``en-gb`` returns ``en``. 71 | 72 | """ 73 | 74 | deprecated = { 75 | # '': [] 76 | "en": [ 77 | "en-us", 78 | "en-ca", 79 | "en-uk", 80 | "en-gb", 81 | "en-au", 82 | "en-gh", 83 | "en-in", 84 | "en-ie", 85 | "en-nz", 86 | "en-ng", 87 | "en-ph", 88 | "en-za", 89 | "en-tz", 90 | ], 91 | "fr": ["fr-ca", "fr-fr"], 92 | "pt": ["pt-br", "pt-pt"], 93 | "es": ["es-es", "es-us"], 94 | "zh-CN": ["zh-cn"], 95 | "zh-TW": ["zh-tw"], 96 | } 97 | 98 | for fallback_lang, deprecated_langs in deprecated.items(): 99 | if lang.lower() in deprecated_langs: 100 | msg = ( 101 | "'{}' has been deprecated, falling back to '{}'. " 102 | "This fallback will be removed in a future version." 103 | ).format(lang, fallback_lang) 104 | 105 | warn(msg, DeprecationWarning) 106 | log.warning(msg) 107 | 108 | return fallback_lang 109 | 110 | return lang 111 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/langs.py: -------------------------------------------------------------------------------- 1 | # Note: this file is generated 2 | _langs = { 3 | "af": "Afrikaans", 4 | "ar": "Arabic", 5 | "bg": "Bulgarian", 6 | "bn": "Bengali", 7 | "bs": "Bosnian", 8 | "ca": "Catalan", 9 | "cs": "Czech", 10 | "da": "Danish", 11 | "de": "German", 12 | "el": "Greek", 13 | "en": "English", 14 | "es": "Spanish", 15 | "et": "Estonian", 16 | "fi": "Finnish", 17 | "fr": "French", 18 | "gu": "Gujarati", 19 | "hi": "Hindi", 20 | "hr": "Croatian", 21 | "hu": "Hungarian", 22 | "id": "Indonesian", 23 | "is": "Icelandic", 24 | "it": "Italian", 25 | "iw": "Hebrew", 26 | "ja": "Japanese", 27 | "jw": "Javanese", 28 | "km": "Khmer", 29 | "kn": "Kannada", 30 | "ko": "Korean", 31 | "la": "Latin", 32 | "lv": "Latvian", 33 | "ml": "Malayalam", 34 | "mr": "Marathi", 35 | "ms": "Malay", 36 | "my": "Myanmar (Burmese)", 37 | "ne": "Nepali", 38 | "nl": "Dutch", 39 | "no": "Norwegian", 40 | "pl": "Polish", 41 | "pt": "Portuguese", 42 | "ro": "Romanian", 43 | "ru": "Russian", 44 | "si": "Sinhala", 45 | "sk": "Slovak", 46 | "sq": "Albanian", 47 | "sr": "Serbian", 48 | "su": "Sundanese", 49 | "sv": "Swedish", 50 | "sw": "Swahili", 51 | "ta": "Tamil", 52 | "te": "Telugu", 53 | "th": "Thai", 54 | "tl": "Filipino", 55 | "tr": "Turkish", 56 | "uk": "Ukrainian", 57 | "ur": "Urdu", 58 | "vi": "Vietnamese", 59 | "zh-CN": "Chinese (Simplified)", 60 | "zh-TW": "Chinese (Traditional)" 61 | } 62 | 63 | def _main_langs(): 64 | return _langs 65 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/gtts_player/vendor/gtts/tests/__init__.py -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tests/input_files/test_cli_test_ascii.txt: -------------------------------------------------------------------------------- 1 | Can you make pink a little more pinkish can you make pink a little more pinkish, nor can you make the font bigger? 2 | How much will it cost the website doesn't have the theme i was going for. -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tests/input_files/test_cli_test_utf8.txt: -------------------------------------------------------------------------------- 1 | 这是一个三岁的小孩 2 | 在讲述她从一系列照片里看到的东西。 3 | 对这个世界, 她也许还有很多要学的东西, 4 | 但在一个重要的任务上, 她已经是专家了: 5 | 去理解她所看到的东西。 6 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import re 3 | import os 4 | from click.testing import CliRunner 5 | from gtts.cli import tts_cli 6 | 7 | # Need to look into gTTS' log output to test proper instantiation 8 | # - Use testfixtures.LogCapture() b/c TestCase.assertLogs() needs py3.4+ 9 | # - Clear 'gtts' logger handlers (set in gtts.cli) to reduce test noise 10 | import logging 11 | from testfixtures import LogCapture 12 | 13 | logger = logging.getLogger("gtts") 14 | logger.handlers = [] 15 | 16 | 17 | """Test options and arguments""" 18 | 19 | 20 | def runner(args, input=None): 21 | return CliRunner().invoke(tts_cli, args, input) 22 | 23 | 24 | def runner_debug(args, input=None): 25 | return CliRunner().invoke(tts_cli, args + ["--debug"], input) 26 | 27 | 28 | # tests 29 | def test_text_no_text_or_file(): 30 | """One of (arg) and should be set""" 31 | result = runner_debug([]) 32 | 33 | assert " required" in result.output 34 | assert result.exit_code != 0 35 | 36 | 37 | def test_text_text_and_file(tmp_path): 38 | """ (arg) and should not be set together""" 39 | filename = tmp_path / "test_and_file.txt" 40 | filename.touch() 41 | 42 | result = runner_debug(["--file", str(filename), "test"]) 43 | 44 | assert " can't be used together" in result.output 45 | assert result.exit_code != 0 46 | 47 | 48 | def test_text_empty(tmp_path): 49 | """Exit on no text to speak (via )""" 50 | filename = tmp_path / "text_empty.txt" 51 | filename.touch() 52 | 53 | result = runner_debug(["--file", str(filename)]) 54 | 55 | assert "No text to speak" in result.output 56 | assert result.exit_code != 0 57 | 58 | 59 | # tests 60 | def test_file_not_exists(): 61 | """ should exist""" 62 | result = runner_debug(["--file", "notexist.txt", "test"]) 63 | 64 | assert "No such file or directory" in result.output 65 | assert result.exit_code != 0 66 | 67 | 68 | # tests 69 | @pytest.mark.net 70 | def test_all(): 71 | """Option should return a list of languages""" 72 | result = runner(["--all"]) 73 | 74 | # One or more of " xy: name" (\n optional to match the last) 75 | # Ex. " xx: xxxxx\n xx-yy: xxxxx\n xx: xxxxx" 76 | 77 | assert re.match(r"^(?:\s{2}(\w{2}|\w{2}-\w{2}): .+\n?)+$", result.output) 78 | assert result.exit_code == 0 79 | 80 | 81 | # tests 82 | @pytest.mark.net 83 | def test_lang_not_valid(): 84 | """Invalid should display an error""" 85 | result = runner(["--lang", "xx", "test"]) 86 | 87 | assert "xx' not in list of supported languages" in result.output 88 | assert result.exit_code != 0 89 | 90 | 91 | @pytest.mark.net 92 | def test_lang_nocheck(): 93 | """Invalid (with ) should display an error message from gtts""" 94 | with LogCapture() as lc: 95 | result = runner_debug(["--lang", "xx", "--nocheck", "test"]) 96 | 97 | log = str(lc) 98 | 99 | assert "lang: xx" in log 100 | assert "lang_check: False" in log 101 | assert "Unsupported language 'xx'" in result.output 102 | assert result.exit_code != 0 103 | 104 | 105 | # Param set tests 106 | @pytest.mark.net 107 | def test_params_set(): 108 | """Options should set gTTS instance arguments (read from debug log)""" 109 | with LogCapture() as lc: 110 | result = runner_debug( 111 | ["--lang", "fr", "--tld", "es", "--slow", "--nocheck", "test"] 112 | ) 113 | 114 | log = str(lc) 115 | 116 | assert "lang: fr" in log 117 | assert "tld: es" in log 118 | assert "lang_check: False" in log 119 | assert "slow: True" in log 120 | assert "text: test" in log 121 | assert result.exit_code == 0 122 | 123 | 124 | # Test all input methods 125 | pwd = os.path.dirname(__file__) 126 | 127 | # Text for stdin ('-' for or ) 128 | textstdin = """stdin 129 | test 130 | 123""" 131 | 132 | # Text for stdin ('-' for or ) (Unicode) 133 | textstdin_unicode = u"""你吃饭了吗? 134 | 你最喜欢哪部电影? 135 | 我饿了,我要去做饭了。""" 136 | 137 | # Text for and 138 | text = """Can you make pink a little more pinkish can you make pink a little more pinkish, nor can you make the font bigger? 139 | How much will it cost the website doesn't have the theme i was going for.""" 140 | 141 | textfile_ascii = os.path.join(pwd, "input_files", "test_cli_test_ascii.txt") 142 | 143 | # Text for and (Unicode) 144 | text_unicode = u"""这是一个三岁的小孩 145 | 在讲述她从一系列照片里看到的东西。 146 | 对这个世界, 她也许还有很多要学的东西, 147 | 但在一个重要的任务上, 她已经是专家了: 148 | 去理解她所看到的东西。""" 149 | 150 | textfile_utf8 = os.path.join(pwd, "input_files", "test_cli_test_utf8.txt") 151 | 152 | """ 153 | Method that mimics's LogCapture's __str__ method to make 154 | the string in the comprehension a unicode literal for P2.7 155 | https://github.com/Simplistix/testfixtures/blob/32c87902cb111b7ede5a6abca9b597db551c88ef/testfixtures/logcapture.py#L149 156 | """ 157 | 158 | 159 | def logcapture_str(lc): 160 | if not lc.records: 161 | return "No logging captured" 162 | 163 | return "\n".join([u"%s %s\n %s" % r for r in lc.actual()]) 164 | 165 | 166 | @pytest.mark.net 167 | def test_stdin_text(): 168 | with LogCapture() as lc: 169 | result = runner_debug(["-"], textstdin) 170 | log = logcapture_str(lc) 171 | 172 | assert "text: %s" % textstdin in log 173 | assert result.exit_code == 0 174 | 175 | 176 | @pytest.mark.net 177 | def test_stdin_text_unicode(): 178 | with LogCapture() as lc: 179 | result = runner_debug(["-"], textstdin_unicode) 180 | log = logcapture_str(lc) 181 | 182 | assert "text: %s" % textstdin_unicode in log 183 | assert result.exit_code == 0 184 | 185 | 186 | @pytest.mark.net 187 | def test_stdin_file(): 188 | with LogCapture() as lc: 189 | result = runner_debug(["--file", "-"], textstdin) 190 | log = logcapture_str(lc) 191 | 192 | assert "text: %s" % textstdin in log 193 | assert result.exit_code == 0 194 | 195 | 196 | @pytest.mark.net 197 | def test_stdin_file_unicode(): 198 | with LogCapture() as lc: 199 | result = runner_debug(["--file", "-"], textstdin_unicode) 200 | log = logcapture_str(lc) 201 | 202 | assert "text: %s" % textstdin_unicode in log 203 | assert result.exit_code == 0 204 | 205 | 206 | @pytest.mark.net 207 | def test_text(): 208 | with LogCapture() as lc: 209 | result = runner_debug([text]) 210 | log = logcapture_str(lc) 211 | 212 | assert "text: %s" % text in log 213 | assert result.exit_code == 0 214 | 215 | 216 | @pytest.mark.net 217 | def test_text_unicode(): 218 | with LogCapture() as lc: 219 | result = runner_debug([text_unicode]) 220 | log = logcapture_str(lc) 221 | 222 | assert "text: %s" % text_unicode in log 223 | assert result.exit_code == 0 224 | 225 | 226 | @pytest.mark.net 227 | def test_file_ascii(): 228 | with LogCapture() as lc: 229 | result = runner_debug(["--file", textfile_ascii]) 230 | log = logcapture_str(lc) 231 | 232 | assert "text: %s" % text in log 233 | assert result.exit_code == 0 234 | 235 | 236 | @pytest.mark.net 237 | def test_file_utf8(): 238 | with LogCapture() as lc: 239 | result = runner_debug(["--file", textfile_utf8]) 240 | log = logcapture_str(lc) 241 | 242 | assert "text: %s" % text_unicode in log 243 | assert result.exit_code == 0 244 | 245 | 246 | @pytest.mark.net 247 | def test_stdout(): 248 | result = runner(["test"]) 249 | 250 | # The MP3 encoding (LAME 3.99.5) used to leave a signature in the raw output 251 | # This no longer appears to be the case 252 | assert result.exit_code == 0 253 | 254 | 255 | @pytest.mark.net 256 | def test_file(tmp_path): 257 | filename = tmp_path / "out.mp3" 258 | 259 | result = runner(["test", "--output", str(filename)]) 260 | 261 | # Check if files created is > 2k 262 | assert filename.stat().st_size > 2000 263 | assert result.exit_code == 0 264 | 265 | 266 | if __name__ == "__main__": 267 | pytest.main(["-x", __file__]) 268 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tests/test_lang.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from gtts.lang import tts_langs, _extra_langs, _fallback_deprecated_lang 3 | from gtts.langs import _main_langs 4 | 5 | """Test language list""" 6 | 7 | 8 | def test_main_langs(): 9 | """Fetch languages successfully""" 10 | # Safe to assume 'en' (English) will always be there 11 | scraped_langs = _main_langs() 12 | assert "en" in scraped_langs 13 | 14 | 15 | def test_deprecated_lang(): 16 | """Test language deprecation fallback""" 17 | with pytest.deprecated_call(): 18 | assert _fallback_deprecated_lang("en-gb") == "en" 19 | 20 | 21 | if __name__ == "__main__": 22 | pytest.main(["-x", __file__]) 23 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tests/test_tts.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from unittest.mock import Mock 4 | 5 | from gtts.tts import gTTS, gTTSError 6 | from gtts.langs import _main_langs 7 | from gtts.lang import _extra_langs 8 | 9 | # Testing all languages takes some time. 10 | # Set TEST_LANGS envvar to choose languages to test. 11 | # * 'main': Languages extracted from the Web 12 | # * 'extra': Languagee set in Languages.EXTRA_LANGS 13 | # * 'all': All of the above 14 | # * : Languages tags list to test 15 | # Unset TEST_LANGS to test everything ('all') 16 | # See: langs_dict() 17 | 18 | 19 | """Construct a dict of suites of languages to test. 20 | { '' : } 21 | 22 | ex.: { 'fetch' : {'en': 'English', 'fr': 'French'}, 23 | 'extra' : {'en': 'English', 'fr': 'French'} } 24 | ex.: { 'environ' : ['en', 'fr'] } 25 | """ 26 | env = os.environ.get("TEST_LANGS") 27 | if not env or env == "all": 28 | langs = _main_langs() 29 | langs.update(_extra_langs()) 30 | elif env == "main": 31 | langs = _main_langs() 32 | elif env == "extra": 33 | langs = _extra_langs() 34 | else: 35 | env_langs = {l: l for l in env.split(",") if l} 36 | langs = env_langs 37 | 38 | 39 | @pytest.mark.net 40 | @pytest.mark.parametrize("lang", langs.keys(), ids=list(langs.values())) 41 | def test_TTS(tmp_path, lang): 42 | """Test all supported languages and file save""" 43 | 44 | text = "This is a test" 45 | """Create output .mp3 file successfully""" 46 | for slow in (False, True): 47 | filename = tmp_path / "test_{}_.mp3".format(lang) 48 | # Create gTTS and save 49 | tts = gTTS(text=text, lang=lang, slow=slow, lang_check=False) 50 | tts.save(filename) 51 | 52 | # Check if files created is > 1.5 53 | assert filename.stat().st_size > 1500 54 | 55 | 56 | @pytest.mark.net 57 | def test_unsupported_language_check(): 58 | """Raise ValueError on unsupported language (with language check)""" 59 | lang = "xx" 60 | text = "Lorem ipsum" 61 | check = True 62 | with pytest.raises(ValueError): 63 | gTTS(text=text, lang=lang, lang_check=check) 64 | 65 | 66 | def test_empty_string(): 67 | """Raise AssertionError on empty string""" 68 | text = "" 69 | with pytest.raises(AssertionError): 70 | gTTS(text=text) 71 | 72 | 73 | def test_no_text_parts(tmp_path): 74 | """Raises AssertionError on no content to send to API (no text_parts)""" 75 | text = " ..,\n" 76 | with pytest.raises(AssertionError): 77 | filename = tmp_path / "no_content.txt" 78 | tts = gTTS(text=text) 79 | tts.save(filename) 80 | 81 | 82 | # Test write_to_fp()/save() cases not covered elsewhere in this file 83 | 84 | 85 | @pytest.mark.net 86 | def test_bad_fp_type(): 87 | """Raise TypeError if fp is not a file-like object (no .write())""" 88 | # Create gTTS and save 89 | tts = gTTS(text="test") 90 | with pytest.raises(TypeError): 91 | tts.write_to_fp(5) 92 | 93 | 94 | @pytest.mark.net 95 | def test_save(tmp_path): 96 | """Save .mp3 file successfully""" 97 | filename = tmp_path / "save.mp3" 98 | # Create gTTS and save 99 | tts = gTTS(text="test") 100 | tts.save(filename) 101 | 102 | # Check if file created is > 2k 103 | assert filename.stat().st_size > 2000 104 | 105 | 106 | @pytest.mark.net 107 | def test_get_bodies(): 108 | """get request bodies list""" 109 | tts = gTTS(text="test", tld="com", lang="en") 110 | body = tts.get_bodies()[0] 111 | assert "test" in body 112 | # \"en\" url-encoded 113 | assert "%5C%22en%5C%22" in body 114 | 115 | 116 | def test_msg(): 117 | """Test gTTsError internal exception handling 118 | Set exception message successfully""" 119 | error1 = gTTSError("test") 120 | assert "test" == error1.msg 121 | 122 | error2 = gTTSError() 123 | assert error2.msg is None 124 | 125 | 126 | def test_infer_msg(): 127 | """Infer message sucessfully based on context""" 128 | 129 | # Without response: 130 | 131 | # Bad TLD 132 | ttsTLD = Mock(tld="invalid") 133 | errorTLD = gTTSError(tts=ttsTLD) 134 | assert ( 135 | errorTLD.msg 136 | == "Failed to connect. Probable cause: Host 'https://translate.google.invalid/' is not reachable" 137 | ) 138 | 139 | # With response: 140 | 141 | # 403 142 | tts403 = Mock() 143 | response403 = Mock(status_code=403, reason="aaa") 144 | error403 = gTTSError(tts=tts403, response=response403) 145 | assert ( 146 | error403.msg 147 | == "403 (aaa) from TTS API. Probable cause: Bad token or upstream API changes" 148 | ) 149 | 150 | # 200 (and not lang_check) 151 | tts200 = Mock(lang="xx", lang_check=False) 152 | response404 = Mock(status_code=200, reason="bbb") 153 | error200 = gTTSError(tts=tts200, response=response404) 154 | assert ( 155 | error200.msg 156 | == "200 (bbb) from TTS API. Probable cause: No audio stream in response. Unsupported language 'xx'" 157 | ) 158 | 159 | # >= 500 160 | tts500 = Mock() 161 | response500 = Mock(status_code=500, reason="ccc") 162 | error500 = gTTSError(tts=tts500, response=response500) 163 | assert ( 164 | error500.msg 165 | == "500 (ccc) from TTS API. Probable cause: Uptream API error. Try again later." 166 | ) 167 | 168 | # Unknown (ex. 100) 169 | tts100 = Mock() 170 | response100 = Mock(status_code=100, reason="ddd") 171 | error100 = gTTSError(tts=tts100, response=response100) 172 | assert error100.msg == "100 (ddd) from TTS API. Probable cause: Unknown" 173 | 174 | 175 | @pytest.mark.net 176 | def test_WebRequest(tmp_path): 177 | """Test Web Requests""" 178 | 179 | text = "Lorem ipsum" 180 | 181 | """Raise gTTSError on unsupported language (without language check)""" 182 | lang = "xx" 183 | check = False 184 | 185 | with pytest.raises(gTTSError): 186 | filename = tmp_path / "xx.txt" 187 | # Create gTTS 188 | tts = gTTS(text=text, lang=lang, lang_check=check) 189 | tts.save(filename) 190 | 191 | 192 | if __name__ == "__main__": 193 | pytest.main(["-x", __file__]) 194 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from gtts.utils import _minimize, _len, _clean_tokens, _translate_url 3 | 4 | delim = " " 5 | Lmax = 10 6 | 7 | 8 | def test_ascii(): 9 | _in = "Bacon ipsum dolor sit amet" 10 | _out = ["Bacon", "ipsum", "dolor sit", "amet"] 11 | assert _minimize(_in, delim, Lmax) == _out 12 | 13 | 14 | def test_ascii_no_delim(): 15 | _in = "Baconipsumdolorsitametflankcornedbee" 16 | _out = ["Baconipsum", "dolorsitam", "etflankcor", "nedbee"] 17 | assert _minimize(_in, delim, Lmax) == _out 18 | 19 | 20 | def test_unicode(): 21 | _in = u"这是一个三岁的小孩在讲述他从一系列照片里看到的东西。" 22 | _out = [u"这是一个三岁的小孩在", u"讲述他从一系列照片里", u"看到的东西。"] 23 | assert _minimize(_in, delim, Lmax) == _out 24 | 25 | 26 | def test_startwith_delim(): 27 | _in = delim + "test" 28 | _out = ["test"] 29 | assert _minimize(_in, delim, Lmax) == _out 30 | 31 | 32 | def test_len_ascii(): 33 | text = "Bacon ipsum dolor sit amet flank corned beef." 34 | assert _len(text) == 45 35 | 36 | 37 | def test_len_unicode(): 38 | text = u"但在一个重要的任务上" 39 | assert _len(text) == 10 40 | 41 | 42 | def test_only_space_and_punc(): 43 | _in = [",(:)?", "\t ", "\n"] 44 | _out = [] 45 | assert _clean_tokens(_in) == _out 46 | 47 | 48 | def test_strip(): 49 | _in = [" Bacon ", "& ", "ipsum\r", "."] 50 | _out = ["Bacon", "&", "ipsum"] 51 | assert _clean_tokens(_in) == _out 52 | 53 | 54 | def test_translate_url(): 55 | _in = {"tld": "qwerty", "path": "asdf"} 56 | _out = "https://translate.google.qwerty/asdf" 57 | assert _translate_url(**_in) == _out 58 | 59 | 60 | if __name__ == "__main__": 61 | pytest.main(["-x", __file__]) 62 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tokenizer/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import ( 2 | RegexBuilder, 3 | PreProcessorRegex, 4 | PreProcessorSub, 5 | Tokenizer, 6 | ) # noqa: F401 7 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tokenizer/core.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | class RegexBuilder: 5 | r"""Builds regex using arguments passed into a pattern template. 6 | 7 | Builds a regex object for which the pattern is made from an argument 8 | passed into a template. If more than one argument is passed (iterable), 9 | each pattern is joined by "|" (regex alternation 'or') to create a 10 | single pattern. 11 | 12 | Args: 13 | pattern_args (iteratable): String element(s) to be each passed to 14 | ``pattern_func`` to create a regex pattern. Each element is 15 | ``re.escape``'d before being passed. 16 | pattern_func (callable): A 'template' function that should take a 17 | string and return a string. It should take an element of 18 | ``pattern_args`` and return a valid regex pattern group string. 19 | flags: ``re`` flag(s) to compile with the regex. 20 | 21 | Example: 22 | To create a simple regex that matches on the characters "a", "b", 23 | or "c", followed by a period:: 24 | 25 | >>> rb = RegexBuilder('abc', lambda x: "{}\.".format(x)) 26 | 27 | Looking at ``rb.regex`` we get the following compiled regex:: 28 | 29 | >>> print(rb.regex) 30 | 'a\.|b\.|c\.' 31 | 32 | The above is fairly simple, but this class can help in writing more 33 | complex repetitive regex, making them more readable and easier to 34 | create by using existing data structures. 35 | 36 | Example: 37 | To match the character following the words "lorem", "ipsum", "meili" 38 | or "koda":: 39 | 40 | >>> words = ['lorem', 'ipsum', 'meili', 'koda'] 41 | >>> rb = RegexBuilder(words, lambda x: "(?<={}).".format(x)) 42 | 43 | Looking at ``rb.regex`` we get the following compiled regex:: 44 | 45 | >>> print(rb.regex) 46 | '(?<=lorem).|(?<=ipsum).|(?<=meili).|(?<=koda).' 47 | 48 | """ 49 | 50 | def __init__(self, pattern_args, pattern_func, flags=0): 51 | self.pattern_args = pattern_args 52 | self.pattern_func = pattern_func 53 | self.flags = flags 54 | 55 | # Compile 56 | self.regex = self._compile() 57 | 58 | def _compile(self): 59 | alts = [] 60 | for arg in self.pattern_args: 61 | arg = re.escape(arg) 62 | alt = self.pattern_func(arg) 63 | alts.append(alt) 64 | 65 | pattern = "|".join(alts) 66 | return re.compile(pattern, self.flags) 67 | 68 | def __repr__(self): # pragma: no cover 69 | return str(self.regex) 70 | 71 | 72 | class PreProcessorRegex: 73 | r"""Regex-based substitution text pre-processor. 74 | 75 | Runs a series of regex substitutions (``re.sub``) from each ``regex`` of a 76 | :class:`gtts.tokenizer.core.RegexBuilder` with an extra ``repl`` 77 | replacement parameter. 78 | 79 | Args: 80 | search_args (iteratable): String element(s) to be each passed to 81 | ``search_func`` to create a regex pattern. Each element is 82 | ``re.escape``'d before being passed. 83 | search_func (callable): A 'template' function that should take a 84 | string and return a string. It should take an element of 85 | ``search_args`` and return a valid regex search pattern string. 86 | repl (string): The common replacement passed to the ``sub`` method for 87 | each ``regex``. Can be a raw string (the case of a regex 88 | backreference, for example) 89 | flags: ``re`` flag(s) to compile with each `regex`. 90 | 91 | Example: 92 | Add "!" after the words "lorem" or "ipsum", while ignoring case:: 93 | 94 | >>> import re 95 | >>> words = ['lorem', 'ipsum'] 96 | >>> pp = PreProcessorRegex(words, 97 | ... lambda x: "({})".format(x), r'\\1!', 98 | ... re.IGNORECASE) 99 | 100 | In this case, the regex is a group and the replacement uses its 101 | backreference ``\\1`` (as a raw string). Looking at ``pp`` we get the 102 | following list of search/replacement pairs:: 103 | 104 | >>> print(pp) 105 | (re.compile('(lorem)', re.IGNORECASE), repl='\1!'), 106 | (re.compile('(ipsum)', re.IGNORECASE), repl='\1!') 107 | 108 | It can then be run on any string of text:: 109 | 110 | >>> pp.run("LOREM ipSuM") 111 | "LOREM! ipSuM!" 112 | 113 | See :mod:`gtts.tokenizer.pre_processors` for more examples. 114 | 115 | """ 116 | 117 | def __init__(self, search_args, search_func, repl, flags=0): 118 | self.repl = repl 119 | 120 | # Create regex list 121 | self.regexes = [] 122 | for arg in search_args: 123 | rb = RegexBuilder([arg], search_func, flags) 124 | self.regexes.append(rb.regex) 125 | 126 | def run(self, text): 127 | """Run each regex substitution on ``text``. 128 | 129 | Args: 130 | text (string): the input text. 131 | 132 | Returns: 133 | string: text after all substitutions have been sequentially 134 | applied. 135 | 136 | """ 137 | for regex in self.regexes: 138 | text = regex.sub(self.repl, text) 139 | return text 140 | 141 | def __repr__(self): # pragma: no cover 142 | subs_strs = [] 143 | for r in self.regexes: 144 | subs_strs.append("({}, repl='{}')".format(r, self.repl)) 145 | return ", ".join(subs_strs) 146 | 147 | 148 | class PreProcessorSub: 149 | r"""Simple substitution text preprocessor. 150 | 151 | Performs string-for-string substitution from list a find/replace pairs. 152 | It abstracts :class:`gtts.tokenizer.core.PreProcessorRegex` with a default 153 | simple substitution regex. 154 | 155 | Args: 156 | sub_pairs (list): A list of tuples of the style 157 | ``(, )`` 158 | ignore_case (bool): Ignore case during search. Defaults to ``True``. 159 | 160 | Example: 161 | Replace all occurences of "Mac" to "PC" and "Firefox" to "Chrome":: 162 | 163 | >>> sub_pairs = [('Mac', 'PC'), ('Firefox', 'Chrome')] 164 | >>> pp = PreProcessorSub(sub_pairs) 165 | 166 | Looking at the ``pp``, we get the following list of 167 | search (regex)/replacement pairs:: 168 | 169 | >>> print(pp) 170 | (re.compile('Mac', re.IGNORECASE), repl='PC'), 171 | (re.compile('Firefox', re.IGNORECASE), repl='Chrome') 172 | 173 | It can then be run on any string of text:: 174 | 175 | >>> pp.run("I use firefox on my mac") 176 | "I use Chrome on my PC" 177 | 178 | See :mod:`gtts.tokenizer.pre_processors` for more examples. 179 | 180 | """ 181 | 182 | def __init__(self, sub_pairs, ignore_case=True): 183 | def search_func(x): 184 | return u"{}".format(x) 185 | 186 | flags = re.I if ignore_case else 0 187 | 188 | # Create pre-processor list 189 | self.pre_processors = [] 190 | for sub_pair in sub_pairs: 191 | pattern, repl = sub_pair 192 | pp = PreProcessorRegex([pattern], search_func, repl, flags) 193 | self.pre_processors.append(pp) 194 | 195 | def run(self, text): 196 | """Run each substitution on ``text``. 197 | 198 | Args: 199 | text (string): the input text. 200 | 201 | Returns: 202 | string: text after all substitutions have been sequentially 203 | applied. 204 | 205 | """ 206 | for pp in self.pre_processors: 207 | text = pp.run(text) 208 | return text 209 | 210 | def __repr__(self): # pragma: no cover 211 | return ", ".join([str(pp) for pp in self.pre_processors]) 212 | 213 | 214 | class Tokenizer: 215 | r"""An extensible but simple generic rule-based tokenizer. 216 | 217 | A generic and simple string tokenizer that takes a list of functions 218 | (called `tokenizer cases`) returning ``regex`` objects and joins them by 219 | "|" (regex alternation 'or') to create a single regex to use with the 220 | standard ``regex.split()`` function. 221 | 222 | ``regex_funcs`` is a list of any function that can return a ``regex`` 223 | (from ``re.compile()``) object, such as a 224 | :class:`gtts.tokenizer.core.RegexBuilder` instance (and its ``regex`` 225 | attribute). 226 | 227 | See the :mod:`gtts.tokenizer.tokenizer_cases` module for examples. 228 | 229 | Args: 230 | regex_funcs (list): List of compiled ``regex`` objects. Each 231 | functions's pattern will be joined into a single pattern and 232 | compiled. 233 | flags: ``re`` flag(s) to compile with the final regex. Defaults to 234 | ``re.IGNORECASE`` 235 | 236 | Note: 237 | When the ``regex`` objects obtained from ``regex_funcs`` are joined, 238 | their individual ``re`` flags are ignored in favour of ``flags``. 239 | 240 | Raises: 241 | TypeError: When an element of ``regex_funcs`` is not a function, or 242 | a function that does not return a compiled ``regex`` object. 243 | 244 | Warning: 245 | Joined ``regex`` patterns can easily interfere with one another in 246 | unexpected ways. It is recommanded that each tokenizer case operate 247 | on distinct or non-overlapping chracters/sets of characters 248 | (For example, a tokenizer case for the period (".") should also 249 | handle not matching/cutting on decimals, instead of making that 250 | a seperate tokenizer case). 251 | 252 | Example: 253 | A tokenizer with a two simple case (*Note: these are bad cases to 254 | tokenize on, this is simply a usage example*):: 255 | 256 | >>> import re, RegexBuilder 257 | >>> 258 | >>> def case1(): 259 | ... return re.compile("\,") 260 | >>> 261 | >>> def case2(): 262 | ... return RegexBuilder('abc', lambda x: "{}\.".format(x)).regex 263 | >>> 264 | >>> t = Tokenizer([case1, case2]) 265 | 266 | Looking at ``case1().pattern``, we get:: 267 | 268 | >>> print(case1().pattern) 269 | '\\,' 270 | 271 | Looking at ``case2().pattern``, we get:: 272 | 273 | >>> print(case2().pattern) 274 | 'a\\.|b\\.|c\\.' 275 | 276 | Finally, looking at ``t``, we get them combined:: 277 | 278 | >>> print(t) 279 | 're.compile('\\,|a\\.|b\\.|c\\.', re.IGNORECASE) 280 | from: [, ]' 281 | 282 | It can then be run on any string of text:: 283 | 284 | >>> t.run("Hello, my name is Linda a. Call me Lin, b. I'm your friend") 285 | ['Hello', ' my name is Linda ', ' Call me Lin', ' ', " I'm your friend"] 286 | 287 | """ 288 | 289 | def __init__(self, regex_funcs, flags=re.IGNORECASE): 290 | self.regex_funcs = regex_funcs 291 | self.flags = flags 292 | 293 | try: 294 | # Combine 295 | self.total_regex = self._combine_regex() 296 | except (TypeError, AttributeError) as e: # pragma: no cover 297 | raise TypeError( 298 | "Tokenizer() expects a list of functions returning " 299 | "regular expression objects (i.e. re.compile). " + str(e) 300 | ) 301 | 302 | def _combine_regex(self): 303 | alts = [] 304 | for func in self.regex_funcs: 305 | alts.append(func()) 306 | 307 | pattern = "|".join(alt.pattern for alt in alts) 308 | return re.compile(pattern, self.flags) 309 | 310 | def run(self, text): 311 | """Tokenize `text`. 312 | 313 | Args: 314 | text (string): the input text to tokenize. 315 | 316 | Returns: 317 | list: A list of strings (token) split according to the tokenizer cases. 318 | 319 | """ 320 | return self.total_regex.split(text) 321 | 322 | def __repr__(self): # pragma: no cover 323 | return str(self.total_regex) + " from: " + str(self.regex_funcs) 324 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tokenizer/pre_processors.py: -------------------------------------------------------------------------------- 1 | from gtts.tokenizer import PreProcessorRegex, PreProcessorSub, symbols 2 | import re 3 | 4 | 5 | def tone_marks(text): 6 | """Add a space after tone-modifying punctuation. 7 | 8 | Because the `tone_marks` tokenizer case will split after a tone-modidfying 9 | punctuation mark, make sure there's whitespace after. 10 | 11 | """ 12 | return PreProcessorRegex( 13 | search_args=symbols.TONE_MARKS, 14 | search_func=lambda x: u"(?<={})".format(x), 15 | repl=" ", 16 | ).run(text) 17 | 18 | 19 | def end_of_line(text): 20 | """Re-form words cut by end-of-line hyphens. 21 | 22 | Remove "". 23 | 24 | """ 25 | return PreProcessorRegex( 26 | search_args="-", search_func=lambda x: u"{}\n".format(x), repl="" 27 | ).run(text) 28 | 29 | 30 | def abbreviations(text): 31 | """Remove periods after an abbreviation from a list of known 32 | abbrevations that can be spoken the same without that period. This 33 | prevents having to handle tokenization of that period. 34 | 35 | Note: 36 | Could potentially remove the ending period of a sentence. 37 | 38 | Note: 39 | Abbreviations that Google Translate can't pronounce without 40 | (or even with) a period should be added as a word substitution with a 41 | :class:`PreProcessorSub` pre-processor. Ex.: 'Esq.', 'Esquire'. 42 | 43 | """ 44 | return PreProcessorRegex( 45 | search_args=symbols.ABBREVIATIONS, 46 | search_func=lambda x: r"(?<={})(?=\.).".format(x), 47 | repl="", 48 | flags=re.IGNORECASE, 49 | ).run(text) 50 | 51 | 52 | def word_sub(text): 53 | """Word-for-word substitutions.""" 54 | return PreProcessorSub(sub_pairs=symbols.SUB_PAIRS).run(text) 55 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tokenizer/symbols.py: -------------------------------------------------------------------------------- 1 | ABBREVIATIONS = ["dr", "jr", "mr", "mrs", "ms", "msgr", "prof", "sr", "st"] 2 | 3 | SUB_PAIRS = [("Esq.", "Esquire")] 4 | 5 | ALL_PUNC = u"?!?!.,¡()[]¿…‥،;:—。,、:\n" 6 | 7 | TONE_MARKS = u"?!?!" 8 | 9 | PERIOD_COMMA = ".," 10 | 11 | COLON = u":" 12 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tokenizer/tests/test_core.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import re 3 | from gtts.tokenizer.core import ( 4 | RegexBuilder, 5 | PreProcessorRegex, 6 | PreProcessorSub, 7 | Tokenizer, 8 | ) 9 | 10 | # Tests based on classes usage examples 11 | # See class documentation for details 12 | 13 | 14 | class TestRegexBuilder(unittest.TestCase): 15 | def test_regexbuilder(self): 16 | rb = RegexBuilder("abc", lambda x: "{}".format(x)) 17 | self.assertEqual(rb.regex, re.compile("a|b|c")) 18 | 19 | 20 | class TestPreProcessorRegex(unittest.TestCase): 21 | def test_preprocessorregex(self): 22 | pp = PreProcessorRegex("ab", lambda x: "{}".format(x), "c") 23 | self.assertEqual(len(pp.regexes), 2) 24 | self.assertEqual(pp.regexes[0].pattern, "a") 25 | self.assertEqual(pp.regexes[1].pattern, "b") 26 | 27 | 28 | class TestPreProcessorSub(unittest.TestCase): 29 | def test_proprocessorsub(self): 30 | sub_pairs = [("Mac", "PC"), ("Firefox", "Chrome")] 31 | pp = PreProcessorSub(sub_pairs) 32 | _in = "I use firefox on my mac" 33 | _out = "I use Chrome on my PC" 34 | self.assertEqual(pp.run(_in), _out) 35 | 36 | 37 | class TestTokenizer(unittest.TestCase): 38 | # tokenizer case 1 39 | def case1(self): 40 | return re.compile(r"\,") 41 | 42 | # tokenizer case 2 43 | def case2(self): 44 | return RegexBuilder("abc", lambda x: r"{}\.".format(x)).regex 45 | 46 | def test_tokenizer(self): 47 | t = Tokenizer([self.case1, self.case2]) 48 | _in = "Hello, my name is Linda a. Call me Lin, b. I'm your friend" 49 | _out = ["Hello", " my name is Linda ", " Call me Lin", " ", " I'm your friend"] 50 | self.assertEqual(t.run(_in), _out) 51 | 52 | def test_bad_params_not_list(self): 53 | # original exception: TypeError 54 | with self.assertRaises(TypeError): 55 | Tokenizer(self.case1) 56 | 57 | def test_bad_params_not_callable(self): 58 | # original exception: TypeError 59 | with self.assertRaises(TypeError): 60 | Tokenizer([100]) 61 | 62 | def test_bad_params_not_callable_returning_regex(self): 63 | # original exception: AttributeError 64 | def not_regex(): 65 | return 1 66 | 67 | with self.assertRaises(TypeError): 68 | Tokenizer([not_regex]) 69 | 70 | 71 | if __name__ == "__main__": 72 | unittest.main() 73 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tokenizer/tests/test_pre_processors.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from gtts.tokenizer.pre_processors import ( 3 | tone_marks, 4 | end_of_line, 5 | abbreviations, 6 | word_sub, 7 | ) 8 | 9 | 10 | class TestPreProcessors(unittest.TestCase): 11 | def test_tone_marks(self): 12 | _in = "lorem!ipsum?" 13 | _out = "lorem! ipsum? " 14 | self.assertEqual(tone_marks(_in), _out) 15 | 16 | def test_end_of_line(self): 17 | _in = """test- 18 | ing""" 19 | _out = "testing" 20 | self.assertEqual(end_of_line(_in), _out) 21 | 22 | def test_abbreviations(self): 23 | _in = "jr. sr. dr." 24 | _out = "jr sr dr" 25 | self.assertEqual(abbreviations(_in), _out) 26 | 27 | def test_word_sub(self): 28 | _in = "Esq. Bacon" 29 | _out = "Esquire Bacon" 30 | self.assertEqual(word_sub(_in), _out) 31 | 32 | 33 | if __name__ == "__main__": 34 | unittest.main() 35 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tokenizer/tests/test_tokenizer_cases.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from gtts.tokenizer.tokenizer_cases import ( 3 | tone_marks, 4 | period_comma, 5 | colon, 6 | other_punctuation, 7 | legacy_all_punctuation, 8 | ) 9 | from gtts.tokenizer import Tokenizer, symbols 10 | 11 | 12 | class TestPreTokenizerCases(unittest.TestCase): 13 | def test_tone_marks(self): 14 | t = Tokenizer([tone_marks]) 15 | _in = "Lorem? Ipsum!" 16 | _out = ["Lorem?", "Ipsum!"] 17 | self.assertEqual(t.run(_in), _out) 18 | 19 | def test_period_comma(self): 20 | t = Tokenizer([period_comma]) 21 | _in = "Hello, it's 24.5 degrees in the U.K. today. $20,000,000." 22 | _out = ["Hello", "it's 24.5 degrees in the U.K. today", "$20,000,000."] 23 | self.assertEqual(t.run(_in), _out) 24 | 25 | def test_colon(self): 26 | t = Tokenizer([colon]) 27 | _in = "It's now 6:30 which means: morning missing:space" 28 | _out = ["It's now 6:30 which means", " morning missing", "space"] 29 | self.assertEqual(t.run(_in), _out) 30 | 31 | def test_other_punctuation(self): 32 | # String of the unique 'other punctuations' 33 | other_punc_str = "".join( 34 | set(symbols.ALL_PUNC) 35 | - set(symbols.TONE_MARKS) 36 | - set(symbols.PERIOD_COMMA) 37 | - set(symbols.COLON) 38 | ) 39 | 40 | t = Tokenizer([other_punctuation]) 41 | self.assertEqual(len(t.run(other_punc_str)) - 1, len(other_punc_str)) 42 | 43 | def test_legacy_all_punctuation(self): 44 | t = Tokenizer([legacy_all_punctuation]) 45 | self.assertEqual(len(t.run(symbols.ALL_PUNC)) - 1, len(symbols.ALL_PUNC)) 46 | 47 | 48 | if __name__ == "__main__": 49 | unittest.main() 50 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/tokenizer/tokenizer_cases.py: -------------------------------------------------------------------------------- 1 | from gtts.tokenizer import RegexBuilder, symbols 2 | 3 | 4 | def tone_marks(): 5 | """Keep tone-modifying punctuation by matching following character. 6 | 7 | Assumes the `tone_marks` pre-processor was run for cases where there might 8 | not be any space after a tone-modifying punctuation mark. 9 | """ 10 | return RegexBuilder( 11 | pattern_args=symbols.TONE_MARKS, pattern_func=lambda x: u"(?<={}).".format(x) 12 | ).regex 13 | 14 | 15 | def period_comma(): 16 | """Period and comma case. 17 | 18 | Match if not preceded by "." and only if followed by space. 19 | Won't cut in the middle/after dotted abbreviations; won't cut numbers. 20 | 21 | Note: 22 | Won't match if a dotted abbreviation ends a sentence. 23 | 24 | Note: 25 | Won't match the end of a sentence if not followed by a space. 26 | 27 | """ 28 | return RegexBuilder( 29 | pattern_args=symbols.PERIOD_COMMA, 30 | pattern_func=lambda x: r"(?`. Different Google domains 41 | can produce different localized 'accents' for a given 42 | language. This is also useful when ``google.com`` might be blocked 43 | within a network but a local or different Google host 44 | (e.g. ``google.com.hk``) is not. Default is ``com``. 45 | lang (string, optional): The language (IETF language tag) to 46 | read the text in. Default is ``en``. 47 | slow (bool, optional): Reads text more slowly. Defaults to ``False``. 48 | lang_check (bool, optional): Strictly enforce an existing ``lang``, 49 | to catch a language error early. If set to ``True``, 50 | a ``ValueError`` is raised if ``lang`` doesn't exist. 51 | Setting ``lang_check`` to ``False`` skips Web requests 52 | (to validate language) and therefore speeds up instanciation. 53 | Default is ``True``. 54 | pre_processor_funcs (list): A list of zero or more functions that are 55 | called to transform (pre-process) text before tokenizing. Those 56 | functions must take a string and return a string. Defaults to:: 57 | 58 | [ 59 | pre_processors.tone_marks, 60 | pre_processors.end_of_line, 61 | pre_processors.abbreviations, 62 | pre_processors.word_sub 63 | ] 64 | 65 | tokenizer_func (callable): A function that takes in a string and 66 | returns a list of string (tokens). Defaults to:: 67 | 68 | Tokenizer([ 69 | tokenizer_cases.tone_marks, 70 | tokenizer_cases.period_comma, 71 | tokenizer_cases.colon, 72 | tokenizer_cases.other_punctuation 73 | ]).run 74 | 75 | See Also: 76 | :doc:`Pre-processing and tokenizing ` 77 | 78 | Raises: 79 | AssertionError: When ``text`` is ``None`` or empty; when there's nothing 80 | left to speak after pre-precessing, tokenizing and cleaning. 81 | ValueError: When ``lang_check`` is ``True`` and ``lang`` is not supported. 82 | RuntimeError: When ``lang_check`` is ``True`` but there's an error loading 83 | the languages dictionary. 84 | 85 | """ 86 | 87 | GOOGLE_TTS_MAX_CHARS = 100 # Max characters the Google TTS API takes at a time 88 | GOOGLE_TTS_HEADERS = { 89 | "Referer": "http://translate.google.com/", 90 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) " 91 | "AppleWebKit/537.36 (KHTML, like Gecko) " 92 | "Chrome/47.0.2526.106 Safari/537.36", 93 | "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", 94 | } 95 | GOOGLE_TTS_RPC = "jQ1olc" 96 | 97 | def __init__( 98 | self, 99 | text, 100 | tld="com", 101 | lang="en", 102 | slow=False, 103 | lang_check=True, 104 | pre_processor_funcs=[ 105 | pre_processors.tone_marks, 106 | pre_processors.end_of_line, 107 | pre_processors.abbreviations, 108 | pre_processors.word_sub, 109 | ], 110 | tokenizer_func=Tokenizer( 111 | [ 112 | tokenizer_cases.tone_marks, 113 | tokenizer_cases.period_comma, 114 | tokenizer_cases.colon, 115 | tokenizer_cases.other_punctuation, 116 | ] 117 | ).run, 118 | ): 119 | 120 | # Debug 121 | for k, v in dict(locals()).items(): 122 | if k == "self": 123 | continue 124 | log.debug("%s: %s", k, v) 125 | 126 | # Text 127 | assert text, "No text to speak" 128 | self.text = text 129 | 130 | # Translate URL top-level domain 131 | self.tld = tld 132 | 133 | # Language 134 | self.lang_check = lang_check 135 | self.lang = lang 136 | 137 | if self.lang_check: 138 | # Fallback lang in case it is deprecated 139 | self.lang = _fallback_deprecated_lang(lang) 140 | 141 | try: 142 | langs = tts_langs() 143 | if self.lang not in langs: 144 | raise ValueError("Language not supported: %s" % lang) 145 | except RuntimeError as e: 146 | log.debug(str(e), exc_info=True) 147 | log.warning(str(e)) 148 | 149 | # Read speed 150 | if slow: 151 | self.speed = Speed.SLOW 152 | else: 153 | self.speed = Speed.NORMAL 154 | 155 | # Pre-processors and tokenizer 156 | self.pre_processor_funcs = pre_processor_funcs 157 | self.tokenizer_func = tokenizer_func 158 | 159 | def _tokenize(self, text): 160 | # Pre-clean 161 | text = text.strip() 162 | 163 | # Apply pre-processors 164 | for pp in self.pre_processor_funcs: 165 | log.debug("pre-processing: %s", pp) 166 | text = pp(text) 167 | 168 | if _len(text) <= self.GOOGLE_TTS_MAX_CHARS: 169 | return _clean_tokens([text]) 170 | 171 | # Tokenize 172 | log.debug("tokenizing: %s", self.tokenizer_func) 173 | tokens = self.tokenizer_func(text) 174 | 175 | # Clean 176 | tokens = _clean_tokens(tokens) 177 | 178 | # Minimize 179 | min_tokens = [] 180 | for t in tokens: 181 | min_tokens += _minimize(t, " ", self.GOOGLE_TTS_MAX_CHARS) 182 | 183 | # Filter empty tokens, post-minimize 184 | tokens = [t for t in min_tokens if t] 185 | 186 | return min_tokens 187 | 188 | def _prepare_requests(self): 189 | """Created the TTS API the request(s) without sending them. 190 | 191 | Returns: 192 | list: ``requests.PreparedRequests_``. `_``. 193 | """ 194 | # TTS API URL 195 | translate_url = _translate_url( 196 | tld=self.tld, path="_/TranslateWebserverUi/data/batchexecute" 197 | ) 198 | 199 | text_parts = self._tokenize(self.text) 200 | log.debug("text_parts: %s", str(text_parts)) 201 | log.debug("text_parts: %i", len(text_parts)) 202 | assert text_parts, "No text to send to TTS API" 203 | 204 | prepared_requests = [] 205 | for idx, part in enumerate(text_parts): 206 | data = self._package_rpc(part) 207 | 208 | log.debug("data-%i: %s", idx, data) 209 | 210 | # Request 211 | r = requests.Request( 212 | method="POST", 213 | url=translate_url, 214 | data=data, 215 | headers=self.GOOGLE_TTS_HEADERS, 216 | ) 217 | 218 | # Prepare request 219 | prepared_requests.append(r.prepare()) 220 | 221 | return prepared_requests 222 | 223 | def _package_rpc(self, text): 224 | parameter = [text, self.lang, self.speed, "null"] 225 | escaped_parameter = json.dumps(parameter, separators=(",", ":")) 226 | 227 | rpc = [[[self.GOOGLE_TTS_RPC, escaped_parameter, None, "generic"]]] 228 | espaced_rpc = json.dumps(rpc, separators=(",", ":")) 229 | return "f.req={}&".format(urllib.parse.quote(espaced_rpc)) 230 | 231 | def get_bodies(self): 232 | """Get TTS API request bodies(s) that would be sent to the TTS API. 233 | 234 | Returns: 235 | list: A list of TTS API request bodiess to make. 236 | """ 237 | return [pr.body for pr in self._prepare_requests()] 238 | 239 | def stream(self): 240 | """Do the TTS API request(s) and stream bytes 241 | 242 | Raises: 243 | :class:`gTTSError`: When there's an error with the API request. 244 | 245 | """ 246 | # When disabling ssl verify in requests (for proxies and firewalls), 247 | # urllib3 prints an insecure warning on stdout. We disable that. 248 | try: 249 | requests.packages.urllib3.disable_warnings( 250 | requests.packages.urllib3.exceptions.InsecureRequestWarning 251 | ) 252 | except: 253 | pass 254 | 255 | prepared_requests = self._prepare_requests() 256 | for idx, pr in enumerate(prepared_requests): 257 | try: 258 | with requests.Session() as s: 259 | # Send request 260 | r = s.send( 261 | request=pr, proxies=urllib.request.getproxies(), verify=False 262 | ) 263 | 264 | log.debug("headers-%i: %s", idx, r.request.headers) 265 | log.debug("url-%i: %s", idx, r.request.url) 266 | log.debug("status-%i: %s", idx, r.status_code) 267 | 268 | r.raise_for_status() 269 | except requests.exceptions.HTTPError as e: # pragma: no cover 270 | # Request successful, bad response 271 | log.debug(str(e)) 272 | raise gTTSError(tts=self, response=r) 273 | except requests.exceptions.RequestException as e: # pragma: no cover 274 | # Request failed 275 | log.debug(str(e)) 276 | raise gTTSError(tts=self) 277 | 278 | # Write 279 | for line in r.iter_lines(chunk_size=1024): 280 | decoded_line = line.decode("utf-8") 281 | if "jQ1olc" in decoded_line: 282 | audio_search = re.search(r'jQ1olc","\[\\"(.*)\\"]', decoded_line) 283 | if audio_search: 284 | as_bytes = audio_search.group(1).encode("ascii") 285 | yield base64.b64decode(as_bytes) 286 | else: 287 | # Request successful, good response, 288 | # no audio stream in response 289 | raise gTTSError(tts=self, response=r) 290 | log.debug("part-%i created", idx) 291 | 292 | def write_to_fp(self, fp): 293 | """Do the TTS API request(s) and write bytes to a file-like object. 294 | 295 | Args: 296 | fp (file object): Any file-like object to write the ``mp3`` to. 297 | 298 | Raises: 299 | :class:`gTTSError`: When there's an error with the API request. 300 | TypeError: When ``fp`` is not a file-like object that takes bytes. 301 | 302 | """ 303 | 304 | try: 305 | for idx, decoded in enumerate(self.stream()): 306 | fp.write(decoded) 307 | log.debug("part-%i written to %s", idx, fp) 308 | except (AttributeError, TypeError) as e: 309 | raise TypeError( 310 | "'fp' is not a file-like object or it does not take bytes: %s" % str(e) 311 | ) 312 | 313 | def save(self, savefile): 314 | """Do the TTS API request and write result to file. 315 | 316 | Args: 317 | savefile (string): The path and file name to save the ``mp3`` to. 318 | 319 | Raises: 320 | :class:`gTTSError`: When there's an error with the API request. 321 | 322 | """ 323 | with open(str(savefile), "wb") as f: 324 | self.write_to_fp(f) 325 | log.debug("Saved to %s", savefile) 326 | 327 | 328 | class gTTSError(Exception): 329 | """Exception that uses context to present a meaningful error message""" 330 | 331 | def __init__(self, msg=None, **kwargs): 332 | self.tts = kwargs.pop("tts", None) 333 | self.rsp = kwargs.pop("response", None) 334 | if msg: 335 | self.msg = msg 336 | elif self.tts is not None: 337 | self.msg = self.infer_msg(self.tts, self.rsp) 338 | else: 339 | self.msg = None 340 | super(gTTSError, self).__init__(self.msg) 341 | 342 | def infer_msg(self, tts, rsp=None): 343 | """Attempt to guess what went wrong by using known 344 | information (e.g. http response) and observed behaviour 345 | 346 | """ 347 | cause = "Unknown" 348 | 349 | if rsp is None: 350 | premise = "Failed to connect" 351 | 352 | if tts.tld != "com": 353 | host = _translate_url(tld=tts.tld) 354 | cause = "Host '{}' is not reachable".format(host) 355 | 356 | else: 357 | # rsp should be 358 | # http://docs.python-requests.org/en/master/api/ 359 | status = rsp.status_code 360 | reason = rsp.reason 361 | 362 | premise = "{:d} ({}) from TTS API".format(status, reason) 363 | 364 | if status == 403: 365 | cause = "Bad token or upstream API changes" 366 | elif status == 404 and tts.tld != "com": 367 | cause = "Unsupported tld '{}'".format(tts.tld) 368 | elif status == 200 and not tts.lang_check: 369 | cause = ( 370 | "No audio stream in response. Unsupported language '%s'" 371 | % self.tts.lang 372 | ) 373 | elif status >= 500: 374 | cause = "Uptream API error. Try again later." 375 | 376 | return "{}. Probable cause: {}".format(premise, cause) 377 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/utils.py: -------------------------------------------------------------------------------- 1 | from gtts.tokenizer.symbols import ALL_PUNC as punc 2 | from string import whitespace as ws 3 | import re 4 | 5 | _ALL_PUNC_OR_SPACE = re.compile(u"^[{}]*$".format(re.escape(punc + ws))) 6 | """Regex that matches if an entire line is only comprised 7 | of whitespace and punctuation 8 | 9 | """ 10 | 11 | 12 | def _minimize(the_string, delim, max_size): 13 | """Recursively split a string in the largest chunks 14 | possible from the highest position of a delimiter all the way 15 | to a maximum size 16 | 17 | Args: 18 | the_string (string): The string to split. 19 | delim (string): The delimiter to split on. 20 | max_size (int): The maximum size of a chunk. 21 | 22 | Returns: 23 | list: the minimized string in tokens 24 | 25 | Every chunk size will be at minimum ``the_string[0:idx]`` where ``idx`` 26 | is the highest index of ``delim`` found in ``the_string``; and at maximum 27 | ``the_string[0:max_size]`` if no ``delim`` was found in ``the_string``. 28 | In the latter case, the split will occur at ``the_string[max_size]`` 29 | which can be any character. The function runs itself again on the rest of 30 | ``the_string`` (``the_string[idx:]``) until no chunk is larger than 31 | ``max_size``. 32 | 33 | """ 34 | # Remove `delim` from start of `the_string` 35 | # i.e. prevent a recursive infinite loop on `the_string[0:0]` 36 | # if `the_string` starts with `delim` and is larger than `max_size` 37 | if the_string.startswith(delim): 38 | the_string = the_string[_len(delim) :] 39 | 40 | if _len(the_string) > max_size: 41 | try: 42 | # Find the highest index of `delim` in `the_string[0:max_size]` 43 | # i.e. `the_string` will be cut in half on `delim` index 44 | idx = the_string.rindex(delim, 0, max_size) 45 | except ValueError: 46 | # `delim` not found in `the_string`, index becomes `max_size` 47 | # i.e. `the_string` will be cut in half arbitrarily on `max_size` 48 | idx = max_size 49 | # Call itself again for `the_string[idx:]` 50 | return [the_string[:idx]] + _minimize(the_string[idx:], delim, max_size) 51 | else: 52 | return [the_string] 53 | 54 | 55 | def _len(text): 56 | """Same as ``len(text)`` for a string but that decodes 57 | ``text`` first in Python 2.x 58 | 59 | Args: 60 | text (string): String to get the size of. 61 | 62 | Returns: 63 | int: The size of the string. 64 | """ 65 | try: 66 | # Python 2 67 | return len(unicode(text)) 68 | except NameError: # pragma: no cover 69 | # Python 3 70 | return len(text) 71 | 72 | 73 | def _clean_tokens(tokens): 74 | """Clean a list of strings 75 | 76 | Args: 77 | tokens (list): A list of strings (tokens) to clean. 78 | 79 | Returns: 80 | list: Stripped strings ``tokens`` without the original elements 81 | that only consisted of whitespace and/or punctuation characters. 82 | 83 | """ 84 | return [t.strip() for t in tokens if not _ALL_PUNC_OR_SPACE.match(t)] 85 | 86 | 87 | def _translate_url(tld="com", path=""): 88 | """Generates a Google Translate URL 89 | 90 | Args: 91 | tld (string): Top-level domain for the Google Translate host, 92 | i.e ``https://translate.google.``. Default is ``com``. 93 | path: (string): A path to append to the Google Translate host, 94 | i.e ``https://translate.google.com/``. Default is ``""``. 95 | 96 | Returns: 97 | string: A Google Translate URL `https://translate.google./path` 98 | """ 99 | _GOOGLE_TTS_URL = "https://translate.google.{}/{}" 100 | return _GOOGLE_TTS_URL.format(tld, path) 101 | -------------------------------------------------------------------------------- /code/gtts_player/vendor/gtts/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.3.2" 2 | -------------------------------------------------------------------------------- /code/japanese/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 3 | 4 | from . import bulkreading, lookup, model, reading, stats 5 | -------------------------------------------------------------------------------- /code/japanese/bulkreading.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 3 | # 4 | # Bulk update of readings. 5 | # 6 | 7 | from collections.abc import Sequence 8 | 9 | from anki.notes import NoteId 10 | from aqt import mw 11 | from aqt.browser.browser import Browser 12 | from aqt.qt import * 13 | 14 | from .notetypes import isJapaneseNoteType 15 | from .reading import regenerateReading 16 | 17 | # Bulk updates 18 | ########################################################################## 19 | 20 | 21 | def regenerateReadings(nids: Sequence[NoteId]) -> None: 22 | mw.progress.start() 23 | for nid in nids: 24 | note = mw.col.get_note(nid) 25 | if not isJapaneseNoteType(note.note_type()["name"]): 26 | continue 27 | fields = mw.col.models.field_names(note.note_type()) 28 | for src in fields: 29 | regenerateReading(note, src) 30 | mw.col.update_note(note) 31 | mw.progress.finish() 32 | mw.reset() 33 | 34 | 35 | def setupMenu(browser: Browser) -> None: 36 | a = QAction("Bulk-add Readings", browser) 37 | a.triggered.connect(lambda: onRegenerate(browser)) 38 | browser.form.menuEdit.addSeparator() 39 | browser.form.menuEdit.addAction(a) 40 | 41 | 42 | def onRegenerate(browser: Browser) -> None: 43 | regenerateReadings(browser.selected_notes()) 44 | 45 | 46 | from aqt import gui_hooks 47 | 48 | gui_hooks.browser_will_show.append(setupMenu) 49 | -------------------------------------------------------------------------------- /code/japanese/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "noteTypes": ["japanese"], 3 | "srcFields": ["Expression", "Kanji"], 4 | "dstFields": ["Reading", "Reading"], 5 | "furiganaSuffix": " (furigana)" 6 | } 7 | -------------------------------------------------------------------------------- /code/japanese/config.md: -------------------------------------------------------------------------------- 1 | **WARNING**: Do not change these options if you're planning to share your deck 2 | with others, as reading generation will not work for other users if you change 3 | the defaults. 4 | 5 | *noteTypes*: By default, the add-on considers a note type Japanese if it finds 6 | the text "japanese" in the note type name. Case is ignored. 7 | 8 | *srcFields*: Fields to generate the reading for. 9 | 10 | *dstFields*: Fields where the reading should be placed. 11 | 12 | *furiganaSuffix*: If a field called "abc" exists, and another field called "abc 13 | (furigana)" exists, they will be used as source and destination fields. 14 | -------------------------------------------------------------------------------- /code/japanese/license.mecab-ipadic.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/license.mecab-ipadic.txt -------------------------------------------------------------------------------- /code/japanese/license.txt: -------------------------------------------------------------------------------- 1 | The add-on itself is AGPL3, with the following third party components included: 2 | 3 | - mecab (GPL) 4 | - kakasi (GPL) 5 | - mecab ipadic (see bundled license file) 6 | - userdic.dic (derived from EDICT, CC Attribution-ShareAlike 3.0) 7 | 8 | 9 | -------------------------------------------------------------------------------- /code/japanese/lookup.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 3 | # 4 | # Dictionary lookup support. 5 | # 6 | 7 | from __future__ import annotations 8 | 9 | import re 10 | from collections.abc import Callable 11 | from enum import Enum 12 | from urllib.parse import quote 13 | 14 | from aqt import mw 15 | from aqt.qt import * 16 | from aqt.utils import showInfo 17 | 18 | setUrl = QUrl.setUrl 19 | 20 | 21 | class Lookup: 22 | def __init__(self) -> None: 23 | pass 24 | 25 | def selection(self, function: Callable) -> None: 26 | "Get the selected text and look it up with FUNCTION." 27 | text = mw.web.selectedText() 28 | text = text.strip() 29 | if not text: 30 | showInfo(("Empty selection.")) 31 | return 32 | if "\n" in text: 33 | showInfo(("Can't look up a selection with a newline.")) 34 | return 35 | function(text) 36 | 37 | def edictKanji(self, text: str) -> None: 38 | self.edict(text, True) 39 | 40 | def edict(self, text: str, kanji: bool = False) -> None: 41 | """Look up the given text in the Japanese–English dictionary EDICT. 42 | 43 | Use Jim Breen's webservice WWWJDIC (Online Japanese Dictionary) to access the 44 | EDICT file. 45 | 46 | Args: 47 | text: The text to be looked up. 48 | kanji: An optional Boolean that specifies whether 'text' is a kanji or not. 49 | """ 50 | baseUrl: str = "http://nihongo.monash.edu/cgi-bin/wwwjdic" 51 | 52 | class KanjiSelectionType(str, Enum): 53 | KANJIDIC = "M" 54 | UNICODE = "U" 55 | JIS = "J" 56 | ENGLISH_MEANING = "E" 57 | 58 | if kanji: 59 | x = KanjiSelectionType.KANJIDIC.value 60 | else: 61 | x = KanjiSelectionType.UNICODE.value 62 | baseUrl = f"{baseUrl}?1M{x}" 63 | if self.isJapaneseText(text): 64 | baseUrl = f"{baseUrl}{KanjiSelectionType.JIS.value}" 65 | else: 66 | baseUrl = f"{baseUrl}{KanjiSelectionType.ENGLISH_MEANING.value}" 67 | search_key = quote(text.encode("utf-8")) 68 | url = f"{baseUrl}{search_key}" 69 | qurl = QUrl() 70 | setUrl(qurl, url) 71 | QDesktopServices.openUrl(qurl) 72 | 73 | def jishoKanji(self, text: str) -> None: 74 | self.jisho(text, True) 75 | 76 | def jisho(self, text: str, kanji: bool = False) -> None: 77 | "Look up TEXT with jisho." 78 | if kanji: 79 | baseUrl = "http://jisho.org/kanji/details/" 80 | else: 81 | baseUrl = "http://jisho.org/words?" 82 | if self.isJapaneseText(text): 83 | baseUrl += "jap=" 84 | else: 85 | baseUrl += "eng=" 86 | url = baseUrl + quote(text.encode("utf-8")) 87 | qurl = QUrl() 88 | setUrl(qurl, url) 89 | QDesktopServices.openUrl(qurl) 90 | 91 | def alc(self, text: str) -> None: 92 | "Look up TEXT with ALC." 93 | newText = quote(text.encode("utf-8")) 94 | url = "http://eow.alc.co.jp/" + newText + "/UTF-8/?ref=sa" 95 | qurl = QUrl() 96 | setUrl(qurl, url) 97 | QDesktopServices.openUrl(qurl) 98 | 99 | def isJapaneseText(self, text: str) -> bool: 100 | "True if 70% of text is a Japanese character." 101 | total = len(text) 102 | if total == 0: 103 | return True 104 | jp = 0 105 | en = 0 106 | for c in text: 107 | if ord(c) >= 0x2E00 and ord(c) <= 0x9FFF: 108 | jp += 1 109 | if re.match("[A-Za-z]", c): 110 | en += 1 111 | if not jp: 112 | return False 113 | return ((jp + 1) / float(en + 1)) >= 1.0 114 | 115 | 116 | def lookup() -> Lookup: 117 | if not getattr(mw, "lookup", None): 118 | mw.lookup = Lookup() # type: ignore 119 | return mw.lookup # type: ignore 120 | 121 | 122 | def _field(name: str) -> str | None: 123 | try: 124 | return mw.reviewer.card.note()[name] 125 | except: 126 | return None 127 | 128 | 129 | def onLookupExpression(name: str = "Expression") -> None: 130 | txt = _field(name) 131 | if not txt: 132 | showInfo("No %s in current note." % name) 133 | return 134 | lookup().alc(txt) 135 | 136 | 137 | def onLookupMeaning() -> None: 138 | onLookupExpression("Meaning") 139 | 140 | 141 | def onLookupEdictSelection() -> None: 142 | lookup().selection(lookup().edict) 143 | 144 | 145 | def onLookupEdictKanjiSelection() -> None: 146 | lookup().selection(lookup().edictKanji) 147 | 148 | 149 | def onLookupJishoSelection() -> None: 150 | lookup().selection(lookup().jisho) 151 | 152 | 153 | def onLookupJishoKanjiSelection() -> None: 154 | lookup().selection(lookup().jishoKanji) 155 | 156 | 157 | def onLookupAlcSelection() -> None: 158 | lookup().selection(lookup().alc) 159 | 160 | 161 | def createMenu() -> None: 162 | # pylint: disable=unnecessary-lambda 163 | ml = QMenu() 164 | ml.setTitle("Lookup") 165 | mw.form.menuTools.addAction(ml.menuAction()) 166 | # make it easier for other plugins to add to the menu 167 | mw.form.menuLookup = ml # type: ignore 168 | # add actions 169 | a = QAction(mw) 170 | a.setText("...expression on alc") 171 | a.setShortcut("Ctrl+Shift+1") 172 | ml.addAction(a) 173 | # Call from lambda to preserve default argument 174 | a.triggered.connect(lambda: onLookupExpression()) 175 | a = QAction(mw) 176 | a.setText("...meaning on alc") 177 | a.setShortcut("Ctrl+Shift+2") 178 | ml.addAction(a) 179 | a.triggered.connect(onLookupMeaning) 180 | a = QAction(mw) 181 | a.setText("...selection on alc") 182 | a.setShortcut("Ctrl+Shift+3") 183 | ml.addAction(a) 184 | ml.addSeparator() 185 | a.triggered.connect(onLookupAlcSelection) 186 | a = QAction(mw) 187 | a.setText("...word selection on edict") 188 | a.setShortcut("Ctrl+Shift+4") 189 | ml.addAction(a) 190 | a.triggered.connect(onLookupEdictSelection) 191 | a = QAction(mw) 192 | a.setText("...kanji selection on edict") 193 | a.setShortcut("Ctrl+Shift+5") 194 | ml.addAction(a) 195 | a.triggered.connect(onLookupEdictKanjiSelection) 196 | ml.addSeparator() 197 | a = QAction(mw) 198 | a.setText("...word selection on jisho") 199 | a.setShortcut("Ctrl+Shift+6") 200 | ml.addAction(a) 201 | a.triggered.connect(onLookupJishoSelection) 202 | a = QAction(mw) 203 | a.setText("...kanji selection on jisho") 204 | a.setShortcut("Ctrl+Shift+7") 205 | ml.addAction(a) 206 | a.triggered.connect(onLookupJishoKanjiSelection) 207 | 208 | 209 | createMenu() 210 | -------------------------------------------------------------------------------- /code/japanese/manifest.json: -------------------------------------------------------------------------------- 1 | {"package": "japanese", "name": "japanese"} -------------------------------------------------------------------------------- /code/japanese/model.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 3 | # 4 | # Standard Japanese model. 5 | # 6 | 7 | from __future__ import annotations 8 | 9 | import anki.stdmodels 10 | from anki.collection import Collection 11 | 12 | 13 | def addJapaneseModel(col: Collection) -> dict: 14 | mm = col.models 15 | m = mm.new(("Japanese (recognition)")) 16 | fm = mm.new_field(("Expression")) 17 | mm.addField(m, fm) 18 | fm = mm.new_field(("Meaning")) 19 | mm.addField(m, fm) 20 | fm = mm.new_field(("Reading")) 21 | mm.addField(m, fm) 22 | t = mm.new_template(("Recognition")) 23 | # css 24 | m[ 25 | "css" 26 | ] += """\ 27 | .jp { font-size: 30px } 28 | .win .jp { font-family: "MS Mincho", "MS 明朝"; } 29 | .mac .jp { font-family: "Hiragino Mincho Pro", "ヒラギノ明朝 Pro"; } 30 | .linux .jp { font-family: "Kochi Mincho", "東風明朝"; } 31 | .mobile .jp { font-family: "Hiragino Mincho ProN"; }""" 32 | # recognition card 33 | t["qfmt"] = "" 34 | t[ 35 | "afmt" 36 | ] = """{{FrontSide}}\n\n
\n\n\ 37 |
\n\ 38 | {{Meaning}}""" 39 | mm.addTemplate(m, t) 40 | mm.add(m) 41 | return m 42 | 43 | 44 | def addDoubleJapaneseModel(col: Collection) -> dict: 45 | mm = col.models 46 | m = addJapaneseModel(col) 47 | m["name"] = "Japanese (recognition&recall)" 48 | rev = mm.new_template(("Recall")) 49 | rev["qfmt"] = "{{Meaning}}" 50 | rev[ 51 | "afmt" 52 | ] = """{{FrontSide}} 53 | 54 |
55 | 56 | 57 | """ 58 | mm.addTemplate(m, rev) 59 | return m 60 | 61 | 62 | def addOptionalJapaneseModel(col: Collection) -> dict: 63 | mm = col.models 64 | m = addDoubleJapaneseModel(col) 65 | m["name"] = "Japanese (optional recall)" 66 | rev = m["tmpls"][1] 67 | rev["qfmt"] = "{{#Add Recall}}\n" + rev["qfmt"] + "\n{{/Add Recall}}" 68 | fm = mm.new_field("Add Recall") 69 | mm.addField(m, fm) 70 | return m 71 | 72 | 73 | anki.stdmodels.models.append((("Japanese (recognition)"), addJapaneseModel)) 74 | anki.stdmodels.models.append( 75 | (("Japanese (recognition&recall)"), addDoubleJapaneseModel) 76 | ) 77 | anki.stdmodels.models.append((("Japanese (optional recall)"), addOptionalJapaneseModel)) 78 | -------------------------------------------------------------------------------- /code/japanese/notetypes.py: -------------------------------------------------------------------------------- 1 | """ 2 | -*- coding: utf-8 -*- 3 | Author: RawToast 4 | License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 5 | 6 | Configure settings for the note types and source fields for the Japanese 7 | Support plugin here 8 | 9 | """ 10 | 11 | from aqt import mw 12 | 13 | config = mw.addonManager.getConfig(__name__) 14 | noteTypes = [noteType.lower() for noteType in config["noteTypes"]] 15 | 16 | 17 | def isJapaneseNoteType(noteName: str) -> bool: 18 | noteName = noteName.lower() 19 | return any(allowedString in noteName for allowedString in noteTypes) 20 | -------------------------------------------------------------------------------- /code/japanese/reading.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 3 | # 4 | # Automatic reading generation with kakasi and mecab. 5 | # 6 | 7 | from __future__ import annotations 8 | 9 | import os 10 | import re 11 | import subprocess 12 | import sys 13 | 14 | from anki.notes import Note 15 | from anki.utils import is_mac, is_win, strip_html 16 | from aqt import gui_hooks, mw 17 | 18 | from .notetypes import isJapaneseNoteType 19 | 20 | config = mw.addonManager.getConfig(__name__) 21 | 22 | srcFields = config["srcFields"] 23 | dstFields = config["dstFields"] 24 | furiganaFieldSuffix = config["furiganaSuffix"] 25 | 26 | kakasiArgs = ["-isjis", "-osjis", "-u", "-JH", "-KH"] 27 | mecabArgs = ["--node-format=%m[%f[7]] ", "--eos-format=\n", "--unk-format=%m[] "] 28 | 29 | supportDir = os.path.join(os.path.dirname(__file__), "support") 30 | 31 | 32 | def escapeText(text: str) -> str: 33 | # strip characters that trip up kakasi/mecab 34 | text = text.replace("\n", " ") 35 | text = text.replace("\uff5e", "~") 36 | text = re.sub("", "---newline---", text) 37 | text = strip_html(text) 38 | text = text.replace("---newline---", "
") 39 | return text 40 | 41 | 42 | if sys.platform == "win32": 43 | si = subprocess.STARTUPINFO() 44 | try: 45 | si.dwFlags |= subprocess.STARTF_USESHOWWINDOW 46 | except: 47 | # pylint: disable=no-member 48 | si.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW 49 | else: 50 | si = None 51 | 52 | # Mecab 53 | ########################################################################## 54 | 55 | 56 | def mungeForPlatform(popen: list[str]) -> list[str]: 57 | if is_win: 58 | popen = [os.path.normpath(x) for x in popen] 59 | popen[0] += ".exe" 60 | elif not is_mac: 61 | popen[0] += ".lin" 62 | return popen 63 | 64 | 65 | def get_index_of_first_mismatch_from_left_to_right(kanji: str, reading: str) -> int: 66 | """Get index of first mismatch between Kanji and reading, from left to right. 67 | 68 | Returns: 69 | - The index of the first mismatch if there is one, starting at the left end. 70 | - The largest index if the two lists are identical (there's no mismatch). 71 | 72 | Examples: 73 | This would return 3 because the leftmost mismatch is at index 3. 74 | 75 | >>> kanji = "0123456789" 76 | >>> reading = "012x456789" 77 | >>> get_index_of_first_mismatch_from_left_to_right(kanji, reading) 78 | 79 | This would return 9 (the largest index) because the lists are identical. 80 | 81 | >>> kanji = "0123456789" 82 | >>> reading = "0123456789" 83 | >>> get_index_of_first_mismatch_from_left_to_right(kanji, reading) 84 | """ 85 | index_of_first_mismatch_from_left_to_right = 0 86 | for i in range(0, len(kanji) - 1): 87 | if kanji[i] != reading[i]: 88 | break 89 | index_of_first_mismatch_from_left_to_right = i + 1 90 | return index_of_first_mismatch_from_left_to_right 91 | 92 | 93 | def get_index_of_first_mismatch_from_right_to_left(kanji: str, reading: str) -> int: 94 | """Get index of first mismatch between Kanji and reading, from right to left. 95 | 96 | Returns: 97 | - The index of the first mismatch if there is one, starting at the right end. 98 | - The largest index if the two lists are identical (there's no mismatch). 99 | 100 | Examples: 101 | This would return 3 because the rightmost mismatch is at index 3. 102 | 103 | >>> kanji = "9876543210" 104 | >>> reading = "987654x210" 105 | >>> get_index_of_first_mismatch_from_right_to_left(kanji, reading) 106 | 107 | This would return 9 (the largest index) because the lists are identical. 108 | 109 | >>> kanji = "9876543210" 110 | >>> reading = "9876543210" 111 | >>> get_index_of_first_mismatch_from_right_to_left(kanji, reading) 112 | """ 113 | index_of_first_mismatch_from_right_to_left = 0 114 | for i in range(1, len(kanji)): 115 | if kanji[-i] != reading[-i]: 116 | break 117 | index_of_first_mismatch_from_right_to_left = i 118 | return index_of_first_mismatch_from_right_to_left 119 | 120 | 121 | class MecabController: 122 | def __init__(self) -> None: 123 | self.mecab: subprocess.Popen | None = None 124 | 125 | def setup(self) -> None: 126 | self.mecabCmd = mungeForPlatform( 127 | [os.path.join(supportDir, "mecab")] 128 | + mecabArgs 129 | + [ 130 | "-d", 131 | supportDir, 132 | "-r", 133 | os.path.join(supportDir, "mecabrc"), 134 | "-u", 135 | os.path.join(supportDir, "user_dic.dic"), 136 | ] 137 | ) 138 | os.environ["DYLD_LIBRARY_PATH"] = supportDir 139 | os.environ["LD_LIBRARY_PATH"] = supportDir 140 | if not is_win: 141 | os.chmod(self.mecabCmd[0], 0o755) 142 | 143 | def ensureOpen(self) -> None: 144 | if not self.mecab: 145 | self.setup() 146 | try: 147 | self.mecab = subprocess.Popen( 148 | self.mecabCmd, 149 | bufsize=-1, 150 | stdin=subprocess.PIPE, 151 | stdout=subprocess.PIPE, 152 | stderr=subprocess.STDOUT, 153 | startupinfo=si, 154 | ) 155 | except OSError as exc: 156 | raise Exception( 157 | "Please ensure your Linux system has 64 bit binary support." 158 | ) from exc 159 | 160 | def reading(self, expr: str) -> str: 161 | self.ensureOpen() 162 | expr = escapeText(expr) 163 | self.mecab.stdin.write(expr.encode("utf-8", "ignore") + b"\n") 164 | self.mecab.stdin.flush() 165 | assert self.mecab 166 | expr = self.mecab.stdout.readline().rstrip(b"\r\n").decode("utf-8", "replace") 167 | out = [] 168 | for node in expr.split(" "): 169 | if not node: 170 | break 171 | m = re.match(r"(.+)\[(.*)\]", node) 172 | if not m: 173 | sys.stderr.write( 174 | "Unexpected output from mecab. Perhaps your Windows username contains non-Latin text?: {}\n".format( 175 | repr(expr) 176 | ) 177 | ) 178 | return "" 179 | 180 | (kanji, reading) = m.groups() 181 | # hiragana, punctuation, not japanese, or lacking a reading 182 | if kanji == reading or not reading: 183 | out.append(kanji) 184 | continue 185 | # katakana 186 | if kanji == kakasi.reading(reading): 187 | out.append(kanji) 188 | continue 189 | # convert to hiragana 190 | reading = kakasi.reading(reading) 191 | # ended up the same 192 | if reading == kanji: 193 | out.append(kanji) 194 | continue 195 | # don't add readings of numbers 196 | if kanji in "一二三四五六七八九十0123456789": 197 | out.append(kanji) 198 | continue 199 | # strip matching characters and beginning and end of reading and kanji 200 | # reading should always be at least as long as the kanji 201 | placeL = get_index_of_first_mismatch_from_left_to_right(kanji, reading) 202 | placeR = get_index_of_first_mismatch_from_right_to_left(kanji, reading) 203 | if placeL == 0: 204 | if placeR == 0: 205 | out.append(f" {kanji}[{reading}]") 206 | else: 207 | out.append( 208 | f" {kanji[:-placeR]}" 209 | f"[{reading[:-placeR]}]" 210 | f"{reading[-placeR:]}" 211 | ) 212 | else: 213 | if placeR == 0: 214 | out.append( 215 | f"{reading[:placeL]}" 216 | " " 217 | f"{kanji[placeL:]}" 218 | f"[{reading[placeL:]}]" 219 | ) 220 | else: 221 | out.append( 222 | f"{reading[:placeL]}" 223 | " " 224 | f"{kanji[placeL:-placeR]}" 225 | f"[{reading[placeL:-placeR]}]" 226 | f"{reading[-placeR:]}" 227 | ) 228 | fin = "" 229 | for c, s in enumerate(out): 230 | if c < len(out) - 1 and re.match("^[A-Za-z0-9]+$", out[c + 1]): 231 | s += " " 232 | fin += s 233 | return fin.strip().replace("< br>", "
") 234 | 235 | 236 | # Kakasi 237 | ########################################################################## 238 | 239 | 240 | class KakasiController: 241 | def __init__(self) -> None: 242 | self.kakasi: subprocess.Popen | None = None 243 | 244 | def setup(self) -> None: 245 | self.kakasiCmd = mungeForPlatform( 246 | [os.path.join(supportDir, "kakasi")] + kakasiArgs 247 | ) 248 | os.environ["ITAIJIDICT"] = os.path.join(supportDir, "itaijidict") 249 | os.environ["KANWADICT"] = os.path.join(supportDir, "kanwadict") 250 | if not is_win: 251 | os.chmod(self.kakasiCmd[0], 0o755) 252 | 253 | def ensureOpen(self) -> None: 254 | if not self.kakasi: 255 | self.setup() 256 | try: 257 | self.kakasi = subprocess.Popen( 258 | self.kakasiCmd, 259 | bufsize=-1, 260 | stdin=subprocess.PIPE, 261 | stdout=subprocess.PIPE, 262 | stderr=subprocess.STDOUT, 263 | startupinfo=si, 264 | ) 265 | except OSError as exc: 266 | raise Exception("Please install kakasi") from exc 267 | 268 | def reading(self, expr: str) -> str: 269 | self.ensureOpen() 270 | expr = escapeText(expr) 271 | self.kakasi.stdin.write(expr.encode("sjis", "ignore") + b"\n") 272 | self.kakasi.stdin.flush() 273 | res = self.kakasi.stdout.readline().rstrip(b"\r\n").decode("sjis", "replace") 274 | return res 275 | 276 | 277 | # Focus lost hook 278 | ########################################################################## 279 | 280 | mecab = None 281 | 282 | 283 | def onFocusLost(changed: bool, note: Note, current_field_index: int) -> bool: 284 | # japanese model? 285 | if not isJapaneseNoteType(note.note_type()["name"]): 286 | return changed 287 | fields = mw.col.models.field_names(note.note_type()) 288 | src = fields[current_field_index] 289 | if not regenerateReading(note, src): 290 | return changed 291 | return True 292 | 293 | 294 | def regenerateReading(n: Note, src: str) -> bool: 295 | global mecab 296 | if not mecab: 297 | return False 298 | dst = None 299 | # Retro compatibility 300 | if src in srcFields: 301 | srcIdx = srcFields.index(src) 302 | dst = dstFields[srcIdx] 303 | if dst not in n: 304 | dst = src + furiganaFieldSuffix 305 | if not src or not dst: 306 | return False 307 | # dst field exists? 308 | if dst not in n: 309 | return False 310 | # dst field already filled? 311 | if n[dst]: 312 | return False 313 | # grab source text 314 | srcTxt = mw.col.media.strip(n[src]) 315 | if not srcTxt: 316 | return False 317 | # update field 318 | try: 319 | n[dst] = mecab.reading(srcTxt) 320 | except Exception as e: 321 | mecab = None 322 | raise 323 | return True 324 | 325 | 326 | # Init 327 | ########################################################################## 328 | 329 | kakasi = KakasiController() 330 | mecab = MecabController() 331 | 332 | gui_hooks.editor_did_unfocus_field.append(onFocusLost) 333 | 334 | # Tests 335 | ########################################################################## 336 | 337 | if __name__ == "__main__": 338 | expr = "カリン、自分でまいた種は自分で刈り取れ" 339 | print(mecab.reading(expr).encode("utf-8")) 340 | expr = "昨日、林檎を2個買った。" 341 | print(mecab.reading(expr).encode("utf-8")) 342 | expr = "真莉、大好きだよん^^" 343 | print(mecab.reading(expr).encode("utf-8")) 344 | expr = "彼2000万も使った。" 345 | print(mecab.reading(expr).encode("utf-8")) 346 | expr = "彼二千三百六十円も使った。" 347 | print(mecab.reading(expr).encode("utf-8")) 348 | expr = "千葉" 349 | print(mecab.reading(expr).encode("utf-8")) 350 | -------------------------------------------------------------------------------- /code/japanese/stats.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # Used/unused kanji list code originally by 'LaC' 3 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 4 | 5 | from __future__ import annotations 6 | 7 | import unicodedata 8 | from collections.abc import Callable 9 | from typing import Any 10 | 11 | import aqt 12 | from anki.collection import Collection 13 | from aqt import mw 14 | from aqt.operations import QueryOp 15 | from aqt.qt import * 16 | from aqt.utils import restoreGeom, saveGeom 17 | from aqt.webview import AnkiWebView 18 | 19 | from .notetypes import isJapaneseNoteType 20 | 21 | config = mw.addonManager.getConfig(__name__) 22 | 23 | 24 | def isKanji(unichar: str) -> bool: 25 | try: 26 | return unicodedata.name(unichar).find("CJK UNIFIED IDEOGRAPH") >= 0 27 | except ValueError: 28 | # a control character 29 | return False 30 | 31 | 32 | class KanjiStats: 33 | def __init__(self, col: Collection, wholeCollection: bool) -> None: 34 | self.col = col 35 | if wholeCollection: 36 | self.lim = "" 37 | else: 38 | self.lim = "deck:current" 39 | self._gradeHash = dict() 40 | for grade, (name, chars) in enumerate(self.kanjiGrades): 41 | for c in chars: 42 | self._gradeHash[c] = grade 43 | 44 | def kanjiGrade(self, unichar: str) -> int: 45 | return self._gradeHash.get(unichar, 0) 46 | 47 | def kanjiCountStr(self, gradename: str, count: int, total: int = 0) -> str: 48 | if total: 49 | return f"{gradename}: {count} of {total} ({count/total:.1%})." 50 | else: 51 | return f"{count} {gradename} kanji." 52 | 53 | def genKanjiSets(self) -> None: 54 | self.kanjiSets: list[set[str]] = [set([]) for g in self.kanjiGrades] 55 | chars: set[str] = set() 56 | for m in self.col.models.all(): 57 | _noteName = m["name"].lower() 58 | if not isJapaneseNoteType(_noteName): 59 | continue 60 | 61 | idxs = [] 62 | for c, name in enumerate(self.col.models.field_names(m)): 63 | for f in config["srcFields"]: 64 | if name == f: 65 | idxs.append(c) 66 | mid = m["id"] 67 | for nid in mw.col.find_notes( 68 | self.lim + f" mid:{mid} -is:new -is:suspended" 69 | ): 70 | note = mw.col.get_note(nid) 71 | for idx in idxs: 72 | chars.update(note.fields[idx]) 73 | for c2 in chars: 74 | if isKanji(c2): 75 | self.kanjiSets[self.kanjiGrade(c2)].add(c2) 76 | 77 | def report(self) -> str: 78 | self.genKanjiSets() 79 | counts = [ 80 | (name, len(found), len(all)) 81 | for (name, all), found in zip(self.kanjiGrades, self.kanjiSets) 82 | ] 83 | out = ( 84 | ( 85 | ("

Kanji statistics

The seen cards in this %s " "contain:") 86 | % (self.lim and "deck" or "collection") 87 | ) 88 | + "
    " 89 | + 90 | # total kanji 91 | ("
  • %d total unique kanji.
  • ") % sum([c[1] for c in counts]) 92 | + 93 | # total joyo 94 | "
  • %s
  • " 95 | % self.kanjiCountStr( 96 | "Old jouyou", 97 | sum([c[1] for c in counts[1:8]]), 98 | sum([c[2] for c in counts[1:8]]), 99 | ) 100 | + 101 | # total new joyo 102 | "
  • %s
  • " % self.kanjiCountStr(*counts[8]) 103 | + 104 | # total jinmei (reg) 105 | "
  • %s
  • " % self.kanjiCountStr(*counts[9]) 106 | + 107 | # total jinmei (var) 108 | "
  • %s
  • " % self.kanjiCountStr(*counts[10]) 109 | + 110 | # total non-joyo 111 | "
  • %s
  • " % self.kanjiCountStr(*counts[0]) 112 | ) 113 | 114 | out += "

" + ("Jouyou levels:") + "

    " 115 | L = [ 116 | "
  • " + self.kanjiCountStr(c[0], c[1], c[2]) + "
  • " for c in counts[1:8] 117 | ] 118 | out += "".join(L) 119 | out += "
" 120 | return out 121 | 122 | def missingReport(self, check: Callable | None = None) -> str: 123 | if not check: 124 | 125 | def check(x: Any, y: list) -> bool: 126 | return x not in y 127 | 128 | out = "

Missing

" 129 | else: 130 | out = "

Seen

" 131 | for grade in range(1, len(self.kanjiGrades)): 132 | missing = "".join(self.missingInGrade(grade, check)) 133 | if not missing: 134 | continue 135 | out += "

" + self.kanjiGrades[grade][0] + "

" 136 | out += "" 137 | out += self.mkEdict(missing) 138 | out += "" 139 | return out + "
" 140 | 141 | def mkEdict(self, kanji: str) -> str: 142 | out = "" 143 | while 1: 144 | if not kanji: 145 | out += "" 146 | return out 147 | # edict will take up to about 10 kanji at once 148 | out += self.edictKanjiLink(kanji[0:10]) 149 | kanji = kanji[10:] 150 | 151 | def seenReport(self) -> str: 152 | return self.missingReport(lambda x, y: x in y) 153 | 154 | def nonJouyouReport(self) -> str: 155 | out = "

Non-Jouyou

" 156 | out += self.mkEdict("".join(self.kanjiSets[0])) 157 | return out + "
" 158 | 159 | def edictKanjiLink(self, kanji: str) -> str: 160 | base = "http://nihongo.monash.edu/cgi-bin/wwwjdic?1MMJ" 161 | url = base + kanji 162 | return f'{kanji}' 163 | 164 | def missingInGrade(self, gradeNum: int, check: Callable) -> list[str]: 165 | existingKanji = self.kanjiSets[gradeNum] 166 | totalKanji = self.kanjiGrades[gradeNum][1] 167 | return [k for k in totalKanji if check(k, existingKanji)] 168 | 169 | kanjiGrades = [ 170 | ("non-jouyou", ""), 171 | ( 172 | "Grade 1", 173 | "一右雨円王音下火花貝学気休玉金九空月犬見五口校左三山四子糸字耳七車手十出女小上森人水正生青石赤先千川早草足村大男竹中虫町天田土二日入年白八百文本名木目夕立力林六", 174 | ), 175 | ( 176 | "Grade 2", 177 | "引羽雲園遠黄何夏家科歌画会回海絵外角楽活間丸岩顔帰汽記弓牛魚京強教近兄形計元原言古戸午後語交光公工広考行高合国黒今才細作算姉市思止紙寺時自室社弱首秋週春書少場色食心新親図数星晴声西切雪線船前組走多太体台谷知地池茶昼朝長鳥直通弟店点電冬刀東当答頭同道読内南肉馬買売麦半番父風分聞米歩母方北妹毎万明鳴毛門夜野矢友曜用来理里話", 178 | ), 179 | ( 180 | "Grade 3", 181 | "悪安暗委意医育員飲院運泳駅央横屋温化荷界開階寒感漢館岸期起客宮急球究級去橋業局曲銀区苦具君係軽決血研県庫湖向幸港号根祭坂皿仕使始指死詩歯事持次式実写者主取守酒受州拾終習集住重宿所暑助勝商昭消章乗植深申真神身進世整昔全想相送息速族他打対待代第題炭短談着柱注丁帳調追定庭笛鉄転登都度島投湯等豆動童農波配倍箱畑発反板悲皮美鼻筆氷表病秒品負部服福物平返勉放味命面問役薬油有由遊予様洋羊葉陽落流旅両緑礼列練路和", 182 | ), 183 | ( 184 | "Grade 4", 185 | "愛案以位囲胃衣印栄英塩億加果課貨芽改械害街各覚完官管観関願喜器希旗機季紀議救求泣給挙漁競共協鏡極訓軍郡型径景芸欠結健建験固候功好康航告差最菜材昨刷察札殺参散産残司史士氏試児治辞失借種周祝順初唱松焼照省笑象賞信臣成清静席積折節説戦浅選然倉巣争側束続卒孫帯隊達単置仲貯兆腸低停底的典伝徒努灯働堂得特毒熱念敗梅博飯費飛必標票不付夫府副粉兵別変辺便包法望牧末満未脈民無約勇要養浴利陸料良量輪類令例冷歴連労老録", 186 | ), 187 | ( 188 | "Grade 5", 189 | "圧易移因営永衛液益演往応恩仮価可河過賀解快格確額刊幹慣眼基寄規技義逆久旧居許境興均禁句群経潔件券検険減現限個故護効厚構耕講鉱混査再妻採災際在罪財桜雑賛酸師志支枝資飼似示識質舎謝授修術述準序承招証常情条状織職制勢性政精製税績責接設絶舌銭祖素総像増造則測属損態貸退団断築張提程敵適統導銅徳独任燃能破判版犯比肥非備俵評貧婦富布武復複仏編弁保墓報豊暴貿防務夢迷綿輸余預容率略留領", 190 | ), 191 | ( 192 | "Grade 6", 193 | "異遺域宇映延沿我灰拡閣革割株巻干看簡危揮机貴疑吸供胸郷勤筋敬系警劇激穴憲権絹厳源呼己誤后孝皇紅鋼降刻穀骨困砂座済裁策冊蚕姿私至視詞誌磁射捨尺若樹収宗就衆従縦縮熟純処署諸除傷将障城蒸針仁垂推寸盛聖誠宣専泉洗染善創奏層操窓装臓蔵存尊宅担探誕暖段値宙忠著庁潮頂賃痛展党糖討届難乳認納脳派俳拝背肺班晩否批秘腹奮並閉陛片補暮宝訪亡忘棒枚幕密盟模訳優郵幼欲翌乱卵覧裏律臨朗論", 194 | ), 195 | ( 196 | "JuniorHS", 197 | "亜哀握扱依偉威尉慰為維緯違井壱逸稲芋姻陰隠韻渦浦影詠鋭疫悦謁越閲宴援炎煙猿縁鉛汚凹奥押欧殴翁沖憶乙卸穏佳嫁寡暇架禍稼箇華菓蚊雅餓介塊壊怪悔懐戒拐皆劾慨概涯該垣嚇核殻獲穫較郭隔岳掛潟喝括渇滑褐轄且刈乾冠勘勧喚堪寛患憾換敢棺款歓汗環甘監緩缶肝艦貫還鑑閑陥含頑企奇岐幾忌既棋棄祈軌輝飢騎鬼偽儀宜戯擬欺犠菊吉喫詰却脚虐丘及朽窮糾巨拒拠虚距享凶叫峡恐恭挟況狂狭矯脅響驚仰凝暁斤琴緊菌襟謹吟駆愚虞偶遇隅屈掘靴繰桑勲薫傾刑啓契恵慶憩掲携渓継茎蛍鶏迎鯨撃傑倹兼剣圏堅嫌懸献肩謙賢軒遣顕幻弦玄孤弧枯誇雇顧鼓互呉娯御悟碁侯坑孔巧恒慌抗拘控攻更江洪溝甲硬稿絞綱肯荒衡貢購郊酵項香剛拷豪克酷獄腰込墾婚恨懇昆紺魂佐唆詐鎖債催宰彩栽歳砕斎載剤咲崎削搾索錯撮擦傘惨桟暫伺刺嗣施旨祉紫肢脂諮賜雌侍慈滋璽軸執湿漆疾芝赦斜煮遮蛇邪爵酌釈寂朱殊狩珠趣儒寿需囚愁秀臭舟襲酬醜充柔汁渋獣銃叔淑粛塾俊瞬准循旬殉潤盾巡遵庶緒叙徐償匠升召奨宵尚床彰抄掌昇晶沼渉焦症硝礁祥称粧紹肖衝訟詔詳鐘丈冗剰壌嬢浄畳譲醸錠嘱飾殖触辱伸侵唇娠寝審慎振浸紳薪診辛震刃尋甚尽迅陣酢吹帥炊睡粋衰遂酔随髄崇枢据杉澄瀬畝是姓征牲誓請逝斉隻惜斥析籍跡拙摂窃仙占扇栓潜旋繊薦践遷鮮漸禅繕塑措疎礎租粗訴阻僧双喪壮捜掃挿曹槽燥荘葬藻遭霜騒憎贈促即俗賊堕妥惰駄耐怠替泰滞胎袋逮滝卓択拓沢濯託濁諾但奪脱棚丹嘆淡端胆鍛壇弾恥痴稚致遅畜蓄逐秩窒嫡抽衷鋳駐弔彫徴懲挑眺聴超跳勅朕沈珍鎮陳津墜塚漬坪釣亭偵貞呈堤帝廷抵締艇訂逓邸泥摘滴哲徹撤迭添殿吐塗斗渡途奴怒倒凍唐塔悼搭桃棟盗痘筒到謄踏逃透陶騰闘洞胴峠匿督篤凸突屯豚曇鈍縄軟尼弐如尿妊忍寧猫粘悩濃把覇婆廃排杯輩培媒賠陪伯拍泊舶薄迫漠爆縛肌鉢髪伐罰抜閥伴帆搬畔繁般藩販範煩頒盤蛮卑妃彼扉披泌疲碑罷被避尾微匹姫漂描苗浜賓頻敏瓶怖扶敷普浮符腐膚譜賦赴附侮舞封伏幅覆払沸噴墳憤紛雰丙併塀幣弊柄壁癖偏遍舗捕穂募慕簿倣俸奉峰崩抱泡砲縫胞芳褒邦飽乏傍剖坊妨帽忙房某冒紡肪膨謀僕墨撲朴没堀奔翻凡盆摩磨魔麻埋膜又抹繭慢漫魅岬妙眠矛霧婿娘銘滅免茂妄猛盲網耗黙戻紋厄躍柳愉癒諭唯幽悠憂猶裕誘雄融与誉庸揚揺擁溶窯謡踊抑翼羅裸頼雷絡酪欄濫吏履痢離硫粒隆竜慮虜了僚寮涼猟療糧陵倫厘隣塁涙累励鈴隷零霊麗齢暦劣烈裂廉恋錬炉露廊楼浪漏郎賄惑枠湾腕", 198 | ), 199 | ( 200 | "New jouyou", 201 | "挨宛闇椅畏萎茨咽淫臼唄餌怨艶旺岡臆俺苛牙崖蓋骸柿顎葛釜鎌瓦韓玩伎畿亀僅巾錦駒串窟熊稽詣隙桁拳鍵舷股虎乞勾喉梗頃痕沙挫塞采阪埼柵拶斬鹿叱嫉腫呪蹴拭尻芯腎須裾凄醒戚脊煎羨腺詮膳曽狙遡爽痩捉袖遜汰唾堆戴誰旦綻酎捗椎潰爪鶴諦溺填貼妬賭藤憧瞳栃頓奈那謎鍋匂虹捻罵剥箸斑氾汎眉膝肘媛阜蔽蔑蜂貌頬睦勃昧枕蜜冥麺餅冶弥湧妖沃嵐藍梨璃侶瞭瑠呂賂弄麓脇丼傲刹哺喩嗅嘲毀彙恣惧慄憬拉摯曖楷鬱璧瘍箋籠緻羞訃諧貪踪辣錮", 202 | ), 203 | ( 204 | "Jinmeiyou (regular)", 205 | "丑丞乃之乎也云亘亙些亦亥亨亮仔伊伍伽佃佑伶侃侑俄俠俣俐倭俱倦倖偲傭儲允兎兜其冴凌凜凛凧凪凰凱函劉劫勁勿匡廿卜卯卿厨厩叉叡叢叶只吾吞吻哉啄哩喬喧喰喋嘩嘉嘗噌噂圃圭坐尭堯坦埴堰堺堵塙塡壕壬夷奄奎套娃姪姥娩嬉孟宏宋宕宥寅寓寵尖尤屑峨峻崚嵯嵩嶺巌巖已巳巴巷巽帖幌幡庄庇庚庵廟廻弘弛彌彗彦彪彬徠忽怜恢恰恕悌惟惚悉惇惹惺惣慧憐戊或戟托按挺挽掬捲捷捺捧掠揃摑摺撒撰撞播撫擢孜敦斐斡斧斯於旭昂昊昏昌昴晏晃晄晒晋晟晦晨智暉暢曙曝曳曾朋朔杏杖杜李杭杵杷枇柑柴柘柊柏柾柚桧檜栞桔桂栖桐栗梧梓梢梛梯桶梶椛梁棲椋椀楯楚楕椿楠楓椰楢楊榎樺榊榛槙槇槍槌樫槻樟樋橘樽橙檎檀櫂櫛櫓欣欽歎此殆毅毘毬汀汝汐汲沌沓沫洸洲洵洛浩浬淵淳渚淀淋渥湘湊湛溢滉溜漱漕漣澪濡瀕灘灸灼烏焰焚煌煤煉熙燕燎燦燭燿爾牒牟牡牽犀狼猪獅玖珂珈珊珀玲琢琉瑛琥琶琵琳瑚瑞瑶瑳瓜瓢甥甫畠畢疋疏瘦皐皓眸瞥矩砦砥砧硯碓碗碩碧磐磯祇祢禰祐禄祿禎禱禽禾秦秤稀稔稟稜穣穰穿窄窪窺竣竪竺竿笈笹笙笠筈筑箕箔篇篠簞簾籾粥粟糊紘紗紐絃紬絆絢綺綜綴緋綾綸縞徽繫繡纂纏羚翔翠耀而耶耽聡肇肋肴胤胡脩腔膏臥舜舵芥芹芭芙芦苑茄苔苺茅茉茸茜莞荻莫莉菅菫菖萄菩萌萠萊菱葦葵萱葺萩董葡蓑蒔蒐蒼蒲蒙蓉蓮蔭蔣蔦蓬蔓蕎蕨蕉蕃蕪薙蕾蕗藁薩蘇蘭蝦蝶螺蟬蟹蠟衿袈袴裡裟裳襖訊訣註詢詫誼諏諄諒謂諺讃豹貰賑赳跨蹄蹟輔輯輿轟辰辻迂迄辿迪迦這逞逗逢遥遙遁遼邑祁郁鄭酉醇醐醍醬釉釘釧鋒鋸錐錆錫鍬鎧閃閏閤阿陀隈隼雀雁雛雫霞靖鞄鞍鞘鞠鞭頁頌頗頰顚颯饗馨馴馳駕駿驍魁魯鮎鯉鯛鰯鱒鱗鳩鳶鳳鴨鴻鵜鵬鷗鷲鷺鷹麒麟麿黎黛鼎", 206 | ), 207 | ( 208 | "Jinmeiyou (variant)", 209 | "亞惡爲衞谒緣應櫻奧橫溫價祸壞懷樂渴卷陷寬氣僞戲虛峽狹曉勳薰惠揭鷄藝擊縣儉劍險圈檢顯驗嚴廣恆黃國黑碎雜兒濕壽收從澁獸縱緖敍將涉燒獎條狀乘淨剩疊孃讓釀眞寢愼盡粹醉穗瀨齊靜攝專戰纖禪壯爭莊搜巢裝騷增藏臟卽帶滯單團彈晝鑄廳徵聽鎭轉傳燈盜稻德拜賣髮拔晚祕拂佛步飜每默藥與搖樣謠來賴覽龍綠淚壘曆歷鍊郞錄", 210 | ), 211 | ] 212 | 213 | 214 | def genKanjiStats() -> str: 215 | wholeCollection = mw.state == "deckBrowser" 216 | s = KanjiStats(mw.col, wholeCollection) 217 | rep = s.report() 218 | rep += s.seenReport() 219 | rep += s.missingReport() 220 | rep += s.nonJouyouReport() 221 | return rep 222 | 223 | 224 | def onKanjiStats() -> None: 225 | def show(stats: str) -> None: 226 | diag = QDialog(mw) 227 | layout = QVBoxLayout() 228 | layout.setContentsMargins(0, 0, 0, 0) 229 | web = AnkiWebView() 230 | layout.addWidget(web) 231 | button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) 232 | layout.addWidget(button_box) 233 | diag.setLayout(layout) 234 | diag.resize(500, 400) 235 | restoreGeom(diag, "kanjistats") 236 | web.stdHtml(stats) 237 | diag.open() 238 | 239 | def close() -> None: 240 | saveGeom(diag, "kanjistats") 241 | diag.reject() 242 | 243 | qconnect(button_box.rejected, close) 244 | 245 | QueryOp( 246 | parent=aqt.mw, op=lambda _: genKanjiStats(), success=show 247 | ).with_progress().run_in_background() 248 | 249 | 250 | def createMenu() -> None: 251 | a = QAction(mw) 252 | a.setText("Kanji Stats") 253 | mw.form.menuTools.addAction(a) 254 | a.triggered.connect(onKanjiStats) 255 | 256 | 257 | createMenu() 258 | -------------------------------------------------------------------------------- /code/japanese/support/char.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/char.bin -------------------------------------------------------------------------------- /code/japanese/support/dicrc: -------------------------------------------------------------------------------- 1 | ; 2 | ; Configuration file of IPADIC 3 | ; 4 | ; $Id: dicrc,v 1.4 2006/04/08 06:41:36 taku-ku Exp $; 5 | ; 6 | cost-factor = 800 7 | bos-feature = BOS/EOS,*,*,*,*,*,*,*,* 8 | eval-size = 8 9 | unk-eval-size = 4 10 | config-charset = EUC-JP 11 | 12 | ; yomi 13 | node-format-yomi = %pS%f[7] 14 | unk-format-yomi = %M 15 | eos-format-yomi = \n 16 | 17 | ; simple 18 | node-format-simple = %m\t%F-[0,1,2,3]\n 19 | eos-format-simple = EOS\n 20 | 21 | ; ChaSen 22 | node-format-chasen = %m\t%f[7]\t%f[6]\t%F-[0,1,2,3]\t%f[4]\t%f[5]\n 23 | unk-format-chasen = %m\t%m\t%m\t%F-[0,1,2,3]\t\t\n 24 | eos-format-chasen = EOS\n 25 | 26 | ; ChaSen (include spaces) 27 | node-format-chasen2 = %M\t%f[7]\t%f[6]\t%F-[0,1,2,3]\t%f[4]\t%f[5]\n 28 | unk-format-chasen2 = %M\t%m\t%m\t%F-[0,1,2,3]\t\t\n 29 | eos-format-chasen2 = EOS\n 30 | -------------------------------------------------------------------------------- /code/japanese/support/itaijidict: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/itaijidict -------------------------------------------------------------------------------- /code/japanese/support/kakasi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/kakasi -------------------------------------------------------------------------------- /code/japanese/support/kakasi.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/kakasi.exe -------------------------------------------------------------------------------- /code/japanese/support/kakasi.lin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/kakasi.lin -------------------------------------------------------------------------------- /code/japanese/support/kanwadict: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/kanwadict -------------------------------------------------------------------------------- /code/japanese/support/libmecab.2.dylib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/libmecab.2.dylib -------------------------------------------------------------------------------- /code/japanese/support/libmecab.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/libmecab.dll -------------------------------------------------------------------------------- /code/japanese/support/libmecab.so.1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/libmecab.so.1 -------------------------------------------------------------------------------- /code/japanese/support/matrix.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/matrix.bin -------------------------------------------------------------------------------- /code/japanese/support/mecab: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/mecab -------------------------------------------------------------------------------- /code/japanese/support/mecab.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/mecab.exe -------------------------------------------------------------------------------- /code/japanese/support/mecab.lin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/mecab.lin -------------------------------------------------------------------------------- /code/japanese/support/mecabrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/mecabrc -------------------------------------------------------------------------------- /code/japanese/support/sys.dic: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/sys.dic -------------------------------------------------------------------------------- /code/japanese/support/unk.dic: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/unk.dic -------------------------------------------------------------------------------- /code/japanese/support/user_dic.dic: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/support/user_dic.dic -------------------------------------------------------------------------------- /code/japanese/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitects/anki-addons/e346b88788ee667d01e14f1e72633d201f19e881/code/japanese/tests/__init__.py -------------------------------------------------------------------------------- /code/japanese/tests/test_stats.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from japanese.stats import KanjiStats 4 | from mock import MagicMock 5 | 6 | 7 | class TestKanjiStats(TestCase): 8 | def setUp(self) -> None: 9 | self.col = MagicMock() 10 | self.kanji_stats = KanjiStats(self.col, wholeCollection=True) 11 | 12 | def test_kanjiCountStr(self) -> None: 13 | self.assertEqual( 14 | self.kanji_stats.kanjiCountStr("Grade-X", 200), "200 Grade-X kanji." 15 | ) 16 | 17 | def test_kanjiCountStr_with_total(self) -> None: 18 | self.assertEqual( 19 | self.kanji_stats.kanjiCountStr("Grade-Y", 200, 1000), 20 | "Grade-Y: 200 of 1000 (20.0%).", 21 | ) 22 | -------------------------------------------------------------------------------- /code/localizemedia/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 3 | # 4 | # Changes remote image links in selected cards to local ones. 5 | # 6 | 7 | from __future__ import annotations 8 | 9 | import os 10 | import re 11 | import time 12 | import urllib.parse 13 | from collections.abc import Sequence 14 | 15 | from anki.httpclient import HttpClient 16 | from anki.notes import Note, NoteId 17 | from aqt import mw 18 | from aqt.browser.browser import Browser 19 | from aqt.operations import CollectionOp, OpChanges 20 | from aqt.qt import * 21 | from aqt.utils import showInfo, showWarning, tr 22 | 23 | 24 | def onLocalize(browser: Browser) -> None: 25 | nids = browser.selected_notes() 26 | if not nids: 27 | showInfo("Please select some notes.") 28 | return 29 | 30 | def on_failure(exc: Exception) -> None: 31 | showInfo( 32 | f"An error occurred. Any media already downloaded has been saved. Error: {exc}" 33 | ) 34 | 35 | CollectionOp(parent=browser, op=lambda _col: _localizeNids(browser, nids)).success( 36 | lambda _: showInfo("Success") 37 | ).failure(on_failure).run_in_background() 38 | 39 | 40 | def _localizeNids(browser: Browser, nids: Sequence[NoteId]) -> OpChanges: 41 | undo_start = mw.col.add_custom_undo_entry("Localize Media") 42 | with HttpClient() as client: 43 | client.timeout = 30 44 | for c, nid in enumerate(nids): 45 | note = mw.col.get_note(nid) 46 | if not _localizeNote(browser, note, undo_start, client): 47 | raise Exception("aborted") 48 | mw.taskman.run_on_main( 49 | lambda c=c: mw.progress.update(f"Processed {c+1}/{len(nids)}...") # type: ignore 50 | ) 51 | return mw.col.merge_undo_entries(undo_start) 52 | 53 | 54 | def _retrieveURL(url: str, client: HttpClient) -> str: 55 | content_type = None 56 | url = urllib.parse.unquote(url) 57 | with client.get(url) as response: 58 | if response.status_code != 200: 59 | raise Exception( 60 | f"got http code {response.status_code} while fetching {url}" 61 | ) 62 | filecontents = response.content 63 | content_type = response.headers.get("content-type") 64 | # strip off any query string 65 | url = re.sub(r"\?.*?$", "", url) 66 | fname = os.path.basename(urllib.parse.unquote(url)) 67 | if not fname.strip(): 68 | fname = "paste" 69 | if content_type: 70 | fname = mw.col.media.add_extension_based_on_mime(fname, content_type) 71 | 72 | return mw.col.media.write_data(fname, filecontents) 73 | 74 | 75 | def _localizeNote( 76 | browser: Browser, note: Note, undo_start: int, client: HttpClient 77 | ) -> bool: 78 | for fld, val in note.items(): 79 | # any remote links? 80 | files = mw.col.media.files_in_str( 81 | note.note_type()["id"], val, include_remote=True 82 | ) 83 | found = False 84 | for file in files: 85 | if file.startswith("http://") or file.startswith("https://"): 86 | found = True 87 | break 88 | elif file.startswith("data:image"): 89 | found = True 90 | break 91 | 92 | if not found: 93 | continue 94 | 95 | # gather and rewrite 96 | for regex in mw.col.media.regexps: 97 | for match in re.finditer(regex, val): 98 | fname = match.group("fname") 99 | remote = re.match("(https?)://", fname.lower()) 100 | if remote: 101 | newName = _retrieveURL(fname, client) 102 | val = val.replace(fname, newName) 103 | 104 | # don't overburden the server(s) 105 | time.sleep(1) 106 | elif fname.startswith("data:image"): 107 | val = val.replace( 108 | fname, browser.editor.inlinedImageToFilename(fname) 109 | ) 110 | 111 | note[fld] = val 112 | mw.col.update_note(note) 113 | mw.col.merge_undo_entries(undo_start) 114 | return True 115 | 116 | 117 | def onMenuSetup(browser: Browser) -> None: 118 | act = QAction(browser) 119 | act.setText("Localize Media") 120 | mn = browser.form.menu_Notes 121 | mn.addSeparator() 122 | mn.addAction(act) 123 | act.triggered.connect(lambda b=browser: onLocalize(browser)) 124 | 125 | 126 | from aqt import gui_hooks 127 | 128 | gui_hooks.browser_will_show.append(onMenuSetup) 129 | -------------------------------------------------------------------------------- /code/localizemedia/manifest.json: -------------------------------------------------------------------------------- 1 | {"package": "localizemedia", "name": "localizemedia"} -------------------------------------------------------------------------------- /code/mergechilddecks/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 3 | 4 | from __future__ import annotations 5 | 6 | import re 7 | from dataclasses import dataclass 8 | from typing import Dict, List 9 | 10 | from anki.decks import DeckId 11 | from anki.utils import ids2str, int_time 12 | from aqt import mw 13 | from aqt.qt import * 14 | from aqt.qt import sip 15 | 16 | 17 | class Wizard(QWizard): 18 | changes: list[Change] = [] 19 | 20 | def __init__(self) -> None: 21 | QWizard.__init__(self) 22 | self.setWizardStyle(QWizard.WizardStyle.ClassicStyle) 23 | self.setWindowTitle("Merge Child Decks") 24 | self.addPage(OptionsPage()) 25 | self.addPage(PreviewPage()) 26 | self.addPage(CommitPage()) 27 | 28 | 29 | class OptionsPage(QWizardPage): 30 | def __init__(self) -> None: 31 | QWizardPage.__init__(self) 32 | 33 | def initializePage(self) -> None: 34 | self.setTitle("Options") 35 | 36 | vbox = QVBoxLayout() 37 | 38 | hbox = QHBoxLayout() 39 | hbox.addWidget(QLabel("Depth")) 40 | depth = QSpinBox() 41 | depth.setRange(1, 10) 42 | depth.setValue(2) 43 | self.registerField("depth", depth) 44 | hbox.addWidget(depth) 45 | hbox.addStretch() 46 | vbox.addLayout(hbox) 47 | 48 | l = QLabel( 49 | "For example, if you set this to 2, cards from 'One::Two::Three' will be moved into 'One::Two'" 50 | ) 51 | l.setWordWrap(True) 52 | vbox.addWidget(l) 53 | 54 | vbox.addSpacing(30) 55 | 56 | hbox = QHBoxLayout() 57 | hbox.addWidget(QLabel("Deck Prefix")) 58 | deck = QLineEdit() 59 | deck.setPlaceholderText("optional prefix") 60 | self.registerField("deckprefix", deck) 61 | hbox.addWidget(deck) 62 | vbox.addLayout(hbox) 63 | l = QLabel( 64 | "Specifying a deck here will ignore any decks that don't start with the prefix." 65 | ) 66 | l.setWordWrap(True) 67 | vbox.addWidget(l) 68 | 69 | vbox.addSpacing(30) 70 | 71 | tag = QCheckBox("Tag Notes") 72 | tag.setChecked(True) 73 | self.registerField("tag", tag) 74 | vbox.addWidget(tag) 75 | l = QLabel( 76 | "When enabled, a tag based on the original child deck name will be added to notes." 77 | ) 78 | l.setWordWrap(True) 79 | vbox.addWidget(l) 80 | 81 | vbox.addStretch() 82 | 83 | self.setLayout(vbox) 84 | 85 | 86 | class PreviewPage(QWizardPage): 87 | def __init__(self) -> None: 88 | QWizardPage.__init__(self) 89 | 90 | def initializePage(self) -> None: 91 | self.setCommitPage(True) 92 | self.setTitle("Preview") 93 | 94 | vbox = QVBoxLayout() 95 | 96 | edit = QPlainTextEdit() 97 | edit.setReadOnly(True) 98 | f = QFont("Courier") 99 | f.setStyleHint(QFont.StyleHint.Monospace) 100 | edit.setFont(f) 101 | vbox.addWidget(edit) 102 | 103 | changes = buildChanges( 104 | self.field("depth"), self.field("deckprefix"), self.field("tag") 105 | ) 106 | 107 | self.get_wizard().changes = changes 108 | 109 | if not changes: 110 | edit.setPlainText("No changes to perform.") 111 | else: 112 | buf = "Cards will be removed from any filtered decks, then moved in the following decks:\n\n" 113 | buf += "\n\n".join(self._renderChange(x) for x in changes) 114 | edit.setPlainText(buf) 115 | 116 | if self.layout(): 117 | sip.delete(self.layout()) 118 | self.setLayout(vbox) 119 | 120 | def isComplete(self) -> bool: 121 | return bool(self.get_wizard().changes) 122 | 123 | def _renderChange(self, change: Change) -> str: 124 | return """\ 125 | From: {} 126 | To: {} 127 | Tag: {} 128 | """.format( 129 | change.oldname, 130 | change.newname, 131 | change.tag or "[no tag added]", 132 | ) 133 | 134 | def get_wizard(self) -> Wizard: 135 | return self.wizard() # type: ignore 136 | 137 | 138 | class CommitPage(QWizardPage): 139 | def __init__(self) -> None: 140 | QWizardPage.__init__(self) 141 | 142 | def initializePage(self) -> None: 143 | self.changeDecks() 144 | 145 | self.setTitle("Done!") 146 | 147 | vbox = QVBoxLayout() 148 | 149 | vbox.addWidget(QLabel("Decks have been updated.")) 150 | 151 | vbox.addStretch() 152 | 153 | self.setLayout(vbox) 154 | print("done!") 155 | 156 | def changeDecks(self) -> None: 157 | changes = self.get_wizard().changes 158 | performDeckChange(changes) 159 | 160 | def get_wizard(self) -> Wizard: 161 | return self.wizard() # type: ignore 162 | 163 | 164 | @dataclass 165 | class Change: 166 | oldname: str 167 | newname: str 168 | tag: str 169 | 170 | 171 | def buildChanges(depth: int, deckprefix: str, tag: str) -> list[Change]: 172 | changes = [] 173 | for deck in sorted(mw.col.decks.all(), key=lambda x: x["name"].lower()): 174 | # ignore if prefix doesn't match 175 | if not deck["name"].lower().startswith(deckprefix.lower()): 176 | continue 177 | 178 | # ignore if it's already short enough 179 | components = deck["name"].split("::") 180 | if len(components) <= depth: 181 | continue 182 | 183 | # ignore if it's a filtered deck 184 | if deck.get("dyn"): 185 | continue 186 | 187 | newcomponents = components[0:depth] 188 | if tag: 189 | rest = components[depth:] 190 | tag = "-".join(rest) 191 | tag = re.sub(r"[\s,]", "-", tag) 192 | else: 193 | tag = "" 194 | 195 | changes.append( 196 | Change( 197 | oldname=deck["name"], newname="::".join(newcomponents), tag=tag.lower() 198 | ) 199 | ) 200 | 201 | return changes 202 | 203 | 204 | def performDeckChange(changes: list[Change]) -> None: 205 | # process in reverse order, leaves first 206 | changes2 = reversed(changes) 207 | nameMap = {d.name: DeckId(d.id) for d in mw.col.decks.all_names_and_ids()} 208 | 209 | mw.progress.start(immediate=True) 210 | try: 211 | for change in changes2: 212 | changeDeck(nameMap, change) 213 | mw.progress.update() 214 | finally: 215 | mw.progress.finish() 216 | 217 | 218 | def changeDeck(nameMap: dict[str, DeckId], change: Change) -> None: 219 | oldDid = nameMap[change.oldname] 220 | newDid = nameMap[change.newname] 221 | 222 | # remove cards from any filtered decks 223 | cids = mw.col.db.list("select id from cards where odid=?", oldDid) 224 | if cids: 225 | mw.col.sched.remFromDyn(cids) 226 | 227 | # tag the notes 228 | if change.tag: 229 | nids = mw.col.db.list("select distinct nid from cards where did=?", oldDid) 230 | if nids: 231 | mw.col.tags.bulk_add(nids, change.tag) 232 | 233 | # move cards 234 | mod = int_time() 235 | usn = mw.col.usn() 236 | mw.col.db.execute( 237 | """ 238 | update cards set did=?, usn=?, mod=? where did=?""", 239 | newDid, 240 | usn, 241 | mod, 242 | oldDid, 243 | ) 244 | 245 | # remove the deck 246 | mw.col.decks.remove([oldDid]) 247 | 248 | 249 | def setupMenu() -> None: 250 | action = QAction("Merge Child Decks...", mw) 251 | 252 | def onMergeAction() -> None: 253 | w = Wizard() 254 | w.exec() 255 | mw.reset() 256 | 257 | action.triggered.connect(onMergeAction) 258 | mw.form.menuTools.addAction(action) 259 | 260 | 261 | setupMenu() 262 | -------------------------------------------------------------------------------- /code/mergechilddecks/manifest.json: -------------------------------------------------------------------------------- 1 | {"package": "mergechilddecks", "name": "mergechilddecks"} -------------------------------------------------------------------------------- /code/no_tts/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | """ 5 | Register a dummy TTS player to prevent a pop-up being shown when none is available. 6 | """ 7 | 8 | from dataclasses import dataclass 9 | from typing import List 10 | 11 | from anki.lang import compatMap 12 | from anki.sound import AVTag 13 | from aqt import mw 14 | from aqt.sound import av_player 15 | from aqt.tts import TTSProcessPlayer, TTSVoice 16 | 17 | # this is the language map that gtts.lang.tts_langs() outputs 18 | orig_langs = { 19 | "af": "Afrikaans", 20 | "ar": "Arabic", 21 | "bn": "Bengali", 22 | "bs": "Bosnian", 23 | "ca": "Catalan", 24 | "cs": "Czech", 25 | "cy": "Welsh", 26 | "da": "Danish", 27 | "de": "German", 28 | "el": "Greek", 29 | "en-au": "English (Australia)", 30 | "en-ca": "English (Canada)", 31 | "en-gb": "English (UK)", 32 | "en-gh": "English (Ghana)", 33 | "en-ie": "English (Ireland)", 34 | "en-in": "English (India)", 35 | "en-ng": "English (Nigeria)", 36 | "en-nz": "English (New Zealand)", 37 | "en-ph": "English (Philippines)", 38 | "en-tz": "English (Tanzania)", 39 | "en-uk": "English (UK)", 40 | "en-us": "English (US)", 41 | "en-za": "English (South Africa)", 42 | "eo": "Esperanto", 43 | "es-es": "Spanish (Spain)", 44 | "es-us": "Spanish (United States)", 45 | "et": "Estonian", 46 | "fi": "Finnish", 47 | "fr-ca": "French (Canada)", 48 | "fr-fr": "French (France)", 49 | "gu": "Gujarati", 50 | "hi": "Hindi", 51 | "hr": "Croatian", 52 | "hu": "Hungarian", 53 | "hy": "Armenian", 54 | "id": "Indonesian", 55 | "is": "Icelandic", 56 | "it": "Italian", 57 | "ja": "Japanese", 58 | "jw": "Javanese", 59 | "km": "Khmer", 60 | "kn": "Kannada", 61 | "ko": "Korean", 62 | "la": "Latin", 63 | "lv": "Latvian", 64 | "mk": "Macedonian", 65 | "ml": "Malayalam", 66 | "mr": "Marathi", 67 | "my": "Myanmar (Burmese)", 68 | "ne": "Nepali", 69 | "nl": "Dutch", 70 | "no": "Norwegian", 71 | "pl": "Polish", 72 | "pt-br": "Portuguese (Brazil)", 73 | "pt-pt": "Portuguese (Portugal)", 74 | "ro": "Romanian", 75 | "ru": "Russian", 76 | "si": "Sinhala", 77 | "sk": "Slovak", 78 | "sq": "Albanian", 79 | "sr": "Serbian", 80 | "su": "Sundanese", 81 | "sv": "Swedish", 82 | "sw": "Swahili", 83 | "ta": "Tamil", 84 | "te": "Telugu", 85 | "th": "Thai", 86 | "tl": "Filipino", 87 | "tr": "Turkish", 88 | "uk": "Ukrainian", 89 | "ur": "Urdu", 90 | "vi": "Vietnamese", 91 | "zh-cn": "Chinese (Mandarin/China)", 92 | "zh-tw": "Chinese (Mandarin/Taiwan)", 93 | } 94 | 95 | 96 | # we subclass the default voice object to store the gtts language code 97 | @dataclass 98 | class DummyVoice(TTSVoice): 99 | pass 100 | 101 | 102 | class DummyPlayer(TTSProcessPlayer): 103 | # this is called the first time Anki tries to play a TTS file 104 | def get_available_voices(self) -> list[TTSVoice]: 105 | voices = [] 106 | for code, name in orig_langs.items(): 107 | if "-" in code: 108 | # get a standard code like en_US from the gtts code en-us 109 | head, tail = code.split("-") 110 | std_code = f"{head}_{tail.upper()}" 111 | else: 112 | # get a standard code like cs_CZ from gtts code cs 113 | std_code = compatMap.get(code) 114 | # skip languages we don't understand 115 | if not std_code: 116 | continue 117 | 118 | voices.append(DummyVoice(name="dummy", lang=std_code)) 119 | return voices # type: ignore 120 | 121 | def _play(self, tag: AVTag) -> None: 122 | return 123 | 124 | 125 | # register our handler 126 | av_player.players.append(DummyPlayer(mw.taskman)) 127 | -------------------------------------------------------------------------------- /code/no_tts/manifest.json: -------------------------------------------------------------------------------- 1 | {"package": "no_tts", "name": "no_tts"} 2 | -------------------------------------------------------------------------------- /code/print/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 3 | # 4 | # Exports the cards in the current deck to a HTML file, so they can be 5 | # printed. Card styling is not included. Cards are printed in sort field 6 | # order. 7 | 8 | from __future__ import annotations 9 | 10 | import os 11 | import re 12 | 13 | from anki.cards import CardId 14 | from anki.decks import DeckId 15 | from anki.utils import ids2str 16 | from aqt import mw 17 | from aqt.qt import * 18 | from aqt.utils import mungeQA, openLink 19 | 20 | config = mw.addonManager.getConfig(__name__) 21 | 22 | 23 | def sortFieldOrderCids(did: DeckId) -> list[CardId]: 24 | dids = [did] 25 | for name, id in mw.col.decks.children(did): 26 | dids.append(id) 27 | return mw.col.db.list( 28 | """ 29 | select c.id from cards c, notes n where did in %s 30 | and c.nid = n.id order by n.sfld""" 31 | % ids2str(dids) 32 | ) 33 | 34 | 35 | def onPrint() -> None: 36 | path = os.path.join( 37 | QStandardPaths.writableLocation( 38 | QStandardPaths.StandardLocation.DesktopLocation 39 | ), 40 | "print.html", 41 | ) 42 | ids = sortFieldOrderCids(mw.col.decks.selected()) 43 | 44 | def esc(s: str) -> str: 45 | # strip off the repeated question in answer if exists 46 | # s = re.sub("(?si)^.*
\n*", "", s) 47 | # remove type answer 48 | s = re.sub(r"\[\[type:[^]]+\]\]", "", s) 49 | return s 50 | 51 | buf = open(path, "w", encoding="utf8") 52 | buf.write( 53 | "" + '' + mw.baseHTML() + "" 54 | ) 55 | buf.write( 56 | """""" 63 | ) 64 | first = True 65 | 66 | mw.progress.start(immediate=True) 67 | for j, cid in enumerate(ids): 68 | if j % config["cardsPerRow"] == 0: 69 | if not first: 70 | buf.write("") 71 | else: 72 | first = False 73 | buf.write("") 74 | c = mw.col.get_card(cid) 75 | qatxt = c.render_output(True, False).answer_text 76 | qatxt = mw.prepare_card_text_for_display(qatxt) 77 | cont = ''.format( 78 | esc(qatxt), 100 / config["cardsPerRow"] 79 | ) 80 | buf.write(cont) 81 | if j % 50 == 0: 82 | mw.progress.update("Cards exported: %d" % (j + 1)) 83 | buf.write("") 84 | buf.write("
{0}
") 85 | mw.progress.finish() 86 | buf.close() 87 | openLink(QUrl.fromLocalFile(path)) 88 | 89 | 90 | q = QAction(mw) 91 | q.setText("Print") 92 | q.setShortcut(QKeySequence("Shift+P")) 93 | mw.form.menuTools.addAction(q) 94 | q.triggered.connect(onPrint) # type: ignore 95 | -------------------------------------------------------------------------------- /code/print/config.json: -------------------------------------------------------------------------------- 1 | {"cardsPerRow": 3} 2 | -------------------------------------------------------------------------------- /code/print/manifest.json: -------------------------------------------------------------------------------- 1 | {"package": "print", "name": "print"} -------------------------------------------------------------------------------- /code/quickcolours/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 3 | # 4 | 5 | from typing import Callable 6 | 7 | from aqt import gui_hooks, mw 8 | from aqt.editor import Editor 9 | 10 | config = mw.addonManager.getConfig(__name__) 11 | 12 | 13 | def updateColour(editor: Editor, colour: str) -> None: 14 | editor.fcolour = colour 15 | editor.onColourChanged() 16 | editor._wrapWithColour(editor.fcolour) 17 | 18 | 19 | def onSetupShortcuts(cuts: list[tuple], editor: Editor) -> None: 20 | # add colours 21 | for code, key in config["keys"]: 22 | cuts.append((key, lambda c=code: updateColour(editor, c))) 23 | 24 | 25 | gui_hooks.editor_did_init_shortcuts.append(onSetupShortcuts) 26 | -------------------------------------------------------------------------------- /code/quickcolours/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "keys": [ 3 | ["red", "Ctrl+F12"], 4 | ["#00f", "F12"] 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /code/quickcolours/config.md: -------------------------------------------------------------------------------- 1 | The default configuration will turn the text red when Ctrl+F12 is pressed, and blue 2 | when F12 is pressed. You can add use other shortcut key names like Ctrl+m as 3 | well. 4 | 5 | Colour names can be standard colour names like "red", or red/green/blue colour codes like #00f. 6 | -------------------------------------------------------------------------------- /code/quickcolours/manifest.json: -------------------------------------------------------------------------------- 1 | {"package": "quickcolours", "name": "quickcolours"} -------------------------------------------------------------------------------- /code/removehistory/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Damien Elmes (http://help.ankiweb.net) 2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html 3 | 4 | from anki.utils import ids2str 5 | from aqt import mw 6 | from aqt.browser.browser import Browser 7 | from aqt.qt import * 8 | from aqt.utils import askUser, showInfo 9 | 10 | 11 | def onRemoveHistory(browser: Browser) -> None: 12 | cids = browser.selected_cards() 13 | if not cids: 14 | showInfo("No cards selected.") 15 | return 16 | if not askUser( 17 | "Are you sure you wish to remove the review history of the selected cards?" 18 | ): 19 | return 20 | 21 | mw.col.mod_schema(check=True) 22 | 23 | mw.progress.start(immediate=True) 24 | mw.col.db.execute("delete from revlog where cid in " + ids2str(cids)) 25 | mw.progress.finish() 26 | 27 | mw.reset() 28 | 29 | showInfo("Removed history of %d cards" % len(cids)) 30 | 31 | 32 | def onMenuSetup(browser: Browser) -> None: 33 | act = QAction(browser) 34 | act.setText("Remove Card History") 35 | mn = browser.form.menu_Cards 36 | mn.addSeparator() 37 | mn.addAction(act) 38 | act.triggered.connect(lambda b=browser: onRemoveHistory(browser)) 39 | 40 | 41 | from aqt import gui_hooks 42 | 43 | gui_hooks.browser_will_show.append(onMenuSetup) 44 | -------------------------------------------------------------------------------- /code/removehistory/manifest.json: -------------------------------------------------------------------------------- 1 | {"package": "removehistory", "name": "removehistory"} -------------------------------------------------------------------------------- /demos/av_player/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | """ 5 | Some quick examples of audio handling in 2.1.20. 6 | """ 7 | 8 | from typing import Any, Callable, List, Optional, Tuple 9 | 10 | from anki.sound import AVTag, SoundOrVideoTag 11 | from aqt import gui_hooks, mw 12 | from aqt.sound import ( 13 | MpvManager, 14 | SimpleMplayerSlaveModePlayer, 15 | SimpleProcessPlayer, 16 | SoundOrVideoPlayer, 17 | av_player, 18 | ) 19 | 20 | # Play files faster in mpv/mplayer 21 | ###################################### 22 | 23 | 24 | def set_speed(player: Any, speed: float) -> None: 25 | if isinstance(player, MpvManager): 26 | player.set_property("speed", speed) 27 | elif isinstance(player, SimpleMplayerSlaveModePlayer): 28 | player.command("speed_set", speed) 29 | 30 | 31 | # automatically play fast 32 | 33 | 34 | def did_begin_playing(player: Any, tag: AVTag) -> None: 35 | # mplayer seems to lose commands sent to it immediately after startup, 36 | # so we add a delay for it - an alternate approach would be to adjust 37 | # the command line arguments passed to it 38 | if isinstance(player, SimpleMplayerSlaveModePlayer): 39 | mw.progress.timer(500, lambda: set_speed(player, 1.25), False) 40 | else: 41 | set_speed(player, 1.25) 42 | 43 | 44 | gui_hooks.av_player_did_begin_playing.append(did_begin_playing) 45 | 46 | # shortcut key to make slower 47 | 48 | 49 | def on_shortcuts_change(state: str, shortcuts: List[Tuple[str, Callable]]) -> None: 50 | if state == "review": 51 | shortcuts.append(("8", lambda: set_speed(av_player.current_player, 0.75))) 52 | 53 | 54 | gui_hooks.state_shortcuts_will_change.append(on_shortcuts_change) 55 | 56 | # Play .ogg files in the external program 'myplayer' 57 | ######################################################## 58 | 59 | 60 | class MyPlayer(SimpleProcessPlayer, SoundOrVideoPlayer): 61 | args = ["myplayer"] 62 | 63 | def rank_for_tag(self, tag: AVTag) -> Optional[int]: 64 | if isinstance(tag, SoundOrVideoTag) and tag.filename.endswith(".ogg"): 65 | return 100 66 | return None 67 | 68 | 69 | av_player.players.append(MyPlayer(mw.taskman)) 70 | -------------------------------------------------------------------------------- /demos/card_did_render/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | """ 5 | An example of how you can transform the rendered card content in Anki 2.1.20. 6 | """ 7 | 8 | from typing import Tuple 9 | 10 | from anki import hooks 11 | from anki.template import TemplateRenderContext, TemplateRenderOutput 12 | 13 | 14 | def on_card_did_render( 15 | output: TemplateRenderOutput, context: TemplateRenderContext 16 | ) -> None: 17 | # let's uppercase the characters of the front text 18 | output.question_text = output.question_text.upper() 19 | 20 | # if the note is tagged "easy", show the answer in green 21 | # otherwise, in red 22 | if context.note().has_tag("easy"): 23 | colour = "green" 24 | else: 25 | colour = "red" 26 | 27 | output.answer_text += f"" 28 | 29 | 30 | # register our function to be called when the hook fires 31 | hooks.card_did_render.append(on_card_did_render) 32 | -------------------------------------------------------------------------------- /demos/deckoptions_raw_html/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of extending the deck options screen with raw HTML/JS. 3 | """ 4 | 5 | import json 6 | from pathlib import Path 7 | 8 | from aqt import gui_hooks, mw 9 | from aqt.deckoptions import DeckOptionsDialog 10 | 11 | file = Path(__file__) 12 | 13 | with open(file.with_name("raw.html"), encoding="utf8") as f: 14 | html = f.read() 15 | with open(file.with_name("raw.js"), encoding="utf8") as f: 16 | script = f.read() 17 | 18 | 19 | def on_mount(dialog: DeckOptionsDialog) -> None: 20 | dialog.web.eval(script.replace("HTML_CONTENT", json.dumps(html))) 21 | 22 | 23 | gui_hooks.deck_options_did_load.append(on_mount) 24 | -------------------------------------------------------------------------------- /demos/deckoptions_raw_html/raw.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 8 |
9 | 10 |

11 |
12 | -------------------------------------------------------------------------------- /demos/deckoptions_raw_html/raw.js: -------------------------------------------------------------------------------- 1 | function setup(options) { 2 | const store = options.auxData(); 3 | const boolInput = document.getElementById("myBool"); 4 | const numberInput = document.getElementById("myNumber"); 5 | 6 | // update html when state changes 7 | store.subscribe((data) => { 8 | boolInput.checked = data["myBoolKey"]; 9 | numberInput.value = data["myNumberKey"]; 10 | 11 | // and show current data for debugging 12 | document.getElementById("myDebug").innerText = JSON.stringify( 13 | data, 14 | null, 15 | 4 16 | ); 17 | }); 18 | 19 | // update config when check state changes 20 | boolInput.addEventListener("change", (_) => 21 | store.update((data) => { 22 | return { ...data, myBoolKey: boolInput.checked }; 23 | }) 24 | ); 25 | numberInput.addEventListener("change", (_) => { 26 | let number = 0; 27 | try { 28 | number = parseInt(numberInput.value, 10); 29 | } catch (err) {} 30 | 31 | return store.update((data) => { 32 | return { ...data, myNumberKey: number }; 33 | }); 34 | }); 35 | } 36 | 37 | $deckOptions.then((options) => { 38 | options.addHtmlAddon(HTML_CONTENT, () => setup(options)); 39 | }); 40 | -------------------------------------------------------------------------------- /demos/field_filter/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ankitects Pty Ltd and contributors 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | """ 5 | An example of how you can add content to a card's question and answer 6 | dynamically in Anki 2.1.20. 7 | 8 | One way to accomplish this would be to automatically insert a 9 | "GeneratedField" field into the list of fields when a card is being 10 | rendered. The user could then use {{GeneratedField}} on their card template 11 | to show the extra information. But this is not a great solution, as when 12 | the user switches to a mobile devices or a device without the add-on 13 | installed, they'd get an error message that the field doesn't exist. 14 | Another downside of that approach is that if you're creating hundreds 15 | of fields, you may be doing extra work that does not end up getting used. 16 | 17 | A better approach is to leverage Anki's field filter system. If the user 18 | places {{myfilter:a_valid_field}} on their template, the field_filter hook 19 | will be called, allowing your add-on to modify or add to the text contained 20 | in a_valid_field. 21 | 22 | While filters can be used to change the text of fields, in this case we are 23 | only interested in adding new content, so we don't need to use the contents 24 | of a_valid_field. While you could ask your user to create a blank field for 25 | the benefit of your add-on so they don't get an invalid field message, 26 | Anki provides a shortcut - if the field reference ends with a :, it will 27 | not display the invalid field message, and will pass a blank string to the 28 | filter. So users can just use {{myfilter:}} on the template instead. 29 | 30 | On devices that are not running the add-on, unrecognized filters will be 31 | silently ignored, so on a stock install {{myfilter:}} will not add anything to 32 | the template. 33 | 34 | We'll make an add-on that allows the user to add the following to their 35 | template: 36 | 37 | {{info-card-interval:}} -- shows the card's interval 38 | {{info-note-creation:}} -- shows the date the note was created 39 | 40 | We make use of the context argument to gain access to the card and note 41 | that is being rendered. See template.py for the other options it provides. 42 | """ 43 | 44 | import time 45 | 46 | from anki import hooks 47 | from anki.template import TemplateRenderContext 48 | 49 | 50 | # called each time a custom filter is encountered 51 | def my_field_filter( 52 | field_text: str, 53 | field_name: str, 54 | filter_name: str, 55 | context: TemplateRenderContext, 56 | ) -> str: 57 | if not filter_name.startswith("info-"): 58 | # not our filter, return string unchanged 59 | return field_text 60 | 61 | # split the name into the 'info' prefix, and the rest 62 | try: 63 | (label, rest) = filter_name.split("-", maxsplit=1) 64 | except ValueError: 65 | return invalid_name(filter_name) 66 | 67 | # call the appropriate function 68 | if rest == "card-interval": 69 | return card_interval(context) 70 | elif rest == "note-creation": 71 | return note_creation(context) 72 | else: 73 | return invalid_name(filter_name) 74 | 75 | 76 | def invalid_name(filter_name: str) -> str: 77 | return f"invalid filter name: {filter_name}" 78 | 79 | 80 | def card_interval(ctx: TemplateRenderContext) -> str: 81 | return str(ctx.card().ivl) 82 | 83 | 84 | def note_creation(ctx: TemplateRenderContext) -> str: 85 | # convert millisecond timestamp to seconds 86 | note_creation_unix_timestamp = ctx.note().id // 1000 87 | # convert timestamp to a human years-months-days 88 | return time.strftime("%Y-%m-%d", time.localtime(note_creation_unix_timestamp)) 89 | 90 | 91 | # register our function to be called when the hook fires 92 | hooks.field_filter.append(my_field_filter) 93 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.9 3 | pretty = true 4 | no_strict_optional = true 5 | show_error_codes = true 6 | exclude = gtts_player 7 | check_untyped_defs = true 8 | disallow_untyped_defs = True 9 | 10 | [mypy-win32file] 11 | ignore_missing_imports = True 12 | [mypy-win32pipe] 13 | ignore_missing_imports = True 14 | [mypy-pywintypes] 15 | ignore_missing_imports = True 16 | [mypy-winerror] 17 | ignore_missing_imports = True 18 | [mypy-distro] 19 | ignore_missing_imports = True 20 | [mypy-pyaudio] 21 | ignore_missing_imports = True 22 | [mypy-win32api] 23 | ignore_missing_imports = True 24 | [mypy-xml.dom] 25 | ignore_missing_imports = True 26 | [mypy-psutil] 27 | ignore_missing_imports = True 28 | [mypy-bs4] 29 | ignore_missing_imports = True 30 | [mypy-ankirspy] 31 | ignore_missing_imports = True 32 | [mypy-stringcase] 33 | ignore_missing_imports = True 34 | [mypy-gtts] 35 | ignore_missing_imports = True 36 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | isort 3 | mypy 4 | pylint 5 | mock 6 | types-mock 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aqt[qt6] 2 | -------------------------------------------------------------------------------- /setup-venv.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | python3 -m venv ~/Local/python/addons 6 | ~/Local/python/addons/bin/pip install -r requirements-dev.txt 7 | -------------------------------------------------------------------------------- /update-anki.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Updates venv to use locally-built wheels, for changes that haven't made it into 4 | # a PyPi update. 5 | 6 | set -e 7 | 8 | (cd ../anki && ./ninja wheels) 9 | ~/Local/python/addons/bin/pip install --upgrade ../anki/out/wheels/* 10 | --------------------------------------------------------------------------------