From ac353e5c742fcdabbf8b8ed03ef18953ae236654 Mon Sep 17 00:00:00 2001 From: arne314 <73391160+arne314@users.noreply.github.com> Date: Sun, 22 Dec 2024 18:22:23 +0100 Subject: [PATCH] feat(anki): basic export --- pyproject.toml | 4 ++ src/anki/__init__.py | 0 src/anki/anki_api.py | 76 ++++++++++++++++++++++++++++++++++++++ src/anki/file_handler.py | 40 ++++++++++++++++++++ src/anki/flashcard.py | 71 +++++++++++++++++++++++++++++++++++ src/anki/main.py | 52 ++++++++++++++++++++++++++ src/anki/parser.py | 57 ++++++++++++++++++++++++++++ src/anki/typst_compiler.py | 61 ++++++++++++++++++++++++++++++ 8 files changed, 361 insertions(+) create mode 100644 src/anki/__init__.py create mode 100644 src/anki/anki_api.py create mode 100644 src/anki/file_handler.py create mode 100644 src/anki/flashcard.py create mode 100644 src/anki/main.py create mode 100644 src/anki/parser.py create mode 100644 src/anki/typst_compiler.py diff --git a/pyproject.toml b/pyproject.toml index bf681ac..9317a58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,3 +15,7 @@ dependencies = [ "aiohttp>=3.11.11", "tree-sitter-language-pack>=0.2.0", ] + +[project.scripts] +typstar-anki = "anki.main:main" + diff --git a/src/anki/__init__.py b/src/anki/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/anki/anki_api.py b/src/anki/anki_api.py new file mode 100644 index 0000000..82c4e1e --- /dev/null +++ b/src/anki/anki_api.py @@ -0,0 +1,76 @@ +import asyncio +import base64 +from typing import List + +import aiohttp + +from .flashcard import Flashcard + + +class AnkiConnectError(Exception): + pass + + +class AnkiConnectApi: + url: str + + def __init__(self, url="http://127.0.0.1:8765"): + self.url = url + + async def push_flashcards(self, cards: List[Flashcard]): + add = [] + update = [] + + for card in cards: + if card.is_new(): + add.append(card) + else: + update.append(card) + await asyncio.gather(self._add(add), self._update(update)) + + async def _request_api(self, action, **params): + async with aiohttp.ClientSession() as session: + data = { + "action": action, + "version": 6, + "params": params, + } + try: + async with session.post(url=self.url, json=data) as response: + result = await response.json(encoding="utf-8") + if err := result["error"]: + raise AnkiConnectError(err) + return result["result"] + except aiohttp.ClientError as e: + raise AnkiConnectError(f"Could not connect to Anki: {e}") + + async def _update_note_model(self, card: Flashcard): + await self._request_api("updateNoteModel", note=card.as_anki_model()) + + async def _store_media(self, card): + await self._request_api("storeMediaFile", + filename=card.svg_filename(True), + data=base64.b64encode(card.svg_front).decode()) + await self._request_api("storeMediaFile", + filename=card.svg_filename(False), + data=base64.b64encode(card.svg_back).decode()) + + async def _add(self, cards: List[Flashcard]): + notes = [] + for card in cards: + data = { + "deckName": card.deck, + "options": { + "allowDuplicate": True, # won't work with svgs + }, + } + data.update(card.as_anki_model(True)) + notes.append(data) + result = await self._request_api("addNotes", notes=notes) + for idx, note_id in enumerate(result): + cards[idx].update_id(note_id) + await self._update(cards) + + async def _update(self, cards: List[Flashcard]): + await asyncio.gather(*(self._update_note_model(card) for card in cards), + *(self._store_media(card) for card in cards)) diff --git a/src/anki/file_handler.py b/src/anki/file_handler.py new file mode 100644 index 0000000..e09397b --- /dev/null +++ b/src/anki/file_handler.py @@ -0,0 +1,40 @@ +from typing import List + +import tree_sitter + + +class FileHandler: + file_path: str + file_content: List[str] + + def __init__(self, path): + self.file_path = path + self.read() + + def get_bytes(self) -> bytes: + return bytes("".join(self.file_content), encoding="utf-8") + + def get_node_content(self, node: tree_sitter.Node, remove_outer=False): + content = "".join( + self.file_content[node.start_point.row:node.end_point.row + 1] + )[node.start_point.column:-(len(self.file_content[node.end_point.row]) - node.end_point.column)] + return content[1:-1] if remove_outer else content + + def update_node_content(self, node: tree_sitter.Node, value): + new_lines = self.file_content[:node.start_point.row] + first_line = self.file_content[node.start_point.row][:node.start_point.column] + last_line = self.file_content[node.end_point.row][node.end_point.column:] + new_lines.extend(( + line + "\n" for line in (first_line + str(value) + last_line).split("\n") + if line != "" + )) + new_lines.extend(self.file_content[node.end_point.row + 1:]) + self.file_content = new_lines + + def read(self): + with open(self.file_path, encoding="utf-8") as f: + self.file_content = f.readlines() + + def write(self): + with open(self.file_path, "w", encoding="utf-8") as f: + f.writelines(self.file_content) diff --git a/src/anki/flashcard.py b/src/anki/flashcard.py new file mode 100644 index 0000000..d487b63 --- /dev/null +++ b/src/anki/flashcard.py @@ -0,0 +1,71 @@ +import tree_sitter + + +class Flashcard: + note_id: int + front: str + back: str + deck: str + id_updated: bool + + note_id_node: tree_sitter.Node + front_node: tree_sitter.Node + back_node: tree_sitter.Node + + svg_front: bytes + svg_back: bytes + + def __init__(self, front: str, back: str, deck: str = None, note_id: int = None): + if not deck: + deck = "Default" + if not note_id: + note_id = 0 + self.front = front + self.back = back + self.deck = deck + self.note_id = note_id + self.id_updated = False + + def __str__(self): + return f"Flashcard(id={self.note_id}, front={self.front})" + + def as_typst(self, front: bool) -> str: + return f"#flashcard({self.note_id})[{self.front if front else ''}][{self.back if not front else ''}]" + + def as_html(self, front: bool) -> str: + prefix = f"
{self.front}: {self.back}{' ' * 10}
" # indexable via anki search + image = f'