askesis
Docs Import & export

Docs / Your cards

Import & export

Move cards in and out as CSV or TSV, merge decks by name, and keep full backups with progress and review history.

File format

Askesis reads and writes plain-text tables in UTF-8: comma-separated (CSV), tab-separated (TSV), semicolon or pipe. One row is one card. Fields with the separator, quotes or line breaks are wrapped in double quotes, with quotes doubled inside.

cards.csv
#separator:comma
#html:false
#columns:deck,front,back,hint,extra,tags
#deck column:1
#tags column:6
Japanese::Kanji,脳,brain,Kanji meaning,On: ノウ,kanji body
Spanish,aprovechar,to make the most of,,,verbs
Neuroscience,"Who coined the term ""engram""?","Richard Semon, in 1904",,,history

Header lines

Optional lines at the top of the file describe it. They are the header lines common flashcard apps use for text import and export, so files move between apps.

LineMeaning
#separator:commaThe separator: comma, tab, semicolon or pipe. Detected automatically if missing.
#html:trueFields contain HTML. It is converted to plain text on import (see below).
#columns:deck,front,backA name for each column.
#deck column:1Which column (counting from 1) holds the deck path.
#tags column:6Which column holds space-separated tags.

Columns

ColumnAlso accepted asNotes
frontquestion, term, wordRequired. Rows without a front are skipped.
backanswer, definition, meaning
deckA path like Parent::Child. Missing decks are created.
tagstagSeparated by spaces. Nest them with ::, like lang::spanish.
hintreadingA small line under the front.
extranotes, noteShown under the answer.

Column names can come from #columns or from a header row (detected when the first row is made of column names). With neither, the first column is the front and the second the back. You can change any mapping before importing. Files can also carry each card’s study progress and review history, see Progress columns.

Importing

  1. Choose a file. Askesis detects the separator, the header lines and the columns.
  2. Check the table of your first rows, one card per row. Each column’s header says what it holds (front, back, deck, tags, …); change it there if Askesis guessed wrong. The rendered cards below follow.
  3. Pick a default deck for rows that don’t name one. Type a new name to create it.
  4. Choose what to do with duplicates, then import.

You get a summary: decks created, decks merged, and cards added, updated and skipped. For a file with progress, it also says “Progress kept for N cards” and “N past reviews restored”.

How merging works

  • Decks are matched by full path, ignoring upper/lower case. Cards are added to an existing deck with the same path. Nothing is ever deleted, so you can import into a deck again and again.
  • Duplicates are cards in the same deck with the same front (ignoring extra spaces). You choose:
OptionResult
UpdateDefault. The back, hint and extra are replaced, tags are merged, and the review history is kept.
SkipExisting cards are left alone.
Import as newA second copy is added.

Exporting

Export everything or only the decks you select (subdecks are included), as CSV (optionally separated with semicolons, for spreadsheets set to a European locale), TSV, or plain text (.txt) for other flashcard apps. Under Include there are two tickboxes:

TickboxDefaultAdds
Study progressOnEach card’s state, due date, interval, ease, memory, learning step and suspended flag. New cards stay new.
Review historyOn (needs Study progress)Every past answer, so your heatmap, streak, XP and stats come back when you import the file.

Your choice is remembered on this device. With both ticked, the file is a complete backup. To share cards so others start fresh, untick Study progress, which leaves out the history too. Other flashcard apps usually ignore these columns on import; Askesis restores them.

Progress columns

With progress included, these columns follow the tags, in this order:

state, due, interval, ease, stability, difficulty, step, reps, lapses, last_review, suspended, history
ColumnMeaning
state0 new, 1 learning, 2 review, 3 relearning.
dueWhen the card is next due, in ISO 8601 (2026-10-16T04:00:00.000Z).
intervalCurrent interval in days (0 while learning).
easeEase factor of the default scheduler: 2.5 means 250%.
stability, difficultyFSRS memory state, 0 when unknown.
stepPosition in the learning or relearning steps, counting from 0.
reps, lapsesTimes reviewed and times forgotten.
last_reviewTime of the last answer.
suspended1 if the card is suspended.
historyEvery past answer (next section). Only with Review history ticked.

Leave them empty for cards that haven’t been studied. A row only counts as having progress when its state and due date are both valid.

Review history

The history column holds a card’s past answers, oldest first, separated by ;. Each answer is up to eight values separated by spaces:

<time> <rating> <ms> <state before> <state after> <interval before> <interval after> <ease after>
ValueMeaning
timeWhen you answered, in ISO 8601. Required.
rating1 Again, 2 Hard, 3 Good, 4 Easy. Required.
msTime spent on the card, in milliseconds.
state before, state after0 new, 1 learning, 2 review, 3 relearning.
interval before, interval afterIn days.
ease afterEase factor after the answer, like 2.5.

Only time and rating are required. This card was learned with two Good answers about ten minutes apart, and graduated to a 1-day interval:

2026-03-01T09:12:00Z 3 5200 0 1 0 0 0;2026-03-01T09:22:40Z 3 3100 1 2 0 1 2.5

Restoring a backup

  • Cards that are new in the file stay new. Cards you don’t have yet are added with the file’s progress.
  • Cards you already have (same deck and front, with duplicates set to Update) take the file’s progress only when its last_review is newer than the card’s own. Restoring an old backup never rolls back study you did since.
  • History is merged into your review log without duplicates: an answer for the same card at the same moment, to the millisecond, is skipped. Importing the same backup twice is safe.
  • Imported history counts everywhere: the heatmap, streaks, XP, trophies and every chart (Reading your stats).
  • Imported answers can’t be undone in a study session.

After the import, the summary says how many cards kept their progress and how many past reviews were restored.

Coming from another app

  • A plain-text notes export (.txt) from another flashcard app imports directly. Its #guid column and #notetype column are skipped, a third field becomes the extra, and cloze notes ({{c1::answer::hint}}) become one card per deletion.
  • HTML fields are converted: line breaks, lists, bold and italic are kept, and the [latex], [$] and [$$] tags some apps use become Askesis math. Other HTML is dropped.
  • To go the other way, export as Plain text (.txt). Apps that read the header lines also import the Askesis CSV, including the deck and tags columns.
  • .apkg and .colpkg packages can’t be imported directly yet; convert them with the prompt below. Media files aren’t imported.

Flashcard packages (.apkg, .colpkg)

Until Askesis reads flashcard packages itself, an AI assistant that can run code (ChatGPT, Claude or Gemini) can convert one for you, keeping your progress:

  1. Copy the prompt below.
  2. Open your AI assistant, attach your .apkg or .colpkg file and paste the prompt.
  3. Download the CSV it creates and import it on the Decks page.

Every card keeps its state, due date, interval, ease, FSRS memory state, review count, lapses and suspended flag. Subdecks come through as Parent::Child paths and cloze notes become one card per deletion.

Your review history comes along too: the package’s review log becomes the history column, so the heatmap, streaks and stats pick up where you left off. Manual reschedules aren’t answers and are skipped. The current learning step isn’t carried over, so cards in the middle of their learning steps start them again.

package-to-askesis-prompt.txt
I'm attaching a flashcard package (.apkg or .colpkg). Convert it to an Askesis CSV
file I can import, keeping each card's study progress and review history. Use
Python. Run the script below as-is (install the "zstandard" package if it's
missing), then give me the resulting askesis-import.csv to download. If something
fails, explain the error and fix the script. Don't invent or rewrite any card
content.

Format rules (the Askesis spec, also at /llms.txt on the Askesis site):
- One row per CARD (not per note), so every card keeps its own progress.
- Header lines first, then rows with columns:
  deck,front,back,hint,extra,tags,state,due,interval,ease,stability,difficulty,step,reps,lapses,last_review,suspended,history
- Deck paths use "::" between levels. Tags are space-separated.
- Fields keep the package's HTML; "#html:true" tells Askesis to convert it.
- Progress: state 0 new, 1 learning, 2 review, 3 relearning; due and
  last_review as ISO 8601 UTC; interval in days; ease as a factor (2.5 = 250%);
  stability/difficulty from FSRS when present; step is left empty (Askesis restarts
  the current learning steps); leave progress empty for new cards so they stay new.
- history: every past answer from the review log (revlog table), oldest first,
  separated by ";". Each answer is "time rating ms state_before state_after
  interval_before interval_after ease" (time ISO 8601 UTC, rating 1-4, intervals
  in days). Manual reschedules (revlog type 4 and 5, or ease 0) are not answers
  and are skipped.
- Cloze cards are expanded here (front hides the deletion, back shows it in bold).
- Media (images, audio) can't be imported; references are dropped by Askesis.

```python
import csv, io, json, re, sqlite3, sys, tempfile, zipfile
from datetime import datetime, timezone

PATH = sys.argv[1] if len(sys.argv) > 1 else "deck.apkg"  # set to the uploaded file
OUT = "askesis-import.csv"

def open_collection(path):
    z = zipfile.ZipFile(path)
    tmp = tempfile.NamedTemporaryFile(suffix=".sqlite", delete=False)
    name = max(n for n in z.namelist() if n.startswith("collection."))   # newest format in the package
    raw = z.read(name)
    if name.endswith("b"):                     # modern packages: zstd-compressed SQLite
        import zstandard
        raw = zstandard.ZstdDecompressor().decompressobj().decompress(raw)
    tmp.write(raw); tmp.close()
    return sqlite3.connect(tmp.name)

db = open_collection(PATH)
crt = db.execute("select crt from col").fetchone()[0]          # collection day zero, seconds

try:                                                            # schema 18: decks table, "\x1f" between levels
    decks = {i: n.replace("\x1f", "::") for i, n in db.execute("select id, name from decks")}
except sqlite3.OperationalError:                                # schema 11: JSON in col.decks
    decks = {int(k): v["name"] for k, v in json.loads(db.execute("select decks from col").fetchone()[0]).items()}

notes = {nid: (flds.split("\x1f"), tags.strip()) for nid, flds, tags in db.execute("select id, flds, tags from notes")}
last = dict(db.execute("select cid, max(id) from revlog group by cid"))   # ms timestamps

# Review history. revlog type: 0 learn, 1 review, 2 relearn, 3 filtered, 4 manual, 5 rescheduled.
# ivl/lastIvl: positive = days, negative = seconds (still in learning steps).
history = {}
for rid, cid, ease, ivl, last_ivl, factor, taken, rtype in db.execute(
        "select id, cid, ease, ivl, lastIvl, factor, time, type from revlog order by id"):
    if ease not in (1, 2, 3, 4) or rtype not in (0, 1, 2, 3):
        continue
    past = history.setdefault(cid, [])
    if rtype == 0:
        before = 1 if past else 0                  # the first learning answer is the new card's
    elif rtype == 2:
        before = 3
    elif rtype == 1:
        before = 2
    else:                                          # filtered deck: judge by the interval before
        before = 2 if last_ivl > 0 else (1 if past else 0)
    if ivl > 0:
        after = 2
    elif rtype == 3:
        after = before if before != 0 else 1
    else:
        after = 1 if rtype == 0 else 3
    past.append(" ".join(str(v) for v in (
        datetime.fromtimestamp(rid / 1000, timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
        ease, max(0, min(taken, 3_600_000)), before, after, max(last_ivl, 0), max(ivl, 0), round(factor / 1000, 3))))

CLOZE = re.compile(r"\{\{c(\d+)::(.*?)(?:::(.*?))?\}\}", re.S)
def cloze(text, n):
    front = CLOZE.sub(lambda m: "[%s]" % ((m.group(3) or "…").strip()) if int(m.group(1)) == n else m.group(2), text)
    back = CLOZE.sub(lambda m: "<b>%s</b>" % m.group(2) if int(m.group(1)) == n else m.group(2), text)
    return front, back

def iso(seconds):
    return datetime.fromtimestamp(seconds, timezone.utc).isoformat().replace("+00:00", "Z")

rows = []
query = "select id, nid, did, ord, type, queue, due, ivl, factor, reps, lapses, odid, odue, data from cards order by nid, ord"
for cid, nid, did, ord_, ctype, queue, due, ivl, factor, reps, lapses, odid, odue, data in db.execute(query):
    fields, tags = notes[nid]
    if odid:                                   # card sits in a filtered deck: use its home deck and due
        did, due = odid, odue
    f = lambda i: fields[i] if i < len(fields) else ""
    if CLOZE.search(f(0)):                     # Cloze: one card per deletion (ord 0 = c1)
        front, back = cloze(f(0), ord_ + 1)
        extra = f(1)
    elif ord_ == 1 and len(fields) >= 2:       # "Basic (and reversed card)": the reverse card
        front, back, extra = f(1), f(0), "<br>".join(fields[2:])
    else:
        front, back, extra = f(0), f(1), "<br>".join(fields[2:])

    if ctype in (1, 2, 3):
        if ctype == 2 or queue == 3 or due < 1_000_000_000:   # day-based due (days since crt)
            due_at = crt + due * 86400
        else:                                                  # intraday learning: epoch seconds
            due_at = due
        try:
            fsrs = json.loads(data or "{}")
        except ValueError:
            fsrs = {}
        progress = [
            ctype, iso(due_at), max(ivl, 0), round(factor / 1000, 3),
            fsrs.get("s", 0) or 0, fsrs.get("d", 0) or 0, "", reps, lapses,
            iso(last[cid] / 1000) if cid in last else "",
        ]
    else:
        progress = [""] * 10
    suspended = 1 if queue == -1 else ""
    rows.append([decks.get(did, "Imported"), front, back, "", extra, tags, *progress, suspended, ";".join(history.get(cid, []))])

with open(OUT, "w", newline="", encoding="utf-8") as out:
    out.write("#separator:comma\n#html:true\n")
    out.write("#columns:deck,front,back,hint,extra,tags,state,due,interval,ease,stability,difficulty,step,reps,lapses,last_review,suspended,history\n")
    out.write("#deck column:1\n#tags column:6\n")
    csv.writer(out, quoting=csv.QUOTE_ALL).writerows(rows)

reviews = sum(len(h) for h in history.values())
print(f"Wrote {len(rows)} cards ({reviews} past reviews) from {len(notes)} notes in {len(set(r[0] for r in rows))} decks to {OUT}")
```

When it's done, tell me how many cards, past reviews and decks were converted.