feat(app): export a studyset as Markdown
Export content, in the studyset's options, writes the studyset and everything inside it as one readable Markdown document (decided 2026-10-08: no re-importable archive before the public tier's release files): its title, secondary title, description and licence, then its flashcards (answers below their questions, choices as task lists), notes, learning paths (steps with their kinds, answers, feedback and sources), PDFs and bibliography, then each sub-studyset a level deeper. Rich text keeps its formatting; formulas go between dollars, callouts become quotes, spoilers HTML details, YouTube videos links. With files (images, sounds, PDFs) it keeps a .zip of the document and them under files/; a file this device can't have is named and counted. Saving goes through a FileSaver: file_selector's dialog on the desktop, file_picker's (Android's Storage Access Framework) on phones, a direct dependency now at the version the editor already brings. 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
a26150a314
commit
882b669c3e
18 changed files with 1767 additions and 3 deletions
|
|
@ -240,6 +240,21 @@
|
|||
"studysetSpacedRepetition": "Verteilte Wiederholung",
|
||||
"studysetSpacedRepetitionHint": "Wiederhole die fälligen Karten, geplant durch FSRS.",
|
||||
"studysetImportContent": "Inhalt importieren",
|
||||
"studysetMenuExport": "Inhalt exportieren",
|
||||
"exportRunning": "Wird exportiert …",
|
||||
"exportedMarkdown": "Als Markdown exportiert.",
|
||||
"exportedZip": "{count, plural, =1{Als Markdown mit 1 Datei exportiert (.zip).} other{Als Markdown mit {count} Dateien exportiert (.zip).}}",
|
||||
"exportMissingFiles": "{count, plural, =1{1 Datei war nicht auf diesem Gerät und fehlt.} other{{count} Dateien waren nicht auf diesem Gerät und fehlen.}}",
|
||||
"exportFailed": "Das Lernset konnte nicht exportiert werden.",
|
||||
"exportQuestion": "F",
|
||||
"exportAnswer": "A",
|
||||
"exportHint": "Hinweis",
|
||||
"exportIfRight": "Wenn richtig",
|
||||
"exportIfWrong": "Wenn falsch",
|
||||
"exportSound": "Ton",
|
||||
"exportVideo": "YouTube-Video",
|
||||
"exportSpoiler": "Spoiler",
|
||||
"exportUnavailable": "nicht auf diesem Gerät verfügbar",
|
||||
"studysetImportPath": "Lernpfad",
|
||||
"studysetImportPathHint": "Aus einer Halkyon-Pfad-JSON-Datei",
|
||||
"studysetImportAnki": "Anki-Deck",
|
||||
|
|
|
|||
|
|
@ -266,6 +266,23 @@
|
|||
"studysetSpacedRepetition": "Spaced repetition",
|
||||
"studysetSpacedRepetitionHint": "Review the cards that are due, scheduled by FSRS.",
|
||||
"studysetImportContent": "Import content",
|
||||
"studysetMenuExport": "Export content",
|
||||
"exportRunning": "Exporting…",
|
||||
"exportedMarkdown": "Exported as Markdown.",
|
||||
"exportedZip": "{count, plural, =1{Exported as Markdown with 1 file (.zip).} other{Exported as Markdown with {count} files (.zip).}}",
|
||||
"@exportedZip": {"placeholders": {"count": {"type": "int"}}},
|
||||
"exportMissingFiles": "{count, plural, =1{1 file wasn't on this device and is left out.} other{{count} files weren't on this device and are left out.}}",
|
||||
"@exportMissingFiles": {"placeholders": {"count": {"type": "int"}}},
|
||||
"exportFailed": "Couldn't export the studyset.",
|
||||
"exportQuestion": "Q",
|
||||
"exportAnswer": "A",
|
||||
"exportHint": "Hint",
|
||||
"exportIfRight": "If right",
|
||||
"exportIfWrong": "If wrong",
|
||||
"exportSound": "Sound",
|
||||
"exportVideo": "YouTube video",
|
||||
"exportSpoiler": "Spoiler",
|
||||
"exportUnavailable": "not available on this device",
|
||||
"studysetImportPath": "Learning path",
|
||||
"studysetImportPathHint": "From a Halkyon path JSON file",
|
||||
"studysetImportAnki": "Anki deck",
|
||||
|
|
|
|||
|
|
@ -1498,6 +1498,96 @@ abstract class AppLocalizations {
|
|||
/// **'Import content'**
|
||||
String get studysetImportContent;
|
||||
|
||||
/// No description provided for @studysetMenuExport.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export content'**
|
||||
String get studysetMenuExport;
|
||||
|
||||
/// No description provided for @exportRunning.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Exporting…'**
|
||||
String get exportRunning;
|
||||
|
||||
/// No description provided for @exportedMarkdown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Exported as Markdown.'**
|
||||
String get exportedMarkdown;
|
||||
|
||||
/// No description provided for @exportedZip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Exported as Markdown with 1 file (.zip).} other{Exported as Markdown with {count} files (.zip).}}'**
|
||||
String exportedZip(int count);
|
||||
|
||||
/// No description provided for @exportMissingFiles.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 file wasn\'t on this device and is left out.} other{{count} files weren\'t on this device and are left out.}}'**
|
||||
String exportMissingFiles(int count);
|
||||
|
||||
/// No description provided for @exportFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t export the studyset.'**
|
||||
String get exportFailed;
|
||||
|
||||
/// No description provided for @exportQuestion.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Q'**
|
||||
String get exportQuestion;
|
||||
|
||||
/// No description provided for @exportAnswer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A'**
|
||||
String get exportAnswer;
|
||||
|
||||
/// No description provided for @exportHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hint'**
|
||||
String get exportHint;
|
||||
|
||||
/// No description provided for @exportIfRight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'If right'**
|
||||
String get exportIfRight;
|
||||
|
||||
/// No description provided for @exportIfWrong.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'If wrong'**
|
||||
String get exportIfWrong;
|
||||
|
||||
/// No description provided for @exportSound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sound'**
|
||||
String get exportSound;
|
||||
|
||||
/// No description provided for @exportVideo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'YouTube video'**
|
||||
String get exportVideo;
|
||||
|
||||
/// No description provided for @exportSpoiler.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Spoiler'**
|
||||
String get exportSpoiler;
|
||||
|
||||
/// No description provided for @exportUnavailable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'not available on this device'**
|
||||
String get exportUnavailable;
|
||||
|
||||
/// No description provided for @studysetImportPath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -885,6 +885,67 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get studysetImportContent => 'Inhalt importieren';
|
||||
|
||||
@override
|
||||
String get studysetMenuExport => 'Inhalt exportieren';
|
||||
|
||||
@override
|
||||
String get exportRunning => 'Wird exportiert …';
|
||||
|
||||
@override
|
||||
String get exportedMarkdown => 'Als Markdown exportiert.';
|
||||
|
||||
@override
|
||||
String exportedZip(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Als Markdown mit $count Dateien exportiert (.zip).',
|
||||
one: 'Als Markdown mit 1 Datei exportiert (.zip).',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String exportMissingFiles(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Dateien waren nicht auf diesem Gerät und fehlen.',
|
||||
one: '1 Datei war nicht auf diesem Gerät und fehlt.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get exportFailed => 'Das Lernset konnte nicht exportiert werden.';
|
||||
|
||||
@override
|
||||
String get exportQuestion => 'F';
|
||||
|
||||
@override
|
||||
String get exportAnswer => 'A';
|
||||
|
||||
@override
|
||||
String get exportHint => 'Hinweis';
|
||||
|
||||
@override
|
||||
String get exportIfRight => 'Wenn richtig';
|
||||
|
||||
@override
|
||||
String get exportIfWrong => 'Wenn falsch';
|
||||
|
||||
@override
|
||||
String get exportSound => 'Ton';
|
||||
|
||||
@override
|
||||
String get exportVideo => 'YouTube-Video';
|
||||
|
||||
@override
|
||||
String get exportSpoiler => 'Spoiler';
|
||||
|
||||
@override
|
||||
String get exportUnavailable => 'nicht auf diesem Gerät verfügbar';
|
||||
|
||||
@override
|
||||
String get studysetImportPath => 'Lernpfad';
|
||||
|
||||
|
|
|
|||
|
|
@ -879,6 +879,67 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studysetImportContent => 'Import content';
|
||||
|
||||
@override
|
||||
String get studysetMenuExport => 'Export content';
|
||||
|
||||
@override
|
||||
String get exportRunning => 'Exporting…';
|
||||
|
||||
@override
|
||||
String get exportedMarkdown => 'Exported as Markdown.';
|
||||
|
||||
@override
|
||||
String exportedZip(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Exported as Markdown with $count files (.zip).',
|
||||
one: 'Exported as Markdown with 1 file (.zip).',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String exportMissingFiles(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count files weren\'t on this device and are left out.',
|
||||
one: '1 file wasn\'t on this device and is left out.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get exportFailed => 'Couldn\'t export the studyset.';
|
||||
|
||||
@override
|
||||
String get exportQuestion => 'Q';
|
||||
|
||||
@override
|
||||
String get exportAnswer => 'A';
|
||||
|
||||
@override
|
||||
String get exportHint => 'Hint';
|
||||
|
||||
@override
|
||||
String get exportIfRight => 'If right';
|
||||
|
||||
@override
|
||||
String get exportIfWrong => 'If wrong';
|
||||
|
||||
@override
|
||||
String get exportSound => 'Sound';
|
||||
|
||||
@override
|
||||
String get exportVideo => 'YouTube video';
|
||||
|
||||
@override
|
||||
String get exportSpoiler => 'Spoiler';
|
||||
|
||||
@override
|
||||
String get exportUnavailable => 'not available on this device';
|
||||
|
||||
@override
|
||||
String get studysetImportPath => 'Learning path';
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_folders.dart'
|
|||
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/files/file_saver.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
|
||||
|
|
@ -89,6 +90,9 @@ class Dependencies({
|
|||
/// Lets the person pick files to import.
|
||||
required final FilePicker files,
|
||||
|
||||
/// Lets the person keep files the app made, such as exports.
|
||||
required final FileSaver fileSaver,
|
||||
|
||||
/// Plays the YouTube videos in content.
|
||||
required final YoutubeVideos videos,
|
||||
|
||||
|
|
@ -155,6 +159,7 @@ class Dependencies({
|
|||
maps: GeoMapRepository(),
|
||||
sounds: sounds,
|
||||
files: FileSelectorFilePicker(),
|
||||
fileSaver: PlatformFileSaver(),
|
||||
videos: IframeYoutubeVideos(),
|
||||
pdfEngine: PdfrxEngine(),
|
||||
ankiPackages: IsolateAnkiPackageReader(),
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ final leaveCalloutOnEnter = CharacterShortcutEvent(
|
|||
},
|
||||
);
|
||||
|
||||
/// The name of the callout kind [kind] (as `calloutKindKey` holds it), in
|
||||
/// [l10n]'s language; a note's for any other.
|
||||
String calloutKindName(AppLocalizations l10n, Object? kind) =>
|
||||
_Kind.of(kind).label(l10n);
|
||||
|
||||
/// A callout's kind: its icon, name and colour.
|
||||
enum _Kind(final IconData icon) {
|
||||
note(LucideIcons.pin),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
|
||||
|
||||
/// What Markdown has no words of its own for, in the person's language: a
|
||||
/// sound's link, a video's link without a caption, a spoiler's summary, a
|
||||
/// callout's kind (as `calloutKindKey` names it) where it has no title, and
|
||||
/// what stands for a file that can't be had.
|
||||
typedef RichTextMarkdownTexts = ({
|
||||
String sound,
|
||||
String video,
|
||||
String spoiler,
|
||||
String Function(Object? kind) calloutName,
|
||||
String unavailable,
|
||||
});
|
||||
|
||||
/// [content] as Markdown: CommonMark, with GitHub's tables and task lists,
|
||||
/// formulas between dollars, and a spoiler as an HTML `<details>`. The
|
||||
/// person's files (images, sounds) link to where [linkTo] says their asset
|
||||
/// is; a YouTube video links to YouTube; a callout is a quote under its
|
||||
/// title. Blocks that mean nothing outside the app (a study button) and
|
||||
/// those of a newer app are left out. A file [linkTo] has no link for (one
|
||||
/// this device can't have now) is named as unavailable.
|
||||
String richContentToMarkdown(
|
||||
RichContent content, {
|
||||
required RichTextMarkdownTexts texts,
|
||||
required String? Function(String assetId) linkTo,
|
||||
}) {
|
||||
final document = content.toDocument();
|
||||
_formulasInDollars(document.root);
|
||||
return documentToMarkdown(
|
||||
document,
|
||||
customParsers: [
|
||||
_ImageParser(linkTo, texts.unavailable),
|
||||
_SoundParser(linkTo, texts.sound, texts.unavailable),
|
||||
_YoutubeParser(texts.video),
|
||||
const _MathParser(),
|
||||
_CalloutParser(texts.calloutName),
|
||||
_SpoilerParser(texts.spoiler),
|
||||
],
|
||||
// A blank line between blocks: Markdown runs lines without one into
|
||||
// one paragraph.
|
||||
lineBreak: '\n',
|
||||
).replaceAll(RegExp(r'\n{3,}'), '\n\n').trim();
|
||||
}
|
||||
|
||||
/// Writes every formula within a line under [node] as its LaTeX between
|
||||
/// dollars, as Markdown's maths does.
|
||||
void _formulasInDollars(Node node) {
|
||||
if (node.delta case final delta?
|
||||
when delta.any((op) => op.attributes?[inlineMathKey] == true)) {
|
||||
final written = Delta();
|
||||
for (final op in delta) {
|
||||
if (op is TextInsert && op.attributes?[inlineMathKey] == true) {
|
||||
written.insert('\$${op.text}\$');
|
||||
} else {
|
||||
written.add(op);
|
||||
}
|
||||
}
|
||||
node.updateAttributes({'delta': written.toJson()});
|
||||
}
|
||||
node.children.forEach(_formulasInDollars);
|
||||
}
|
||||
|
||||
class const _ImageParser(
|
||||
final String? Function(String assetId) _linkTo,
|
||||
final String _unavailable,
|
||||
) extends NodeParser {
|
||||
@override
|
||||
String get id => ImageBlockKeys.type;
|
||||
|
||||
@override
|
||||
String transform(Node node, DocumentMarkdownEncoder? encoder) {
|
||||
final url = node.attributes[ImageBlockKeys.url];
|
||||
final link = switch (assetIdOf(url)) {
|
||||
final id? => _linkTo(id),
|
||||
null => url is String ? url : null,
|
||||
};
|
||||
return link == null ? '*($_unavailable)*\n' : '\n';
|
||||
}
|
||||
}
|
||||
|
||||
class const _SoundParser(
|
||||
final String? Function(String assetId) _linkTo,
|
||||
final String _label,
|
||||
final String _unavailable,
|
||||
) extends NodeParser {
|
||||
@override
|
||||
String get id => audioBlockType;
|
||||
|
||||
@override
|
||||
String transform(Node node, DocumentMarkdownEncoder? encoder) {
|
||||
final id = assetIdOf(node.attributes['url']);
|
||||
if (id == null) {
|
||||
return '';
|
||||
}
|
||||
return switch (_linkTo(id)) {
|
||||
final link? => '[$_label]($link)\n',
|
||||
null => '*($_label: $_unavailable)*\n',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class const _YoutubeParser(final String _label) extends NodeParser {
|
||||
@override
|
||||
String get id => youtubeBlockType;
|
||||
|
||||
@override
|
||||
String transform(Node node, DocumentMarkdownEncoder? encoder) {
|
||||
final video = node.attributes[youtubeVideoKey];
|
||||
if (video is! String || video.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final start = node.attributes[youtubeStartKey];
|
||||
final caption = node.attributes[youtubeCaptionKey];
|
||||
final link = Uri.https('www.youtube.com', '/watch', {
|
||||
'v': video,
|
||||
if (start is int && start > 0) 't': '${start}s',
|
||||
});
|
||||
return '[${caption is String && caption.trim().isNotEmpty ? caption.trim() : _label}]($link)\n';
|
||||
}
|
||||
}
|
||||
|
||||
class const _MathParser() extends NodeParser {
|
||||
@override
|
||||
String get id => mathBlockType;
|
||||
|
||||
@override
|
||||
String transform(Node node, DocumentMarkdownEncoder? encoder) =>
|
||||
switch (node.attributes[mathFormulaKey]) {
|
||||
final String formula when formula.trim().isNotEmpty =>
|
||||
'\$\$\n${formula.trim()}\n\$\$\n',
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
class const _CalloutParser(final String Function(Object? kind) _name)
|
||||
extends NodeParser {
|
||||
@override
|
||||
String get id => calloutBlockType;
|
||||
|
||||
@override
|
||||
String transform(Node node, DocumentMarkdownEncoder? encoder) {
|
||||
final title = switch (node.attributes[calloutTitleKey]) {
|
||||
final String title when title.trim().isNotEmpty => title.trim(),
|
||||
_ => _name(node.attributes[calloutKindKey]),
|
||||
};
|
||||
final body = (encoder?.convertNodes(node.children) ?? '').trim();
|
||||
final lines = [
|
||||
'> **$title**',
|
||||
if (body.isNotEmpty) ...[
|
||||
'>',
|
||||
for (final line in body.split('\n'))
|
||||
if (line.isEmpty) '>' else '> $line',
|
||||
],
|
||||
];
|
||||
return '${lines.join('\n')}\n';
|
||||
}
|
||||
}
|
||||
|
||||
class const _SpoilerParser(final String _summary) extends NodeParser {
|
||||
@override
|
||||
String get id => spoilerBlockType;
|
||||
|
||||
@override
|
||||
String transform(Node node, DocumentMarkdownEncoder? encoder) {
|
||||
final text = DeltaMarkdownEncoder().convert(node.delta ?? Delta()).trim();
|
||||
return '<details><summary>$_summary</summary>\n\n$text\n\n</details>\n';
|
||||
}
|
||||
}
|
||||
44
halkyon_learn_flutter/lib/src/core/files/file_saver.dart
Normal file
44
halkyon_learn_flutter/lib/src/core/files/file_saver.dart
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_picker/file_picker.dart' as file_picker;
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
|
||||
/// Lets the person keep a file the app made, where they choose.
|
||||
abstract interface class FileSaver() {
|
||||
/// Asks where to keep [bytes], a file of the media type [mimeType] named
|
||||
/// [name] for a start; completes with whether it was kept, false when the
|
||||
/// person cancelled.
|
||||
Future<bool> save({
|
||||
required String name,
|
||||
required Uint8List bytes,
|
||||
required String mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
/// [FileSaver] with the platform's own dialog: `file_selector`'s on the
|
||||
/// desktop; on Android and iOS, where that has none, the system's through
|
||||
/// `file_picker` (Android's Storage Access Framework, iOS's document picker).
|
||||
class PlatformFileSaver() implements FileSaver {
|
||||
@override
|
||||
Future<bool> save({
|
||||
required String name,
|
||||
required Uint8List bytes,
|
||||
required String mimeType,
|
||||
}) async {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
return await file_picker.FilePicker.saveFile(
|
||||
fileName: name,
|
||||
bytes: bytes,
|
||||
mimeType: mimeType,
|
||||
) !=
|
||||
null;
|
||||
}
|
||||
final location = await getSaveLocation(suggestedName: name);
|
||||
if (location == null) {
|
||||
return false;
|
||||
}
|
||||
await File(location.path).writeAsBytes(bytes);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,477 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_markdown.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license_link.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
||||
/// The words of an export, in the person's language.
|
||||
typedef StudysetExportTexts = ({
|
||||
RichTextMarkdownTexts richText,
|
||||
String flashcards,
|
||||
String notes,
|
||||
String paths,
|
||||
String files,
|
||||
String bibliography,
|
||||
String question,
|
||||
String answer,
|
||||
String hint,
|
||||
String explanation,
|
||||
String untitledNote,
|
||||
String untitledStep,
|
||||
String Function(StepKind kind) stepKind,
|
||||
String ifRight,
|
||||
String ifWrong,
|
||||
String sources,
|
||||
});
|
||||
|
||||
/// A file an export brings along, at its path relative to the document.
|
||||
typedef ExportFile = ({String path, Uint8List bytes});
|
||||
|
||||
/// A studyset as Markdown: the document, the files it links to, and how
|
||||
/// many of those this device couldn't have (named in the document instead).
|
||||
typedef StudysetMarkdown = ({
|
||||
String markdown,
|
||||
List<ExportFile> files,
|
||||
int missingFiles,
|
||||
});
|
||||
|
||||
/// Writes a studyset and everything inside it as one readable Markdown
|
||||
/// document, to keep or to read elsewhere: it can't be imported again
|
||||
/// (decided 2026-10-08: the format to re-import comes with the release
|
||||
/// files of the public tier, so that no format is frozen before).
|
||||
///
|
||||
/// Under the studyset's title, its secondary title, description and
|
||||
/// licence; then a section each for its flashcards (a list, the answers
|
||||
/// below the questions), notes, learning paths (each step with its kind,
|
||||
/// its exercise's answers marked as task lists, its feedback and sources),
|
||||
/// PDFs and bibliography, as far as it has them; then each sub-studyset in
|
||||
/// the same way, a heading level deeper (Markdown stops at six). The
|
||||
/// person's files (images, sounds, PDFs) come along under `files/`, which
|
||||
/// the document links to; [_fileOf] gets each one.
|
||||
class StudysetMarkdownExporter(
|
||||
final StudysetRepository _studysets,
|
||||
final CardRepository _cards,
|
||||
final NoteRepository _notes,
|
||||
final LearningPathRepository _paths,
|
||||
final PdfSourceRepository _pdfs,
|
||||
final Future<StoredFile?> Function(String assetId) _fileOf,
|
||||
) {
|
||||
/// The studyset [studysetId] and everything inside it, sources cited in
|
||||
/// [style].
|
||||
Future<StudysetMarkdown> export(
|
||||
String studysetId, {
|
||||
required StudysetExportTexts texts,
|
||||
required CitationStyle style,
|
||||
}) async {
|
||||
final all = await _studysets.all();
|
||||
final root = all.where((each) => each.id == studysetId).firstOrNull;
|
||||
if (root == null) {
|
||||
throw ArgumentError.value(studysetId, 'studysetId', 'is no studyset');
|
||||
}
|
||||
final export = _Export(texts, style, _fileOf);
|
||||
await _studyset(export, root, all, depth: 1);
|
||||
return (
|
||||
markdown: '${export.blocks.join('\n\n')}\n',
|
||||
files: export.files,
|
||||
missingFiles: export.missing,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _studyset(
|
||||
_Export out,
|
||||
Studyset studyset,
|
||||
List<Studyset> all, {
|
||||
required int depth,
|
||||
}) async {
|
||||
final content = studyset.content;
|
||||
out.heading(depth, content.title);
|
||||
if (content.secondaryTitle case final secondary?
|
||||
when secondary.trim().isNotEmpty) {
|
||||
out.add('*${secondary.trim()}*');
|
||||
}
|
||||
await out.rich(content.description);
|
||||
final holder = content.licenseHolder?.trim() ?? '';
|
||||
final license = content.license?.trim() ?? '';
|
||||
final credit = [
|
||||
if (content.licenseYear case final year?) '$year',
|
||||
if (holder.isNotEmpty) holder,
|
||||
].join(' ');
|
||||
out.add(
|
||||
[
|
||||
if (credit.isNotEmpty) '© $credit',
|
||||
if (license.isNotEmpty)
|
||||
switch (licenseUrl(license)) {
|
||||
final url? => '[$license]($url)',
|
||||
null => license,
|
||||
},
|
||||
].join(' · '),
|
||||
);
|
||||
|
||||
final cards = await _cards.cardsOf(studyset.id);
|
||||
if (cards.isNotEmpty) {
|
||||
out.heading(depth + 1, out.texts.flashcards);
|
||||
final list = StringBuffer();
|
||||
for (final card in cards) {
|
||||
await _card(out, list, card);
|
||||
}
|
||||
out.add(list.toString());
|
||||
}
|
||||
|
||||
final notes = await _notes.notesOf(studyset.id);
|
||||
if (notes.isNotEmpty) {
|
||||
out.heading(depth + 1, out.texts.notes);
|
||||
for (final note in notes) {
|
||||
out.heading(
|
||||
depth + 2,
|
||||
note.content.title.trim().isEmpty
|
||||
? out.texts.untitledNote
|
||||
: note.content.title.trim(),
|
||||
);
|
||||
await out.rich(note.content.body);
|
||||
}
|
||||
}
|
||||
|
||||
final paths = await _paths.pathsOf(studyset.id);
|
||||
if (paths.isNotEmpty) {
|
||||
out.heading(depth + 1, out.texts.paths);
|
||||
for (final path in paths) {
|
||||
out.heading(depth + 2, path.content.title);
|
||||
if (path.content.secondaryTitle case final secondary?
|
||||
when secondary.trim().isNotEmpty) {
|
||||
out.add('*${secondary.trim()}*');
|
||||
}
|
||||
await out.rich(path.content.description);
|
||||
for (final (index, step) in (await _paths.stepsOf(path.id)).indexed) {
|
||||
await _step(out, step, number: index + 1, depth: depth + 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final pdfs = await _pdfs.sourcesOf(studyset.id);
|
||||
if (pdfs.isNotEmpty) {
|
||||
out.heading(depth + 1, out.texts.files);
|
||||
final list = StringBuffer();
|
||||
for (final pdf in pdfs) {
|
||||
final title = pdf.content.title;
|
||||
list.writeln(switch (await out.link(
|
||||
pdf.content.assetId,
|
||||
name: pdf.content.fileName,
|
||||
)) {
|
||||
final link? => '- [$title]($link)',
|
||||
null => '- $title *(${out.texts.richText.unavailable})*',
|
||||
});
|
||||
}
|
||||
out.add(list.toString());
|
||||
}
|
||||
|
||||
if (content.bibliography.isNotEmpty) {
|
||||
out.heading(depth + 1, out.texts.bibliography);
|
||||
out.add(out.sourceList(content.bibliography));
|
||||
}
|
||||
|
||||
for (final child in childrenOf(studyset.id, all)) {
|
||||
await _studyset(out, child, all, depth: depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// [card] as an item of the flashcards' list, written to [list]: its
|
||||
/// question, and below it its answer (a word's meanings, a choice's
|
||||
/// answers as a task list, a sorting's targets with their items), its
|
||||
/// hints and its explanation.
|
||||
Future<void> _card(_Export out, StringBuffer list, Card card) async {
|
||||
final content = card.content;
|
||||
final texts = out.texts;
|
||||
final question = await out.rich(content.front, write: false);
|
||||
switch (content.type) {
|
||||
case CardType.vocabulary:
|
||||
final reading = content.reading.trim();
|
||||
out.item(
|
||||
list,
|
||||
0,
|
||||
texts.question,
|
||||
reading.isEmpty ? question : '$question ($reading)',
|
||||
);
|
||||
final meanings = content.senses.isEmpty
|
||||
? content.meanings
|
||||
: [for (final sense in content.senses) ...sense.glosses];
|
||||
out.item(
|
||||
list,
|
||||
1,
|
||||
texts.answer,
|
||||
meanings.where((meaning) => meaning.trim().isNotEmpty).join('; '),
|
||||
);
|
||||
case CardType.multipleChoice:
|
||||
out.item(list, 0, texts.question, question);
|
||||
for (final answer in content.answers) {
|
||||
final text = await out.rich(answer.content, write: false);
|
||||
final region = answer.region == null ? '' : ' (${answer.region})';
|
||||
list.writeln(' - [${answer.correct ? 'x' : ' '}] $text$region');
|
||||
}
|
||||
case CardType.matching:
|
||||
out.item(list, 0, texts.question, question);
|
||||
final matching = content.matching;
|
||||
for (final (index, target) in matching.targets.indexed) {
|
||||
final items = [
|
||||
for (final item in matching.items)
|
||||
if (item.target == index) item.text,
|
||||
];
|
||||
list.writeln(' - **$target:** ${items.join(', ')}');
|
||||
}
|
||||
case CardType.frontBack:
|
||||
out.item(list, 0, texts.question, question);
|
||||
out.item(
|
||||
list,
|
||||
1,
|
||||
texts.answer,
|
||||
await out.rich(content.back, write: false),
|
||||
);
|
||||
}
|
||||
for (final hint in content.hints) {
|
||||
out.item(list, 1, texts.hint, await out.rich(hint, write: false));
|
||||
}
|
||||
if (content.explanation case final explanation?) {
|
||||
out.item(
|
||||
list,
|
||||
1,
|
||||
texts.explanation,
|
||||
await out.rich(explanation, write: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [step], the [number]th of its path: its title and kind, its text, its
|
||||
/// exercise with the right answers marked, its feedback and its sources.
|
||||
Future<void> _step(
|
||||
_Export out,
|
||||
LearningPathStep step, {
|
||||
required int number,
|
||||
required int depth,
|
||||
}) async {
|
||||
final content = step.content;
|
||||
final texts = out.texts;
|
||||
final title = content.title.trim().isEmpty
|
||||
? texts.untitledStep
|
||||
: content.title.trim();
|
||||
out.heading(depth, '$number. $title (${texts.stepKind(step.kind)})');
|
||||
await out.rich(content.body);
|
||||
switch (content.exercise) {
|
||||
case SingleChoice(:final options, :final correct):
|
||||
out.add(
|
||||
[
|
||||
for (final (index, option) in options.indexed)
|
||||
'- [${index == correct ? 'x' : ' '}] $option',
|
||||
].join('\n'),
|
||||
);
|
||||
case MultipleChoice(:final options, :final correct):
|
||||
out.add(
|
||||
[
|
||||
for (final (index, option) in options.indexed)
|
||||
'- [${correct.contains(index) ? 'x' : ' '}] $option',
|
||||
].join('\n'),
|
||||
);
|
||||
case DragAndDrop(:final targets, :final items):
|
||||
out.add(
|
||||
[
|
||||
for (final (index, target) in targets.indexed)
|
||||
'- **$target:** ${[
|
||||
for (final item in items)
|
||||
if (item.target == index) item.text,
|
||||
].join(', ')}',
|
||||
].join('\n'),
|
||||
);
|
||||
case final FillInTheBlank exercise:
|
||||
final parts = exercise.parts;
|
||||
final passage = StringBuffer(parts.first);
|
||||
for (var gap = 1; gap < parts.length; gap++) {
|
||||
final blank = gap - 1 < exercise.blanks.length
|
||||
? exercise.blanks[gap - 1]
|
||||
: '';
|
||||
passage
|
||||
..write('**[$blank]**')
|
||||
..write(parts[gap]);
|
||||
}
|
||||
out.add(passage.toString());
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
for (final (label, feedback) in [
|
||||
(texts.ifRight, content.rightFeedback),
|
||||
(texts.ifWrong, content.wrongFeedback),
|
||||
]) {
|
||||
final written = await out.rich(feedback, write: false);
|
||||
if (written.isNotEmpty) {
|
||||
out.add(
|
||||
written.contains('\n')
|
||||
? '**$label:**\n\n$written'
|
||||
: '**$label:** $written',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (content.sources.isNotEmpty) {
|
||||
out
|
||||
..add('**${texts.sources}:**')
|
||||
..add(out.sourceList(content.sources));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One export as it is written: the document's blocks, and the files it
|
||||
/// brings along.
|
||||
class _Export(
|
||||
final StudysetExportTexts texts,
|
||||
final CitationStyle _style,
|
||||
final Future<StoredFile?> Function(String assetId) _fileOf,
|
||||
) {
|
||||
/// The document's blocks, a blank line between each.
|
||||
final blocks = <String>[];
|
||||
|
||||
/// The files linked to, in the order first linked.
|
||||
final files = <ExportFile>[];
|
||||
|
||||
/// How many files couldn't be had.
|
||||
int missing = 0;
|
||||
|
||||
final _links = <String, String?>{};
|
||||
final _paths = <String>{};
|
||||
|
||||
/// Adds [text] as a block, unless it is blank.
|
||||
void add(String text) {
|
||||
final trimmed = text.trimRight();
|
||||
if (trimmed.trim().isNotEmpty) {
|
||||
blocks.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
/// A heading of [level], Markdown's sixth at most.
|
||||
void heading(int level, String text) =>
|
||||
add('${'#' * level.clamp(1, 6)} ${text.trim()}');
|
||||
|
||||
/// [content] as Markdown, its files brought along; added as a block
|
||||
/// unless not to [write].
|
||||
Future<String> rich(RichContent content, {bool write = true}) async {
|
||||
for (final id in content.assetIds) {
|
||||
await link(id);
|
||||
}
|
||||
final markdown = richContentToMarkdown(
|
||||
content,
|
||||
texts: texts.richText,
|
||||
linkTo: (id) => _links[id],
|
||||
);
|
||||
if (write) {
|
||||
add(markdown);
|
||||
}
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/// An item of a list at [level] (0 at the top, 1 below an item), its
|
||||
/// [label] in bold before [markdown]; written below the label, indented,
|
||||
/// when it takes more than a line.
|
||||
void item(StringBuffer list, int level, String label, String markdown) {
|
||||
final indent = ' ' * level;
|
||||
if (!markdown.contains('\n')) {
|
||||
list.writeln('$indent- **$label:** $markdown'.trimRight());
|
||||
return;
|
||||
}
|
||||
list.writeln('$indent- **$label:**');
|
||||
for (final line in markdown.split('\n')) {
|
||||
list.writeln(line.isEmpty ? '' : '$indent $line');
|
||||
}
|
||||
}
|
||||
|
||||
/// [sources] as a list, cited in the export's style and ordered as a
|
||||
/// reference list orders them.
|
||||
String sourceList(List<Source> sources) {
|
||||
final cited = [
|
||||
for (final source in sources)
|
||||
formatCitation(
|
||||
source,
|
||||
_style,
|
||||
).map((run) => run.italic ? '*${run.text}*' : run.text).join(),
|
||||
]..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||
return [for (final citation in cited) '- $citation'].join('\n');
|
||||
}
|
||||
|
||||
/// Where the document links to the file of [assetId], brought along under
|
||||
/// `files/`, in a folder by its kind and named [name] or by its id; null,
|
||||
/// and counted as missing, when it can't be had now.
|
||||
Future<String?> link(String assetId, {String? name}) async {
|
||||
if (_links.containsKey(assetId)) {
|
||||
return _links[assetId];
|
||||
}
|
||||
final file = await _fileOf(assetId);
|
||||
if (file == null) {
|
||||
missing++;
|
||||
return _links[assetId] = null;
|
||||
}
|
||||
final mimeType = file.mimeType;
|
||||
final folder = switch (mimeType) {
|
||||
'application/pdf' => 'pdfs',
|
||||
_ when mimeType.startsWith('image/') => 'images',
|
||||
_ when mimeType.startsWith('audio/') => 'sounds',
|
||||
_ => 'other',
|
||||
};
|
||||
final path = _free(
|
||||
'files/$folder',
|
||||
_safe(name) ??
|
||||
'${assetId.substring(0, assetId.length.clamp(0, 8))}'
|
||||
'.${_extensionOf(mimeType)}',
|
||||
);
|
||||
files.add((path: path, bytes: file.bytes));
|
||||
return _links[assetId] = path;
|
||||
}
|
||||
|
||||
/// [name] in [folder], numbered (`-2`, `-3`…) where a file has it already.
|
||||
String _free(String folder, String name) {
|
||||
final dot = name.lastIndexOf('.');
|
||||
final (stem, extension) = dot > 0
|
||||
? (name.substring(0, dot), name.substring(dot))
|
||||
: (name, '');
|
||||
var path = '$folder/$name';
|
||||
for (var number = 2; !_paths.add(path); number++) {
|
||||
path = '$folder/$stem-$number$extension';
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
/// [name] without what file systems refuse in a file's name, and spaces as
|
||||
/// underscores; null for none left.
|
||||
String? _safe(String? name) {
|
||||
final safe = (name ?? '')
|
||||
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), '')
|
||||
.trim()
|
||||
.replaceAll(RegExp(r'\s+'), '_');
|
||||
return safe.isEmpty || safe.startsWith('.') ? null : safe;
|
||||
}
|
||||
|
||||
/// The usual extension of a file of [mimeType].
|
||||
String _extensionOf(String mimeType) => switch (mimeType) {
|
||||
'image/png' => 'png',
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'image/bmp' => 'bmp',
|
||||
'image/svg+xml' => 'svg',
|
||||
'audio/mpeg' => 'mp3',
|
||||
'audio/mp4' => 'm4a',
|
||||
'audio/aac' => 'aac',
|
||||
'audio/ogg' => 'ogg',
|
||||
'audio/wav' => 'wav',
|
||||
'audio/flac' => 'flac',
|
||||
'application/pdf' => 'pdf',
|
||||
_ => 'bin',
|
||||
};
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/callout_block.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/step_kind_labels.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Exports [studyset] and everything inside it as Markdown, to keep where
|
||||
/// the person chooses: a `.md` file, or a `.zip` of it and the files it
|
||||
/// links to when it has any. Says how it went.
|
||||
Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final person = PersonScope.of(context);
|
||||
final saver = DependenciesScope.of(context).fileSaver;
|
||||
final style = context.read<DeviceSettingsBloc>().state.citationStyle;
|
||||
final exporter = StudysetMarkdownExporter(
|
||||
person.studysets,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.paths,
|
||||
person.pdfSources,
|
||||
person.assets.fileOf,
|
||||
);
|
||||
void say(String message) => messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message)));
|
||||
|
||||
say(l10n.exportRunning);
|
||||
final StudysetMarkdown exported;
|
||||
try {
|
||||
exported = await exporter.export(
|
||||
studyset.id,
|
||||
texts: exportTexts(l10n),
|
||||
style: style,
|
||||
);
|
||||
} on Exception catch (_) {
|
||||
say(l10n.exportFailed);
|
||||
return;
|
||||
}
|
||||
final name = _fileStem(studyset.content.title);
|
||||
final markdown = utf8.encode(exported.markdown);
|
||||
final bool saved;
|
||||
try {
|
||||
if (exported.files.isEmpty) {
|
||||
saved = await saver.save(
|
||||
name: '$name.md',
|
||||
bytes: markdown,
|
||||
mimeType: 'text/markdown',
|
||||
);
|
||||
} else {
|
||||
final archive = Archive()..add(ArchiveFile.bytes('$name.md', markdown));
|
||||
for (final file in exported.files) {
|
||||
archive.add(ArchiveFile.bytes(file.path, file.bytes));
|
||||
}
|
||||
saved = await saver.save(
|
||||
name: '$name.zip',
|
||||
bytes: ZipEncoder().encodeBytes(archive),
|
||||
mimeType: 'application/zip',
|
||||
);
|
||||
}
|
||||
} on Exception catch (_) {
|
||||
say(l10n.exportFailed);
|
||||
return;
|
||||
}
|
||||
if (!saved) {
|
||||
messenger.hideCurrentSnackBar();
|
||||
return;
|
||||
}
|
||||
say(
|
||||
[
|
||||
if (exported.files.isEmpty)
|
||||
l10n.exportedMarkdown
|
||||
else
|
||||
l10n.exportedZip(exported.files.length),
|
||||
if (exported.missingFiles > 0)
|
||||
l10n.exportMissingFiles(exported.missingFiles),
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
/// The words of an export in [l10n]'s language.
|
||||
StudysetExportTexts exportTexts(AppLocalizations l10n) => (
|
||||
richText: (
|
||||
sound: l10n.exportSound,
|
||||
video: l10n.exportVideo,
|
||||
spoiler: l10n.exportSpoiler,
|
||||
calloutName: (kind) => calloutKindName(l10n, kind),
|
||||
unavailable: l10n.exportUnavailable,
|
||||
),
|
||||
flashcards: l10n.studysetTabFlashcards,
|
||||
notes: l10n.studysetTabNotes,
|
||||
paths: l10n.studysetTabPaths,
|
||||
files: l10n.studysetTabFiles,
|
||||
bibliography: l10n.studysetBibliography,
|
||||
question: l10n.exportQuestion,
|
||||
answer: l10n.exportAnswer,
|
||||
hint: l10n.exportHint,
|
||||
explanation: l10n.cardExplanation,
|
||||
untitledNote: l10n.noteUntitled,
|
||||
untitledStep: l10n.stepUntitled,
|
||||
stepKind: (kind) => stepKindLabel(l10n, kind),
|
||||
ifRight: l10n.exportIfRight,
|
||||
ifWrong: l10n.exportIfWrong,
|
||||
sources: l10n.sources,
|
||||
);
|
||||
|
||||
/// [title] as a file's name: without what file systems refuse, or
|
||||
/// "studyset" when nothing is left.
|
||||
String _fileStem(String title) {
|
||||
final stem = title
|
||||
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), '')
|
||||
.trim()
|
||||
.replaceAll(RegExp(r'\s+'), ' ');
|
||||
return stem.isEmpty || stem.startsWith('.') ? 'studyset' : stem;
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_text_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/export/widget/export_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart';
|
||||
|
|
@ -1366,6 +1367,7 @@ enum _Option() {
|
|||
move,
|
||||
studyOptions,
|
||||
importContent,
|
||||
exportContent,
|
||||
reset,
|
||||
archive,
|
||||
unarchive,
|
||||
|
|
@ -1373,8 +1375,8 @@ enum _Option() {
|
|||
info,
|
||||
}
|
||||
|
||||
/// The studyset's options: edit, move, its study options, import content,
|
||||
/// reset its progress, archive or unarchive, delete, and what there is to
|
||||
/// The studyset's options: edit, move, its study options, import and export
|
||||
/// content, reset its progress, archive or unarchive, delete, and what there is to
|
||||
/// know about it.
|
||||
class const _Options({
|
||||
required final Studyset studyset,
|
||||
|
|
@ -1454,6 +1456,7 @@ class const _Options({
|
|||
_Option.move => moveStudyset(context, studyset, all),
|
||||
_Option.studyOptions => changeStudyOptions(context, studyset),
|
||||
_Option.importContent => _importContent(context, studyset.id),
|
||||
_Option.exportContent => exportStudyset(context, studyset),
|
||||
_Option.reset => resetStudysetProgress(context, studyset),
|
||||
_Option.archive => _archive(context, archive: true),
|
||||
_Option.unarchive => _archive(context, archive: false),
|
||||
|
|
@ -1477,6 +1480,11 @@ class const _Options({
|
|||
LucideIcons.fileInput,
|
||||
l10n.studysetImportContent,
|
||||
),
|
||||
item(
|
||||
_Option.exportContent,
|
||||
LucideIcons.fileOutput,
|
||||
l10n.studysetMenuExport,
|
||||
),
|
||||
item(_Option.reset, LucideIcons.rotateCcw, l10n.studysetMenuReset),
|
||||
if (studyset.isArchived)
|
||||
item(
|
||||
|
|
|
|||
|
|
@ -45,7 +45,12 @@ dependencies:
|
|||
dbus: ^0.7.15
|
||||
equatable: ^3.0.0
|
||||
expressive_loading_indicator: ^0.0.1
|
||||
# Picks the files to import (and later the images to add): flutter.dev's own.
|
||||
# Saves exports on Android and iOS, where file_selector has no save dialog
|
||||
# (the system's through it: Android's Storage Access Framework); the editor
|
||||
# brings this version along already.
|
||||
file_picker: ^13.1.0
|
||||
# Picks the files to import (and later the images to add), and saves exports
|
||||
# on the desktop: flutter.dev's own.
|
||||
file_selector: ^1.1.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_markdown.dart';
|
||||
|
||||
void main() {
|
||||
RichContent document(List<Map<String, Object?>> blocks) => RichContent({
|
||||
'document': {'type': 'page', 'children': blocks},
|
||||
});
|
||||
|
||||
Map<String, Object?> text(
|
||||
String type,
|
||||
List<Map<String, Object?>> delta, [
|
||||
Map<String, Object?> data = const {},
|
||||
]) => {
|
||||
'type': type,
|
||||
'data': {...data, 'delta': delta},
|
||||
};
|
||||
|
||||
Map<String, Object?> plain(String type, String insert) => text(type, [
|
||||
{'insert': insert},
|
||||
]);
|
||||
|
||||
final texts = (
|
||||
sound: 'Sound',
|
||||
video: 'YouTube video',
|
||||
spoiler: 'Spoiler',
|
||||
calloutName: (Object? kind) => kind == 'tip' ? 'Tip' : 'Note',
|
||||
unavailable: 'not on this device',
|
||||
);
|
||||
|
||||
String markdown(List<Map<String, Object?>> blocks) => richContentToMarkdown(
|
||||
document(blocks),
|
||||
texts: texts,
|
||||
linkTo: (assetId) => assetId == 'gone' ? null : 'files/$assetId.bin',
|
||||
);
|
||||
|
||||
test('sets paragraphs apart by a blank line, keeping their marks', () {
|
||||
expect(
|
||||
markdown([
|
||||
text('paragraph', [
|
||||
{
|
||||
'insert': 'hola',
|
||||
'attributes': {'bold': true},
|
||||
},
|
||||
{'insert': ' means hello'},
|
||||
]),
|
||||
plain('paragraph', 'adiós means goodbye'),
|
||||
]),
|
||||
'**hola** means hello\n\nadiós means goodbye',
|
||||
);
|
||||
});
|
||||
|
||||
test('writes headings and lists as Markdown does', () {
|
||||
expect(
|
||||
markdown([
|
||||
text(
|
||||
'heading',
|
||||
[
|
||||
{'insert': 'Greetings'},
|
||||
],
|
||||
{'level': 2},
|
||||
),
|
||||
plain('bulleted_list', 'hola'),
|
||||
plain('numbered_list', 'adiós'),
|
||||
]),
|
||||
'## Greetings\n\n* hola\n\n1. adiós',
|
||||
);
|
||||
});
|
||||
|
||||
test('sets formulas between dollars, within a line and as a block', () {
|
||||
expect(
|
||||
markdown([
|
||||
text('paragraph', [
|
||||
{'insert': 'Area: '},
|
||||
{
|
||||
'insert': r'\pi r^2',
|
||||
'attributes': {'math': true},
|
||||
},
|
||||
]),
|
||||
{
|
||||
'type': 'math',
|
||||
'data': {'formula': 'E = mc^2'},
|
||||
},
|
||||
]),
|
||||
'Area: \$\\pi r^2\$\n\n\$\$\nE = mc^2\n\$\$',
|
||||
);
|
||||
});
|
||||
|
||||
test("links the person's images and sounds to their files, other images "
|
||||
'to where they are', () {
|
||||
expect(
|
||||
markdown([
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl('cat')},
|
||||
},
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': 'https://example.org/dog.png'},
|
||||
},
|
||||
{
|
||||
'type': 'audio',
|
||||
'data': {'url': assetUrl('meow')},
|
||||
},
|
||||
]),
|
||||
'\n\n\n\n'
|
||||
'[Sound](files/meow.bin)',
|
||||
);
|
||||
});
|
||||
|
||||
test("says so where a file of the person can't be had", () {
|
||||
expect(
|
||||
markdown([
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl('gone')},
|
||||
},
|
||||
{
|
||||
'type': 'audio',
|
||||
'data': {'url': assetUrl('gone')},
|
||||
},
|
||||
]),
|
||||
'*(not on this device)*\n\n*(Sound: not on this device)*',
|
||||
);
|
||||
});
|
||||
|
||||
test('links a YouTube video at where it starts, named by its caption', () {
|
||||
expect(
|
||||
markdown([
|
||||
{
|
||||
'type': 'youtube',
|
||||
'data': {'video': 'dQw4w9WgXcQ', 'start': 90, 'caption': 'Song'},
|
||||
},
|
||||
{
|
||||
'type': 'youtube',
|
||||
'data': {'video': 'dQw4w9WgXcQ'},
|
||||
},
|
||||
]),
|
||||
'[Song](https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90s)\n\n'
|
||||
'[YouTube video](https://www.youtube.com/watch?v=dQw4w9WgXcQ)',
|
||||
);
|
||||
});
|
||||
|
||||
test('quotes a callout under its title or its kind, and folds a spoiler '
|
||||
'away', () {
|
||||
expect(
|
||||
markdown([
|
||||
{
|
||||
'type': 'callout',
|
||||
'data': {'kind': 'tip'},
|
||||
'children': [
|
||||
plain('paragraph', 'Say it twice.'),
|
||||
plain('paragraph', 'Then once more.'),
|
||||
],
|
||||
},
|
||||
plain('spoiler', 'The h is silent.'),
|
||||
]),
|
||||
'> **Tip**\n>\n> Say it twice.\n>\n> Then once more.\n\n'
|
||||
'<details><summary>Spoiler</summary>\n\nThe h is silent.\n\n</details>',
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves out what means nothing outside the app: study buttons, and '
|
||||
'blocks of a newer app', () {
|
||||
expect(
|
||||
markdown([
|
||||
plain('paragraph', 'Before'),
|
||||
{
|
||||
'type': 'study_button',
|
||||
'data': {'label': 'Study'},
|
||||
},
|
||||
{'type': 'hologram', 'data': <String, Object?>{}},
|
||||
plain('paragraph', 'After'),
|
||||
]),
|
||||
'Before\n\nAfter',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license_link.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
||||
import '../../../support/fake_asset_gateway.dart';
|
||||
import '../../../support/fake_asset_server_gateway.dart';
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_learning_path_gateway.dart';
|
||||
import '../../../support/fake_note_gateway.dart';
|
||||
import '../../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
|
||||
void main() {
|
||||
late StudysetRepository studysets;
|
||||
late CardRepository cards;
|
||||
late NoteRepository notes;
|
||||
late LearningPathRepository paths;
|
||||
late AssetRepository assets;
|
||||
late PdfSourceRepository pdfs;
|
||||
late StudysetMarkdownExporter exporter;
|
||||
|
||||
setUp(() {
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
notes = NoteRepository(FakeNoteGateway());
|
||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
);
|
||||
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
|
||||
exporter = StudysetMarkdownExporter(
|
||||
studysets,
|
||||
cards,
|
||||
notes,
|
||||
paths,
|
||||
pdfs,
|
||||
assets.fileOf,
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
await studysets.dispose();
|
||||
await cards.dispose();
|
||||
await notes.dispose();
|
||||
await paths.dispose();
|
||||
await pdfs.dispose();
|
||||
await assets.dispose();
|
||||
});
|
||||
|
||||
final texts = (
|
||||
richText: (
|
||||
sound: 'Sound',
|
||||
video: 'YouTube video',
|
||||
spoiler: 'Spoiler',
|
||||
calloutName: (Object? kind) => 'Note',
|
||||
unavailable: 'not available on this device',
|
||||
),
|
||||
flashcards: 'Flashcards',
|
||||
notes: 'Notes',
|
||||
paths: 'Learning paths',
|
||||
files: 'Files',
|
||||
bibliography: 'Bibliography',
|
||||
question: 'Q',
|
||||
answer: 'A',
|
||||
hint: 'Hint',
|
||||
explanation: 'Explanation',
|
||||
untitledNote: 'Untitled note',
|
||||
untitledStep: 'Untitled step',
|
||||
stepKind: (StepKind kind) => switch (kind) {
|
||||
StepKind.info => 'Info',
|
||||
StepKind.singleChoice => 'Single choice',
|
||||
_ => kind.value,
|
||||
},
|
||||
ifRight: 'If right',
|
||||
ifWrong: 'If wrong',
|
||||
sources: 'Sources',
|
||||
);
|
||||
|
||||
Future<StudysetMarkdown> export(String id) =>
|
||||
exporter.export(id, texts: texts, style: CitationStyle.apa);
|
||||
|
||||
RichContent blocks(List<Map<String, Object?>> children) => RichContent({
|
||||
'document': {'type': 'page', 'children': children},
|
||||
});
|
||||
|
||||
test('writes the studyset, then its flashcards, notes, paths and '
|
||||
'bibliography, then its sub-studysets a level deeper', () async {
|
||||
const source = Source(authors: 'Ana López', year: '2024', title: 'Hola');
|
||||
final spanish = await studysets.create(
|
||||
StudysetContent(
|
||||
title: 'Spanish vocab',
|
||||
secondaryTitle: 'A1',
|
||||
description: RichContent.plain('Words for a first trip.'),
|
||||
license: 'CC-BY-4.0',
|
||||
licenseHolder: 'Ana',
|
||||
licenseYear: 2026,
|
||||
bibliography: const [source],
|
||||
),
|
||||
);
|
||||
await cards.create(
|
||||
spanish,
|
||||
CardContent(
|
||||
front: RichContent.plain('hola'),
|
||||
back: RichContent.plain('hello'),
|
||||
hints: [RichContent.plain('starts with h')],
|
||||
),
|
||||
);
|
||||
await cards.create(
|
||||
spanish,
|
||||
CardContent(
|
||||
type: CardType.multipleChoice,
|
||||
front: RichContent.plain('Which means goodbye?'),
|
||||
back: RichContent.empty(),
|
||||
answers: [
|
||||
CardAnswer(content: RichContent.plain('adiós'), correct: true),
|
||||
CardAnswer(content: RichContent.plain('hola')),
|
||||
],
|
||||
),
|
||||
);
|
||||
await notes.create(
|
||||
spanish,
|
||||
NoteContent(
|
||||
title: 'Pronunciation',
|
||||
body: RichContent.plain('The h is silent.'),
|
||||
),
|
||||
);
|
||||
final basics = await paths.createPath(
|
||||
spanish,
|
||||
LearningPathContent.titled('Basics'),
|
||||
);
|
||||
await paths.createStep(
|
||||
basics,
|
||||
StepKind.info,
|
||||
StepContent.empty(StepKind.info)
|
||||
.withField(StepField.title, 'Saying hello')
|
||||
.withField(StepField.body, RichContent.plain('Hola greets.')),
|
||||
);
|
||||
await paths.createStep(
|
||||
basics,
|
||||
StepKind.singleChoice,
|
||||
StepContent.empty(StepKind.singleChoice)
|
||||
.withField(StepField.title, 'Which one means goodbye?')
|
||||
.withField(
|
||||
StepField.exercise,
|
||||
const SingleChoice(options: ['hola', 'adiós'], correct: 1),
|
||||
)
|
||||
.withField(StepField.rightFeedback, RichContent.plain('¡Sí!')),
|
||||
);
|
||||
final food = await studysets.create(
|
||||
StudysetContent.titled('Food'),
|
||||
parentId: spanish,
|
||||
);
|
||||
await cards.create(
|
||||
food,
|
||||
CardContent(
|
||||
front: RichContent.plain('pan'),
|
||||
back: RichContent.plain('bread'),
|
||||
),
|
||||
);
|
||||
final citation = formatCitation(
|
||||
source,
|
||||
CitationStyle.apa,
|
||||
).map((run) => run.italic ? '*${run.text}*' : run.text).join();
|
||||
|
||||
final exported = await export(spanish);
|
||||
|
||||
expect(exported.markdown, '''
|
||||
# Spanish vocab
|
||||
|
||||
*A1*
|
||||
|
||||
Words for a first trip.
|
||||
|
||||
© 2026 Ana · [CC-BY-4.0](${licenseUrl('CC-BY-4.0')})
|
||||
|
||||
## Flashcards
|
||||
|
||||
- **Q:** hola
|
||||
- **A:** hello
|
||||
- **Hint:** starts with h
|
||||
- **Q:** Which means goodbye?
|
||||
- [x] adiós
|
||||
- [ ] hola
|
||||
|
||||
## Notes
|
||||
|
||||
### Pronunciation
|
||||
|
||||
The h is silent.
|
||||
|
||||
## Learning paths
|
||||
|
||||
### Basics
|
||||
|
||||
#### 1. Saying hello (Info)
|
||||
|
||||
Hola greets.
|
||||
|
||||
#### 2. Which one means goodbye? (Single choice)
|
||||
|
||||
- [ ] hola
|
||||
- [x] adiós
|
||||
|
||||
**If right:** ¡Sí!
|
||||
|
||||
## Bibliography
|
||||
|
||||
- $citation
|
||||
|
||||
## Food
|
||||
|
||||
### Flashcards
|
||||
|
||||
- **Q:** pan
|
||||
- **A:** bread
|
||||
''');
|
||||
expect(exported.files, isEmpty);
|
||||
expect(exported.missingFiles, 0);
|
||||
});
|
||||
|
||||
test("brings the studyset's files along: its PDFs under their names, the "
|
||||
'images in its content by their kind', () async {
|
||||
final spanish = await studysets.create(StudysetContent.titled('Spanish'));
|
||||
final png = Uint8List.fromList([137, 80, 78, 71, 1, 2, 3]);
|
||||
final cat = await assets.add(
|
||||
png,
|
||||
fileName: 'cat.png',
|
||||
mimeType: 'image/png',
|
||||
);
|
||||
await cards.create(
|
||||
spanish,
|
||||
CardContent(
|
||||
front: blocks([
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl(cat)},
|
||||
},
|
||||
]),
|
||||
back: RichContent.plain('gato'),
|
||||
),
|
||||
);
|
||||
final kana = Uint8List.fromList(utf8.encode('%PDF-1.7 kana'));
|
||||
final hiragana = Uint8List.fromList(utf8.encode('%PDF-1.7 hiragana'));
|
||||
await pdfs.import(spanish, bytes: kana, fileName: 'kana.pdf');
|
||||
await pdfs.import(spanish, bytes: hiragana, fileName: 'kana.pdf');
|
||||
|
||||
final exported = await export(spanish);
|
||||
|
||||
final image = exported.files.singleWhere(
|
||||
(file) => file.path.startsWith('files/images/'),
|
||||
);
|
||||
expect(image.path, endsWith('.png'));
|
||||
expect(image.bytes, png);
|
||||
expect(exported.markdown, contains('- **Q:** '));
|
||||
expect(
|
||||
{
|
||||
for (final file in exported.files)
|
||||
if (file.path.startsWith('files/pdfs/')) file.path: file.bytes,
|
||||
},
|
||||
{'files/pdfs/kana.pdf': kana, 'files/pdfs/kana-2.pdf': hiragana},
|
||||
);
|
||||
expect(
|
||||
exported.markdown,
|
||||
contains(
|
||||
'## Files\n\n- [kana](files/pdfs/kana.pdf)\n'
|
||||
'- [kana](files/pdfs/kana-2.pdf)\n',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("a file this device can't have now is named, and counted", () async {
|
||||
final spanish = await studysets.create(StudysetContent.titled('Spanish'));
|
||||
await cards.create(
|
||||
spanish,
|
||||
CardContent(
|
||||
front: blocks([
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl('gone')},
|
||||
},
|
||||
]),
|
||||
back: RichContent.plain('gato'),
|
||||
),
|
||||
);
|
||||
|
||||
final exported = await export(spanish);
|
||||
|
||||
expect(exported.files, isEmpty);
|
||||
expect(exported.missingFiles, 1);
|
||||
expect(exported.markdown, contains('(not available on this device)'));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../support/fake_asset_gateway.dart';
|
||||
import '../../../support/fake_asset_server_gateway.dart';
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_file_saver.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway studysets;
|
||||
late FakeCardGateway cardGateway;
|
||||
late FakeAssetGateway assetGateway;
|
||||
late MemoryAssetFileCache cache;
|
||||
late FakeFileSaver saver;
|
||||
|
||||
setUp(() {
|
||||
studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'spanish',
|
||||
encodeStudysetPayload(StudysetContent.titled('Spanish vocab')),
|
||||
);
|
||||
cardGateway = FakeCardGateway();
|
||||
assetGateway = FakeAssetGateway();
|
||||
cache = MemoryAssetFileCache();
|
||||
saver = FakeFileSaver();
|
||||
});
|
||||
|
||||
Future<void> card(CardContent content) async {
|
||||
final cards = CardRepository(cardGateway);
|
||||
addTearDown(cards.dispose);
|
||||
await cards.create('spanish', content);
|
||||
}
|
||||
|
||||
Future<void> exportFromMenu(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: studysets,
|
||||
cardGateway: cardGateway,
|
||||
assetGateway: assetGateway,
|
||||
assetFiles: cache,
|
||||
fileSaver: saver,
|
||||
),
|
||||
location: RoutePaths.studyset('spanish'),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.byTooltip('Options'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Export content'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('Export content keeps the studyset as a Markdown file, named '
|
||||
'after it', (tester) async {
|
||||
await card(
|
||||
CardContent(
|
||||
front: RichContent.plain('hola'),
|
||||
back: RichContent.plain('hello'),
|
||||
),
|
||||
);
|
||||
|
||||
await exportFromMenu(tester);
|
||||
|
||||
final saved = saver.saved.single;
|
||||
expect(saved.name, 'Spanish vocab.md');
|
||||
expect(saved.mimeType, 'text/markdown');
|
||||
expect(
|
||||
utf8.decode(saved.bytes),
|
||||
startsWith(
|
||||
'# Spanish vocab\n\n## Flashcards\n\n'
|
||||
'- **Q:** hola\n - **A:** hello\n',
|
||||
),
|
||||
);
|
||||
expect(find.text('Exported as Markdown.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('with files, it keeps a zip of the document and the files it '
|
||||
'links to', (tester) async {
|
||||
final assets = AssetRepository(
|
||||
assetGateway,
|
||||
FakeAssetServerGateway(),
|
||||
cache,
|
||||
);
|
||||
addTearDown(assets.dispose);
|
||||
final png = Uint8List.fromList([137, 80, 78, 71, 1, 2, 3]);
|
||||
final cat = await assets.add(
|
||||
png,
|
||||
fileName: 'cat.png',
|
||||
mimeType: 'image/png',
|
||||
);
|
||||
await card(
|
||||
CardContent(
|
||||
front: RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl(cat)},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
back: RichContent.plain('gato'),
|
||||
),
|
||||
);
|
||||
|
||||
await exportFromMenu(tester);
|
||||
|
||||
final saved = saver.saved.single;
|
||||
expect(saved.name, 'Spanish vocab.zip');
|
||||
final zip = ZipDecoder().decodeBytes(saved.bytes);
|
||||
final image = zip.files.singleWhere(
|
||||
(file) => file.name.startsWith('files/images/'),
|
||||
);
|
||||
expect(image.content, png);
|
||||
expect(
|
||||
utf8.decode(zip.findFile('Spanish vocab.md')!.content),
|
||||
contains(''),
|
||||
);
|
||||
expect(
|
||||
find.text('Exported as Markdown with 1 file (.zip).'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('cancelled where to keep it, it says nothing more', (
|
||||
tester,
|
||||
) async {
|
||||
saver.cancel = true;
|
||||
|
||||
await exportFromMenu(tester);
|
||||
|
||||
expect(saver.saved, isEmpty);
|
||||
expect(find.text('Exporting…'), findsNothing);
|
||||
expect(find.textContaining('Exported'), findsNothing);
|
||||
});
|
||||
}
|
||||
26
halkyon_learn_flutter/test/support/fake_file_saver.dart
Normal file
26
halkyon_learn_flutter/test/support/fake_file_saver.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/files/file_saver.dart';
|
||||
|
||||
/// A [FileSaver] that keeps what it is given, unless [cancel] says the
|
||||
/// person cancelled.
|
||||
class FakeFileSaver() implements FileSaver {
|
||||
/// Whether the next saves are cancelled.
|
||||
bool cancel = false;
|
||||
|
||||
/// What was saved, in order.
|
||||
final saved = <({String name, Uint8List bytes, String mimeType})>[];
|
||||
|
||||
@override
|
||||
Future<bool> save({
|
||||
required String name,
|
||||
required Uint8List bytes,
|
||||
required String mimeType,
|
||||
}) async {
|
||||
if (cancel) {
|
||||
return false;
|
||||
}
|
||||
saved.add((name: name, bytes: bytes, mimeType: mimeType));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
|
|||
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/files/file_saver.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||
|
|
@ -54,6 +55,7 @@ import 'fake_asset_server_gateway.dart';
|
|||
import 'fake_auth_gateway.dart';
|
||||
import 'fake_card_gateway.dart';
|
||||
import 'fake_file_picker.dart';
|
||||
import 'fake_file_saver.dart';
|
||||
import 'fake_learning_path_gateway.dart';
|
||||
import 'fake_note_gateway.dart';
|
||||
import 'fake_path_answer_gateway.dart';
|
||||
|
|
@ -97,6 +99,7 @@ Dependencies testDependencies({
|
|||
GeoMapRepository? maps,
|
||||
SoundPlayer? sounds,
|
||||
FilePicker? files,
|
||||
FileSaver? fileSaver,
|
||||
YoutubeVideos? videos,
|
||||
PdfEngine? pdfEngine,
|
||||
SettingsGateway? settingsGateway,
|
||||
|
|
@ -118,6 +121,7 @@ Dependencies testDependencies({
|
|||
maps: maps ?? GeoMapRepository(),
|
||||
sounds: sounds ?? FakeSoundPlayer(),
|
||||
files: files ?? FakeFilePicker(),
|
||||
fileSaver: fileSaver ?? FakeFileSaver(),
|
||||
videos: videos ?? FakeYoutubeVideos(),
|
||||
pdfEngine: pdfEngine ?? FakePdfEngine(),
|
||||
ankiPackages: InlineAnkiPackageReader(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue