diff --git a/antp/__init__.py b/antp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/antp/__main__.py b/antp/__main__.py index d5af54a..329ba17 100644 --- a/antp/__main__.py +++ b/antp/__main__.py @@ -1,8 +1,12 @@ +# Copyright: Ren Tatsumoto +# License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + import sys from urllib.error import URLError -from antp.exporter import export_note_type -from antp.importer import import_note_type +from .common import ANTPError +from .exporter import export_note_type +from .importer import import_note_type def main(): @@ -11,15 +15,13 @@ def main(): print("No action provided.\n") print(f"import\tAdd one of the available note types to Anki.") print(f"export\tSave your note type as a template.") - return - cmd = sys.argv[1] - if cmd == 'export': + elif (cmd := sys.argv[1]) == 'export': export_note_type() elif cmd == 'import': import_note_type() except URLError: print("Couldn't connect. Make sure Anki is open and AnkiConnect is installed.") - except Exception as ex: + except ANTPError as ex: print(ex) diff --git a/antp/ankiconnect.py b/antp/ankiconnect.py index b018387..eeb0506 100644 --- a/antp/ankiconnect.py +++ b/antp/ankiconnect.py @@ -1,8 +1,14 @@ # Taken from https://github.com/FooSoft/anki-connect +# Copyright: Ren Tatsumoto +# License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +__all__ = ['invoke'] import json import urllib.request +from .common import ANTPError + def request(action, **params): return {'action': action, 'params': params, 'version': 6} @@ -12,22 +18,11 @@ def invoke(action, **params): request_json = json.dumps(request(action, **params)).encode('utf-8') response = json.load(urllib.request.urlopen(urllib.request.Request('http://localhost:8765', request_json))) if len(response) != 2: - raise Exception('response has an unexpected number of fields') + raise ANTPError('response has an unexpected number of fields') if 'error' not in response: - raise Exception('response is missing required error field') + raise ANTPError('response is missing required error field') if 'result' not in response: - raise Exception('response is missing required result field') + raise ANTPError('response is missing required result field') if response['error'] is not None: - raise Exception(response['error']) + raise ANTPError(response['error']) return response['result'] - - -def main(): - decks = invoke('deckNames') - print("List of decks:") - for deck in decks: - print(deck) - - -if __name__ == '__main__': - main() diff --git a/antp/common.py b/antp/common.py index d068438..5008efe 100644 --- a/antp/common.py +++ b/antp/common.py @@ -1,22 +1,36 @@ -import os +# Copyright: Ren Tatsumoto +# License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + import re +from dataclasses import dataclass from typing import Optional -JSON_INDENT = 4 -JSON_FILENAME = 'template.json' -README_FILENAME = 'README.md' -SCRIPT_DIR = os.path.dirname(__file__) -NOTE_TYPES_DIR = os.path.join(SCRIPT_DIR, os.pardir, 'templates') -FONTS_DIR = os.path.join(SCRIPT_DIR, os.pardir, 'fonts') +from .consts import * -if not os.path.isdir(FONTS_DIR): - os.mkdir(FONTS_DIR) + +class ANTPError(Exception): + pass + + +@dataclass(frozen=True) +class CardTemplate: + name: str + front: str + back: str + + +@dataclass(frozen=True) +class NoteType: + name: str + fields: list[str] + css: str + templates: list[CardTemplate] def read_num(msg: str = "Input number: ", min_val: int = 0, max_val: int = None) -> int: resp = int(input(msg)) if resp < min_val or (max_val and resp > max_val): - raise ValueError("Value out of range.") + raise ANTPError("Value out of range.") return resp @@ -35,3 +49,14 @@ def select(items: list[str], msg: str = "Select item number: ") -> Optional[str] def get_used_fonts(template_css: str): return re.findall(r"url\([\"'](\w+\.[ot]tf)[\"']\)", template_css, re.IGNORECASE) + + +def init(): + from .consts import NOTE_TYPES_DIR, FONTS_DIR + + for path in (NOTE_TYPES_DIR, FONTS_DIR): + if not os.path.isdir(path): + os.mkdir(path) + + +init() diff --git a/antp/consts.py b/antp/consts.py new file mode 100644 index 0000000..4363514 --- /dev/null +++ b/antp/consts.py @@ -0,0 +1,14 @@ +# Copyright: Ren Tatsumoto +# License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import os + +JSON_INDENT = 4 +JSON_FILENAME = 'template.json' +CSS_FILENAME = 'template.css' +FRONT_FILENAME = 'front.html' +BACK_FILENAME = 'back.html' +README_FILENAME = 'README.md' +SCRIPT_DIR = os.path.dirname(__file__) +NOTE_TYPES_DIR = os.path.join(SCRIPT_DIR, os.pardir, 'templates') +FONTS_DIR = os.path.join(SCRIPT_DIR, os.pardir, 'fonts') diff --git a/antp/exporter.py b/antp/exporter.py index 98b2594..51292f8 100644 --- a/antp/exporter.py +++ b/antp/exporter.py @@ -1,89 +1,98 @@ # Exporter exports note types from Anki to ../templates/ +# Copyright: Ren Tatsumoto +# License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import base64 import json -import os import random -from typing import AnyStr -from urllib.error import URLError +from typing import Any -import antp.common as ac -from antp.ankiconnect import invoke +from .ankiconnect import invoke +from .common import NoteType, CardTemplate, get_used_fonts, select +from .consts import * -def fetch_card_templates(model_name: str): - card_templates = [] - for key, val in invoke("modelTemplates", modelName=model_name).items(): - card_templates.append({ - "Name": key, - "Front": val["Front"], - "Back": val["Back"], - }) - return card_templates +def fetch_card_templates(model_name: str) -> list[CardTemplate]: + return [ + CardTemplate(name, val["Front"], val["Back"]) + for name, val in invoke("modelTemplates", modelName=model_name).items() + ] -def fetch_template(model_name: str): - return { - "modelName": model_name, - "inOrderFields": invoke("modelFieldNames", modelName=model_name), - "css": invoke("modelStyling", modelName=model_name)["css"], - "cardTemplates": fetch_card_templates(model_name), - } +def fetch_template(model_name: str) -> NoteType: + return NoteType( + name=model_name, + fields=invoke("modelFieldNames", modelName=model_name), + css=invoke("modelStyling", modelName=model_name)["css"], + templates=fetch_card_templates(model_name), + ) -def create_template_dir(template_name: str) -> AnyStr: - dir_path = os.path.join(ac.NOTE_TYPES_DIR, template_name) - dir_content = os.listdir(ac.NOTE_TYPES_DIR) +def select_model_dir_path(model_name: str) -> str: + dir_path = os.path.join(NOTE_TYPES_DIR, model_name) + dir_content = os.listdir(NOTE_TYPES_DIR) - if template_name in dir_content: + if model_name in dir_content: ans = input("Template with this name already exists. Overwrite [y/N]? ") if ans.lower() != 'y': while os.path.basename(dir_path) in dir_content: - dir_path = os.path.join(ac.NOTE_TYPES_DIR, f"{template_name}_{random.randint(0, 9999)}") - if not os.path.isdir(dir_path): - os.mkdir(dir_path) + dir_path = os.path.join(NOTE_TYPES_DIR, f"{model_name}_{random.randint(0, 9999)}") return dir_path -def save_note_type(template_json: dict): - template_dir = create_template_dir(template_json["modelName"]) - template_fp = os.path.join(template_dir, ac.JSON_FILENAME) - readme_fp = os.path.join(template_dir, ac.README_FILENAME) - - with open(template_fp, 'w') as f: - f.write(json.dumps(template_json, indent=ac.JSON_INDENT)) - - if not os.path.isfile(readme_fp): - with open(readme_fp, 'w') as f: - f.write(f"# {template_json['modelName']}\n\n*Description and screenshots here.*") +def write_card_templates(template_dir: str, templates: list[CardTemplate]) -> None: + for template in templates: + dir_path = os.path.join(template_dir, template.name) + if not os.path.isdir(dir_path): + os.mkdir(dir_path) + for filename, content in zip((FRONT_FILENAME, BACK_FILENAME), (template.front, template.back)): + with open(os.path.join(dir_path, filename), 'w') as f: + f.write(content) -def save_fonts(template_json: dict): - linked_fonts = ac.get_used_fonts(template_json['css']) +def format_export(model: NoteType) -> dict[str, Any]: + return { + "modelName": model.name, + "inOrderFields": model.fields, + "cardTemplates": [template.name for template in model.templates] + } + + +def save_note_type(model: NoteType): + dir_path = select_model_dir_path(model.name) + json_path = os.path.join(dir_path, JSON_FILENAME) + css_path = os.path.join(dir_path, CSS_FILENAME) + readme_path = os.path.join(dir_path, README_FILENAME) + + if not os.path.isdir(dir_path): + os.mkdir(dir_path) + + with open(json_path, 'w') as f: + json.dump(format_export(model), f, indent=JSON_INDENT) + + with open(css_path, 'w') as f: + f.write(model.css) + + write_card_templates(dir_path, model.templates) + + if not os.path.isfile(readme_path): + with open(readme_path, 'w') as f: + f.write(f"# {model.name}\n\n*Description and screenshots here.*") + + +def save_fonts(model: NoteType) -> None: + linked_fonts = get_used_fonts(model.css) for font in linked_fonts: - file_b64 = invoke("retrieveMediaFile", filename=font) - if file_b64 is False: - continue - with open(os.path.join(ac.FONTS_DIR, font), 'bw') as f: - f.write(base64.b64decode(file_b64)) + if file_b64 := invoke("retrieveMediaFile", filename=font): + with open(os.path.join(FONTS_DIR, font), 'bw') as f: + f.write(base64.b64decode(file_b64)) def export_note_type(): - model = ac.select(invoke('modelNames')) - if not model: - return - print(f"Selected model: {model}") - template = fetch_template(model) - save_fonts(template) - save_note_type(template) - print("Done.") - - -if __name__ == '__main__': - try: - export_note_type() - except URLError: - print("Couldn't connect. Make sure Anki is open and AnkiConnect is installed.") - except Exception as ex: - print(ex) + if model := select(invoke('modelNames')): + print(f"Selected model: {model}") + template = fetch_template(model) + save_fonts(template) + save_note_type(template) + print("Done.") diff --git a/antp/importer.py b/antp/importer.py index 7722e14..3c71ed2 100644 --- a/antp/importer.py +++ b/antp/importer.py @@ -1,46 +1,73 @@ # Importer imports note types from ../templates/ to Anki. +# Copyright: Ren Tatsumoto +# License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import json -import os -from urllib.error import URLError +from typing import Any -import antp.common as ac -from antp.ankiconnect import invoke +from .ankiconnect import invoke +from .common import select, get_used_fonts, NoteType, CardTemplate +from .consts import * -def read_template(model_name: str): - with open(os.path.join(ac.NOTE_TYPES_DIR, model_name, ac.JSON_FILENAME), 'r') as f: - return json.load(f) +def read_css(model_name: str) -> str: + with open(os.path.join(NOTE_TYPES_DIR, model_name, CSS_FILENAME)) as f: + return f.read() -def send_note_type(template_json: dict): +def read_card_templates(model_name, template_names: list[str]) -> list[CardTemplate]: + templates = [] + for template_name in template_names: + dir_path = os.path.join(NOTE_TYPES_DIR, model_name, template_name) + with open(os.path.join(dir_path, FRONT_FILENAME)) as front, open(os.path.join(dir_path, BACK_FILENAME)) as back: + templates.append(CardTemplate(template_name, front.read(), back.read())) + return templates + + +def read_model(model_dict: dict[str, Any]) -> NoteType: + return NoteType( + name=model_dict['modelName'], + fields=model_dict['inOrderFields'], + css=read_css(model_dict['modelName']), + templates=read_card_templates(model_dict['modelName'], model_dict['cardTemplates']), + ) + + +def format_import(model: NoteType) -> dict[str, Any]: + return { + "modelName": model.name, + "inOrderFields": model.fields, + "css": model.css, + "cardTemplates": [ + { + "Name": template.name, + "Front": template.front, + "Back": template.back, + } + for template in model.templates + ] + } + + +def send_note_type(model: NoteType): models = invoke('modelNames') + template_json = format_import(model) while template_json["modelName"] in models: template_json["modelName"] = input("Model with this name already exists. Enter new name: ") invoke("createModel", **template_json) def store_fonts(fonts: list[str]): - for file in os.listdir(ac.FONTS_DIR): + for file in os.listdir(FONTS_DIR): if file in fonts: - invoke("storeMediaFile", filename=file, path=os.path.join(ac.FONTS_DIR, file)) + invoke("storeMediaFile", filename=file, path=os.path.join(FONTS_DIR, file)) def import_note_type(): - model = ac.select(os.listdir(ac.NOTE_TYPES_DIR)) - if not model: - return - print(f"Selected model: {model}") - template = read_template(model) - store_fonts(ac.get_used_fonts(template['css'])) - send_note_type(template) - print("Done.") - - -if __name__ == '__main__': - try: - import_note_type() - except URLError: - print("Couldn't connect. Make sure Anki is open and AnkiConnect is installed.") - except Exception as ex: - print(ex) + if model_name := select(os.listdir(NOTE_TYPES_DIR)): + print(f"Selected model: {model_name}") + with open(os.path.join(NOTE_TYPES_DIR, model_name, JSON_FILENAME), 'r') as f: + model = read_model(json.load(f)) + store_fonts(get_used_fonts(model.css)) + send_note_type(model) + print("Done.") diff --git a/tests/export.py b/tests/export.py new file mode 100644 index 0000000..b56a239 --- /dev/null +++ b/tests/export.py @@ -0,0 +1,4 @@ +from antp.exporter import export_note_type + +if __name__ == '__main__': + export_note_type() diff --git a/tests/import.py b/tests/import.py new file mode 100644 index 0000000..7b2fe12 --- /dev/null +++ b/tests/import.py @@ -0,0 +1,4 @@ +from antp.importer import import_note_type + +if __name__ == '__main__': + import_note_type() diff --git a/tests/list_decks.py b/tests/list_decks.py new file mode 100644 index 0000000..f1dce7b --- /dev/null +++ b/tests/list_decks.py @@ -0,0 +1,12 @@ +from antp.ankiconnect import invoke + + +def list_decks(): + decks = invoke('deckNames') + print("List of decks:") + for deck in decks: + print(deck) + + +if __name__ == '__main__': + list_decks()