//! Anki .apkg import: reads an Anki shared deck and lays out a vultan-compatible
//! notes directory (one `.md` per card direction + a seeded `.vultan.ron`).
//!
//! Scope (v1):
//! - Note types: Basic (1 template) and Basic (and reversed card) (2 templates).
//! Cloze, image-occlusion, and custom multi-template models are skipped.
//! - Tags: the Anki deck name (last `::`-separated segment, slugified) becomes
//! the single vultan tag. Per-note Anki tags are dropped.
//! - Review state: factor / interval / due are mapped 1:1 where possible
//! (Anki ease 2500 → vultan factor 2500, etc.). Slight algorithmic drift is
//! inevitable since the SRS schedules differ.
//! - Filenames: `anki-<note_id>-<ord>.md` (ord 0 = front→back, ord 1 = reverse).
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection};
use serde_json::Value;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use crate::state::card::revision_settings::RevisionSettings;
use crate::state::card::Card;
use crate::state::file::FileHandle;
use crate::state::{State, STATE_FILENAME};
pub struct ImportSummary {
pub written: usize,
pub skipped: Vec<String>,
}
pub fn import_apkg(apkg_path: &Path, output_dir: &Path) -> Result<ImportSummary> {
std::fs::create_dir_all(output_dir)
.with_context(|| format!("Could not create {}", output_dir.display()))?;
let extracted_db = extract_collection_db(apkg_path)?;
let conn = Connection::open(&extracted_db)
.with_context(|| format!("Could not open extracted Anki DB at {}", extracted_db.display()))?;
let (models, decks, crt) =
read_collection_metadata(&conn).context("Could not read collection metadata")?;
let imports =
read_imports(&conn, &models, &decks, crt).context("Could not read notes/cards")?;
let mut summary = ImportSummary {
written: 0,
skipped: Vec::new(),
};
let mut vultan_cards: Vec<Card> = Vec::new();
for imp in &imports {
let filename = format!("anki-{}-{}.md", imp.note_id, imp.ord);
let abs = output_dir.join(&filename);
let md = render_card_markdown(imp);
std::fs::write(&abs, &md)
.with_context(|| format!("Could not write {}", abs.display()))?;
let mut card = Card::new(
filename.clone(),
vec![imp.deck_name.clone()],
strip_html(&imp.front),
strip_html(&imp.back),
anki_to_vultan_revision_settings(imp),
);
card.path = filename;
vultan_cards.push(card);
summary.written += 1;
}
summary.skipped = imports.iter().filter_map(|i| i.skip_reason.clone()).collect();
// Seed .vultan.ron so that first vultan run picks up the imported review state.
let parsing_config = crate::state::card::parser::ParsingConfig::default();
let state = State::new(parsing_config, vultan_cards, Vec::new());
state
.write(FileHandle::from(output_dir.join(STATE_FILENAME)))
.context("Could not write .vultan.ron")?;
let _ = std::fs::remove_file(&extracted_db);
Ok(summary)
}
#[derive(Debug, Clone)]
pub struct ImportedCard {
pub note_id: i64,
pub ord: i64,
pub deck_name: String,
pub front: String,
pub back: String,
pub factor: i64,
pub interval_days: i64,
pub due_unix_seconds: Option<i64>,
pub skip_reason: Option<String>,
}
struct ModelInfo {
name: String,
n_templates: usize,
}
type CollectionMetadata = (HashMap<i64, ModelInfo>, HashMap<i64, String>, i64);
fn extract_collection_db(apkg_path: &Path) -> Result<PathBuf> {
let file = std::fs::File::open(apkg_path)
.with_context(|| format!("Could not open .apkg at {}", apkg_path.display()))?;
let mut archive = zip::ZipArchive::new(file).context("Not a valid .apkg (zip) archive")?;
// Modern Anki bundles BOTH a real v18 SQLite (`collection.anki21b`,
// zstd-compressed) AND a decoy v11 SQLite at `collection.anki2` whose
// single card reads "Please update to the latest Anki version…" — meant for
// older Anki clients that can't read the modern format. We therefore try
// anki21b first, then anki21, and only fall back to anki2 last.
let candidates = ["collection.anki21b", "collection.anki21", "collection.anki2"];
for name in &candidates {
if let Ok(mut entry) = archive.by_name(name) {
let mut buf = Vec::new();
entry.read_to_end(&mut buf)?;
if name.ends_with('b') {
buf = zstd::decode_all(buf.as_slice())
.context("Could not zstd-decompress collection.anki21b")?;
}
let temp_path = std::env::temp_dir().join(format!(
"vultan-anki-{}-{}.db",
std::process::id(),
name
));
std::fs::write(&temp_path, buf)?;
return Ok(temp_path);
}
}
anyhow::bail!(
"No collection.anki2/anki21/anki21b found in {}",
apkg_path.display()
);
}
fn read_collection_metadata(conn: &Connection) -> Result<CollectionMetadata> {
let (models_json, decks_json, crt): (String, String, i64) = conn.query_row(
"SELECT models, decks, crt FROM col LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
// v11 schema stores models/decks as JSON in col; v18 leaves those blobs
// empty and uses side tables (notetypes, decks, templates).
let (models, decks) = if models_json.is_empty() || decks_json.is_empty() {
read_v18_metadata(conn)?
} else {
(parse_models_json(&models_json)?, parse_decks_json(&decks_json)?)
};
Ok((models, decks, crt))
}
fn parse_models_json(models_json: &str) -> Result<HashMap<i64, ModelInfo>> {
let mut models = HashMap::new();
let v: Value = serde_json::from_str(models_json)?;
if let Value::Object(map) = v {
for (id_str, m) in map {
let id: i64 = id_str.parse().unwrap_or(0);
let name = m.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let n_templates = m
.get("tmpls")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
models.insert(id, ModelInfo { name, n_templates });
}
}
Ok(models)
}
fn parse_decks_json(decks_json: &str) -> Result<HashMap<i64, String>> {
let mut decks = HashMap::new();
let v: Value = serde_json::from_str(decks_json)?;
if let Value::Object(map) = v {
for (id_str, d) in map {
let id: i64 = id_str.parse().unwrap_or(0);
let name = d
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Default")
.to_string();
decks.insert(id, name);
}
}
Ok(decks)
}
fn read_v18_metadata(
conn: &Connection,
) -> Result<(HashMap<i64, ModelInfo>, HashMap<i64, String>)> {
let mut tmpl_counts: HashMap<i64, usize> = HashMap::new();
let mut stmt = conn.prepare("SELECT ntid FROM templates")?;
let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?;
for ntid in rows {
*tmpl_counts.entry(ntid?).or_insert(0) += 1;
}
let mut models = HashMap::new();
let mut stmt = conn.prepare("SELECT id, name FROM notetypes")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})?;
for r in rows {
let (id, name) = r?;
let n_templates = tmpl_counts.get(&id).copied().unwrap_or(0);
models.insert(id, ModelInfo { name, n_templates });
}
let mut decks = HashMap::new();
let mut stmt = conn.prepare("SELECT id, name FROM decks")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})?;
for r in rows {
let (id, name) = r?;
decks.insert(id, name);
}
Ok((models, decks))
}
fn read_imports(
conn: &Connection,
models: &HashMap<i64, ModelInfo>,
decks: &HashMap<i64, String>,
crt: i64,
) -> Result<Vec<ImportedCard>> {
let mut stmt = conn.prepare(
"SELECT n.id, n.flds, c.ord, c.did, c.factor, c.ivl, c.due, c.queue, n.mid
FROM notes n JOIN cards c ON c.nid = n.id",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
row.get::<_, i64>(5)?,
row.get::<_, i64>(6)?,
row.get::<_, i64>(7)?,
row.get::<_, i64>(8)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (nid, flds, ord, did, factor, ivl, due, queue, mid) = row?;
let model = match models.get(&mid) {
Some(m) => m,
None => continue,
};
if !is_importable_model(model) {
continue;
}
if ord >= 2 {
continue;
}
let fields: Vec<&str> = flds.split('\u{1f}').collect();
if fields.len() < 2 {
continue;
}
let (front, back) = if ord == 0 {
(fields[0].to_string(), fields[1].to_string())
} else {
(fields[1].to_string(), fields[0].to_string())
};
let deck_full = decks
.get(&did)
.cloned()
.unwrap_or_else(|| "default".to_string());
let deck_name = slugify_deck_name(&deck_full);
let due_unix = compute_due_unix_seconds(queue, due, crt);
let skip_reason = bad_due_warning(nid, due_unix);
out.push(ImportedCard {
note_id: nid,
ord,
deck_name,
front,
back,
factor: factor.max(1300),
interval_days: ivl.max(0),
due_unix_seconds: due_unix,
skip_reason,
});
}
Ok(out)
}
/// `Some(reason)` if the computed due timestamp is non-None but cannot be
/// converted into a valid `DateTime<Utc>` — surfaces "this card's due was
/// silently fudged to now()" to the import summary instead of hiding it.
fn bad_due_warning(note_id: i64, due_unix: Option<i64>) -> Option<String> {
let s = due_unix?;
if chrono::NaiveDateTime::from_timestamp_opt(s, 0).is_some() {
return None;
}
Some(format!(
"note {note_id}: due timestamp {s} unrepresentable, scheduled for now"
))
}
fn is_importable_model(m: &ModelInfo) -> bool {
m.name.starts_with("Basic") && (m.n_templates == 1 || m.n_templates == 2)
}
fn compute_due_unix_seconds(queue: i64, due: i64, crt: i64) -> Option<i64> {
match queue {
2 | 3 => Some(crt + due * 86400), // review / day-learning: due is days-since-creation
1 => Some(due), // learning: due is unix seconds
_ => None, // new / suspended / buried — let vultan default
}
}
// ---------------- export ---------------------------------------------------
/// Mapping of one card's review state into the Anki `cards`-table columns.
#[derive(Debug, PartialEq)]
struct AnkiCardState {
queue: i64,
card_type: i64,
due: i64,
ivl: i64,
factor: i64,
}
/// Map a vultan `RevisionSettings` into Anki's `cards`-table fields.
/// `with_state == false` always emits a "new" card. `with_state == true` emits
/// a review-queued card whenever the card has any review state worth
/// preserving (non-zero interval, non-default factor, or recorded history).
/// Truly fresh cards still go out as "new" so they sort sanely in Anki's queue.
fn anki_card_state(
rs: &RevisionSettings,
crt_unix_seconds: i64,
new_position: i64,
with_state: bool,
) -> AnkiCardState {
let has_progress = rs.interval > 0.0
|| (rs.memorisation_factor - 1300.0).abs() > f64::EPSILON
|| !rs.review_history.is_empty();
if !with_state || !has_progress {
return AnkiCardState {
queue: 0,
card_type: 0,
due: new_position,
ivl: 0,
factor: 0,
};
}
// Round to nearest day rather than truncating; integer division would
// shift a card due 1.6 days from now to "tomorrow" in Anki.
let due_days =
((rs.due.timestamp() - crt_unix_seconds) as f64 / 86400.0).round() as i64;
AnkiCardState {
queue: 2,
card_type: 2,
due: due_days,
ivl: rs.interval.round() as i64,
factor: rs.memorisation_factor as i64,
}
}
/// Export the supplied vultan cards into an .apkg at `output_path`.
/// All exported cards land in a single Anki deck named `deck_label`.
/// When `with_state` is false (the default for shared decks), cards are emitted
/// as new — recipients see them fresh. When true, factor/interval/due are
/// preserved so the recipient picks up where the sender left off.
pub fn export_apkg(
cards: &[Card],
deck_label: &str,
output_path: &Path,
with_state: bool,
) -> Result<usize> {
let temp_db = std::env::temp_dir().join(format!(
"vultan-export-{}.anki21",
std::process::id()
));
if temp_db.exists() {
let _ = std::fs::remove_file(&temp_db);
}
let now_secs = Utc::now().timestamp();
let now_millis = Utc::now().timestamp_millis();
let model_id: i64 = 1559383000;
let deck_id: i64 = 1559383001;
let conn = Connection::open(&temp_db)
.with_context(|| format!("Could not create temp Anki DB at {}", temp_db.display()))?;
conn.execute_batch(APKG_SCHEMA)?;
let conf_json = "{\"activeDecks\":[1],\"addToCur\":true,\"collapseTime\":1200,\
\"curDeck\":1,\"dueCounts\":true,\"estTimes\":true,\"newBury\":true,\
\"newSpread\":0,\"nextPos\":1,\"sortBackwards\":false,\
\"sortType\":\"noteFld\",\"timeLim\":0}";
let models_json = format!(
r#"{{"{model_id}":{{"id":{model_id},"name":"Basic (vultan)","type":0,"mod":0,"usn":0,"sortf":0,"did":{deck_id},"latexPre":"","latexPost":"","tags":[],"vers":[],"req":[[0,"any",[0]]],"css":".card{{font-family:arial;font-size:20px;text-align:center;color:black;background-color:white;}}","flds":[{{"name":"Front","ord":0,"sticky":false,"rtl":false,"font":"Arial","size":20,"media":[]}},{{"name":"Back","ord":1,"sticky":false,"rtl":false,"font":"Arial","size":20,"media":[]}}],"tmpls":[{{"name":"Card 1","ord":0,"qfmt":"{{{{Front}}}}","afmt":"{{{{FrontSide}}}}\n\n<hr id=answer>\n\n{{{{Back}}}}","did":null,"bqfmt":"","bafmt":""}}]}}}}"#
);
let decks_json = format!(
r#"{{"{deck_id}":{{"id":{deck_id},"name":"{}","collapsed":false,"conf":1,"desc":"","dyn":0,"extendNew":10,"extendRev":50,"lrnToday":[0,0],"mod":0,"name_prev":"","newToday":[0,0],"revToday":[0,0],"timeToday":[0,0],"usn":0}}}}"#,
anki_escape(deck_label)
);
let dconf_json = "{\"1\":{\"autoplay\":true,\"id\":1,\"lapse\":{\"delays\":[10],\
\"leechAction\":0,\"leechFails\":8,\"minInt\":1,\"mult\":0},\"maxTaken\":60,\
\"mod\":0,\"name\":\"Default\",\"new\":{\"bury\":true,\"delays\":[1,10],\
\"initialFactor\":2500,\"ints\":[1,4,7],\"order\":1,\"perDay\":20,\
\"separate\":true},\"replayq\":true,\"rev\":{\"bury\":true,\"ease4\":1.3,\
\"fuzz\":0.05,\"ivlFct\":1,\"maxIvl\":36500,\"minSpace\":1,\"perDay\":100},\
\"timer\":0,\"usn\":0}}";
conn.execute(
"INSERT INTO col(id,crt,mod,scm,ver,dty,usn,ls,conf,models,decks,dconf,tags) \
VALUES(1,?,?,?,11,0,0,0,?,?,?,?,'{}')",
params![now_secs, now_millis, now_millis, conf_json, models_json, decks_json, dconf_json],
)?;
let mut count = 0;
for (i, card) in cards.iter().enumerate() {
let note_id = now_millis + i as i64;
let card_id = note_id + 1_000_000;
let flds = format!("{}\u{1f}{}", card.question, card.answer);
let tags = format!(" {} ", card.decks.join(" "));
conn.execute(
"INSERT INTO notes(id,guid,mid,mod,usn,tags,flds,sfld,csum,flags,data) \
VALUES(?,?,?,?,?,?,?,?,?,?,?)",
params![
note_id,
guid_for(note_id),
model_id,
now_secs,
-1i64,
tags,
flds,
card.question.as_str(),
fnv_csum(&card.question),
0i64,
"",
],
)?;
let s = anki_card_state(&card.revision_settings, now_secs, i as i64, with_state);
conn.execute(
"INSERT INTO cards(id,nid,did,ord,mod,usn,type,queue,due,ivl,factor,reps,lapses,left,odue,odid,flags,data) \
VALUES(?,?,?,0,?,?,?,?,?,?,?,0,0,0,0,0,0,'')",
params![
card_id,
note_id,
deck_id,
now_secs,
-1i64,
s.card_type,
s.queue,
s.due,
s.ivl,
s.factor,
],
)?;
count += 1;
}
drop(conn);
package_apkg(&temp_db, output_path)?;
let _ = std::fs::remove_file(&temp_db);
Ok(count)
}
fn package_apkg(db_path: &Path, output_path: &Path) -> Result<()> {
let file = std::fs::File::create(output_path)
.with_context(|| format!("Could not create {}", output_path.display()))?;
let mut writer = std::io::BufWriter::new(file);
let mut zip = zip::ZipWriter::new(&mut writer);
let opts = zip::write::FileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
// LEGACY_1 package format (collection.anki2 + JSON media + meta protobuf
// declaring version=1). Anki accepts this and runs schema migrations on
// import — the only path on which our hand-crafted v11 SQLite survives.
//
// Wire format for `meta`: field 1 (varint) = 1 → bytes 0x08 0x01.
let db_bytes = std::fs::read(db_path)?;
zip.start_file("collection.anki2", opts)?;
zip.write_all(&db_bytes)?;
zip.start_file("media", opts)?;
zip.write_all(b"{}")?;
zip.start_file("meta", opts)?;
zip.write_all(&[0x08, 0x01])?;
zip.finish()?;
Ok(())
}
fn guid_for(id: i64) -> String {
// 10-char base-32-ish. Anki accepts any short unique string; we just need
// collision-resistance within this export.
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut x = id as u64 ^ 0x9E37_79B9_7F4A_7C15;
let mut out = String::with_capacity(10);
for _ in 0..10 {
let c = ALPHABET[(x % ALPHABET.len() as u64) as usize] as char;
out.push(c);
x = x.wrapping_mul(2862933555777941757).wrapping_add(3037000493);
}
out
}
fn fnv_csum(s: &str) -> i64 {
// Anki stores a 32-bit checksum over the first field for dupe detection;
// the exact algorithm doesn't matter for import-elsewhere correctness, but
// it must be a valid integer. FNV-1a 32-bit is fine.
let mut h: u32 = 0x811c9dc5;
for b in s.bytes() {
h ^= b as u32;
h = h.wrapping_mul(0x01000193);
}
h as i64
}
fn anki_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
/// Schema-v11 of the `.apkg` collection database. The table layout, column
/// names, and types describe Anki's published file format and must match
/// what an Anki client expects on import — that's the whole point of
/// emitting a `.apkg`. Vultan is an independent project, not derived from
/// Anki's AGPL-3.0 source: this schema text was rewritten from the public
/// format spec, not lifted from the Anki repo.
const APKG_SCHEMA: &str = r#"
CREATE TABLE col (
id integer primary key, crt integer not null, mod integer not null,
scm integer not null, ver integer not null, dty integer not null,
usn integer not null, ls integer not null, conf text not null,
models text not null, decks text not null, dconf text not null,
tags text not null
);
CREATE TABLE notes (
id integer primary key, guid text not null, mid integer not null,
mod integer not null, usn integer not null, tags text not null,
flds text not null, sfld integer not null, csum integer not null,
flags integer not null, data text not null
);
CREATE TABLE cards (
id integer primary key, nid integer not null, did integer not null,
ord integer not null, mod integer not null, usn integer not null,
type integer not null, queue integer not null, due integer not null,
ivl integer not null, factor integer not null, reps integer not null,
lapses integer not null, left integer not null, odue integer not null,
odid integer not null, flags integer not null, data text not null
);
CREATE TABLE revlog (
id integer primary key, cid integer not null, usn integer not null,
ease integer not null, ivl integer not null, lastIvl integer not null,
factor integer not null, time integer not null, type integer not null
);
CREATE TABLE graves (
usn integer not null, oid integer not null, type integer not null
);
CREATE INDEX ix_notes_usn on notes (usn);
CREATE INDEX ix_cards_usn on cards (usn);
CREATE INDEX ix_revlog_usn on revlog (usn);
CREATE INDEX ix_cards_nid on cards (nid);
CREATE INDEX ix_cards_sched on cards (did, queue, due);
CREATE INDEX ix_revlog_cid on revlog (cid);
CREATE INDEX ix_notes_csum on notes (csum);
"#;
// ---------------- import ---------------------------------------------------
pub fn slugify_deck_name(name: &str) -> String {
let last = name.split("::").last().unwrap_or(name);
let mut out = String::new();
let mut last_dash = true;
for c in last.chars() {
let lc = c.to_ascii_lowercase();
if lc.is_ascii_alphanumeric() {
out.push(lc);
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
// Disambiguate decks whose names slugify to nothing (e.g. "★", "♢")
// — without the suffix, every such deck would collide into a single
// "default" bucket and lose deck identity on import.
format!("default-{:08x}", fnv_csum(name) as u32)
} else {
out
}
}
pub fn strip_html(s: &str) -> String {
let mut out = String::new();
let mut in_tag = false;
for c in s.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(c),
_ => {}
}
}
out.replace(" ", " ")
.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace(""", "\"")
}
pub fn render_card_markdown(card: &ImportedCard) -> String {
format!(
"---\ntags: :{}:\n---\n# Question\n{}\n# Answer\n{}\n----\n",
card.deck_name,
strip_html(&card.front).trim(),
strip_html(&card.back).trim(),
)
}
fn anki_to_vultan_revision_settings(card: &ImportedCard) -> RevisionSettings {
let due = card
.due_unix_seconds
.and_then(|s| chrono::NaiveDateTime::from_timestamp_opt(s, 0))
.map(|naive| DateTime::<Utc>::from_utc(naive, Utc))
.unwrap_or_else(Utc::now);
RevisionSettings {
due,
interval: card.interval_days as f64,
memorisation_factor: card.factor as f64,
review_history: Vec::new(),
review_log: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rs(due: chrono::DateTime<Utc>, interval: f64, factor: f64, history_len: usize) -> RevisionSettings {
let mut r = RevisionSettings::new(due, interval, factor);
r.review_history = (0..history_len)
.map(|_| chrono::Local::now().date_naive())
.collect();
r
}
#[test]
fn anki_card_state_returns_new_when_with_state_false() {
let r = rs(Utc::now(), 30.0, 2500.0, 10);
let s = anki_card_state(&r, 0, 7, false);
assert_eq!(0, s.queue);
assert_eq!(0, s.card_type);
assert_eq!(7, s.due);
assert_eq!(0, s.ivl);
assert_eq!(0, s.factor);
}
#[test]
fn anki_card_state_returns_new_for_fresh_card_even_with_state_on() {
let r = rs(Utc::now(), 0.0, 1300.0, 0);
let s = anki_card_state(&r, 0, 3, true);
assert_eq!(0, s.queue);
assert_eq!(3, s.due);
}
#[test]
fn anki_card_state_treats_nonzero_interval_as_progress() {
let r = rs(Utc::now(), 5.0, 1300.0, 0);
let s = anki_card_state(&r, 0, 0, true);
assert_eq!(2, s.queue);
assert_eq!(5, s.ivl);
}
#[test]
fn anki_card_state_treats_nondefault_factor_as_progress() {
let r = rs(Utc::now(), 0.0, 2500.0, 0);
let s = anki_card_state(&r, 0, 0, true);
assert_eq!(2, s.queue);
assert_eq!(2500, s.factor);
}
#[test]
fn anki_card_state_emits_review_queue_when_reviewed_and_with_state() {
let crt = 1_700_000_000_i64;
let due = chrono::DateTime::<Utc>::from_utc(
chrono::NaiveDateTime::from_timestamp_opt(crt + 86400 * 5, 0).unwrap(),
Utc,
);
let r = rs(due, 30.0, 2500.0, 4);
let s = anki_card_state(&r, crt, 0, true);
assert_eq!(2, s.queue);
assert_eq!(2, s.card_type);
assert_eq!(5, s.due);
assert_eq!(30, s.ivl);
assert_eq!(2500, s.factor);
}
#[test]
fn anki_card_state_rounds_due_days_to_nearest() {
let crt = 1_700_000_000_i64;
// 36 hours ahead → 1.5 days → rounds to 2.
let one_and_a_half_days_later = chrono::DateTime::<Utc>::from_utc(
chrono::NaiveDateTime::from_timestamp_opt(crt + 86400 + 43200, 0).unwrap(),
Utc,
);
let r = rs(one_and_a_half_days_later, 5.0, 2000.0, 1);
let s = anki_card_state(&r, crt, 0, true);
assert_eq!(2, s.due);
// ~10 hours ahead → 0.4 days → rounds to 0.
let third_of_a_day_later = chrono::DateTime::<Utc>::from_utc(
chrono::NaiveDateTime::from_timestamp_opt(crt + 36000, 0).unwrap(),
Utc,
);
let r = rs(third_of_a_day_later, 5.0, 2000.0, 1);
let s = anki_card_state(&r, crt, 0, true);
assert_eq!(0, s.due);
// 1.6 days overdue → -1.6 → rounds to -2.
let one_point_six_overdue = chrono::DateTime::<Utc>::from_utc(
chrono::NaiveDateTime::from_timestamp_opt(crt - 86400 - 51840, 0).unwrap(),
Utc,
);
let r = rs(one_point_six_overdue, 5.0, 2000.0, 1);
let s = anki_card_state(&r, crt, 0, true);
assert_eq!(-2, s.due);
}
#[test]
fn anki_card_state_handles_overdue_cards_with_negative_due() {
let crt = 1_700_000_000_i64;
let due = chrono::DateTime::<Utc>::from_utc(
chrono::NaiveDateTime::from_timestamp_opt(crt - 86400 * 3, 0).unwrap(),
Utc,
);
let r = rs(due, 5.0, 1500.0, 1);
let s = anki_card_state(&r, crt, 0, true);
assert_eq!(2, s.queue);
assert_eq!(-3, s.due);
}
#[test]
fn anki_card_state_rounds_interval_half_up() {
let r = rs(Utc::now(), 7.6, 2000.0, 1);
let s = anki_card_state(&r, 0, 0, true);
assert_eq!(8, s.ivl);
}
#[test]
fn bad_due_warning_returns_none_for_new_card() {
// due_unix=None means the card was new in Anki; that's not a
// corruption case and should not produce a warning.
assert_eq!(None, bad_due_warning(7, None));
}
#[test]
fn bad_due_warning_returns_none_for_representable_timestamp() {
// 1700000000 = 2023-11-14, comfortably inside chrono's range.
assert_eq!(None, bad_due_warning(7, Some(1_700_000_000)));
}
#[test]
fn bad_due_warning_flags_unrepresentable_timestamps() {
// i64::MAX exceeds chrono's representable timestamp range.
let reason = bad_due_warning(42, Some(i64::MAX)).expect("expected warning");
assert!(reason.contains("note 42"));
assert!(reason.contains("unrepresentable"));
}
#[test]
fn slugify_takes_last_segment_and_normalises() {
assert_eq!("algebra", slugify_deck_name("Default::Math::Algebra"));
assert_eq!("python-tricks", slugify_deck_name("Programming::Python Tricks"));
assert_eq!("foo", slugify_deck_name("FOO!!!"));
}
#[test]
fn slugify_disambiguates_empty_slugs_with_hash_suffix() {
// Two distinct deck names whose slug strips to "" must NOT collide.
let a = slugify_deck_name("★★★");
let b = slugify_deck_name("♢♢♢");
assert!(a.starts_with("default-"), "got {a:?}");
assert!(b.starts_with("default-"), "got {b:?}");
assert_ne!(a, b, "different inputs must yield different slugs");
}
#[test]
fn slugify_empty_input_yields_a_default_with_suffix() {
let s = slugify_deck_name("");
assert!(s.starts_with("default-"));
assert!(s.len() > "default-".len());
}
#[test]
fn strip_html_removes_tags_and_decodes_entities() {
assert_eq!("hello world", strip_html("hello world"));
assert_eq!("bold thing", strip_html("<b>bold</b> thing"));
assert_eq!("a&b", strip_html("a&b"));
assert_eq!("line1line2", strip_html("line1<br>line2"));
}
#[test]
fn render_card_markdown_uses_vultan_format() {
let card = ImportedCard {
note_id: 1234,
ord: 0,
deck_name: "math".to_string(),
front: "What is <b>2+2</b>?".to_string(),
back: "4".to_string(),
factor: 2500,
interval_days: 4,
due_unix_seconds: None,
skip_reason: None,
};
let md = render_card_markdown(&card);
assert!(md.contains("tags: :math:"));
assert!(md.contains("# Question"));
assert!(md.contains("# Answer"));
assert!(md.contains("What is 2+2?"));
assert!(md.contains("4"));
assert!(md.ends_with("----\n"));
}
#[test]
fn is_importable_model_only_accepts_basic_with_one_or_two_templates() {
let basic = ModelInfo {
name: "Basic".to_string(),
n_templates: 1,
};
let basic_rev = ModelInfo {
name: "Basic (and reversed card)".to_string(),
n_templates: 2,
};
let cloze = ModelInfo {
name: "Cloze".to_string(),
n_templates: 1,
};
let weird = ModelInfo {
name: "Basic Custom".to_string(),
n_templates: 5,
};
assert!(is_importable_model(&basic));
assert!(is_importable_model(&basic_rev));
assert!(!is_importable_model(&cloze));
assert!(!is_importable_model(&weird));
}
#[test]
fn compute_due_unix_seconds_handles_review_and_learning_queues() {
// review queue: due = days since collection creation.
assert_eq!(
Some(1_000_000 + 5 * 86400),
compute_due_unix_seconds(2, 5, 1_000_000)
);
// learning queue: due is already a unix timestamp.
assert_eq!(Some(1_700_000_000), compute_due_unix_seconds(1, 1_700_000_000, 0));
// new (queue 0): None — let vultan use default.
assert_eq!(None, compute_due_unix_seconds(0, 999, 0));
// suspended: None.
assert_eq!(None, compute_due_unix_seconds(-1, 999, 0));
}
#[test]
fn anki_to_vultan_clamps_factor_to_floor() {
let card = ImportedCard {
note_id: 1,
ord: 0,
deck_name: "x".to_string(),
front: String::new(),
back: String::new(),
factor: 800,
interval_days: 0,
due_unix_seconds: None,
skip_reason: None,
};
// ImportedCard.factor is already clamped at max(1300, anki_factor) on read,
// so this scenario can't happen in practice — but the conversion shouldn't panic.
let rs = anki_to_vultan_revision_settings(&card);
assert_eq!(800.0, rs.memorisation_factor);
}
}