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:
Tizian 「ティツィアーン」 2026-10-08 15:02:22 +02:00
commit 882b669c3e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
18 changed files with 1767 additions and 3 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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(),

View file

@ -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),

View file

@ -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' : '![]($link)\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';
}
}

View 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;
}
}

View file

@ -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',
};

View file

@ -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;
}

View file

@ -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(

View file

@ -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

View file

@ -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')},
},
]),
'![](files/cat.bin)\n\n![](https://example.org/dog.png)\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',
);
});
}

View file

@ -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:** ![](${image.path})'));
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)'));
});
}

View file

@ -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('![](${image.name})'),
);
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);
});
}

View 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;
}
}

View file

@ -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(),