Skip to content

Commit 132462c

Browse files
author
Talha Altınel
authored
add anki python addon (#20)
* add anki python addon * add 'import-csv' POST endpoint for CSV * finish the import functionality
1 parent 1de316b commit 132462c

8 files changed

Lines changed: 1111 additions & 1 deletion

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
*.mp3
22
.idea/
33
.code/
4-
main
4+
main
5+
__pycache__
6+
addon.log

addon/.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

addon/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Laverna Addon for Anki Application
2+
3+
This Addon is a bridge server between Laverna CLI and Anki. It's purpose is to act as a middleman to receive the data from Laverna CLI.
4+
5+
Why do we need this Addon? Because Anki doesn't have a SDK so we use this Addon to make new decks for your profile.
6+
7+
## Requirements
8+
9+
- Anki desktop app
10+
- Anki version minimum: 25.9.2 (Bundled Python version minimum: 3.13)

addon/__init__.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
from concurrent.futures import Future
2+
from contextlib import contextmanager
3+
from http import HTTPStatus
4+
import logging
5+
import os
6+
from pathlib import Path
7+
import tempfile
8+
from threading import Thread
9+
from typing import Iterator
10+
11+
from anki.collection import (
12+
NotetypeDict,
13+
ImportCsvRequest,
14+
Delimiter,
15+
ImportLogWithChanges,
16+
CsvMetadata,
17+
)
18+
from aqt import mw, gui_hooks, appVersion
19+
from flask import Flask, jsonify, request, Response, Blueprint
20+
from waitress.server import create_server
21+
22+
23+
MODEL_NAME = "Cloze Multi Choice Audio"
24+
25+
DEFAULT_ADDRESS = "127.0.0.1"
26+
DEFAULT_PORT = 5555
27+
28+
29+
@contextmanager
30+
def temp_csv_file(csv_data: str) -> Iterator[str]:
31+
tmp = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".csv", newline="")
32+
try:
33+
tmp.write(csv_data)
34+
tmp.close()
35+
yield tmp.name
36+
finally:
37+
os.remove(tmp.name)
38+
39+
40+
# when run inside Anki, __name__ variable would be numeric value (addon ID)
41+
if __name__ != "addon":
42+
addon_dir = Path(__file__).parent
43+
log_file = addon_dir / "addon.log"
44+
logging.basicConfig(
45+
level=logging.INFO,
46+
format="%(asctime)s.%(msecs)03d | %(levelname)s | %(name)s | %(message)s",
47+
datefmt="%Y-%m-%d %H:%M:%S",
48+
handlers=[logging.FileHandler(log_file, mode="a")],
49+
force=True,
50+
)
51+
logger = logging.getLogger(__name__)
52+
53+
cfg = mw.addonManager.getConfig(__name__) or {}
54+
if not cfg:
55+
logger.fatal("config is empty")
56+
57+
app = Flask(__name__)
58+
59+
@app.before_request
60+
def log_request_info() -> None:
61+
logger.info(f"{request.method} {request.path}")
62+
63+
@app.route("/")
64+
def index() -> tuple[Response, HTTPStatus]:
65+
res: dict = {
66+
"status": "healthy",
67+
"minimum_required_anki_app_version": appVersion,
68+
}
69+
return jsonify(res), HTTPStatus.OK
70+
71+
# API v1 Blueprint with version prefix
72+
api_v1 = Blueprint("api_v1", __name__, url_prefix="/v1")
73+
74+
@api_v1.route("/import-csv", methods=["POST"])
75+
def import_csv() -> tuple[Response, HTTPStatus]:
76+
if request.content_type != "text/csv":
77+
return jsonify(
78+
{"message": f"Content-type '{request.content_type}' must be 'text/csv'"}
79+
), HTTPStatus.BAD_REQUEST
80+
81+
profile: str | None = request.args.get("profile", default=None)
82+
if profile is None or profile.strip() == "":
83+
return jsonify(
84+
{"message": "Missing 'profile' query parameter"}
85+
), HTTPStatus.BAD_REQUEST
86+
87+
deck_name: str | None = request.args.get("deck", default=None)
88+
if deck_name is None or deck_name.strip() == "":
89+
return jsonify(
90+
{"message": "Missing 'deck' query parameter"}
91+
), HTTPStatus.BAD_REQUEST
92+
93+
raw: str = request.data.decode()
94+
future: Future = Future()
95+
96+
def execute() -> None:
97+
if profile not in mw.pm.profiles():
98+
future.set_result((None, f"Profile '{profile}' does not exist"))
99+
return
100+
101+
current_profile = mw.pm.name
102+
if current_profile != profile:
103+
if mw.col:
104+
mw.col.close()
105+
mw.col = None
106+
mw.pm.load(profile)
107+
mw.loadProfile()
108+
mw.reset()
109+
mw.deckBrowser.show()
110+
111+
col = mw.col
112+
if col is None:
113+
future.set_result((None, "Failed to load collection"))
114+
return
115+
116+
model: NotetypeDict | None = col.models.by_name(MODEL_NAME)
117+
if model is None:
118+
# TODO: we should create the model for Lamia as a fallback and not fail here
119+
future.set_result(
120+
(
121+
None,
122+
f"Model '{MODEL_NAME}' does not exist, please download and create the notetype first (https://github.com/mrwormhole/laverna/blob/main/note-type.apkg)",
123+
)
124+
)
125+
return
126+
127+
deck_id = col.decks.id(deck_name, create=True)
128+
129+
with temp_csv_file(raw) as path:
130+
# CsvMetadata PB defined here https://github.com/ankitects/anki/blob/main/proto/anki/import_export.proto#L148
131+
md: CsvMetadata = col.get_csv_metadata(
132+
path=path, delimiter=Delimiter.COMMA
133+
)
134+
md.deck_id = deck_id
135+
md.global_notetype.id = model["id"]
136+
md.global_notetype.field_columns[:] = list(
137+
range(1, len(model["flds"]) + 1)
138+
)
139+
md.tags_column = 0 # no tags column
140+
md.dupe_resolution = CsvMetadata.DupeResolution.UPDATE
141+
md.match_scope = CsvMetadata.MatchScope.NOTETYPE_AND_DECK
142+
req: ImportCsvRequest = ImportCsvRequest(path=path, metadata=md)
143+
resp: ImportLogWithChanges = col.import_csv(req)
144+
mw.reset()
145+
mw.deckBrowser.show()
146+
res = {
147+
"found_notes": resp.log.found_notes,
148+
"updated_notes": len(list(resp.log.updated)),
149+
"new_notes": len(list(resp.log.new)),
150+
}
151+
152+
future.set_result((res, None))
153+
154+
# run on main thread of Anki to avoid SQLite threading issues
155+
mw.taskman.run_on_main(execute)
156+
157+
# block & wait for the result
158+
(res, err) = future.result()
159+
if err is not None:
160+
return jsonify({"message": err}), HTTPStatus.INTERNAL_SERVER_ERROR
161+
return jsonify(res), HTTPStatus.OK
162+
163+
address: str = cfg.get("address", DEFAULT_ADDRESS)
164+
port: int = cfg.get("port", DEFAULT_PORT)
165+
166+
app.register_blueprint(api_v1)
167+
srv = create_server(app, host=address, port=port)
168+
169+
def run_srv() -> None:
170+
logger.info(f"HTTP server running on http://{address}:{port}")
171+
srv.run()
172+
173+
def shutdown_srv() -> None:
174+
srv.close()
175+
logger.info("HTTP server shutdown successfully")
176+
177+
gui_hooks.profile_will_close.append(shutdown_srv)
178+
179+
th = Thread(target=run_srv, daemon=True)
180+
th.start()
181+
logger.info("addon initialized successfully")

addon/config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"port": 5555,
3+
"address": "127.0.0.1"
4+
}

addon/pyproject.toml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[project]
2+
name = "addon"
3+
version = "0.1.0"
4+
description = "Laverna Addon for Anki Application"
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
dependencies = [
8+
"aqt[qt]>=25.9.2",
9+
"flask>=3.1.2",
10+
"types-waitress>=3.0.1.20250801",
11+
"waitress>=3.0.2",
12+
]
13+
14+
[dependency-groups]
15+
dev = [
16+
"mypy>=1.19.1",
17+
"pytest>=9.0.2",
18+
"ruff>=0.14.9",
19+
]
20+
21+
[tool.mypy]
22+
python_version = "3.13"
23+
pretty = true
24+
no_strict_optional = true
25+
show_error_codes = true
26+
# exclude = "abc"
27+
check_untyped_defs = true
28+
disallow_untyped_defs = true

0 commit comments

Comments
 (0)