feat(paths): path files carry tables, callouts and furigana, and several import at once

The import format gains the blocks lessons need: tables (rows of cells),
callouts (a kind, a title, blocks inside, but no table or callout),
spoilers, quotes and dividers; and runs of text gain a reading shown as
furigana, a colour, underline and strikethrough. A line break is a "\n"
in the text. Still simple blocks turned into rich text here, so a change
of the editor's own format never breaks a file written before. A table's
columns are as wide as their longest line, narrowed alike to fit a
phone, where the cells wrap.

A studyset's Import › Learning paths now takes several files at once,
one path each, stored in the order of their names, so a chapter's
lessons come in with one pick. Every file is read before anything is
stored: one that can't be read is named, and nothing comes in.

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-11 19:31:39 +02:00
commit 7d99feca7f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 781 additions and 112 deletions

View file

@ -96,6 +96,8 @@
"pathImported": "{count, plural, =1{„{title}“ mit 1 Schritt importiert.} other{„{title}“ mit {count} Schritten importiert.}}",
"pathImportSkipped": "{count, plural, =1{1 Block wurde ausgelassen: Bilder und Animationen werden nicht importiert.} other{{count} Blöcke wurden ausgelassen: Bilder und Animationen werden nicht importiert.}}",
"pathImportFailed": "Diese Datei konnte nicht importiert werden. {reason}",
"pathImportFileFailed": "{file} konnte nicht importiert werden. {reason}",
"pathsImported": "{steps, plural, =0{{count} Pfade ohne Schritte importiert.} =1{{count} Pfade mit 1 Schritt importiert.} other{{count} Pfade mit {steps} Schritten importiert.}}",
"pathImportUnreadable": "Der Inhalt ist kein Text in UTF-8.",
"pathsEmpty": "Noch keine Pfade",
"pathsEmptyHint": "Ein Pfad führt Schritt für Schritt durch eine Lektion. Nutze „Material hinzufügen → Pfad erstellen“, um einen zu schreiben, oder „⋮ → Inhalt importieren“, um einen zu importieren.",
@ -277,8 +279,8 @@
"exportVideo": "YouTube-Video",
"exportSpoiler": "Spoiler",
"exportUnavailable": "nicht auf diesem Gerät verfügbar",
"studysetImportPath": "Lernpfad",
"studysetImportPathHint": "Aus einer Halkyon-Pfad-JSON-Datei",
"studysetImportPath": "Lernpfade",
"studysetImportPathHint": "Aus Halkyon-Pfad-JSON-Dateien, ein Pfad je Datei",
"studysetImportAnki": "Anki-Deck",
"studysetImportAnkiHint": "Aus einer .apkg- oder .colpkg-Datei",
"studysetImportCards": "Karteikarten",
@ -1329,6 +1331,9 @@
"pathImportMalformedNumber": "eine fehlerhafte Zahl.",
"pathImportTitleNotText": "ein Titel muss Text sein.",
"pathImportMalformedSource": "eine Quelle muss ein Objekt oder Text sein und ihre Teile Text.",
"pathImportMalformedTable": "eine Tabelle braucht „rows“ aus Zellen, jede so lang wie die erste.",
"pathImportMalformedCallout": "eine Hinweisbox mit fehlerhaftem „{detail}“.",
"pathImportNotInCallout": "ein „{detail}“ kann nicht in einer Hinweisbox stehen.",
"pomodoroTopBarHint": "Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.",
"pomodoroTopBarSetUp": "Einrichten",
"pomodoroTopBarDismiss": "Nicht mehr vorschlagen",

View file

@ -111,6 +111,10 @@
"@pathImportSkipped": {"placeholders": {"count": {"type": "int"}}},
"pathImportFailed": "Couldn't import this file. {reason}",
"@pathImportFailed": {"placeholders": {"reason": {"type": "String"}}},
"pathImportFileFailed": "Couldn't import {file}. {reason}",
"@pathImportFileFailed": {"placeholders": {"file": {"type": "String"}, "reason": {"type": "String"}}},
"pathsImported": "{steps, plural, =0{Imported {count} paths without steps.} =1{Imported {count} paths with 1 step.} other{Imported {count} paths with {steps} steps.}}",
"@pathsImported": {"placeholders": {"count": {"type": "int"}, "steps": {"type": "int"}}},
"pathImportUnreadable": "It isn't text in UTF-8.",
"pathsEmpty": "No paths yet",
"pathsEmptyHint": "A path leads through a lesson, step by step. Use \"Add material → Create a path\" to write one, or \"⋮ → Import content\" to import one.",
@ -311,8 +315,8 @@
"exportVideo": "YouTube video",
"exportSpoiler": "Spoiler",
"exportUnavailable": "not available on this device",
"studysetImportPath": "Learning path",
"studysetImportPathHint": "From a Halkyon path JSON file",
"studysetImportPath": "Learning paths",
"studysetImportPathHint": "From Halkyon path JSON files, one path each",
"studysetImportAnki": "Anki deck",
"studysetImportAnkiHint": "From an .apkg or .colpkg file",
"studysetImportCards": "Flashcards",
@ -1553,6 +1557,11 @@
"pathImportMalformedNumber": "a malformed number.",
"pathImportTitleNotText": "a title must be text.",
"pathImportMalformedSource": "a source must be an object or text, and its parts text.",
"pathImportMalformedTable": "a table needs \"rows\" of cells, each as long as the first.",
"pathImportMalformedCallout": "a callout with a malformed {detail}.",
"@pathImportMalformedCallout": {"placeholders": {"detail": {"type": "String"}}},
"pathImportNotInCallout": "a {detail} can't be inside a callout.",
"@pathImportNotInCallout": {"placeholders": {"detail": {"type": "String"}}},
"pomodoroTopBarHint": "The timer can count down in GNOME's top bar, with its buttons, once the AppIndicator extension is on.",
"pomodoroTopBarSetUp": "Set up",
"pomodoroTopBarDismiss": "Don't suggest again",

