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:
parent
277994d31b
commit
7d99feca7f
15 changed files with 781 additions and 112 deletions
|
|
@ -96,6 +96,8 @@
|
||||||
"pathImported": "{count, plural, =1{„{title}“ mit 1 Schritt importiert.} other{„{title}“ mit {count} Schritten importiert.}}",
|
"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.}}",
|
"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}",
|
"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.",
|
"pathImportUnreadable": "Der Inhalt ist kein Text in UTF-8.",
|
||||||
"pathsEmpty": "Noch keine Pfade",
|
"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.",
|
"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",
|
"exportVideo": "YouTube-Video",
|
||||||
"exportSpoiler": "Spoiler",
|
"exportSpoiler": "Spoiler",
|
||||||
"exportUnavailable": "nicht auf diesem Gerät verfügbar",
|
"exportUnavailable": "nicht auf diesem Gerät verfügbar",
|
||||||
"studysetImportPath": "Lernpfad",
|
"studysetImportPath": "Lernpfade",
|
||||||
"studysetImportPathHint": "Aus einer Halkyon-Pfad-JSON-Datei",
|
"studysetImportPathHint": "Aus Halkyon-Pfad-JSON-Dateien, ein Pfad je Datei",
|
||||||
"studysetImportAnki": "Anki-Deck",
|
"studysetImportAnki": "Anki-Deck",
|
||||||
"studysetImportAnkiHint": "Aus einer .apkg- oder .colpkg-Datei",
|
"studysetImportAnkiHint": "Aus einer .apkg- oder .colpkg-Datei",
|
||||||
"studysetImportCards": "Karteikarten",
|
"studysetImportCards": "Karteikarten",
|
||||||
|
|
@ -1329,6 +1331,9 @@
|
||||||
"pathImportMalformedNumber": "eine fehlerhafte Zahl.",
|
"pathImportMalformedNumber": "eine fehlerhafte Zahl.",
|
||||||
"pathImportTitleNotText": "ein Titel muss Text sein.",
|
"pathImportTitleNotText": "ein Titel muss Text sein.",
|
||||||
"pathImportMalformedSource": "eine Quelle muss ein Objekt oder Text sein und ihre Teile Text.",
|
"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.",
|
"pomodoroTopBarHint": "Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.",
|
||||||
"pomodoroTopBarSetUp": "Einrichten",
|
"pomodoroTopBarSetUp": "Einrichten",
|
||||||
"pomodoroTopBarDismiss": "Nicht mehr vorschlagen",
|
"pomodoroTopBarDismiss": "Nicht mehr vorschlagen",
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,10 @@
|
||||||
"@pathImportSkipped": {"placeholders": {"count": {"type": "int"}}},
|
"@pathImportSkipped": {"placeholders": {"count": {"type": "int"}}},
|
||||||
"pathImportFailed": "Couldn't import this file. {reason}",
|
"pathImportFailed": "Couldn't import this file. {reason}",
|
||||||
"@pathImportFailed": {"placeholders": {"reason": {"type": "String"}}},
|
"@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.",
|
"pathImportUnreadable": "It isn't text in UTF-8.",
|
||||||
"pathsEmpty": "No paths yet",
|
"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.",
|
"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",
|
"exportVideo": "YouTube video",
|
||||||
"exportSpoiler": "Spoiler",
|
"exportSpoiler": "Spoiler",
|
||||||
"exportUnavailable": "not available on this device",
|
"exportUnavailable": "not available on this device",
|
||||||
"studysetImportPath": "Learning path",
|
"studysetImportPath": "Learning paths",
|
||||||
"studysetImportPathHint": "From a Halkyon path JSON file",
|
"studysetImportPathHint": "From Halkyon path JSON files, one path each",
|
||||||
"studysetImportAnki": "Anki deck",
|
"studysetImportAnki": "Anki deck",
|
||||||
"studysetImportAnkiHint": "From an .apkg or .colpkg file",
|
"studysetImportAnkiHint": "From an .apkg or .colpkg file",
|
||||||
"studysetImportCards": "Flashcards",
|
"studysetImportCards": "Flashcards",
|
||||||
|
|
@ -1553,6 +1557,11 @@
|
||||||
"pathImportMalformedNumber": "a malformed number.",
|
"pathImportMalformedNumber": "a malformed number.",
|
||||||
"pathImportTitleNotText": "a title must be text.",
|
"pathImportTitleNotText": "a title must be text.",
|
||||||
"pathImportMalformedSource": "a source must be an object or text, and its parts 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.",
|
"pomodoroTopBarHint": "The timer can count down in GNOME's top bar, with its buttons, once the AppIndicator extension is on.",
|
||||||
"pomodoroTopBarSetUp": "Set up",
|
"pomodoroTopBarSetUp": "Set up",
|
||||||
"pomodoroTopBarDismiss": "Don't suggest again",
|
"pomodoroTopBarDismiss": "Don't suggest again",
|
||||||
|
|
|
||||||
|
|
@ -634,6 +634,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Couldn\'t import this file. {reason}'**
|
/// **'Couldn\'t import this file. {reason}'**
|
||||||
String pathImportFailed(String 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.
|
/// No description provided for @pathImportUnreadable.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -1723,13 +1735,13 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @studysetImportPath.
|
/// No description provided for @studysetImportPath.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Learning path'**
|
/// **'Learning paths'**
|
||||||
String get studysetImportPath;
|
String get studysetImportPath;
|
||||||
|
|
||||||
/// No description provided for @studysetImportPathHint.
|
/// No description provided for @studysetImportPathHint.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'From a Halkyon path JSON file'**
|
/// **'From Halkyon path JSON files, one path each'**
|
||||||
String get studysetImportPathHint;
|
String get studysetImportPathHint;
|
||||||
|
|
||||||
/// No description provided for @studysetImportAnki.
|
/// No description provided for @studysetImportAnki.
|
||||||
|
|
@ -7869,6 +7881,24 @@ abstract class AppLocalizations {
|
||||||
/// **'a source must be an object or text, and its parts text.'**
|
/// **'a source must be an object or text, and its parts text.'**
|
||||||
String get pathImportMalformedSource;
|
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.
|
/// No description provided for @pomodoroTopBarHint.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,23 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return 'Diese Datei konnte nicht importiert werden. $reason';
|
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
|
@override
|
||||||
String get pathImportUnreadable => 'Der Inhalt ist kein Text in UTF-8.';
|
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';
|
String get exportUnavailable => 'nicht auf diesem Gerät verfügbar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetImportPath => 'Lernpfad';
|
String get studysetImportPath => 'Lernpfade';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetImportPathHint => 'Aus einer Halkyon-Pfad-JSON-Datei';
|
String get studysetImportPathHint =>
|
||||||
|
'Aus Halkyon-Pfad-JSON-Dateien, ein Pfad je Datei';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetImportAnki => 'Anki-Deck';
|
String get studysetImportAnki => 'Anki-Deck';
|
||||||
|
|
@ -4895,6 +4913,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get pathImportMalformedSource =>
|
String get pathImportMalformedSource =>
|
||||||
'eine Quelle muss ein Objekt oder Text sein und ihre Teile Text.';
|
'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
|
@override
|
||||||
String get pomodoroTopBarHint =>
|
String get pomodoroTopBarHint =>
|
||||||
'Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.';
|
'Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.';
|
||||||
|
|
|
||||||
|
|
@ -387,6 +387,23 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return 'Couldn\'t import this file. $reason';
|
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
|
@override
|
||||||
String get pathImportUnreadable => 'It isn\'t text in UTF-8.';
|
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';
|
String get exportUnavailable => 'not available on this device';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetImportPath => 'Learning path';
|
String get studysetImportPath => 'Learning paths';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetImportPathHint => 'From a Halkyon path JSON file';
|
String get studysetImportPathHint =>
|
||||||
|
'From Halkyon path JSON files, one path each';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetImportAnki => 'Anki deck';
|
String get studysetImportAnki => 'Anki deck';
|
||||||
|
|
@ -4851,6 +4869,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get pathImportMalformedSource =>
|
String get pathImportMalformedSource =>
|
||||||
'a source must be an object or text, and its parts text.';
|
'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
|
@override
|
||||||
String get pomodoroTopBarHint =>
|
String get pomodoroTopBarHint =>
|
||||||
'The timer can count down in GNOME\'s top bar, with its buttons, once the AppIndicator extension is on.';
|
'The timer can count down in GNOME\'s top bar, with its buttons, once the AppIndicator extension is on.';
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ abstract interface class FilePicker() {
|
||||||
required String label,
|
required String label,
|
||||||
required List<String> extensions,
|
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`).
|
/// [FilePicker] with the platform's own dialog (`file_selector`).
|
||||||
|
|
@ -24,19 +31,32 @@ class FileSelectorFilePicker() implements FilePicker {
|
||||||
required List<String> extensions,
|
required List<String> extensions,
|
||||||
}) async {
|
}) async {
|
||||||
final file = await openFile(
|
final file = await openFile(
|
||||||
acceptedTypeGroups: [
|
acceptedTypeGroups: _typeGroups(label, 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'],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
return file == null
|
return file == null
|
||||||
? null
|
? null
|
||||||
: (name: file.name, bytes: await file.readAsBytes());
|
: (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'],
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,35 +31,50 @@ class PathListBloc(
|
||||||
});
|
});
|
||||||
on<PathListEventImportRequested>((event, emit) async {
|
on<PathListEventImportRequested>((event, emit) async {
|
||||||
emit(state.copyWith(importing: true));
|
emit(state.copyWith(importing: true));
|
||||||
try {
|
// Every file is read before anything is stored, so one that can't be
|
||||||
final parsed = parsePathImport(event.source);
|
// read leaves the studyset as it was.
|
||||||
final imported = await PathImporter(
|
final parsed = <ParsedPath>[];
|
||||||
_paths,
|
for (final file in event.files) {
|
||||||
).import(_studysetId, event.source);
|
try {
|
||||||
emit(
|
parsed.add(parsePathImport(file.source));
|
||||||
state.copyWith(
|
} on PathImportException catch (error) {
|
||||||
importing: false,
|
emit(
|
||||||
importResult: (
|
state.copyWith(
|
||||||
title: parsed.content.title,
|
importing: false,
|
||||||
steps: imported.steps,
|
importResult: (
|
||||||
skippedBlocks: imported.skippedBlocks,
|
title: '',
|
||||||
failure: null,
|
paths: 0,
|
||||||
|
steps: 0,
|
||||||
|
skippedBlocks: 0,
|
||||||
|
file: event.files.length > 1 ? file.name : null,
|
||||||
|
failure: error,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
return;
|
||||||
} on PathImportException catch (error) {
|
}
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
importing: false,
|
|
||||||
importResult: (
|
|
||||||
title: '',
|
|
||||||
steps: 0,
|
|
||||||
skippedBlocks: 0,
|
|
||||||
failure: error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
on<PathListEventMoveRequested>((event, emit) {
|
||||||
// The list takes the new order at once; the stored one follows.
|
// 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
|
/// How an import went: the first path's title, how many paths and steps
|
||||||
/// many blocks were left out; or, with a `failure`, why nothing came in.
|
/// 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 = ({
|
typedef ImportResult = ({
|
||||||
String title,
|
String title,
|
||||||
|
int paths,
|
||||||
int steps,
|
int steps,
|
||||||
int skippedBlocks,
|
int skippedBlocks,
|
||||||
|
String? file,
|
||||||
PathImportException? failure,
|
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.
|
/// A studyset's paths, as the list shows them.
|
||||||
final class const PathListState({
|
final class const PathListState({
|
||||||
/// Whether the paths were read.
|
/// Whether the paths were read.
|
||||||
|
|
@ -283,13 +304,13 @@ final class const PathListEventCreateRequested(
|
||||||
List<Object?> get props => [content];
|
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(
|
final class const PathListEventImportRequested(
|
||||||
/// The file's text.
|
/// The files.
|
||||||
final String source,
|
final List<PathFile> files,
|
||||||
) extends PathListEvent {
|
) extends PathListEvent {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [source];
|
List<Object?> get props => [files];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move the path [id] to [toIndex].
|
/// Move the path [id] to [toIndex].
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.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
|
// writes them ("citation" counts as "container", as files of before name
|
||||||
// it), or text, kept as the citation it is.
|
// it), or text, kept as the citation it is.
|
||||||
//
|
//
|
||||||
// Blocks: {"type": "paragraph" | "heading" (with "level"), "text": "…"} or
|
// Blocks: {"type": "paragraph" | "heading" (with "level") | "quote" |
|
||||||
// with "runs": [{"text", "bold", "italic", "link", "math"}]; "bulleted_list"
|
// "spoiler", "text": "…"} or with "runs": [{"text", "bold", "italic",
|
||||||
// and "numbered_list" with "items" (text or {"runs"}); "math" ("formula")
|
// "underline", "strike", "link", "ruby" (the text's furigana), "color"
|
||||||
// as a formula on its own line, and a run's "math" as one within the line;
|
// ("#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.
|
// "code" ("code") as monospaced text, the app having no code blocks.
|
||||||
// "image" and "lottie" are left out, keeping an "alt" caption: their files
|
// "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.
|
// would have to be fetched from the web, which the app never does.
|
||||||
|
|
@ -69,6 +76,9 @@ enum PathImportProblem() {
|
||||||
malformedNumber,
|
malformedNumber,
|
||||||
titleNotText,
|
titleNotText,
|
||||||
malformedSource,
|
malformedSource,
|
||||||
|
malformedTable,
|
||||||
|
malformedCallout,
|
||||||
|
notInCallout,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file in the import format that couldn't be read: the [problem], the
|
/// 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
|
/// Reads [source] and stores its path at the end of [studysetId], with its
|
||||||
/// steps; a [PathImportException] for a file it can't read, before
|
/// steps; a [PathImportException] for a file it can't read, before
|
||||||
/// anything is stored.
|
/// anything is stored.
|
||||||
Future<ImportedPath> import(String studysetId, String source) async {
|
Future<ImportedPath> import(String studysetId, String source) =>
|
||||||
final parsed = parsePathImport(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);
|
final pathId = await _paths.createPath(studysetId, parsed.content);
|
||||||
for (final (:kind, :content) in parsed.steps) {
|
for (final (:kind, :content) in parsed.steps) {
|
||||||
await _paths.createStep(pathId, kind, content);
|
await _paths.createStep(pathId, kind, content);
|
||||||
|
|
@ -138,10 +152,7 @@ ParsedPath parsePathImport(String source) {
|
||||||
};
|
};
|
||||||
final blocks = _Blocks();
|
final blocks = _Blocks();
|
||||||
final steps = [
|
final steps = [
|
||||||
for (final (index, step) in _list(
|
for (final (index, step) in _list(path['steps'], null).indexed)
|
||||||
path['steps'],
|
|
||||||
null,
|
|
||||||
).indexed)
|
|
||||||
_step(step, index + 1, blocks),
|
_step(step, index + 1, blocks),
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
|
|
@ -312,9 +323,7 @@ class _Blocks() {
|
||||||
if (json == null) {
|
if (json == null) {
|
||||||
return RichContent.empty();
|
return RichContent.empty();
|
||||||
}
|
}
|
||||||
final nodes = [
|
final nodes = _all(json, where, inCallout: false);
|
||||||
for (final block in _list(json, where)) ...?_nodes(block, where),
|
|
||||||
];
|
|
||||||
return nodes.isEmpty
|
return nodes.isEmpty
|
||||||
? RichContent.empty()
|
? RichContent.empty()
|
||||||
: RichContent({
|
: 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.
|
/// 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) {
|
final type = switch (block) {
|
||||||
{'type': final String type} => type,
|
{'type': final String type} => type,
|
||||||
_ => throw PathImportException(
|
_ => 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':
|
case 'bulleted_list' || 'numbered_list':
|
||||||
return [
|
return [
|
||||||
for (final item in _list(json['items'], where))
|
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(
|
Map<String, Object?> _text(
|
||||||
String type,
|
String type,
|
||||||
Map<String, Object?> json,
|
Map<String, Object?> json,
|
||||||
|
|
@ -465,11 +646,7 @@ class _Blocks() {
|
||||||
},
|
},
|
||||||
{'text': final String text} => {
|
{'text': final String text} => {
|
||||||
'insert': text,
|
'insert': text,
|
||||||
if ({
|
if (_attributes(run as Map<String, Object?>, where)
|
||||||
if (run['bold'] == true) 'bold': true,
|
|
||||||
if (run['italic'] == true) 'italic': true,
|
|
||||||
if (run['link'] case final String link) 'href': link,
|
|
||||||
}
|
|
||||||
case final attributes when attributes.isNotEmpty)
|
case final attributes when attributes.isNotEmpty)
|
||||||
'attributes': attributes,
|
'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) {
|
List<Object?> _list(Object? json, int? where) => switch (json) {
|
||||||
final List<Object?> list => list,
|
final List<Object?> list => list,
|
||||||
null => const [],
|
null => const [],
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ extension PathImportTexts on AppLocalizations {
|
||||||
PathImportProblem.malformedNumber => pathImportMalformedNumber,
|
PathImportProblem.malformedNumber => pathImportMalformedNumber,
|
||||||
PathImportProblem.titleNotText => pathImportTitleNotText,
|
PathImportProblem.titleNotText => pathImportTitleNotText,
|
||||||
PathImportProblem.malformedSource => pathImportMalformedSource,
|
PathImportProblem.malformedSource => pathImportMalformedSource,
|
||||||
|
PathImportProblem.malformedTable => pathImportMalformedTable,
|
||||||
|
PathImportProblem.malformedCallout => pathImportMalformedCallout(detail),
|
||||||
|
PathImportProblem.notInCallout => pathImportNotInCallout(detail),
|
||||||
};
|
};
|
||||||
final sentence = switch (error.step) {
|
final sentence = switch (error.step) {
|
||||||
final step? => pathImportAtStep(step, problem),
|
final step? => pathImportAtStep(step, problem),
|
||||||
|
|
|
||||||
|
|
@ -158,12 +158,19 @@ class const StudysetScreen({
|
||||||
if (result.failure == null) {
|
if (result.failure == null) {
|
||||||
context.read<ContentTabCubit>().show(StudysetTab.paths);
|
context.read<ContentTabCubit>().show(StudysetTab.paths);
|
||||||
}
|
}
|
||||||
final message = switch (result.failure) {
|
final message = switch ((result.failure, result.file)) {
|
||||||
final failure? => l10n.pathImportFailed(
|
(final failure?, final file?) => l10n.pathImportFileFailed(
|
||||||
|
file,
|
||||||
l10n.pathImportProblemText(failure),
|
l10n.pathImportProblemText(failure),
|
||||||
),
|
),
|
||||||
null => [
|
(final failure?, null) => l10n.pathImportFailed(
|
||||||
l10n.pathImported(result.title, result.steps),
|
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)
|
if (result.skippedBlocks > 0)
|
||||||
l10n.pathImportSkipped(result.skippedBlocks),
|
l10n.pathImportSkipped(result.skippedBlocks),
|
||||||
].join(' '),
|
].join(' '),
|
||||||
|
|
@ -1529,30 +1536,39 @@ enum _Import() {
|
||||||
cards,
|
cards,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picks a file in the paths' import format and hands it to the paths'
|
/// Picks files in the paths' import format, one path each, and hands them
|
||||||
/// bloc, which says how it went.
|
/// 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 {
|
Future<void> _importPath(BuildContext context) async {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final bloc = context.read<PathListBloc>();
|
final bloc = context.read<PathListBloc>();
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final file = await DependenciesScope.of(
|
final picked = await DependenciesScope.of(
|
||||||
context,
|
context,
|
||||||
).files.pick(label: l10n.pathImportFileType, extensions: const ['json']);
|
).files.pickMany(label: l10n.pathImportFileType, extensions: const ['json']);
|
||||||
if (file == null) {
|
final files = <PathFile>[];
|
||||||
return;
|
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;
|
if (files.isNotEmpty) {
|
||||||
try {
|
bloc.add(PathListEventImportRequested(files));
|
||||||
source = utf8.decode(file.bytes);
|
|
||||||
} on FormatException {
|
|
||||||
messenger.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(l10n.pathImportFailed(l10n.pathImportUnreadable)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
bloc.add(PathListEventImportRequested(source));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Below expanded windows, at the bottom: Add material and Study, centred.
|
/// Below expanded windows, at the bottom: Add material and Study, centred.
|
||||||
|
|
|
||||||
|
|
@ -100,26 +100,79 @@ void main() {
|
||||||
await until((s) => s.loaded);
|
await until((s) => s.loaded);
|
||||||
|
|
||||||
bloc.add(
|
bloc.add(
|
||||||
const PathListEventImportRequested(
|
const PathListEventImportRequested([
|
||||||
'{"path": {"title": "Particles", "steps": ['
|
(
|
||||||
'{"title": "Read", "type": "info", "content": ['
|
name: 'particles.json',
|
||||||
'{"type": "image", "src": "https://example.org/x.png"}]}]}}',
|
source:
|
||||||
),
|
'{"path": {"title": "Particles", "steps": ['
|
||||||
|
'{"title": "Read", "type": "info", "content": ['
|
||||||
|
'{"type": "image", "src": "https://example.org/x.png"}]}]}}',
|
||||||
|
),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
await until((s) => s.importing);
|
await until((s) => s.importing);
|
||||||
final imported = await until((s) => s.importResult != null);
|
final imported = await until((s) => s.importResult != null);
|
||||||
expect(imported.importing, isFalse);
|
expect(imported.importing, isFalse);
|
||||||
expect(imported.importResult, (
|
expect(imported.importResult, (
|
||||||
title: 'Particles',
|
title: 'Particles',
|
||||||
|
paths: 1,
|
||||||
steps: 1,
|
steps: 1,
|
||||||
skippedBlocks: 1,
|
skippedBlocks: 1,
|
||||||
|
file: null,
|
||||||
failure: null,
|
failure: null,
|
||||||
));
|
));
|
||||||
await until((s) => s.paths.any((p) => p.content.title == 'Particles'));
|
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);
|
final failed = await until((s) => s.importResult?.failure != null);
|
||||||
expect(failed.importResult!.failure?.problem, PathImportProblem.notJson);
|
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 {
|
test('creating a path hands back its id, and lists it', () async {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||||
|
|
@ -161,6 +163,162 @@ void main() {
|
||||||
expect(parsed.skippedBlocks, 2);
|
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 "
|
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', () {
|
'part of, and text as a citation to keep as it is', () {
|
||||||
final parsed = parsePathImport('''
|
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', () {
|
test('blanks that do not fit the gaps', () {
|
||||||
refuses(
|
refuses(
|
||||||
'{"path": {"title": "x", "steps": [{"title": "a", '
|
'{"path": {"title": "x", "steps": [{"title": "a", '
|
||||||
|
|
@ -280,17 +485,8 @@ void main() {
|
||||||
final stored = await paths.stepsOf(imported.pathId);
|
final stored = await paths.stepsOf(imported.pathId);
|
||||||
expect(
|
expect(
|
||||||
[for (final step in stored) step.content.title],
|
[for (final step in stored) step.content.title],
|
||||||
[
|
['Read', 'One', 'All', 'Sort', 'Fill'],
|
||||||
'Read',
|
|
||||||
'One',
|
|
||||||
'All',
|
|
||||||
'Sort',
|
|
||||||
'Fill',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
(await paths.pathsOf('japanese')).single.content.title,
|
|
||||||
'Particles',
|
|
||||||
);
|
);
|
||||||
|
expect((await paths.pathsOf('japanese')).single.content.title, 'Particles');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
||||||
|
|
@ -240,7 +241,7 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Import content'));
|
await tester.tap(find.text('Import content'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Learning path'));
|
await tester.tap(find.text('Learning paths'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(files.asked.single, ['json']);
|
expect(files.asked.single, ['json']);
|
||||||
|
|
@ -254,6 +255,40 @@ void main() {
|
||||||
expect(find.text('Particles'), findsOneWidget);
|
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 '
|
testWidgets('a note written from Add material shows in the Notes tab on '
|
||||||
'coming back, as flashcards do in theirs', (tester) async {
|
'coming back, as flashcards do in theirs', (tester) async {
|
||||||
final paths = repository();
|
final paths = repository();
|
||||||
|
|
|
||||||
|
|
@ -428,7 +428,7 @@ void main() {
|
||||||
|
|
||||||
await choose(tester, 'Import content');
|
await choose(tester, 'Import content');
|
||||||
expect(find.text('Anki deck'), findsOneWidget);
|
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 "
|
testWidgets("a studyset's More info shows its id and its parent's, to "
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ class FakeFilePicker() implements FilePicker {
|
||||||
/// What the next pick gives; null as if the person cancelled.
|
/// What the next pick gives; null as if the person cancelled.
|
||||||
PickedFile? next;
|
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.
|
/// The extensions of each pick asked for, in order.
|
||||||
final asked = <List<String>>[];
|
final asked = <List<String>>[];
|
||||||
|
|
||||||
|
|
@ -17,4 +20,13 @@ class FakeFilePicker() implements FilePicker {
|
||||||
asked.add(extensions);
|
asked.add(extensions);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PickedFile>> pickMany({
|
||||||
|
required String label,
|
||||||
|
required List<String> extensions,
|
||||||
|
}) async {
|
||||||
|
asked.add(extensions);
|
||||||
|
return nextMany ?? [?next];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue