Skip to content

Commit 5121116

Browse files
author
Talha Altinel
committed
add 'import-csv' POST endpoint for CSV
1 parent 558333d commit 5121116

2 files changed

Lines changed: 144 additions & 21 deletions

File tree

addon/__init__.py

Lines changed: 140 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,44 @@
1-
# when run inside Anki, __name__ variable would be numeric value (addon ID)
2-
if __name__ != "addon":
3-
import atexit
4-
import logging
5-
import threading
6-
from pathlib import Path
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.unlink(tmp.name)
738

8-
from aqt import mw
9-
from flask import Flask, jsonify, request, Response
10-
from waitress.server import create_server
1139

12-
# Configure logging to file only, Anki shows stderr as error popups!
40+
# when run inside Anki, __name__ variable would be numeric value (addon ID)
41+
if __name__ != "addon":
1342
addon_dir = Path(__file__).parent
1443
log_file = addon_dir / "addon.log"
1544
logging.basicConfig(
@@ -20,7 +49,10 @@
2049
force=True,
2150
)
2251
logger = logging.getLogger(__name__)
23-
logger.info("addon initializing...")
52+
53+
cfg = mw.addonManager.getConfig(__name__) or {}
54+
if not cfg:
55+
logger.fatal("config is empty")
2456

2557
app = Flask(__name__)
2658

@@ -29,24 +61,111 @@ def log_request_info() -> None:
2961
logger.info(f"{request.method} {request.path}")
3062

3163
@app.route("/")
32-
def index() -> Response:
33-
return jsonify({"status": "healthy"})
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 = 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 = 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
3492

35-
@app.route("/cards/count")
36-
def card_count() -> Response:
37-
count = mw.col.card_count()
38-
return jsonify({"card_count": count})
93+
raw: str = request.data.decode()
94+
future: Future = Future()
95+
96+
def execute() -> None:
97+
try:
98+
if profile not in mw.pm.profiles():
99+
future.set_result((None, f"Profile '{profile}' does not exist"))
100+
return
101+
102+
mw.pm.load(profile)
103+
col = mw.col
104+
if col is None:
105+
future.set_result((None, "Failed to load collection"))
106+
return
107+
108+
model: NotetypeDict | None = col.models.by_name(MODEL_NAME)
109+
if model is None:
110+
# we should create the model for Lamia as a fallback and not fail here
111+
future.set_result((None, f"Model '{MODEL_NAME}' does not exist"))
112+
return
113+
114+
# just checking what kind of model we want to create so that I can delete note-type.apkg and change README.md
115+
for k, v in model.items():
116+
logger.info(f"{MODEL_NAME} = {k} - {v}")
117+
118+
deck_id = col.decks.id(deck_name, create=True)
119+
120+
# think about existing notes and match scopes of CsvMetadata during import
121+
with temp_csv_file(raw) as path:
122+
# metadata: CsvMetadata = col.get_csv_metadata(
123+
# path=path, delimiter=Delimiter.COMMA
124+
# )
125+
# import_request: ImportCsvRequest = ImportCsvRequest(
126+
# path=path, metadata=metadata
127+
# )
128+
# response: ImportLogWithChanges = col.import_csv(import_request)
129+
130+
# res = {
131+
# "found_notes": response.log.found_notes,
132+
# "updated_notes": list(response.log.updated),
133+
# "new_notes": list(response.log.new),
134+
# }
135+
pass
136+
137+
future.set_result((None, None))
138+
except Exception as e:
139+
logger.exception("Error in execute_on_main")
140+
# this freaking propagates exception to another toxic try/catch above stack level
141+
future.set_exception(e)
142+
143+
# run on main thread of Anki to avoid SQLite threading issues
144+
mw.taskman.run_on_main(execute)
145+
146+
# block and wait for the result
147+
(res, err) = future.result()
148+
if err is not None:
149+
return jsonify({"message": err}), HTTPStatus.INTERNAL_SERVER_ERROR
150+
151+
return jsonify({"message": "Not implemented yet"}), HTTPStatus.NOT_IMPLEMENTED
152+
153+
address: str = cfg.get("address", DEFAULT_ADDRESS)
154+
port: int = cfg.get("port", DEFAULT_PORT)
155+
156+
app.register_blueprint(api_v1)
157+
srv = create_server(app, host=address, port=port)
39158

40-
srv = create_server(app, host="127.0.0.1", port=5000)
41159
def run_srv() -> None:
42-
logger.info("HTTP server running on http://127.0.0.1:5000")
160+
logger.info(f"HTTP server running on http://{address}:{port}")
43161
srv.run()
44162

45163
def shutdown_srv() -> None:
46164
srv.close()
47165
logger.info("HTTP server shutdown successfully")
48-
atexit.register(shutdown_srv)
49166

50-
th = threading.Thread(target=run_srv, daemon=True)
167+
gui_hooks.profile_will_close.append(shutdown_srv)
168+
169+
th = Thread(target=run_srv, daemon=True)
51170
th.start()
52171
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+
}

0 commit comments

Comments
 (0)