diff --git a/pylib/anki/foreign_data/__init__.py b/pylib/anki/foreign_data/__init__.py
new file mode 100644
index 000000000..a54bad70b
--- /dev/null
+++ b/pylib/anki/foreign_data/__init__.py
@@ -0,0 +1,107 @@
+# Copyright: Ankitects Pty Ltd and contributors
+# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+"""Helpers for serializing third-party collections to a common JSON form.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import asdict, dataclass, field
+from typing import Union
+
+from anki.consts import STARTING_FACTOR
+from anki.decks import DeckId
+from anki.models import NotetypeId
+
+
+@dataclass
+class ForeignCardType:
+ name: str
+ qfmt: str
+ afmt: str
+
+ @staticmethod
+ def front_back() -> ForeignCardType:
+ return ForeignCardType(
+ "Card 1",
+ qfmt="{{Front}}",
+ afmt="{{FrontSide}}\n\n
\n\n{{Back}}",
+ )
+
+ @staticmethod
+ def back_front() -> ForeignCardType:
+ return ForeignCardType(
+ "Card 2",
+ qfmt="{{Back}}",
+ afmt="{{FrontSide}}\n\n
\n\n{{Front}}",
+ )
+
+ @staticmethod
+ def cloze() -> ForeignCardType:
+ return ForeignCardType(
+ "Cloze", qfmt="{{cloze:Text}}", afmt="{{cloze:Text}}
\n{{Back Extra}}"
+ )
+
+
+@dataclass
+class ForeignNotetype:
+ name: str
+ fields: list[str]
+ templates: list[ForeignCardType]
+ is_cloze: bool = False
+
+ @staticmethod
+ def basic(name: str) -> ForeignNotetype:
+ return ForeignNotetype(name, ["Front", "Back"], [ForeignCardType.front_back()])
+
+ @staticmethod
+ def basic_reverse(name: str) -> ForeignNotetype:
+ return ForeignNotetype(
+ name,
+ ["Front", "Back"],
+ [ForeignCardType.front_back(), ForeignCardType.back_front()],
+ )
+
+ @staticmethod
+ def cloze(name: str) -> ForeignNotetype:
+ return ForeignNotetype(
+ name, ["Text", "Back Extra"], [ForeignCardType.cloze()], is_cloze=True
+ )
+
+
+@dataclass
+class ForeignCard:
+ due: int = 0
+ ivl: int = 1
+ factor: int = STARTING_FACTOR
+ reps: int = 0
+ lapses: int = 0
+
+
+@dataclass
+class ForeignNote:
+ fields: list[str] = field(default_factory=list)
+ tags: list[str] = field(default_factory=list)
+ notetype: Union[str, NotetypeId] = ""
+ deck: Union[str, DeckId] = ""
+ cards: list[ForeignCard] = field(default_factory=list)
+
+
+@dataclass
+class ForeignData:
+ notes: list[ForeignNote] = field(default_factory=list)
+ notetypes: list[ForeignNotetype] = field(default_factory=list)
+
+ def serialize(self) -> str:
+ return json.dumps(self, cls=ForeignDataEncoder, separators=(",", ":"))
+
+
+class ForeignDataEncoder(json.JSONEncoder):
+ def default(self, obj: object) -> dict:
+ if isinstance(
+ obj,
+ (ForeignData, ForeignNote, ForeignCard, ForeignNotetype, ForeignCardType),
+ ):
+ return asdict(obj)
+ return json.JSONEncoder.default(self, obj)
diff --git a/pylib/anki/foreign_data/mnemosyne.py b/pylib/anki/foreign_data/mnemosyne.py
new file mode 100644
index 000000000..167583c3d
--- /dev/null
+++ b/pylib/anki/foreign_data/mnemosyne.py
@@ -0,0 +1,254 @@
+# Copyright: Ankitects Pty Ltd and contributors
+# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+"""Serializer for Mnemosyne collections.
+
+Some notes about their structure:
+https://github.com/mnemosyne-proj/mnemosyne/blob/master/mnemosyne/libmnemosyne/docs/source/index.rst
+
+Anki | Mnemosyne
+----------+-----------
+Note | Fact
+Card Type | Fact View
+Card | Card
+Notetype | Card Type
+"""
+
+import re
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import Tuple, Type
+
+from anki.db import DB
+from anki.foreign_data import (
+ ForeignCard,
+ ForeignCardType,
+ ForeignData,
+ ForeignNote,
+ ForeignNotetype,
+)
+
+
+def serialize(db_path: str) -> str:
+ db = open_mnemosyne_db(db_path)
+ return gather_data(db).serialize()
+
+
+def gather_data(db: DB) -> ForeignData:
+ facts = gather_facts(db)
+ gather_cards_into_facts(db, facts)
+ used_fact_views: dict[Type[MnemoFactView], bool] = {}
+ notes = [fact.foreign_note(used_fact_views) for fact in facts.values()]
+ notetypes = [fact_view.foreign_notetype() for fact_view in used_fact_views]
+ return ForeignData(notes, notetypes)
+
+
+def open_mnemosyne_db(db_path: str) -> DB:
+ db = DB(db_path)
+ ver = db.scalar("SELECT value FROM global_variables WHERE key='version'")
+ if not ver.startswith("Mnemosyne SQL 1") and ver not in ("2", "3"):
+ print("Mnemosyne version unknown, trying to import anyway")
+ return db
+
+
+class MnemoFactView(ABC):
+ notetype: str
+ field_keys: Tuple[str, ...]
+
+ @classmethod
+ @abstractmethod
+ def foreign_notetype(cls) -> ForeignNotetype:
+ pass
+
+
+class FrontOnly(MnemoFactView):
+ notetype = "Mnemosyne-FrontOnly"
+ field_keys = ("f", "b")
+
+ @classmethod
+ def foreign_notetype(cls) -> ForeignNotetype:
+ return ForeignNotetype.basic(cls.notetype)
+
+
+class FrontBack(MnemoFactView):
+ notetype = "Mnemosyne-FrontBack"
+ field_keys = ("f", "b")
+
+ @classmethod
+ def foreign_notetype(cls) -> ForeignNotetype:
+ return ForeignNotetype.basic_reverse(cls.notetype)
+
+
+class Vocabulary(MnemoFactView):
+ notetype = "Mnemosyne-Vocabulary"
+ field_keys = ("f", "p_1", "m_1", "n")
+
+ @classmethod
+ def foreign_notetype(cls) -> ForeignNotetype:
+ return ForeignNotetype(
+ cls.notetype,
+ ["Expression", "Pronunciation", "Meaning", "Notes"],
+ [cls._recognition_card_type(), cls._production_card_type()],
+ )
+
+ @staticmethod
+ def _recognition_card_type() -> ForeignCardType:
+ return ForeignCardType(
+ name="Recognition",
+ qfmt="{{Expression}}",
+ afmt="{{Expression}}\n\n
\n\n{{{{Pronunciation}}}}"
+ "
\n{{{{Meaning}}}}
\n{{{{Notes}}}}",
+ )
+
+ @staticmethod
+ def _production_card_type() -> ForeignCardType:
+ return ForeignCardType(
+ name="Production",
+ qfmt="{{Meaning}}",
+ afmt="{{Meaning}}\n\n
\n\n{{{{Expression}}}}"
+ "
\n{{{{Pronunciation}}}}
\n{{{{Notes}}}}",
+ )
+
+
+class Cloze(MnemoFactView):
+ notetype = "Mnemosyne-Cloze"
+ field_keys = ("text",)
+
+ @classmethod
+ def foreign_notetype(cls) -> ForeignNotetype:
+ return ForeignNotetype.cloze(cls.notetype)
+
+
+@dataclass
+class MnemoCard:
+ fact_view_id: str
+ tags: str
+ next_rep: int
+ last_rep: int
+ easiness: float
+ reps: int
+ lapses: int
+
+ def card_ord(self) -> int:
+ ord = self.fact_view_id.rsplit(".", maxsplit=1)[-1]
+ try:
+ return int(ord) - 1
+ except ValueError as err:
+ raise Exception(
+ f"Fact view id '{self.fact_view_id}' has unknown format"
+ ) from err
+
+ def is_new(self) -> bool:
+ return self.last_rep == -1
+
+ def foreign_card(self) -> ForeignCard:
+ return ForeignCard(
+ factor=self.anki_ease(),
+ reps=self.reps,
+ lapses=self.lapses,
+ ivl=self.anki_interval(),
+ due=self.next_rep,
+ )
+
+ def anki_ease(self) -> int:
+ return int(self.easiness * 1000)
+
+ def anki_interval(self) -> int:
+ return max(1, (self.next_rep - self.last_rep) // 86400)
+
+
+@dataclass
+class MnemoFact:
+ id: int
+ fields: dict[str, str] = field(default_factory=dict)
+ cards: list[MnemoCard] = field(default_factory=list)
+
+ def foreign_note(
+ self, used_fact_views: dict[Type[MnemoFactView], bool]
+ ) -> ForeignNote:
+ fact_view = self.fact_view()
+ used_fact_views[fact_view] = True
+ return ForeignNote(
+ fields=self.anki_fields(fact_view),
+ tags=self.anki_tags(),
+ notetype=fact_view.notetype,
+ cards=self.foreign_cards(),
+ )
+
+ def fact_view(self) -> Type[MnemoFactView]:
+ try:
+ fact_view = self.cards[0].fact_view_id
+ except IndexError as err:
+ raise Exception(f"Fact {id} has no cards") from err
+
+ if fact_view.startswith("1.") or fact_view.startswith("1::"):
+ return FrontOnly
+ elif fact_view.startswith("2.") or fact_view.startswith("2::"):
+ return FrontBack
+ elif fact_view.startswith("3.") or fact_view.startswith("3::"):
+ return Vocabulary
+ elif fact_view.startswith("5.1"):
+ return Cloze
+
+ raise Exception(f"Fact {id} has unknown fact view: {fact_view}")
+
+ def anki_fields(self, fact_view: Type[MnemoFactView]) -> list[str]:
+ return [munge_field(self.fields.get(k, "")) for k in fact_view.field_keys]
+
+ def anki_tags(self) -> list[str]:
+ tags: list[str] = []
+ for card in self.cards:
+ if not card.tags:
+ continue
+ tags.extend(
+ t.replace(" ", "_").replace("\u3000", "_")
+ for t in card.tags.split(", ")
+ )
+ return tags
+
+ def foreign_cards(self) -> list[ForeignCard]:
+ # generate defaults for new cards
+ return [card.foreign_card() for card in self.cards if not card.is_new()]
+
+
+def munge_field(field: str) -> str:
+ # \n -> br
+ field = re.sub("\r?\n", "
", field)
+ # latex differences
+ field = re.sub(r"(?i)<(/?(\$|\$\$|latex))>", "[\\1]", field)
+ # audio differences
+ field = re.sub(')?', "[sound:\\1]", field)
+ return field
+
+
+def gather_facts(db: DB) -> dict[int, MnemoFact]:
+ facts: dict[int, MnemoFact] = {}
+ for id, key, value in db.execute(
+ """
+SELECT _id, key, value
+FROM facts, data_for_fact
+WHERE facts._id=data_for_fact._fact_id"""
+ ):
+ if not (fact := facts.get(id)):
+ facts[id] = fact = MnemoFact(id)
+ fact.fields[key] = value
+ return facts
+
+
+def gather_cards_into_facts(db: DB, facts: dict[int, MnemoFact]) -> None:
+ for fact_id, *row in db.execute(
+ """
+SELECT
+ _fact_id,
+ fact_view_id,
+ tags,
+ next_rep,
+ last_rep,
+ easiness,
+ acq_reps + ret_reps,
+ lapses
+FROM cards"""
+ ):
+ facts[fact_id].cards.append(MnemoCard(*row))
+ for fact in facts.values():
+ fact.cards.sort(key=lambda c: c.card_ord())