flashcards_export
Convert CSV flashcards (front, back, tags, optional deck and extra fields) into an Anki-importable tab-separated text file with the file headers of the Anki manual (#separator:Tab, #html, #notetype, #deck, #tags, #columns, #tags column, #deck column), quoting fields that hold tabs, newlines or quotes as Anki expects, or turning newlines into <br> in HTML mode.
Install
/tools install flashcards_export /tools install flashcards_export@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · flashcards_export 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool flashcards_export; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
csv | string | yes | CSV text | |
delimiter | string | no | "," | length ≥ 1; length ≤ 1 |
header | no | "auto" | auto: the first row is a header when one of its cells is 'front'. one of true, false, "auto" | |
columns | array of string | no | ["front", "back", "tags", "deck"] | Column names when there is no header row |
deck | string | no | #deck for every note | |
notetype | string | no | #notetype for every note | |
tags | array | string | no | #tags added to every note | |
html | boolean | no | false | |
newlines | string | no | Default br with html, else quote. one of "br", "quote" |
Input schema (JSON)
{
"type": "object",
"required": [
"csv"
],
"additionalProperties": false,
"properties": {
"csv": {
"type": "string",
"description": "CSV text"
},
"delimiter": {
"type": "string",
"minLength": 1,
"maxLength": 1,
"default": ","
},
"header": {
"enum": [
true,
false,
"auto"
],
"default": "auto",
"description": "auto: the first row is a header when one of its cells is 'front'"
},
"columns": {
"type": "array",
"items": {
"type": "string"
},
"default": [
"front",
"back",
"tags",
"deck"
],
"description": "Column names when there is no header row"
},
"deck": {
"type": "string",
"description": "#deck for every note"
},
"notetype": {
"type": "string",
"description": "#notetype for every note"
},
"tags": {
"type": [
"array",
"string"
],
"items": {
"type": "string"
},
"description": "#tags added to every note"
},
"html": {
"type": "boolean",
"default": false
},
"newlines": {
"type": "string",
"enum": [
"br",
"quote"
],
"description": "Default br with html, else quote"
}
}
}
Example
Input
{
"csv": "front,back,tags\nbonjour,hello,french greetings\n"
}
Output (stdout)
{
"tsv": "#separator:Tab\n#html:false\n#columns:Front\tBack\tTags\n#tags column:3\nbonjour\thello\tfrench greetings\n",
"notes": 1
}
Files
The command the install card registers is python3 flashcards_export.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
flashcards_export.py · sha256 0472edc38693b0c90b8425039c8b121bb09359ecd3b2d5cc773029666bfd28fc
#!/usr/bin/env python3
"""Turn CSV flashcards (front, back, tags, optional deck and extra fields) into an
Anki-importable tab-separated text file with the file headers of the Anki manual
(https://docs.ankiweb.net/importing/text-files.html, "File Headers", Anki 2.1.54+).
Input (JSON on stdin):
csv the CSV text (required)
delimiter CSV delimiter, default ","
header true | false | "auto" (default: the first row is a header when one cell is "front"
and every cell is a short lowercase-able name)
columns column names when there is no header row, default ["front", "back", "tags", "deck"]
deck deck for every note (#deck:), unless a deck column is present
notetype note type for every note (#notetype:)
tags tags added to every note (#tags:), list or space-separated string
html treat fields as HTML (#html:true), default false
newlines "br" (newline -> <br>, needs html) | "quote" (keep newlines inside a quoted field);
default br when html is true, else quote
"""
import csv
import io
import json
import re
import sys
KNOWN = ("front", "back", "tags", "deck")
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
def tag_list(value):
return [t for t in re.split(r"[\s,;]+", value.strip()) if t]
def quote(field, newlines):
"""Anki text import: a field holding the separator, a newline or a double quote is put in
double quotes, with each double quote inside doubled."""
text = field.replace("\r\n", "\n").replace("\r", "\n")
if newlines == "br":
text = text.replace("\n", "<br>")
if any(c in text for c in '\t\n"'):
return '"' + text.replace('"', '""') + '"'
return text
def header_value(value, field):
if not isinstance(value, str) or not value.strip() or any(c in value for c in "\t\r\n"):
fail(f"{field} must be a non-empty single-line string")
return value.strip()
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
extra = sorted(set(request) - {"csv", "delimiter", "header", "columns", "deck", "notetype", "tags", "html", "newlines"})
if extra:
fail(f"unknown field(s): {', '.join(extra)}")
source = request.get("csv")
if not isinstance(source, str) or not source.strip():
fail("csv must be the CSV text")
if len(source) > 5_000_000:
fail("csv is larger than 5 MB")
delimiter = request.get("delimiter", ",")
if not isinstance(delimiter, str) or len(delimiter) != 1 or delimiter in '"\r\n':
fail("delimiter must be one character (not a quote or newline)")
html = request.get("html", False)
if not isinstance(html, bool):
fail("html must be true or false")
newlines = request.get("newlines", "br" if html else "quote")
if newlines not in ("br", "quote"):
fail("newlines must be br or quote")
if newlines == "br" and not html:
fail("newlines br needs html true (Anki shows <br> literally otherwise)")
try:
rows = [r for r in csv.reader(io.StringIO(source, newline=""), delimiter=delimiter) if any(c.strip() for c in r)]
except csv.Error as error:
fail(f"csv is not valid: {error}")
if not rows:
fail("csv has no rows")
header = request.get("header", "auto")
if header not in (True, False, "auto"):
fail("header must be true, false or auto")
first = [c.strip().lower() for c in rows[0]]
if header == "auto":
header = "front" in first and all(re.fullmatch(r"[a-z][a-z0-9_ ]{0,39}", c) for c in first)
if header:
names, rows = first, rows[1:]
else:
names = request.get("columns", list(KNOWN))
if not isinstance(names, list) or not all(isinstance(n, str) and n.strip() for n in names):
fail("columns must be a list of column names")
names = [n.strip().lower() for n in names]
if len(set(names)) != len(names):
fail("column names must be unique")
if "front" not in names:
fail("there must be a front column")
width = max(len(r) for r in rows) if rows else 0
if width > len(names):
fail(f"a row has {width} fields but only {len(names)} columns are named")
fields = ["front"] + (["back"] if "back" in names else []) + [n for n in names if n not in KNOWN]
has_tags, has_deck = "tags" in names, "deck" in names
if has_deck and request.get("deck") is not None:
fail("give a deck column or a deck for every note, not both")
out_columns = fields + (["tags"] if has_tags else []) + (["deck"] if has_deck else [])
lines = ["#separator:Tab", f"#html:{'true' if html else 'false'}"]
if request.get("notetype") is not None:
lines.append(f"#notetype:{header_value(request['notetype'], 'notetype')}")
if request.get("deck") is not None:
lines.append(f"#deck:{header_value(request['deck'], 'deck')}")
if request.get("tags") is not None:
given = request["tags"]
if isinstance(given, str):
given = tag_list(given)
if not isinstance(given, list) or not all(isinstance(t, str) for t in given):
fail("tags must be a list of strings or a space-separated string")
given = [t for g in given for t in tag_list(g)]
if given:
lines.append("#tags:" + " ".join(given))
lines.append("#columns:" + "\t".join(c.capitalize() for c in out_columns))
if has_tags:
lines.append(f"#tags column:{out_columns.index('tags') + 1}")
if has_deck:
lines.append(f"#deck column:{out_columns.index('deck') + 1}")
header_count = len(lines)
skipped, notes = [], 0
offset = 2 if header else 1
for number, row in enumerate(rows):
cells = dict(zip(names, row + [""] * (len(names) - len(row))))
if not cells["front"].strip():
skipped.append({"row": number + offset, "reason": "empty front"})
continue
values = [quote(cells[f], newlines) for f in fields]
if has_tags:
values.append(" ".join(tag_list(cells["tags"])))
if has_deck:
values.append(quote(cells["deck"].strip(), newlines))
lines.append("\t".join(values))
notes += 1
if not notes:
fail("no notes: every row has an empty front")
print(json.dumps({"tsv": "\n".join(lines) + "\n", "notes": notes, "header_lines": lines[:header_count],
"columns": out_columns, "skipped": skipped,
"import": "Anki: File > Import, choose the .txt file; the header lines preset the options "
"(Anki 2.1.54 or later)"}, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as error: # last resort: a usage error, never a traceback
fail(f"invalid input ({type(error).__name__})")
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/flashcards_export/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/flashcards_export/1.0.0/flashcards_export.py