feat(app): Anki's bracket furigana becomes readings where its templates show them

A field a note type shows through Anki's furigana filters
({{furigana:Reading}}, kanji:, kana:) holds 漢字[かんじ]; the import now
turns it into readings over the text before the brackets, as Anki's own
filters read it (back to a space, which goes). Other fields stay as
written; a sound stays a sound. The note types' templates and field
names are read from both package formats (Anki's unicase collation
registered so their tables can be read at all). New fixtures exported
by Anki itself (tool/anki/make_fixtures.py) test both formats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-09 13:26:37 +02:00
commit dd36424917
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 208 additions and 15 deletions

View file

@ -339,3 +339,26 @@ class _Field(
_line = [];
}
}
/// Anki's furigana in [html] (`日本語[にほんご]を 勉強[べんきょう]`) as HTML
/// ruby, which [ankiContent] reads as readings: the text before the
/// brackets back to a space or a tag, as Anki's own filters take it, the
/// space before it dropped. A sound (`[sound:…]`) stays as it is.
String ankiBracketsToRuby(String html) => html.replaceAllMapped(
RegExp(r' ?([^ >]+?)\[(.+?)\]'),
(match) => match[2]!.startsWith('sound:')
? match[0]!
: '<ruby>${match[1]}<rt>${match[2]}</rt></ruby>',
);
/// The names of the fields [templates] (a note type's question and answer
/// formats) show through Anki's furigana filters: `furigana`, `kanji` or
/// `kana` among the filters before a field's name (`{{furigana:Reading}}`,
/// `{{text:kana:Reading}}`).
Set<String> ankiFuriganaFieldNames(Iterable<String> templates) => {
for (final template in templates)
for (final match in RegExp(r'\{\{([^{}]+)\}\}').allMatches(template))
if (match[1]!.split(':') case [...final filters, final name]
when filters.any(const {'furigana', 'kanji', 'kana'}.contains))
name.trim(),
};

View file

@ -255,7 +255,14 @@ List<String> referencedMedia(AnkiCollection collection) => [
/// A card for [note]; null when one of its sides would hold nothing.
CardContent? _contentOf(AnkiNote note, Map<String, String> assetOf) {
final fields = note.fields;
// Fields its templates show with furigana hold Anki's 漢字[かんじ].
final fields = [
for (final (place, field) in note.fields.indexed)
if (note.furiganaFields.contains(place))
ankiBracketsToRuby(field)
else
field,
];
if (fields.isEmpty) {
return null;
}

View file

@ -4,6 +4,7 @@ import 'dart:math';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_field.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/protobuf_fields.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/model/anki_collection.dart';
import 'package:path/path.dart' as p;
@ -237,7 +238,15 @@ AnkiCollection _readCollection(
p.join(temp.path, 'anki-${Random().nextInt(1 << 32)}.sqlite'),
)..writeAsBytesSync(copy, flush: true);
try {
final db = sqlite3.open(file.path, mode: OpenMode.readOnly);
final db = sqlite3.open(file.path, mode: OpenMode.readOnly)
// Anki's own collation (case folded, Unicode-aware) on the names of
// its note types' fields and templates; SQLite needs it to read those
// tables at all.
..createCollation(
name: 'unicase',
function: (a, b) =>
(a ?? '').toLowerCase().compareTo((b ?? '').toLowerCase()),
);
try {
return _collectionIn(db, mediaNames);
} on SqliteException catch (error) {
@ -332,6 +341,7 @@ AnkiCollection _collectionIn(Database db, Set<String> mediaNames) {
],
kind: noteTypes[row['mid']]?.kind ?? AnkiNoteKind.standard,
templateCount: noteTypes[row['mid']]?.templates ?? 1,
furiganaFields: noteTypes[row['mid']]?.furigana ?? const {},
deckPath: decks[deckByNote[row['id']]] ?? const [],
cards: cardsByNote[row['id']] ?? const [],
),
@ -340,25 +350,53 @@ AnkiCollection _collectionIn(Database db, Set<String> mediaNames) {
);
}
typedef _NoteType = ({AnkiNoteKind kind, int templates});
typedef _NoteType = ({AnkiNoteKind kind, int templates, Set<int> furigana});
/// The note types by id: from their tables (schema 18), else the JSON in
/// `col` (schema 11).
/// `col` (schema 11). Each with its kind, how many templates it has, and
/// the fields its templates show through Anki's furigana filters.
Map<int, _NoteType> _noteTypes(Database db, Set<String> tables) {
if (tables.contains('notetypes')) {
final templates = {
if (tables.contains('templates'))
for (final row in db.select(
'SELECT ntid, COUNT(*) AS n FROM templates GROUP BY ntid',
))
row['ntid'] as int: row['n'] as int,
};
// A template's config holds its question format (field 1) and its
// answer format (field 2).
final formats = <int, List<String>>{};
final templates = <int, int>{};
if (tables.contains('templates')) {
for (final row in db.select('SELECT ntid, config FROM templates')) {
final ntid = row['ntid'] as int;
templates[ntid] = (templates[ntid] ?? 0) + 1;
final list = formats[ntid] ??= [];
if (row['config'] case final Uint8List config) {
try {
for (final (field, value) in protobufFields(config)) {
if ((field == 1 || field == 2) && value is Uint8List) {
list.add(protobufString(value));
}
}
} on FormatException {
// A template it can't read shows no furigana.
}
}
}
}
final fieldOrds = <int, Map<String, int>>{};
if (tables.contains('fields')) {
for (final row in db.select('SELECT ntid, ord, name FROM fields')) {
(fieldOrds[row['ntid'] as int] ??= {})[row['name'] as String] =
row['ord'] as int;
}
}
return {
for (final row in db.select('SELECT id, config FROM notetypes'))
row['id'] as int: (
kind: _isCloze(row['config']) ? AnkiNoteKind.cloze : .standard,
templates: templates[row['id']] ?? 1,
),
if (row['id'] case final int id)
id: (
kind: _isCloze(row['config']) ? AnkiNoteKind.cloze : .standard,
templates: templates[id] ?? 1,
furigana: {
for (final name in ankiFuriganaFieldNames(formats[id] ?? []))
?fieldOrds[id]?[name],
},
),
};
}
final models = _colJson(db, 'models');
@ -371,10 +409,31 @@ Map<int, _NoteType> _noteTypes(Database db, Set<String> tables) {
final List<Object?> list => list.length,
_ => 1,
},
furigana: _schema11Furigana(value),
),
};
}
/// The fields (by place) a schema-11 note type's templates show through
/// Anki's furigana filters.
Set<int> _schema11Furigana(Map<String, Object?> model) {
final names = ankiFuriganaFieldNames([
if (model['tmpls'] case final List<Object?> templates)
for (final template in templates.whereType<Map<String, Object?>>())
for (final key in const ['qfmt', 'afmt'])
if (template[key] case final String format) format,
]);
return {
if (model['flds'] case final List<Object?> fields)
for (final (place, field) in fields.indexed)
if (field case {'name': final String name} when names.contains(name))
switch (field) {
{'ord': final int ord} => ord,
_ => place,
},
};
}
/// Whether a note type's configuration (a protocol buffer) says cloze: its
/// field 1, the kind, is 1.
bool _isCloze(Object? config) {

View file

@ -41,6 +41,11 @@ class const AnkiNote({
/// How many card templates its note type has (cloze notes: one).
required final int templateCount,
/// The fields (by place) its note type's templates show through Anki's
/// furigana filters (`{{furigana:Reading}}`, `kanji:`, `kana:`): their
/// `漢字[かんじ]` is a reading over the text before it.
final Set<int> furiganaFields = const {},
/// The deck of its first card, as a path from the top; empty when unknown.
required final List<String> deckPath,
@ -54,6 +59,7 @@ class const AnkiNote({
tags,
kind,
templateCount,
furiganaFields,
deckPath,
cards,
];

View file

@ -202,6 +202,31 @@ void main() {
},
]);
});
test("Anki's bracket furigana becomes readings: the text before the "
'brackets back to a space, the space dropped; a sound stays', () {
expect(
ankiBracketsToRuby('日本語[にほんご]を 勉強[べんきょう]する'),
'<ruby>日本語<rt>にほんご</rt></ruby>を<ruby>勉強<rt>べんきょう</rt></ruby>する',
);
expect(
ankiBracketsToRuby('<b>猫[ねこ]</b>'),
'<b><ruby>猫<rt>ねこ</rt></ruby></b>',
);
expect(ankiBracketsToRuby('犬[sound:inu.mp3]'), '犬[sound:inu.mp3]');
});
test('the fields a note type shows through a furigana filter, whatever '
'filters come with it', () {
expect(
ankiFuriganaFieldNames([
'{{Expression}}',
'{{FrontSide}}<hr id=answer>{{furigana:Reading}}<br>{{kanji:Word}}',
'{{text:kana:Sentence}}{{#Meaning}}{{Meaning}}{{/Meaning}}',
]),
{'Reading', 'Word', 'Sentence'},
);
});
}
extension on Map<String, String> {

View file

@ -193,6 +193,38 @@ void main() {
);
});
for (final name in ['anki_furigana.apkg', 'anki_furigana_legacy.apkg']) {
test("a field the note type shows with Anki's furigana gets readings; "
'the same text in another field stays as written ($name)', () async {
final furigana = readAnkiPackage(fixture(name), temp: temp);
expect(furigana.collection.notes.single.furiganaFields, {1});
await importer.import(
furigana,
studysetId: japanese,
mode: AnkiImportMode.flatten,
keepProgress: false,
includeMedia: false,
);
final card = (await cards.cardsOf(japanese)).single;
expect(card.content.front.plainText.trim(), '日本語[にほんご]');
final document =
card.content.back.editorJson['document']! as Map<String, Object?>;
final readings = [
for (final block in document['children']! as List<Object?>)
if (block case {'data': {'delta': final List<Object?> delta}})
for (final op in delta)
if (op case {
'insert': final String text,
'attributes': {'ruby': final String ruby},
})
(text, ruby),
];
expect(readings, [('日本語', 'にほんご'), ('勉強', 'べんきょう')]);
});
}
test('makes a studyset of each deck below the top one', () async {
await import(mode: AnkiImportMode.subStudysets);

View file

@ -15,6 +15,9 @@ The collection holds, on purpose:
- a Basic note with a list and HTML entities ("Japanese::Animals");
- a manual reschedule in the review log (no answer).
A second deck (`anki_furigana*.apkg`) has a note type that shows its
Reading through Anki's furigana filter, and the same text in its Expression.
Run from the repository root, in a virtual environment with Anki's package:
python3 -m venv /tmp/anki && /tmp/anki/bin/pip install anki==26.9.3
@ -125,5 +128,43 @@ def main():
(OUT / "anki_before_2_1_50.apkg").write_bytes(out.getvalue())
def furigana():
"""A deck whose note type shows a field through Anki's furigana filter,
in the latest format and the legacy one: the import turns that field's
漢字[かんじ] into readings, and leaves the same text in another field."""
with tempfile.TemporaryDirectory() as work:
col = Collection(os.path.join(work, "collection.anki2"))
models = col.models
model = models.new("Japanese (furigana)")
for name in ("Expression", "Reading", "Meaning"):
models.add_field(model, models.new_field(name))
template = models.new_template("Recognition")
template["qfmt"] = "{{Expression}}"
template["afmt"] = "{{FrontSide}}<hr id=answer>{{furigana:Reading}}<br>{{Meaning}}"
models.add_template(model, template)
models.add(model)
note = col.new_note(col.models.by_name("Japanese (furigana)"))
note.fields[0] = "日本語[にほんご]"
note.fields[1] = "日本語[にほんご]を 勉強[べんきょう]する"
note.fields[2] = "to study Japanese"
col.add_note(note, DeckId(col.decks.id("Furigana")))
for legacy, name in [
(False, "anki_furigana.apkg"),
(True, "anki_furigana_legacy.apkg"),
]:
col.export_anki_package(
out_path=str(OUT / name),
options=ExportAnkiPackageOptions(
with_scheduling=False,
with_deck_configs=False,
with_media=False,
legacy=legacy,
),
limit=None,
)
col.close()
if __name__ == "__main__":
main()
furigana()