mirror of
https://github.com/ankitects/anki.git
synced 2025-09-25 09:16:38 -04:00
Add Menomosyne serializer
This commit is contained in:
parent
29c691eabd
commit
5bacc9554a
2 changed files with 361 additions and 0 deletions
107
pylib/anki/foreign_data/__init__.py
Normal file
107
pylib/anki/foreign_data/__init__.py
Normal file
|
@ -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<hr id=answer>\n\n{{Back}}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def back_front() -> ForeignCardType:
|
||||||
|
return ForeignCardType(
|
||||||
|
"Card 2",
|
||||||
|
qfmt="{{Back}}",
|
||||||
|
afmt="{{FrontSide}}\n\n<hr id=answer>\n\n{{Front}}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cloze() -> ForeignCardType:
|
||||||
|
return ForeignCardType(
|
||||||
|
"Cloze", qfmt="{{cloze:Text}}", afmt="{{cloze:Text}}<br>\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)
|
254
pylib/anki/foreign_data/mnemosyne.py
Normal file
254
pylib/anki/foreign_data/mnemosyne.py
Normal file
|
@ -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<hr id=answer>\n\n{{{{Pronunciation}}}}"
|
||||||
|
"<br>\n{{{{Meaning}}}}<br>\n{{{{Notes}}}}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _production_card_type() -> ForeignCardType:
|
||||||
|
return ForeignCardType(
|
||||||
|
name="Production",
|
||||||
|
qfmt="{{Meaning}}",
|
||||||
|
afmt="{{Meaning}}\n\n<hr id=answer>\n\n{{{{Expression}}}}"
|
||||||
|
"<br>\n{{{{Pronunciation}}}}<br>\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", "<br>", field)
|
||||||
|
# latex differences
|
||||||
|
field = re.sub(r"(?i)<(/?(\$|\$\$|latex))>", "[\\1]", field)
|
||||||
|
# audio differences
|
||||||
|
field = re.sub('<audio src="(.+?)">(</audio>)?', "[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())
|
Loading…
Reference in a new issue