View file

@ -634,6 +634,18 @@ abstract class AppLocalizations {
/// **'Couldn\'t import this file. {reason}'**
String pathImportFailed(String reason);
/// No description provided for @pathImportFileFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t import {file}. {reason}'**
String pathImportFileFailed(String file, String reason);
/// No description provided for @pathsImported.
///
/// In en, this message translates to:
/// **'{steps, plural, =0{Imported {count} paths without steps.} =1{Imported {count} paths with 1 step.} other{Imported {count} paths with {steps} steps.}}'**
String pathsImported(int count, int steps);
/// No description provided for @pathImportUnreadable.
///
/// In en, this message translates to:
@ -1723,13 +1735,13 @@ abstract class AppLocalizations {
/// No description provided for @studysetImportPath.
///
/// In en, this message translates to:
/// **'Learning path'**
/// **'Learning paths'**
String get studysetImportPath;
/// No description provided for @studysetImportPathHint.
///
/// In en, this message translates to:
/// **'From a Halkyon path JSON file'**
/// **'From Halkyon path JSON files, one path each'**
String get studysetImportPathHint;
/// No description provided for @studysetImportAnki.
@ -7869,6 +7881,24 @@ abstract class AppLocalizations {
/// **'a source must be an object or text, and its parts text.'**
String get pathImportMalformedSource;
/// No description provided for @pathImportMalformedTable.
///
/// In en, this message translates to:
/// **'a table needs \"rows\" of cells, each as long as the first.'**
String get pathImportMalformedTable;
/// No description provided for @pathImportMalformedCallout.
///
/// In en, this message translates to:
/// **'a callout with a malformed {detail}.'**
String pathImportMalformedCallout(String detail);
/// No description provided for @pathImportNotInCallout.
///
/// In en, this message translates to:
/// **'a {detail} can\'t be inside a callout.'**
String pathImportNotInCallout(String detail);
/// No description provided for @pomodoroTopBarHint.
///
/// In en, this message translates to:

View file

@ -395,6 +395,23 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Diese Datei konnte nicht importiert werden. $reason';
}
@override
String pathImportFileFailed(String file, String reason) {
return '$file konnte nicht importiert werden. $reason';
}
@override
String pathsImported(int count, int steps) {
String _temp0 = intl.Intl.pluralLogic(
steps,
locale: localeName,
other: '$count Pfade mit $steps Schritten importiert.',
one: '$count Pfade mit 1 Schritt importiert.',
zero: '$count Pfade ohne Schritte importiert.',
);
return '$_temp0';
}
@override
String get pathImportUnreadable => 'Der Inhalt ist kein Text in UTF-8.';
@ -1052,10 +1069,11 @@ class AppLocalizationsDe extends AppLocalizations {
String get exportUnavailable => 'nicht auf diesem Gerät verfügbar';
@override
String get studysetImportPath => 'Lernpfad';
String get studysetImportPath => 'Lernpfade';
@override
String get studysetImportPathHint => 'Aus einer Halkyon-Pfad-JSON-Datei';
String get studysetImportPathHint =>
'Aus Halkyon-Pfad-JSON-Dateien, ein Pfad je Datei';
@override
String get studysetImportAnki => 'Anki-Deck';
@ -4895,6 +4913,20 @@ class AppLocalizationsDe extends AppLocalizations {
String get pathImportMalformedSource =>
'eine Quelle muss ein Objekt oder Text sein und ihre Teile Text.';
@override
String get pathImportMalformedTable =>
'eine Tabelle braucht „rows“ aus Zellen, jede so lang wie die erste.';
@override
String pathImportMalformedCallout(String detail) {
return 'eine Hinweisbox mit fehlerhaftem „$detail“.';
}
@override
String pathImportNotInCallout(String detail) {
return 'ein „$detail“ kann nicht in einer Hinweisbox stehen.';
}
@override
String get pomodoroTopBarHint =>
'Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.';

View file

@ -387,6 +387,23 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Couldn\'t import this file. $reason';
}
@override
String pathImportFileFailed(String file, String reason) {
return 'Couldn\'t import $file. $reason';
}
@override
String pathsImported(int count, int steps) {
String _temp0 = intl.Intl.pluralLogic(
steps,
locale: localeName,
other: 'Imported $count paths with $steps steps.',
one: 'Imported $count paths with 1 step.',
zero: 'Imported $count paths without steps.',
);
return '$_temp0';
}
@override
String get pathImportUnreadable => 'It isn\'t text in UTF-8.';
@ -1043,10 +1060,11 @@ class AppLocalizationsEn extends AppLocalizations {
String get exportUnavailable => 'not available on this device';
@override
String get studysetImportPath => 'Learning path';
String get studysetImportPath => 'Learning paths';
@override
String get studysetImportPathHint => 'From a Halkyon path JSON file';
String get studysetImportPathHint =>
'From Halkyon path JSON files, one path each';
@override
String get studysetImportAnki => 'Anki deck';
@ -4851,6 +4869,20 @@ class AppLocalizationsEn extends AppLocalizations {
String get pathImportMalformedSource =>
'a source must be an object or text, and its parts text.';
@override
String get pathImportMalformedTable =>
'a table needs \"rows\" of cells, each as long as the first.';
@override
String pathImportMalformedCallout(String detail) {
return 'a callout with a malformed $detail.';
}
@override
String pathImportNotInCallout(String detail) {
return 'a $detail can\'t be inside a callout.';
}
@override
String get pomodoroTopBarHint =>
'The timer can count down in GNOME\'s top bar, with its buttons, once the AppIndicator extension is on.';

View file

@ -14,6 +14,13 @@ abstract interface class FilePicker() {
required String label,
required List<String> extensions,
});
/// The files the person picked, of a type [label] names with
/// [extensions]; none if they picked none.
Future<List<PickedFile>> pickMany({
required String label,
required List<String> extensions,
});
}
/// [FilePicker] with the platform's own dialog (`file_selector`).
@ -24,19 +31,32 @@ class FileSelectorFilePicker() implements FilePicker {
required List<String> extensions,
}) async {
final file = await openFile(
acceptedTypeGroups: [
// Android filters by media type, which extensions like Anki's lack,
// so it offers every file there; what isn't one is refused later.
if (!Platform.isAndroid)
XTypeGroup(
label: label,
extensions: extensions,
uniformTypeIdentifiers: const ['public.data'],
),
],
acceptedTypeGroups: _typeGroups(label, extensions),
);
return file == null
? null
: (name: file.name, bytes: await file.readAsBytes());
}
@override
Future<List<PickedFile>> pickMany({
required String label,
required List<String> extensions,
}) async => [
for (final file in await openFiles(
acceptedTypeGroups: _typeGroups(label, extensions),
))
(name: file.name, bytes: await file.readAsBytes()),
];
List<XTypeGroup> _typeGroups(String label, List<String> extensions) => [
// Android filters by media type, which extensions like Anki's lack, so
// it offers every file there; what isn't one is refused later.
if (!Platform.isAndroid)
XTypeGroup(
label: label,
extensions: extensions,
uniformTypeIdentifiers: const ['public.data'],
),
];
}

View file

@ -31,35 +31,50 @@ class PathListBloc(
});
on<PathListEventImportRequested>((event, emit) async {
emit(state.copyWith(importing: true));
try {
final parsed = parsePathImport(event.source);
final imported = await PathImporter(
_paths,
).import(_studysetId, event.source);
emit(
state.copyWith(
importing: false,
importResult: (
title: parsed.content.title,
steps: imported.steps,
skippedBlocks: imported.skippedBlocks,
failure: null,
// Every file is read before anything is stored, so one that can't be
// read leaves the studyset as it was.
final parsed = <ParsedPath>[];
for (final file in event.files) {
try {
parsed.add(parsePathImport(file.source));
} on PathImportException catch (error) {
emit(
state.copyWith(
importing: false,
importResult: (
title: '',
paths: 0,
steps: 0,
skippedBlocks: 0,
file: event.files.length > 1 ? file.name : null,
failure: error,
),
),
),
);
} on PathImportException catch (error) {
emit(
state.copyWith(
importing: false,
importResult: (
title: '',
steps: 0,
skippedBlocks: 0,
failure: error,
),
),
);
);
return;
}
}
final importer = PathImporter(_paths);
var steps = 0;
var skippedBlocks = 0;
for (final path in parsed) {
final imported = await importer.store(_studysetId, path);
steps += imported.steps;
skippedBlocks += imported.skippedBlocks;
}
emit(
state.copyWith(
importing: false,
importResult: (
title: parsed.firstOrNull?.content.title ?? '',
paths: parsed.length,
steps: steps,
skippedBlocks: skippedBlocks,
file: null,
failure: null,
),
),
);
});
on<PathListEventMoveRequested>((event, emit) {
// The list takes the new order at once; the stored one follows.
@ -177,15 +192,21 @@ class PathListBloc(
}
}
/// How an import went: the path's title, how many steps came in and how
/// many blocks were left out; or, with a `failure`, why nothing came in.
/// How an import went: the first path's title, how many paths and steps
/// came in and how many blocks were left out; or, with a `failure`, why
/// nothing came in, and of several files, the `file` it is in.
typedef ImportResult = ({
String title,
int paths,
int steps,
int skippedBlocks,
String? file,
PathImportException? failure,
});
/// A file in the paths' import format: its name and its text.
typedef PathFile = ({String name, String source});
/// A studyset's paths, as the list shows them.
final class const PathListState({
/// Whether the paths were read.
@ -283,13 +304,13 @@ final class const PathListEventCreateRequested(
List<Object?> get props => [content];
}
/// Import the path in [source], a file in the import format.
/// Import the paths in [files], in the import format, in this order.
final class const PathListEventImportRequested(
/// The file's text.
final String source,
/// The files.
final List<PathFile> files,
) extends PathListEvent {
@override
List<Object?> get props => [source];
List<Object?> get props => [files];
}
/// Move the path [id] to [toIndex].

View file

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:math';
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
@ -32,10 +33,16 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_conte
// writes them ("citation" counts as "container", as files of before name
// it), or text, kept as the citation it is.
//
// Blocks: {"type": "paragraph" | "heading" (with "level"), "text": "…"} or
// with "runs": [{"text", "bold", "italic", "link", "math"}]; "bulleted_list"
// and "numbered_list" with "items" (text or {"runs"}); "math" ("formula")
// as a formula on its own line, and a run's "math" as one within the line;
// Blocks: {"type": "paragraph" | "heading" (with "level") | "quote" |
// "spoiler", "text": "…"} or with "runs": [{"text", "bold", "italic",
// "underline", "strike", "link", "ruby" (the text's furigana), "color"
// ("#rrggbb"), "math"}]; a line break is a "\n" in the text;
// "bulleted_list" and "numbered_list" with "items" (text or {"runs"});
// "divider"; "table" with "rows", each a list of cells (text or {"runs"}),
// all as long; "callout" with "kind" (note, info, tip, success, question,
// warning, danger, quote; note if missing), an optional "title" and its
// "content" (blocks, but no table or callout); "math" ("formula") as a
// formula on its own line, and a run's "math" as one within the line;
// "code" ("code") as monospaced text, the app having no code blocks.
// "image" and "lottie" are left out, keeping an "alt" caption: their files
// would have to be fetched from the web, which the app never does.
@ -69,6 +76,9 @@ enum PathImportProblem() {
malformedNumber,
titleNotText,
malformedSource,
malformedTable,
malformedCallout,
notInCallout,
}
/// A file in the import format that couldn't be read: the [problem], the
@ -105,8 +115,12 @@ class PathImporter(final LearningPathRepository _paths) {
/// Reads [source] and stores its path at the end of [studysetId], with its
/// steps; a [PathImportException] for a file it can't read, before
/// anything is stored.
Future<ImportedPath> import(String studysetId, String source) async {
final parsed = parsePathImport(source);
Future<ImportedPath> import(String studysetId, String source) =>
store(studysetId, parsePathImport(source));
/// Stores [parsed], a path read already, at the end of [studysetId], with
/// its steps.
Future<ImportedPath> store(String studysetId, ParsedPath parsed) async {
final pathId = await _paths.createPath(studysetId, parsed.content);
for (final (:kind, :content) in parsed.steps) {
await _paths.createStep(pathId, kind, content);
@ -138,10 +152,7 @@ ParsedPath parsePathImport(String source) {
};
final blocks = _Blocks();
final steps = [
for (final (index, step) in _list(
path['steps'],
null,
).indexed)
for (final (index, step) in _list(path['steps'], null).indexed)
_step(step, index + 1, blocks),
];
return (
@ -312,9 +323,7 @@ class _Blocks() {
if (json == null) {
return RichContent.empty();
}
final nodes = [
for (final block in _list(json, where)) ...?_nodes(block, where),
];
final nodes = _all(json, where, inCallout: false);
return nodes.isEmpty
? RichContent.empty()
: RichContent({
@ -322,8 +331,22 @@ class _Blocks() {
});
}
/// The editor's nodes for the blocks in [json].
List<Map<String, Object?>> _all(
Object? json,
int? where, {
required bool inCallout,
}) => [
for (final block in _list(json, where))
...?_nodes(block, where, inCallout: inCallout),
];
/// The editor's nodes for [block]; null for a block left out.
List<Map<String, Object?>>? _nodes(Object? block, int? where) {
List<Map<String, Object?>>? _nodes(
Object? block,
int? where, {
required bool inCallout,
}) {
final type = switch (block) {
{'type': final String type} => type,
_ => throw PathImportException(
@ -354,6 +377,32 @@ class _Blocks() {
},
},
];
case 'quote':
return [_text('quote', json, where)];
case 'spoiler':
return [_text(spoilerBlockType, json, where)];
case 'divider':
return [
{'type': 'divider'},
];
case 'table':
if (inCallout) {
throw PathImportException(
PathImportProblem.notInCallout,
step: where,
detail: type,
);
}
return [_table(json, where)];
case 'callout':
if (inCallout) {
throw PathImportException(
PathImportProblem.notInCallout,
step: where,
detail: type,
);
}
return [_callout(json, where)];
case 'bulleted_list' || 'numbered_list':
return [
for (final item in _list(json['items'], where))
@ -433,6 +482,138 @@ class _Blocks() {
}
}
/// A table: a cell for each column of each row, as the editor keeps
/// them, by column and row.
Map<String, Object?> _table(Map<String, Object?> json, int? where) {
final rows = [
for (final row in _list(json['rows'], where))
[
for (final cell in _list(row, where))
switch (cell) {
final String text => _plain(text),
{'runs': final Object? runs} => _runs(runs, where),
_ => throw PathImportException(
PathImportProblem.malformedTable,
step: where,
),
},
],
];
final columns = rows.firstOrNull?.length ?? 0;
if (columns == 0 || rows.any((row) => row.length != columns)) {
throw PathImportException(PathImportProblem.malformedTable, step: where);
}
final natural = [
for (var column = 0; column < columns; column++)
_columnWidth([for (final row in rows) row[column]]),
];
// A table wider than a phone's text narrows, every column alike, its
// cells wrapping their lines.
final total = natural.fold<double>(0, (sum, width) => sum + width);
final scale = min(1, _phoneWidth / total);
final widths = [
for (final width in natural)
max(_narrowest, width * scale).roundToDouble(),
];
return {
'type': 'table',
// As the editor sizes a table it makes: its rows and columns, the
// height of a row and the width a column takes at least.
'data': {
'colsLen': columns,
'rowsLen': rows.length,
'colDefaultWidth': 160.0,
'rowDefaultHeight': 40.0,
'colMinimumWidth': 40.0,
},
'children': [
for (var column = 0; column < columns; column++)
for (var row = 0; row < rows.length; row++)
{
'type': 'table/cell',
'data': {
'colPosition': column,
'rowPosition': row,
'width': widths[column],
},
'children': [
{
'type': 'paragraph',
'data': {'delta': rows[row][column]},
},
],
},
],
};
}
/// About how wide a column must be for its longest line, in logical
/// pixels, as the editor keeps it on the column's cells: a wide character
/// (Chinese, Japanese, Korean) an em of the editor's text, any other
/// about half of one, with the cell's padding; at least as wide as a short
/// word, at most as half a phone's width, wrapping longer lines.
double _columnWidth(List<List<Map<String, Object?>>> cells) {
var widest = 0.0;
for (final delta in cells) {
final text = [for (final op in delta) op['insert']]
.whereType<String>()
.join();
for (final line in text.split('\n')) {
var ems = 0.0;
for (final rune in line.runes) {
ems += rune >= 0x2e80 ? 1 : 0.6;
}
widest = max(widest, ems);
}
}
return (widest * 16 + 24).clamp(_narrowest, 200).roundToDouble();
}
/// How narrow a column gets: a short word.
static const _narrowest = 48.0;
/// How wide a table may be on a phone, beside the step's margins.
static const _phoneWidth = 312.0;
/// A callout of a kind, with a title or its kind's name, holding blocks.
Map<String, Object?> _callout(Map<String, Object?> json, int? where) {
final kind = switch (json['kind']) {
null => calloutKinds.first,
final String kind when calloutKinds.contains(kind) => kind,
_ => throw PathImportException(
PathImportProblem.malformedCallout,
step: where,
detail: 'kind',
),
};
final title = switch (json['title']) {
null => null,
final String title => title.trim(),
_ => throw PathImportException(
PathImportProblem.malformedCallout,
step: where,
detail: 'title',
),
};
final children = _all(json['content'], where, inCallout: true);
return {
'type': calloutBlockType,
'data': {
calloutKindKey: kind,
if (title != null && title.isNotEmpty) calloutTitleKey: title,
},
// An empty callout gets a line to write in, as the editor's has.
'children': children.isEmpty
? [
{
'type': 'paragraph',
'data': {'delta': <Object?>[]},
},
]
: children,
};
}
Map<String, Object?> _text(
String type,
Map<String, Object?> json,
@ -465,11 +646,7 @@ class _Blocks() {
},
{'text': final String text} => {
'insert': text,
if ({
if (run['bold'] == true) 'bold': true,
if (run['italic'] == true) 'italic': true,
if (run['link'] case final String link) 'href': link,
}
if (_attributes(run as Map<String, Object?>, where)
case final attributes when attributes.isNotEmpty)
'attributes': attributes,
},
@ -481,6 +658,34 @@ class _Blocks() {
];
}
/// The editor's marks for a run of text: its style, link, reading and
/// colour; a [PathImportException] for a malformed reading or colour.
Map<String, Object?> _attributes(Map<String, Object?> run, int? where) {
final reading = switch (run['ruby']) {
null => null,
final String reading => reading.trim(),
_ => throw PathImportException(PathImportProblem.malformedRun, step: where),
};
final color = switch (run['color']) {
null => null,
final String color when _hexColor.hasMatch(color) =>
'0xff${color.substring(1).toLowerCase()}',
_ => throw PathImportException(PathImportProblem.malformedRun, step: where),
};
return {
if (run['bold'] == true) 'bold': true,
if (run['italic'] == true) 'italic': true,
if (run['underline'] == true) 'underline': true,
if (run['strike'] == true) 'strikethrough': true,
if (run['link'] case final String link) 'href': link,
if (reading != null && reading.isNotEmpty) rubyKey: reading,
// As the editor keeps a text colour: opaque, in hexadecimal.
'font_color': ?color,
};
}
final _hexColor = RegExp(r'^#[0-9a-fA-F]{6}$');
List<Object?> _list(Object? json, int? where) => switch (json) {
final List<Object?> list => list,
null => const [],

View file

@ -38,6 +38,9 @@ extension PathImportTexts on AppLocalizations {
PathImportProblem.malformedNumber => pathImportMalformedNumber,
PathImportProblem.titleNotText => pathImportTitleNotText,
PathImportProblem.malformedSource => pathImportMalformedSource,
PathImportProblem.malformedTable => pathImportMalformedTable,
PathImportProblem.malformedCallout => pathImportMalformedCallout(detail),
PathImportProblem.notInCallout => pathImportNotInCallout(detail),
};
final sentence = switch (error.step) {
final step? => pathImportAtStep(step, problem),

View file

@ -158,12 +158,19 @@ class const StudysetScreen({
if (result.failure == null) {
context.read<ContentTabCubit>().show(StudysetTab.paths);
}
final message = switch (result.failure) {
final failure? => l10n.pathImportFailed(
final message = switch ((result.failure, result.file)) {
(final failure?, final file?) => l10n.pathImportFileFailed(
file,
l10n.pathImportProblemText(failure),
),
null => [
l10n.pathImported(result.title, result.steps),
(final failure?, null) => l10n.pathImportFailed(
l10n.pathImportProblemText(failure),
),
(null, _) => [
if (result.paths == 1)
l10n.pathImported(result.title, result.steps)
else
l10n.pathsImported(result.paths, result.steps),
if (result.skippedBlocks > 0)
l10n.pathImportSkipped(result.skippedBlocks),
].join(' '),
@ -1529,30 +1536,39 @@ enum _Import() {
cards,
}
/// Picks a file in the paths' import format and hands it to the paths'
/// bloc, which says how it went.
/// Picks files in the paths' import format, one path each, and hands them
/// to the paths' bloc in the order of their names (a chapter's lessons
/// numbered so), which says how it went.
Future<void> _importPath(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final bloc = context.read<PathListBloc>();
final messenger = ScaffoldMessenger.of(context);
final file = await DependenciesScope.of(
final picked = await DependenciesScope.of(
context,
).files.pick(label: l10n.pathImportFileType, extensions: const ['json']);
if (file == null) {
return;
).files.pickMany(label: l10n.pathImportFileType, extensions: const ['json']);
final files = <PathFile>[];
for (final file in picked..sort((a, b) => a.name.compareTo(b.name))) {
try {
files.add((name: file.name, source: utf8.decode(file.bytes)));
} on FormatException {
messenger.showSnackBar(
SnackBar(
content: Text(
picked.length == 1
? l10n.pathImportFailed(l10n.pathImportUnreadable)
: l10n.pathImportFileFailed(
file.name,
l10n.pathImportUnreadable,
),
),
),
);
return;
}
}
final String source;
try {
source = utf8.decode(file.bytes);
} on FormatException {
messenger.showSnackBar(
SnackBar(
content: Text(l10n.pathImportFailed(l10n.pathImportUnreadable)),
),
);
return;
if (files.isNotEmpty) {
bloc.add(PathListEventImportRequested(files));
}
bloc.add(PathListEventImportRequested(source));
}
/// Below expanded windows, at the bottom: Add material and Study, centred.

View file

@ -100,26 +100,79 @@ void main() {
await until((s) => s.loaded);
bloc.add(
const PathListEventImportRequested(
'{"path": {"title": "Particles", "steps": ['
'{"title": "Read", "type": "info", "content": ['
'{"type": "image", "src": "https://example.org/x.png"}]}]}}',
),
const PathListEventImportRequested([
(
name: 'particles.json',
source:
'{"path": {"title": "Particles", "steps": ['
'{"title": "Read", "type": "info", "content": ['
'{"type": "image", "src": "https://example.org/x.png"}]}]}}',
),
]),
);
await until((s) => s.importing);
final imported = await until((s) => s.importResult != null);
expect(imported.importing, isFalse);
expect(imported.importResult, (
title: 'Particles',
paths: 1,
steps: 1,
skippedBlocks: 1,
file: null,
failure: null,
));
await until((s) => s.paths.any((p) => p.content.title == 'Particles'));
bloc.add(const PathListEventImportRequested('{'));
bloc.add(
const PathListEventImportRequested([(name: 'broken.json', source: '{')]),
);
final failed = await until((s) => s.importResult?.failure != null);
expect(failed.importResult!.failure?.problem, PathImportProblem.notJson);
expect(failed.importResult!.file, isNull);
});
test('imports several paths in the order given, or none if one of the '
'files is broken, naming it', () async {
await until((s) => s.loaded);
String path(String title) =>
'{"path": {"title": "$title", "steps": ['
'{"title": "Read", "type": "info"}, {"title": "More", "type": "info"}]}}';
bloc.add(
PathListEventImportRequested([
(name: '01.json', source: path('State-of-being')),
(name: '02.json', source: '{'),
]),
);
final failed = await until((s) => s.importResult?.failure != null);
expect(failed.importResult!.file, '02.json');
expect(failed.paths, isEmpty);
bloc.add(
PathListEventImportRequested([
(name: '01.json', source: path('State-of-being')),
(name: '02.json', source: path('Particles')),
]),
);
final imported = await until((s) => s.importResult?.paths == 2);
expect(imported.importResult, (
title: 'State-of-being',
paths: 2,
steps: 4,
skippedBlocks: 0,
file: null,
failure: null,
));
final listed = await until((s) => s.paths.length == 2);
expect(
[for (final p in listed.paths) p.content.title],
[
'State-of-being',
'Particles',
],
);
// The list is read anew after each of the import's writes.
await pumpEventQueue();
});
test('creating a path hands back its id, and lists it', () async {

View file

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
@ -161,6 +163,162 @@ void main() {
expect(parsed.skippedBlocks, 2);
});
test('reads tables, callouts, spoilers, quotes, dividers, and text with '
'readings, colours, underlines and strikes', () {
final parsed = parsePathImport(r'''
{"path": {"title": "x", "steps": [{"title": "a", "type": "info", "content": [
{"type": "paragraph", "runs": [
{"text": "学生", "ruby": " がくせい "},
{"text": "だ", "color": "#E8590C"},
{"text": "い", "strike": true, "underline": true},
{"text": "\n", "ruby": " "}]},
{"type": "table", "rows": [["", {"runs": [{"text": "Positive", "bold": true}]}],
["Past", "学生だった"]]},
{"type": "callout", "kind": "info", "title": " Summary ", "content": [
{"type": "numbered_list", "items": ["Attach だ"]}]},
{"type": "callout", "content": []},
{"type": "spoiler", "text": "これだ"},
{"type": "quote", "text": "A: 元気?"},
{"type": "divider"}
]}]}}''');
final document =
parsed.steps.single.content.body.editorJson['document']!
as Map<String, Object?>;
expect(document['children'], [
paragraph([
{
'insert': '学生',
'attributes': {rubyKey: 'がくせい'},
},
{
'insert': 'だ',
'attributes': {'font_color': '0xffe8590c'},
},
{
'insert': 'い',
'attributes': {'underline': true, 'strikethrough': true},
},
// A reading of nothing but spaces is none.
{'insert': '\n'},
]),
{
'type': 'table',
'data': {
'colsLen': 2,
'rowsLen': 2,
'colDefaultWidth': 160.0,
'rowDefaultHeight': 40.0,
'colMinimumWidth': 40.0,
},
// By column, then row, as the editor keeps them.
'children': [
for (final (column, row, delta) in [
(0, 0, <Object?>[]),
(
0,
1,
[
{'insert': 'Past'},
],
),
(
1,
0,
[
{
'insert': 'Positive',
'attributes': {'bold': true},
},
],
),
(
1,
1,
[
{'insert': '学生だった'},
],
),
])
{
'type': 'table/cell',
'data': {
'colPosition': column,
'rowPosition': row,
// Each column as wide as its longest line: "Past", four
// narrow letters; "学生だった", five wide ones.
'width': [62.0, 104.0][column],
},
'children': [paragraph(delta.cast())],
},
],
},
{
'type': calloutBlockType,
'data': {calloutKindKey: 'info', calloutTitleKey: 'Summary'},
'children': [
{
'type': 'numbered_list',
'data': {
'delta': [
{'insert': 'Attach だ'},
],
},
},
],
},
// A note, with a line to write in.
{
'type': calloutBlockType,
'data': {calloutKindKey: 'note'},
'children': [paragraph([])],
},
{
'type': spoilerBlockType,
'data': {
'delta': [
{'insert': 'これだ'},
],
},
},
{
'type': 'quote',
'data': {
'delta': [
{'insert': 'A: 元気?'},
],
},
},
{'type': 'divider'},
]);
});
test('narrows a table wider than a phone, every column alike', () {
final parsed = parsePathImport(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
'"content": [{"type": "table", "rows": [["", "Positive", "", '
'"Negative", ""], ["Non-Past", "学生(だ)", "Is student", '
'"学生じゃない", "Is not student"]]}]}]}}',
);
final document =
parsed.steps.single.content.body.editorJson['document']!
as Map<String, Object?>;
final table =
(document['children']! as List<Object?>).single!
as Map<String, Object?>;
final widths = {
for (final cell
in (table['children']! as List<Object?>).cast<Map<String, Object?>>())
(cell['data']! as Map<String, Object?>)['colPosition']:
(cell['data']! as Map<String, Object?>)['width']! as double,
};
expect(widths.values.reduce((a, b) => a + b), lessThanOrEqualTo(314));
expect(widths.values, everyElement(greaterThanOrEqualTo(48)));
// The widest column stays the widest.
expect(widths[4], widths.values.reduce(max));
});
test("reads a step's sources: their parts, \"citation\" as what they are "
'part of, and text as a citation to keep as it is', () {
final parsed = parsePathImport('''
@ -233,6 +391,53 @@ void main() {
}
});
test('a table whose rows differ in length, or without rows', () {
for (final rows in ['[["a", "b"], ["c"]]', '[]', '[[{"text": "a"}]]']) {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
'"content": [{"type": "table", "rows": $rows}]}]}}',
PathImportProblem.malformedTable,
step: 1,
);
}
});
test('a callout of an unknown kind, or with a title that is no text', () {
for (final callout in ['"kind": "aside"', '"title": 3']) {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
'"content": [{"type": "callout", $callout}]}]}}',
PathImportProblem.malformedCallout,
step: 1,
);
}
});
test('a table or a callout inside a callout', () {
for (final block in [
'{"type": "table", "rows": [["a"]]}',
'{"type": "callout"}',
]) {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
'"content": [{"type": "callout", "content": [$block]}]}]}}',
PathImportProblem.notInCallout,
step: 1,
);
}
});
test('a colour that is not #rrggbb, or a reading that is no text', () {
for (final run in ['"color": "orange"', '"color": "#fff"', '"ruby": 1']) {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
'"content": [{"type": "paragraph", "runs": [{"text": "a", $run}]}]}]}}',
PathImportProblem.malformedRun,
step: 1,
);
}
});
test('blanks that do not fit the gaps', () {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", '
@ -280,17 +485,8 @@ void main() {
final stored = await paths.stepsOf(imported.pathId);
expect(
[for (final step in stored) step.content.title],
[
'Read',
'One',
'All',
'Sort',
'Fill',
],
);
expect(
(await paths.pathsOf('japanese')).single.content.title,
'Particles',
['Read', 'One', 'All', 'Sort', 'Fill'],
);
expect((await paths.pathsOf('japanese')).single.content.title, 'Particles');
});
}

View file

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
@ -240,7 +241,7 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Import content'));
await tester.pumpAndSettle();
await tester.tap(find.text('Learning path'));
await tester.tap(find.text('Learning paths'));
await tester.pumpAndSettle();
expect(files.asked.single, ['json']);
@ -254,6 +255,40 @@ void main() {
expect(find.text('Particles'), findsOneWidget);
});
testWidgets("imports several paths at once, in the order of their files' "
'names', (tester) async {
Uint8List path(String title) => utf8.encode(
'{"path": {"title": "$title", "steps": ['
'{"title": "Read", "type": "info"}]}}',
);
files.nextMany = [
(name: '02-particles.json', bytes: path('Particles')),
(name: '01-state-of-being.json', bytes: path('State-of-being')),
];
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(
find.descendant(
of: find.byType(AppBar),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Import content'));
await tester.pumpAndSettle();
await tester.tap(find.text('Learning paths'));
await tester.pumpAndSettle();
expect(
find.textContaining('Imported 2 paths with 2 steps.'),
findsOneWidget,
);
expect(
tester.getTopLeft(find.text('State-of-being')).dy,
lessThan(tester.getTopLeft(find.text('Particles')).dy),
);
});
testWidgets('a note written from Add material shows in the Notes tab on '
'coming back, as flashcards do in theirs', (tester) async {
final paths = repository();

View file

@ -428,7 +428,7 @@ void main() {
await choose(tester, 'Import content');
expect(find.text('Anki deck'), findsOneWidget);
expect(find.text('Learning path'), findsOneWidget);
expect(find.text('Learning paths'), findsOneWidget);
});
testWidgets("a studyset's More info shows its id and its parent's, to "

View file

@ -6,6 +6,9 @@ class FakeFilePicker() implements FilePicker {
/// What the next pick gives; null as if the person cancelled.
PickedFile? next;
/// What the next pick of several gives; [next] alone if null.
List<PickedFile>? nextMany;
/// The extensions of each pick asked for, in order.
final asked = <List<String>>[];
@ -17,4 +20,13 @@ class FakeFilePicker() implements FilePicker {
asked.add(extensions);
return next;
}
@override
Future<List<PickedFile>> pickMany({
required String label,
required List<String> extensions,
}) async {
asked.add(extensions);
return nextMany ?? [?next];
}
}