feat(app): rich content, its editor and one read-only view
RichContent (lib/src/core/content) keeps the editor's document JSON as a deeply unmodifiable copy plus a formatVersion; it is pure Dart and compared by value (equatable), so models and later the server can use it. fromJson refuses formats newer than the app knows. The name avoids a clash with Flutter's RichText widget.
lib/src/core/editor converts it to and from the editor's Document (a mutable deep copy on the way in, since the editor keeps and changes parts of the JSON it gets) and holds RichContentEditor, which reports every document change as new RichContent and opens empty content with a paragraph to type into, and RichContentView, the one read-only renderer, sized to its content with IntrinsicHeight as the editor's own example does for lists.
A placeholder screen edits a document and shows it again after storing it as JSON and reading it back. It keeps its state with setState because it is temporary; blocs come with the first real feature. AGENTS.md describes RichContent and equatable.
Tests: equality, immutability, the JSON round trip with Japanese text and marks, format checks, the round trip through the editor document including an edit, and the view.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 08:38:08 +02:00
|
|
|
import 'dart:convert';
|
|
|
|
|
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
refactor(learn): the editor's general parts move to halkyon_editor (ADR 0033, D37)
Todo stores editor documents too, so the document, the editor, its view,
the format bar, links, the clipboard, scripts and EditorBlocks move, with
their strings and tests, to a shared package on the same appflowy_editor
fork. What is Learn's alone stays in Learn on top of it, and what the
editor took from it directly is inverted: EditorBlocks gains textMarks,
formatActions and onEditorClosed, RichContentEditor a wrapEditor, and an
InsertableBlock's name takes the BuildContext. Learn's screens use
LearnContentEditor and LearnContentView, which add Learn's blocks and the
grammar check. Learn behaves as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-06 21:25:44 +01:00
|
|
|
import 'package:halkyon_editor/halkyon_editor.dart';
|
feat(app): rich content, its editor and one read-only view
RichContent (lib/src/core/content) keeps the editor's document JSON as a deeply unmodifiable copy plus a formatVersion; it is pure Dart and compared by value (equatable), so models and later the server can use it. fromJson refuses formats newer than the app knows. The name avoids a clash with Flutter's RichText widget.
lib/src/core/editor converts it to and from the editor's Document (a mutable deep copy on the way in, since the editor keeps and changes parts of the JSON it gets) and holds RichContentEditor, which reports every document change as new RichContent and opens empty content with a paragraph to type into, and RichContentView, the one read-only renderer, sized to its content with IntrinsicHeight as the editor's own example does for lists.
A placeholder screen edits a document and shows it again after storing it as JSON and reading it back. It keeps its state with setState because it is temporary; blocs come with the first real feature. AGENTS.md describes RichContent and equatable.
Tests: equality, immutability, the JSON round trip with Japanese text and marks, format checks, the round trip through the editor document including an edit, and the view.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 08:38:08 +02:00
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
/// The editor's JSON for a page with one paragraph per text.
|
|
|
|
|
Map<String, Object?> paragraphs(List<String> texts) => {
|
|
|
|
|
'document': {
|
|
|
|
|
'type': 'page',
|
|
|
|
|
'children': [
|
|
|
|
|
for (final text in texts)
|
|
|
|
|
{
|
|
|
|
|
'type': 'paragraph',
|
|
|
|
|
'data': {
|
|
|
|
|
'delta': [
|
|
|
|
|
{'insert': text},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
group('RichContent', () {
|
feat: a round in the order cards come, shared daily limits, and a start screen
Studying:
- A round is one list in the order its cards come: those done, the one
shown, those to come. A card due again within the day goes back in
after 4 more cards when rated Bad, at the end otherwise, so the pills
above the card follow it; the buttons say "in 4 cards", "at the end",
"now" (round_order.dart).
- The studied studyset's daily limits hold for everything inside it
together, as Anki's do for subdecks: twenty topics start 20 new cards
a day in all, not 20 each.
- The study header's studyset name shows whole on hover or long press.
- The assistant's button sits at the lower left: in the Library across
from Create studyset, in a studyset above its bar, and higher while
studying, above the buttons that answer the card.
A studyset:
- Progress also counts the learning paths' steps done and lists the
PDFs not read to their end, begun first, each opening the reader,
across its topics (MaterialProgressCubit).
- Next up starts with studying for the exam while one is ahead: its
reviews due and the new cards its pace leaves for the day.
- Learning paths tagged with a level count towards its automatic exam
plan and offer it, as cards do.
- A step's first heading equal to its title is dropped on import and
hidden in paths imported before.
Signing out, or a session the server ended, leads to a start screen
(GitHub, email, without an account, another instance) rather than a new
anonymous account; only the very first start makes one by itself.
Settings names the kind of account and its instance.
Also: the Watch screen reads the cards once for held words and their
progress; its ±10 s buttons tick as the navigation bar does; a map card
keeps its view as its caption changes and brings a picked region
towards the middle; an empty or failed library can be pulled to sync.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-17 00:39:34 +02:00
|
|
|
test('without a first heading that repeats a title, whatever its case '
|
|
|
|
|
'and spaces; anything else stays', () {
|
|
|
|
|
RichContent page(List<Map<String, Object?>> children) => RichContent({
|
|
|
|
|
'document': {'type': 'page', 'children': children},
|
|
|
|
|
});
|
|
|
|
|
Map<String, Object?> heading(String text) => {
|
|
|
|
|
'type': 'heading',
|
|
|
|
|
'data': {
|
|
|
|
|
'level': 1,
|
|
|
|
|
'delta': [
|
|
|
|
|
{'insert': text},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
final text = {
|
|
|
|
|
'type': 'paragraph',
|
|
|
|
|
'data': {
|
|
|
|
|
'delta': [
|
|
|
|
|
{'insert': 'In Japanese…'},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
page([heading('What is Kanji?'), text])
|
|
|
|
|
.withoutHeading('what is kanji?'),
|
|
|
|
|
page([text]),
|
|
|
|
|
);
|
|
|
|
|
final other = page([heading('Kanji'), text]);
|
|
|
|
|
expect(other.withoutHeading('What is Kanji?'), other);
|
|
|
|
|
final later = page([text, heading('What is Kanji?')]);
|
|
|
|
|
expect(later.withoutHeading('What is Kanji?'), later);
|
|
|
|
|
expect(other.withoutHeading(''), other);
|
|
|
|
|
});
|
|
|
|
|
|
feat(app): rich content, its editor and one read-only view
RichContent (lib/src/core/content) keeps the editor's document JSON as a deeply unmodifiable copy plus a formatVersion; it is pure Dart and compared by value (equatable), so models and later the server can use it. fromJson refuses formats newer than the app knows. The name avoids a clash with Flutter's RichText widget.
lib/src/core/editor converts it to and from the editor's Document (a mutable deep copy on the way in, since the editor keeps and changes parts of the JSON it gets) and holds RichContentEditor, which reports every document change as new RichContent and opens empty content with a paragraph to type into, and RichContentView, the one read-only renderer, sized to its content with IntrinsicHeight as the editor's own example does for lists.
A placeholder screen edits a document and shows it again after storing it as JSON and reading it back. It keeps its state with setState because it is temporary; blocs come with the first real feature. AGENTS.md describes RichContent and equatable.
Tests: equality, immutability, the JSON round trip with Japanese text and marks, format checks, the round trip through the editor document including an edit, and the view.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 08:38:08 +02:00
|
|
|
test('equals content with the same JSON, whatever the key order', () {
|
|
|
|
|
final a = RichContent({
|
|
|
|
|
'document': {'type': 'page', 'children': <Object?>[]},
|
|
|
|
|
});
|
|
|
|
|
final b = RichContent({
|
|
|
|
|
'document': {'children': <Object?>[], 'type': 'page'},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(a, b);
|
|
|
|
|
expect(a.hashCode, b.hashCode);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('differs when the text differs', () {
|
|
|
|
|
expect(
|
|
|
|
|
RichContent(paragraphs(['猫'])),
|
|
|
|
|
isNot(RichContent(paragraphs(['犬']))),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('differs when the format version differs', () {
|
|
|
|
|
expect(
|
|
|
|
|
RichContent(paragraphs(['猫'])),
|
|
|
|
|
isNot(RichContent(paragraphs(['猫']), formatVersion: 0)),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('is not affected by later changes to the JSON it was made from', () {
|
|
|
|
|
final json = paragraphs(['猫']);
|
|
|
|
|
final content = RichContent(json);
|
|
|
|
|
|
|
|
|
|
final document = json['document']! as Map<String, Object?>;
|
|
|
|
|
(document['children']! as List<Object?>).clear();
|
|
|
|
|
|
|
|
|
|
expect(content, RichContent(paragraphs(['猫'])));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('cannot be changed through its JSON', () {
|
|
|
|
|
final content = RichContent(paragraphs(['猫']));
|
|
|
|
|
final document = content.editorJson['document']! as Map<String, Object?>;
|
|
|
|
|
final children = document['children']! as List<Object?>;
|
|
|
|
|
|
|
|
|
|
expect(() => document['type'] = 'heading', throwsUnsupportedError);
|
|
|
|
|
expect(children.clear, throwsUnsupportedError);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('survives a JSON round trip, Japanese text and marks included', () {
|
|
|
|
|
final content = RichContent({
|
|
|
|
|
'document': {
|
|
|
|
|
'type': 'page',
|
|
|
|
|
'children': [
|
|
|
|
|
{
|
|
|
|
|
'type': 'heading',
|
|
|
|
|
'data': {
|
|
|
|
|
'level': 2,
|
|
|
|
|
'delta': [
|
|
|
|
|
{'insert': '日本語'},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'type': 'paragraph',
|
|
|
|
|
'data': {
|
|
|
|
|
'delta': [
|
|
|
|
|
{
|
|
|
|
|
'insert': '猫',
|
|
|
|
|
'attributes': {'bold': true},
|
|
|
|
|
},
|
|
|
|
|
{'insert': 'は動物です。'},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final stored = jsonEncode(content.toJson());
|
|
|
|
|
final read = RichContent.fromJson(
|
|
|
|
|
jsonDecode(stored) as Map<String, Object?>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(read, content);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('rejects content in a newer format than this app knows', () {
|
|
|
|
|
expect(
|
|
|
|
|
() => RichContent.fromJson({
|
|
|
|
|
'formatVersion': RichContent.currentFormatVersion + 1,
|
|
|
|
|
'editor': RichContent.empty().editorJson,
|
|
|
|
|
}),
|
|
|
|
|
throwsFormatException,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('rejects JSON that is not rich content', () {
|
|
|
|
|
expect(
|
|
|
|
|
() => RichContent.fromJson({'formatVersion': 1, 'editor': 'text'}),
|
|
|
|
|
throwsFormatException,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => RichContent.fromJson({'editor': RichContent.empty().editorJson}),
|
|
|
|
|
throwsFormatException,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
feat(app): flashcards on append-only revisions
A studyset's screen lists its cards (reorderable; a marker on cards changed on two devices) and opens a card's screen with an editor for each side. Saving appends a revision whose parents are all current heads. mergeRevisions merges several heads per side against their newest common ancestor, as decided: different sides merge automatically, the same side changed differently is a conflict. The card screen then shows every version of that side with "Keep this", and saving is possible once each conflicted side is settled, which resolves the conflict for every device. Nothing is merged silently inside a side.
CardRepository watches a studyset's cards, creates at the end and moves by fractional index (fractional_indexing_dart, checked against the reference algorithm's test vectors), and deletes by blanking every revision first (ADR 0002, rule 2). Deleting a studyset now deletes its cards the same way before the studyset goes. Every person gets a CardRepository whose revisions name them as author. RichContent gains plainText for the list's previews.
Tests: the merge (chains, different and same sides, conflicts, resolving, three heads, missing parents, arrival order), the payload, the repository against a fake gateway, both blocs, and the widgets, including that cancelling a delete keeps the card. The sync tests, which need the running server, cover merging and conflicts across two devices and blank revisions on the server; their device helper is now shared in test/sync/support. AGENTS.md describes the revision pattern, the fractional order, and two tooling traps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 14:41:44 +02:00
|
|
|
|
2026-10-25 21:54:56 +01:00
|
|
|
test("RichContent.imageIds lists the person's images it shows, in order "
|
|
|
|
|
'and once each; no sound, nor an image from elsewhere', () {
|
|
|
|
|
Map<String, Object?> block(String type, String url) => {
|
|
|
|
|
'type': type,
|
|
|
|
|
'data': {'url': url},
|
|
|
|
|
};
|
|
|
|
|
final content = RichContent({
|
|
|
|
|
'document': {
|
|
|
|
|
'type': 'page',
|
|
|
|
|
'children': [
|
|
|
|
|
block('image', 'asset:b'),
|
|
|
|
|
block('audio', 'asset:sound'),
|
|
|
|
|
block('image', 'https://example.org/cat.png'),
|
|
|
|
|
block('image', 'asset:a'),
|
|
|
|
|
block('image', 'asset:b'),
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(content.imageIds, ['b', 'a']);
|
|
|
|
|
});
|
|
|
|
|
|
feat(app): flashcards on append-only revisions
A studyset's screen lists its cards (reorderable; a marker on cards changed on two devices) and opens a card's screen with an editor for each side. Saving appends a revision whose parents are all current heads. mergeRevisions merges several heads per side against their newest common ancestor, as decided: different sides merge automatically, the same side changed differently is a conflict. The card screen then shows every version of that side with "Keep this", and saving is possible once each conflicted side is settled, which resolves the conflict for every device. Nothing is merged silently inside a side.
CardRepository watches a studyset's cards, creates at the end and moves by fractional index (fractional_indexing_dart, checked against the reference algorithm's test vectors), and deletes by blanking every revision first (ADR 0002, rule 2). Deleting a studyset now deletes its cards the same way before the studyset goes. Every person gets a CardRepository whose revisions name them as author. RichContent gains plainText for the list's previews.
Tests: the merge (chains, different and same sides, conflicts, resolving, three heads, missing parents, arrival order), the payload, the repository against a fake gateway, both blocs, and the widgets, including that cancelling a delete keeps the card. The sync tests, which need the running server, cover merging and conflicts across two devices and blank revisions on the server; their device helper is now shared in test/sync/support. AGENTS.md describes the revision pattern, the fractional order, and two tooling traps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 14:41:44 +02:00
|
|
|
group('RichContent.plainText', () {
|
|
|
|
|
test('is the text without formatting, one line per block', () {
|
|
|
|
|
expect(
|
|
|
|
|
RichContent(paragraphs(['ひらがな', 'かたかな'])).plainText,
|
|
|
|
|
'ひらがな\nかたかな',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('joins the pieces of a formatted block', () {
|
|
|
|
|
final content = RichContent({
|
|
|
|
|
'document': {
|
|
|
|
|
'type': 'page',
|
|
|
|
|
'children': [
|
|
|
|
|
{
|
|
|
|
|
'type': 'paragraph',
|
|
|
|
|
'data': {
|
|
|
|
|
'delta': [
|
|
|
|
|
{'insert': 'big '},
|
|
|
|
|
{
|
|
|
|
|
'insert': 'cat',
|
|
|
|
|
'attributes': {'bold': true},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(content.plainText, 'big cat');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('is empty for empty content', () {
|
|
|
|
|
expect(RichContent.empty().plainText, isEmpty);
|
|
|
|
|
});
|
|
|
|
|
});
|
Import Anki decks with their files and history
Files in content: the person's images and sounds as asset rows synced like
all content, their files kept by hash on each device, uploaded after each
sync, downloaded on demand and checked against their hash
(core/asset/AssetRepository). Rich text names them as asset:<id> URLs, in
the editor's image block and an audio block of ours; any other URL is never
fetched. Sounds play through audioplayers, one at a time. Content holding
only an image or a sound isn't blank, and lists show it as such.
The Anki import ("Import from Anki" under Add material): .apkg and .colpkg
packages in all three formats Anki has written, read on an isolate of their
own. Modern packages compress everything with zstd in frames that don't
record their size, which pure_zstd v1.0.0 refuses; our fork decodes them.
Fields become rich text by a converter of our own (lines, lists, formatting,
images, sounds, cloze deletions hidden on the front). One card per note,
the files first, studysets as chosen (flatten, sub-studysets, or tags by
subdeck), the notes' tags, and, if wanted, the review history, which FSRS
replays: the first template's reviews to the card's schedule, a reversed
note type's second to the reverse one.
Imported cards say where they came from in their first revision (card
schema 3); the dashboard doesn't count them as written. The test fixtures
are exported by Anki itself (tool/anki/make_fixtures.py).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-02 04:52:02 +02:00
|
|
|
|
|
|
|
|
group('files', () {
|
|
|
|
|
RichContent blocks(List<Map<String, Object?>> children) => RichContent({
|
|
|
|
|
'document': {'type': 'page', 'children': children},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('names the asset rows its images and sounds show', () {
|
|
|
|
|
final content = blocks([
|
|
|
|
|
{
|
|
|
|
|
'type': 'image',
|
|
|
|
|
'data': {'url': assetUrl('cat')},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'type': 'audio',
|
|
|
|
|
'data': {'url': assetUrl('neko')},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'type': 'image',
|
|
|
|
|
'data': {'url': 'https://example.com/dog.png'},
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(content.assetIds, {'cat', 'neko'});
|
|
|
|
|
expect(content.hasImages, isTrue);
|
|
|
|
|
expect(content.hasSounds, isTrue);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('content of only an image or a sound is not blank', () {
|
|
|
|
|
expect(
|
|
|
|
|
blocks([
|
|
|
|
|
{
|
|
|
|
|
'type': 'image',
|
|
|
|
|
'data': {'url': assetUrl('cat')},
|
|
|
|
|
},
|
|
|
|
|
]).isBlank,
|
|
|
|
|
isFalse,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
blocks([
|
|
|
|
|
{
|
|
|
|
|
'type': 'audio',
|
|
|
|
|
'data': {'url': assetUrl('neko')},
|
|
|
|
|
},
|
|
|
|
|
]).isBlank,
|
|
|
|
|
isFalse,
|
|
|
|
|
);
|
|
|
|
|
expect(RichContent.plain(' ').isBlank, isTrue);
|
|
|
|
|
expect(RichContent.empty().isBlank, isTrue);
|
|
|
|
|
});
|
|
|
|
|
|
feat(app): editor blocks: callout, spoiler, formulas, files, video, study button
Blocks beyond the editor's own, each built once for editor and view:
- callout: eight kinds in theme colours, kind, title and "folded at
first" from its header; written in place, left with Enter on its
empty last line; never inside another
- spoiler: written in plain sight, hidden behind Reveal read-only
- formula on its own line, written on its own screen with a preview
and snippet buttons, and formulas within a line (Σ in the format
bar); set by flutter_math_fork behind MathView, LaTeX's numbered
environments read as their aligned forms
- image and sound picked from the device, kept as synced files
- YouTube video from its link, with its start and a caption: a card
until played, nothing fetched before; played in place where a web
view exists (youtube_player_iframe, privacy-enhanced), else opened
in the browser, behind YoutubeVideos in Dependencies
- study button: a studyset, a tag or all cards, with or without its
topics, back first; its StudyFocus rides on the study route and
narrows that session only
- tables, already in the editor, now insertable everywhere
An Insert button opens on the format bar, so blocks can be inserted on
phones too; the desktop's / menu lists the same blocks, translated.
Card faces now dock the format bar while written, so cards get blocks
and formatting on phones as well. The blocks are listed in
EditorBlocks: the core's, plus the features' through
EditorBlocksScope. Blocks of a type the app doesn't know say they need
a newer version and stay as they are, so formatVersion stays 1.
Their types and keys are in content_blocks.dart, which RichContent
reads: a block's text counts as text, and a video or study button
isn't blank. The path import now makes formulas real ones.
integration_test/youtube_player_test.dart plays a real video through
the app's player on a device, which no widget test can: YouTube
refuses embeds that don't say where they are embedded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-03 12:20:41 +02:00
|
|
|
test("a block's text counts as text: a formula, a callout's title, a "
|
|
|
|
|
"video's caption, a study button's label, a spoiler's hidden text", () {
|
|
|
|
|
final content = blocks([
|
|
|
|
|
{
|
|
|
|
|
'type': mathBlockType,
|
|
|
|
|
'data': {mathFormulaKey: 'E = mc^2'},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'type': calloutBlockType,
|
|
|
|
|
'data': {calloutTitleKey: 'Mind the particle'},
|
|
|
|
|
'children': [
|
|
|
|
|
{
|
|
|
|
|
'type': 'paragraph',
|
|
|
|
|
'data': {
|
|
|
|
|
'delta': [
|
|
|
|
|
{'insert': 'は, not わ'},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'type': youtubeBlockType,
|
|
|
|
|
'data': {youtubeVideoKey: 'dQw4w9WgXcQ', youtubeCaptionKey: 'Listen'},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'type': studyButtonBlockType,
|
|
|
|
|
'data': {
|
|
|
|
|
studyButtonStudysetKey: 'japanese',
|
|
|
|
|
studyButtonLabelKey: 'Go',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'type': spoilerBlockType,
|
|
|
|
|
'data': {
|
|
|
|
|
'delta': [
|
|
|
|
|
{'insert': 'ねこ'},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
content.plainText,
|
|
|
|
|
'E = mc^2\nMind the particle\nは, not わ\nListen\nGo\nねこ',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a video or a study button is something without any text; an '
|
|
|
|
|
'empty formula or callout is nothing', () {
|
|
|
|
|
expect(
|
|
|
|
|
blocks([
|
|
|
|
|
{
|
|
|
|
|
'type': youtubeBlockType,
|
|
|
|
|
'data': {youtubeVideoKey: 'dQw4w9WgXcQ'},
|
|
|
|
|
},
|
|
|
|
|
]).isBlank,
|
|
|
|
|
isFalse,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
blocks([
|
|
|
|
|
{
|
|
|
|
|
'type': studyButtonBlockType,
|
|
|
|
|
'data': {studyButtonStudysetKey: 'japanese'},
|
|
|
|
|
},
|
|
|
|
|
]).isBlank,
|
|
|
|
|
isFalse,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
blocks([
|
|
|
|
|
{
|
|
|
|
|
'type': mathBlockType,
|
|
|
|
|
'data': {mathFormulaKey: ' '},
|
|
|
|
|
},
|
|
|
|
|
{'type': calloutBlockType, 'data': <String, Object?>{}},
|
|
|
|
|
]).isBlank,
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
Import Anki decks with their files and history
Files in content: the person's images and sounds as asset rows synced like
all content, their files kept by hash on each device, uploaded after each
sync, downloaded on demand and checked against their hash
(core/asset/AssetRepository). Rich text names them as asset:<id> URLs, in
the editor's image block and an audio block of ours; any other URL is never
fetched. Sounds play through audioplayers, one at a time. Content holding
only an image or a sound isn't blank, and lists show it as such.
The Anki import ("Import from Anki" under Add material): .apkg and .colpkg
packages in all three formats Anki has written, read on an isolate of their
own. Modern packages compress everything with zstd in frames that don't
record their size, which pure_zstd v1.0.0 refuses; our fork decodes them.
Fields become rich text by a converter of our own (lines, lists, formatting,
images, sounds, cloze deletions hidden on the front). One card per note,
the files first, studysets as chosen (flatten, sub-studysets, or tags by
subdeck), the notes' tags, and, if wanted, the review history, which FSRS
replays: the first template's reviews to the card's schedule, a reversed
note type's second to the reverse one.
Imported cards say where they came from in their first revision (card
schema 3); the dashboard doesn't count them as written. The test fixtures
are exported by Anki itself (tool/anki/make_fixtures.py).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-02 04:52:02 +02:00
|
|
|
test('reads only asset URLs as files of the person', () {
|
|
|
|
|
expect(assetIdOf(assetUrl('cat')), 'cat');
|
|
|
|
|
expect(assetIdOf('asset:'), isNull);
|
|
|
|
|
expect(assetIdOf('https://example.com/cat.png'), isNull);
|
|
|
|
|
expect(assetIdOf(42), isNull);
|
|
|
|
|
});
|
|
|
|
|
});
|
feat(app): rich content, its editor and one read-only view
RichContent (lib/src/core/content) keeps the editor's document JSON as a deeply unmodifiable copy plus a formatVersion; it is pure Dart and compared by value (equatable), so models and later the server can use it. fromJson refuses formats newer than the app knows. The name avoids a clash with Flutter's RichText widget.
lib/src/core/editor converts it to and from the editor's Document (a mutable deep copy on the way in, since the editor keeps and changes parts of the JSON it gets) and holds RichContentEditor, which reports every document change as new RichContent and opens empty content with a paragraph to type into, and RichContentView, the one read-only renderer, sized to its content with IntrinsicHeight as the editor's own example does for lists.
A placeholder screen edits a document and shows it again after storing it as JSON and reading it back. It keeps its state with setState because it is temporary; blocs come with the first real feature. AGENTS.md describes RichContent and equatable.
Tests: equality, immutability, the JSON round trip with Japanese text and marks, format checks, the round trip through the editor document including an edit, and the view.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 08:38:08 +02:00
|
|
|
}
|