feat(archive): export a studyset as an archive to import again
Export content now asks what to export as: an archive first, a .zip the Library's Import takes back as the person's own, for a backup or a move to another instance; Markdown, to read elsewhere, below it. The archive holds the studyset with everything inside it but what waits in Recently deleted, laid out as the instance writes a release, unlinked from Explore; dictionary cards go whole, with the person's edits. Every file its content names is downloaded first, and an archive missing one is not kept: the person is told how many and to try again online. With my progress (ticked at first) adds the reviews, the answers to learning paths' steps, the pages read in PDFs, and what a release leaves out as only its author's: the exam prepared for, a card's report, a PDF's reading deadline. Stars, archived studysets and the tags' colours come along either way. The import of v0.17.0 refuses a file it doesn't know, so what it doesn't read goes into the files it reads, as keys it skips, and the format stays 1: archive.json's tags, a studyset.json's archived and starred, a step's answers, a PDF's readings. This import reads them; an archive it can't read all of still writes nothing, its tags included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
197726f5a3
commit
6f3ba7b421
21 changed files with 1671 additions and 30 deletions
File diff suppressed because one or more lines are too long
|
|
@ -302,6 +302,16 @@
|
|||
"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.",
|
||||
"exportTitle": "Exportieren",
|
||||
"exportArchive": "Archiv",
|
||||
"exportArchiveSubtitle": "Ein .zip, das sich wieder importieren lässt: eine Sicherung oder ein Umzug auf eine andere Instanz.",
|
||||
"exportMarkdown": "Markdown",
|
||||
"exportMarkdownSubtitle": "Zum Lesen anderswo. Es lässt sich nicht wieder importieren.",
|
||||
"exportWithProgress": "Mit meinem Fortschritt",
|
||||
"exportWithProgressSubtitle": "Wiederholungen, Antworten, gelesene Seiten, die Prüfung, auf die du dich vorbereitest, deine Meldungen und Lesefristen",
|
||||
"exportConfirm": "Exportieren",
|
||||
"exportedArchive": "Als Archiv exportiert (.zip).",
|
||||
"exportArchiveFilesMissing": "{count, plural, =1{1 Datei ist nicht auf diesem Gerät und ließ sich nicht herunterladen.} other{{count} Dateien sind nicht auf diesem Gerät und ließen sich nicht herunterladen.}} Verbinde dich mit dem Internet und versuche es noch einmal.",
|
||||
"exportQuestion": "F",
|
||||
"exportAnswer": "A",
|
||||
"exportHint": "Hinweis",
|
||||
|
|
|
|||
|
|
@ -349,6 +349,17 @@
|
|||
"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.",
|
||||
"exportTitle": "Export",
|
||||
"exportArchive": "Archive",
|
||||
"exportArchiveSubtitle": "A .zip to import again: a backup, or a move to another instance.",
|
||||
"exportMarkdown": "Markdown",
|
||||
"exportMarkdownSubtitle": "To read elsewhere. It can't be imported again.",
|
||||
"exportWithProgress": "With my progress",
|
||||
"exportWithProgressSubtitle": "Reviews, answers, pages read, the exam you're preparing for, your reports and reading deadlines",
|
||||
"exportConfirm": "Export",
|
||||
"exportedArchive": "Exported as an archive (.zip).",
|
||||
"exportArchiveFilesMissing": "{count, plural, =1{1 file isn't on this device and couldn't be downloaded.} other{{count} files aren't on this device and couldn't be downloaded.}} Connect to the internet and try again.",
|
||||
"@exportArchiveFilesMissing": {"placeholders": {"count": {"type": "int"}}},
|
||||
"exportQuestion": "Q",
|
||||
"exportAnswer": "A",
|
||||
"exportHint": "Hint",
|
||||
|
|
|
|||
|
|
@ -1858,6 +1858,66 @@ abstract class AppLocalizations {
|
|||
/// **'Couldn\'t export the studyset.'**
|
||||
String get exportFailed;
|
||||
|
||||
/// No description provided for @exportTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export'**
|
||||
String get exportTitle;
|
||||
|
||||
/// No description provided for @exportArchive.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Archive'**
|
||||
String get exportArchive;
|
||||
|
||||
/// No description provided for @exportArchiveSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A .zip to import again: a backup, or a move to another instance.'**
|
||||
String get exportArchiveSubtitle;
|
||||
|
||||
/// No description provided for @exportMarkdown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Markdown'**
|
||||
String get exportMarkdown;
|
||||
|
||||
/// No description provided for @exportMarkdownSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'To read elsewhere. It can\'t be imported again.'**
|
||||
String get exportMarkdownSubtitle;
|
||||
|
||||
/// No description provided for @exportWithProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'With my progress'**
|
||||
String get exportWithProgress;
|
||||
|
||||
/// No description provided for @exportWithProgressSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reviews, answers, pages read, the exam you\'re preparing for, your reports and reading deadlines'**
|
||||
String get exportWithProgressSubtitle;
|
||||
|
||||
/// No description provided for @exportConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export'**
|
||||
String get exportConfirm;
|
||||
|
||||
/// No description provided for @exportedArchive.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Exported as an archive (.zip).'**
|
||||
String get exportedArchive;
|
||||
|
||||
/// No description provided for @exportArchiveFilesMissing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 file isn\'t on this device and couldn\'t be downloaded.} other{{count} files aren\'t on this device and couldn\'t be downloaded.}} Connect to the internet and try again.'**
|
||||
String exportArchiveFilesMissing(int count);
|
||||
|
||||
/// No description provided for @exportQuestion.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1171,6 +1171,48 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get exportFailed => 'Das Lernset konnte nicht exportiert werden.';
|
||||
|
||||
@override
|
||||
String get exportTitle => 'Exportieren';
|
||||
|
||||
@override
|
||||
String get exportArchive => 'Archiv';
|
||||
|
||||
@override
|
||||
String get exportArchiveSubtitle =>
|
||||
'Ein .zip, das sich wieder importieren lässt: eine Sicherung oder ein Umzug auf eine andere Instanz.';
|
||||
|
||||
@override
|
||||
String get exportMarkdown => 'Markdown';
|
||||
|
||||
@override
|
||||
String get exportMarkdownSubtitle =>
|
||||
'Zum Lesen anderswo. Es lässt sich nicht wieder importieren.';
|
||||
|
||||
@override
|
||||
String get exportWithProgress => 'Mit meinem Fortschritt';
|
||||
|
||||
@override
|
||||
String get exportWithProgressSubtitle =>
|
||||
'Wiederholungen, Antworten, gelesene Seiten, die Prüfung, auf die du dich vorbereitest, deine Meldungen und Lesefristen';
|
||||
|
||||
@override
|
||||
String get exportConfirm => 'Exportieren';
|
||||
|
||||
@override
|
||||
String get exportedArchive => 'Als Archiv exportiert (.zip).';
|
||||
|
||||
@override
|
||||
String exportArchiveFilesMissing(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other:
|
||||
'$count Dateien sind nicht auf diesem Gerät und ließen sich nicht herunterladen.',
|
||||
one: '1 Datei ist nicht auf diesem Gerät und ließ sich nicht herunterladen.',
|
||||
);
|
||||
return '$_temp0 Verbinde dich mit dem Internet und versuche es noch einmal.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get exportQuestion => 'F';
|
||||
|
||||
|
|
|
|||
|
|
@ -1161,6 +1161,47 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get exportFailed => 'Couldn\'t export the studyset.';
|
||||
|
||||
@override
|
||||
String get exportTitle => 'Export';
|
||||
|
||||
@override
|
||||
String get exportArchive => 'Archive';
|
||||
|
||||
@override
|
||||
String get exportArchiveSubtitle =>
|
||||
'A .zip to import again: a backup, or a move to another instance.';
|
||||
|
||||
@override
|
||||
String get exportMarkdown => 'Markdown';
|
||||
|
||||
@override
|
||||
String get exportMarkdownSubtitle =>
|
||||
'To read elsewhere. It can\'t be imported again.';
|
||||
|
||||
@override
|
||||
String get exportWithProgress => 'With my progress';
|
||||
|
||||
@override
|
||||
String get exportWithProgressSubtitle =>
|
||||
'Reviews, answers, pages read, the exam you\'re preparing for, your reports and reading deadlines';
|
||||
|
||||
@override
|
||||
String get exportConfirm => 'Export';
|
||||
|
||||
@override
|
||||
String get exportedArchive => 'Exported as an archive (.zip).';
|
||||
|
||||
@override
|
||||
String exportArchiveFilesMissing(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count files aren\'t on this device and couldn\'t be downloaded.',
|
||||
one: '1 file isn\'t on this device and couldn\'t be downloaded.',
|
||||
);
|
||||
return '$_temp0 Connect to the internet and try again.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get exportQuestion => 'Q';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,277 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart'
|
||||
show ExportFile;
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.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_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
|
||||
/// An archive this device can't make whole now: [count] of the files its
|
||||
/// content names can't be had (no connection, or not uploaded yet from the
|
||||
/// device that has them).
|
||||
final class const ArchiveFilesMissing(final int count) implements Exception {
|
||||
@override
|
||||
String toString() => 'ArchiveFilesMissing($count)';
|
||||
}
|
||||
|
||||
/// Writes a studyset with everything inside it as an archive (see
|
||||
/// [ImportArchive]), to import again as the person's own: a backup, a move
|
||||
/// to another instance (decided 2026-10-15). Laid out as a release, as the
|
||||
/// instance publishes one (`lib/src/publish/release_files.dart` in the
|
||||
/// server: each item its id, position, tags by name, a step's kind and its
|
||||
/// payload, keys sorted and indented), and linked to no publication: a copy
|
||||
/// from Explore comes back as the person's own, with no updates offered.
|
||||
///
|
||||
/// With the progress it holds everything the person had: their reviews,
|
||||
/// the answers to the steps, the pages read, and what a release leaves out
|
||||
/// as only its author's (the exam prepared for, a card's report, a PDF's
|
||||
/// reading deadline); without, the content alone, as a release does. Stars,
|
||||
/// the archive and the tags' colours come along either way.
|
||||
class ArchiveExporter(
|
||||
final StudysetRepository _studysets,
|
||||
final CardRepository _cards,
|
||||
final NoteRepository _notes,
|
||||
final LearningPathRepository _paths,
|
||||
final PathProgressRepository _progress,
|
||||
final PdfSourceRepository _pdfs,
|
||||
final TagRepository _tags,
|
||||
final ReviewRepository _reviews,
|
||||
final Future<StoredFile?> Function(String assetId) _fileOf,
|
||||
) {
|
||||
/// What a payload holds that is the person's own and goes only with the
|
||||
/// progress, by kind: as the instance leaves it out of a release.
|
||||
static const _personal = {
|
||||
'card': {'report'},
|
||||
'studyset': {'exam'},
|
||||
'pdf': {'deadline'},
|
||||
};
|
||||
|
||||
/// The archive's files for the studyset [studysetId] and everything
|
||||
/// inside it but what waits in Recently deleted, `archive.json` first;
|
||||
/// [withProgress] as the class says. Throws [ArchiveFilesMissing] when a
|
||||
/// file the content names can't be had now, rather than leaving it out of
|
||||
/// an archive that would look whole.
|
||||
Future<List<ExportFile>> export(
|
||||
String studysetId, {
|
||||
required bool withProgress,
|
||||
}) 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');
|
||||
}
|
||||
// Each studyset's directory, the top one's empty; once each, so two
|
||||
// studysets inside each other (moved so on two devices offline) end
|
||||
// the walk.
|
||||
final directories = <String, String>{root.id: ''};
|
||||
final studysets = <Studyset>[root];
|
||||
for (var i = 0; i < studysets.length; i++) {
|
||||
for (final child in childrenOf(studysets[i].id, all)) {
|
||||
if (!directories.containsKey(child.id)) {
|
||||
directories[child.id] = '${directories[studysets[i].id]}${child.id}/';
|
||||
studysets.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final tagging = await _tags.read();
|
||||
final tags = <String, Tag>{};
|
||||
List<String> namesOf(List<Tag> its) {
|
||||
for (final tag in its) {
|
||||
tags[tag.id] = tag;
|
||||
}
|
||||
return [for (final tag in its) tag.name]..sort();
|
||||
}
|
||||
|
||||
Map<String, Object?> payloadOf(ByteData payload, String kind) => {
|
||||
for (final MapEntry(:key, :value) in _decoded(payload).entries)
|
||||
if (key != 'published' &&
|
||||
(withProgress || !(_personal[kind]?.contains(key) ?? false)))
|
||||
key: value,
|
||||
};
|
||||
|
||||
final items = <String, Map<String, Object?>>{};
|
||||
final steps = <String, Map<String, Object?>>{};
|
||||
final pdfs = <String, Map<String, Object?>>{};
|
||||
final cardIds = <String>{};
|
||||
for (final studyset in studysets) {
|
||||
final directory = directories[studyset.id]!;
|
||||
items['${directory}studyset.json'] = {
|
||||
'id': studyset.id,
|
||||
if (studyset.id != root.id) 'position': studyset.position,
|
||||
'payload': payloadOf(
|
||||
encodeStudysetPayload(studyset.content),
|
||||
'studyset',
|
||||
),
|
||||
if (studyset.isArchived) 'archived': true,
|
||||
if (studyset.isStarred) 'starred': true,
|
||||
};
|
||||
for (final card in await _cards.cardsOf(studyset.id)) {
|
||||
cardIds.add(card.id);
|
||||
items['${directory}cards/${card.id}.json'] = {
|
||||
'id': card.id,
|
||||
'position': card.position,
|
||||
'tags': namesOf(tagging.of(card.id)),
|
||||
'payload': payloadOf(encodeCardPayload(card.content), 'card'),
|
||||
};
|
||||
}
|
||||
for (final note in await _notes.notesOf(studyset.id)) {
|
||||
items['${directory}notes/${note.id}.json'] = {
|
||||
'id': note.id,
|
||||
'position': note.position,
|
||||
'tags': namesOf(tagging.ofNote(note.id)),
|
||||
'payload': payloadOf(encodeNotePayload(note.content), 'note'),
|
||||
};
|
||||
}
|
||||
for (final path in await _paths.pathsOf(studyset.id)) {
|
||||
final pathDirectory = '${directory}paths/${path.id}/';
|
||||
items['${pathDirectory}path.json'] = {
|
||||
'id': path.id,
|
||||
'position': path.position,
|
||||
'tags': namesOf(tagging.ofPath(path.id)),
|
||||
'payload': payloadOf(encodeLearningPathPayload(path.content), 'path'),
|
||||
};
|
||||
for (final step in await _paths.stepsOf(path.id)) {
|
||||
items['${pathDirectory}steps/${step.id}.json'] = steps[step.id] = {
|
||||
'id': step.id,
|
||||
'position': step.position,
|
||||
'kind': step.kind.value,
|
||||
'payload': payloadOf(encodeStepPayload(step.content), 'step'),
|
||||
};
|
||||
}
|
||||
}
|
||||
for (final pdf in await _pdfs.sourcesOf(studyset.id)) {
|
||||
items['${directory}pdfs/${pdf.id}.json'] = pdfs[pdf.id] = {
|
||||
'id': pdf.id,
|
||||
'position': pdf.position,
|
||||
'payload': payloadOf(encodePdfSourcePayload(pdf.content), 'pdf'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final files = <ExportFile>[];
|
||||
if (withProgress) {
|
||||
for (final answer in await _progress.answersOf(steps.keys.toSet())) {
|
||||
((steps[answer.stepId]!['answers'] ??= <Object?>[]) as List).add({
|
||||
'answeredAt': answer.answeredAt.toUtc().toIso8601String(),
|
||||
'outcome': answer.outcome,
|
||||
'answer': ?answer.answer,
|
||||
});
|
||||
}
|
||||
for (final reading in await _pdfs.readingsOf(pdfs.keys.toSet())) {
|
||||
((pdfs[reading.pdfId]!['readings'] ??= <Object?>[]) as List).add({
|
||||
'readAt': reading.readAt.toUtc().toIso8601String(),
|
||||
'kind': reading.kind,
|
||||
'page': reading.page,
|
||||
});
|
||||
}
|
||||
final reviews = await _reviews.reviewsOf(cardIds);
|
||||
if (reviews.isNotEmpty) {
|
||||
files.add(
|
||||
_file('reviews.json', [
|
||||
for (final review in reviews)
|
||||
{
|
||||
'card': review.cardId,
|
||||
'rating': review.rating.value,
|
||||
'reviewedAt': review.reviewedAt.toUtc().toIso8601String(),
|
||||
'durationMs': review.duration.inMilliseconds,
|
||||
'mode': review.mode.value,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The files the content names, and the PDFs'.
|
||||
final assetIds = {
|
||||
for (final item in items.values) ...assetIdsIn(item['payload']),
|
||||
for (final pdf in pdfs.values)
|
||||
if ((pdf['payload']! as Map)['asset'] case final String id) id,
|
||||
};
|
||||
final index = <String, Object?>{};
|
||||
final contents = <String, Uint8List>{};
|
||||
var missing = 0;
|
||||
for (final id in assetIds) {
|
||||
final file = await _fileOf(id);
|
||||
if (file == null) {
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
final hash = sha256.convert(file.bytes).toString();
|
||||
contents[hash] = file.bytes;
|
||||
index[id] = {
|
||||
'sha256': hash,
|
||||
'mimeType': file.mimeType,
|
||||
'size': file.bytes.length,
|
||||
'payload': _decoded(encodeAssetPayload(fileName: file.fileName)),
|
||||
};
|
||||
}
|
||||
if (missing > 0) {
|
||||
throw ArchiveFilesMissing(missing);
|
||||
}
|
||||
|
||||
return [
|
||||
_file('archive.json', {
|
||||
'format': ImportArchive.format,
|
||||
if (tags.isNotEmpty)
|
||||
'tags': [
|
||||
for (final tag
|
||||
in tags.values.toList()
|
||||
..sort((a, b) => a.name.compareTo(b.name)))
|
||||
{
|
||||
'name': tag.name,
|
||||
'color': tag.colorName,
|
||||
if (tag.fromJellyfin) 'origin': 'jellyfin',
|
||||
},
|
||||
],
|
||||
}),
|
||||
for (final MapEntry(key: path, value: item) in items.entries)
|
||||
_file(path, item),
|
||||
...files,
|
||||
if (index.isNotEmpty) _file('files.json', index),
|
||||
for (final MapEntry(key: hash, value: bytes) in contents.entries)
|
||||
(path: 'files/$hash', bytes: bytes),
|
||||
];
|
||||
}
|
||||
|
||||
static Map<String, Object?> _decoded(ByteData payload) =>
|
||||
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||
as Map<String, Object?>;
|
||||
|
||||
static const _encoder = JsonEncoder.withIndent(' ');
|
||||
|
||||
/// [json] as the file at [path]: keys sorted at every level, indented,
|
||||
/// ending in a new line, as the instance writes a release's.
|
||||
static ExportFile _file(String path, Object? json) => (
|
||||
path: path,
|
||||
bytes: utf8.encode('${_encoder.convert(_sorted(json))}\n'),
|
||||
);
|
||||
|
||||
static Object? _sorted(Object? json) => switch (json) {
|
||||
final Map<String, Object?> map => {
|
||||
for (final key in map.keys.toList()..sort()) key: _sorted(map[key]),
|
||||
},
|
||||
final List<Object?> list => [for (final value in list) _sorted(value)],
|
||||
_ => json,
|
||||
};
|
||||
}
|
||||
|
|
@ -6,9 +6,13 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
|
||||
/// What an import brought: the top studyset made, how many cards and
|
||||
/// reviews, and how many cards were left out, naming dictionary entries the
|
||||
|
|
@ -21,15 +25,20 @@ typedef ArchiveImported = ({
|
|||
});
|
||||
|
||||
/// Brings an archive ([ImportArchive]) into the person's library as their
|
||||
/// own: the studysets, cards, notes, learning paths and PDFs through the
|
||||
/// copier, as Explore's copy is written but linked to no publication; the
|
||||
/// cards naming a dictionary entry made from the instance's dictionary as
|
||||
/// adding from it makes them; then the reviews, so every card's schedule
|
||||
/// goes on from them (a schedule is replayed from its reviews).
|
||||
/// own: its tags in their colours (a tag the person has keeps theirs); the
|
||||
/// studysets, cards, notes, learning paths and PDFs through the copier, as
|
||||
/// Explore's copy is written but linked to no publication; the cards naming
|
||||
/// a dictionary entry made from the instance's dictionary as adding from it
|
||||
/// makes them; then the progress, so every schedule and path goes on from
|
||||
/// it (each is replayed from its log): the reviews, the answers to steps,
|
||||
/// the pages read; and which studysets are archived and starred.
|
||||
class ArchiveImporter(
|
||||
final ReleaseCopier _copier,
|
||||
final DictionaryRepository _dictionaries,
|
||||
final ReviewRepository _reviews,
|
||||
final PathProgressRepository _progress,
|
||||
final StudysetRepository _studysets,
|
||||
final PdfSourceRepository _pdfs,
|
||||
) {
|
||||
/// What the archive's cards say they came from, as an Anki deck's say
|
||||
/// "anki": the dashboard doesn't count them as written here.
|
||||
|
|
@ -47,6 +56,13 @@ class ArchiveImporter(
|
|||
PublishedRelease(publicId: '', version: 0, items: items),
|
||||
fileOf: (sha256) async => archive.files[sha256]!,
|
||||
cardOrigin: cardOrigin,
|
||||
tags: {
|
||||
for (final tag in archive.tags)
|
||||
canonicalTagName(tag.name): (
|
||||
colorName: tag.colorName,
|
||||
fromJellyfin: tag.fromJellyfin,
|
||||
),
|
||||
},
|
||||
onProgress: onProgress,
|
||||
);
|
||||
final reviews = [
|
||||
|
|
@ -66,6 +82,36 @@ class ArchiveImporter(
|
|||
),
|
||||
];
|
||||
await _reviews.recordAll(reviews);
|
||||
await _progress.recordAll([
|
||||
for (final answer in archive.answers)
|
||||
if (ids[answer.step] case final stepId?)
|
||||
(
|
||||
stepId: stepId,
|
||||
answeredAt: answer.answeredAt,
|
||||
outcome: answer.outcome,
|
||||
answer: answer.answer,
|
||||
),
|
||||
]);
|
||||
await _pdfs.recordReadings([
|
||||
for (final reading in archive.readings)
|
||||
if (ids[reading.pdf] case final pdfId?)
|
||||
(
|
||||
pdfId: pdfId,
|
||||
readAt: reading.readAt,
|
||||
kind: reading.kind,
|
||||
page: reading.page,
|
||||
),
|
||||
]);
|
||||
for (final id in archive.archived) {
|
||||
if (ids[id] case final studysetId?) {
|
||||
await _studysets.archive(studysetId);
|
||||
}
|
||||
}
|
||||
for (final id in archive.starred) {
|
||||
if (ids[id] case final studysetId?) {
|
||||
await _studysets.star(studysetId);
|
||||
}
|
||||
}
|
||||
return (
|
||||
studysetId: top,
|
||||
cards: archive.cards - missing,
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ ImportArchive readArchive(Uint8List bytes) {
|
|||
if (head == null) {
|
||||
throw const ArchiveUnreadable(ArchiveProblem.notAnArchive);
|
||||
}
|
||||
switch (json(head)) {
|
||||
final header = json(head);
|
||||
switch (header) {
|
||||
case {'format': final int format} when format > ImportArchive.format:
|
||||
throw const ArchiveUnreadable(ArchiveProblem.newerFormat);
|
||||
case {'format': int()}:
|
||||
|
|
@ -92,9 +93,112 @@ ImportArchive readArchive(Uint8List bytes) {
|
|||
if (!items.containsKey('studyset.json')) {
|
||||
throw const ArchiveUnreadable(ArchiveProblem.unreadable);
|
||||
}
|
||||
return ImportArchive(items: items, files: files, reviews: reviews);
|
||||
|
||||
// What the import of v0.17.0 leaves alone: keys of the files it reads.
|
||||
final answers = <ArchiveAnswer>[];
|
||||
final readings = <ArchiveReading>[];
|
||||
final archived = <String>{};
|
||||
final starred = <String>{};
|
||||
for (final MapEntry(key: path, value: item) in items.entries) {
|
||||
if (item['id'] is! String) {
|
||||
continue;
|
||||
}
|
||||
final id = item['id']! as String;
|
||||
final segments = path.split('/');
|
||||
if (segments.last == 'studyset.json') {
|
||||
if (item['archived'] == true) {
|
||||
archived.add(id);
|
||||
}
|
||||
if (item['starred'] == true) {
|
||||
starred.add(id);
|
||||
}
|
||||
} else if (segments.length >= 4 &&
|
||||
segments[segments.length - 2] == 'steps' &&
|
||||
segments[segments.length - 4] == 'paths') {
|
||||
answers.addAll(_answers(id, item['answers']));
|
||||
} else if (segments.length >= 2 &&
|
||||
segments[segments.length - 2] == 'pdfs') {
|
||||
readings.addAll(_readings(id, item['readings']));
|
||||
}
|
||||
}
|
||||
return ImportArchive(
|
||||
items: items,
|
||||
files: files,
|
||||
reviews: reviews,
|
||||
tags: _tags((header as Map<String, Object?>)['tags']),
|
||||
answers: answers,
|
||||
readings: readings,
|
||||
archived: archived,
|
||||
starred: starred,
|
||||
);
|
||||
}
|
||||
|
||||
/// [text] as a time in UTC; null for anything else.
|
||||
DateTime? _time(Object? text) => switch (text) {
|
||||
final String text => DateTime.tryParse(text)?.toUtc(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// The tags `archive.json` lists, none without.
|
||||
List<ArchiveTag> _tags(Object? json) => switch (json) {
|
||||
null => const [],
|
||||
final List<Object?> tags => [
|
||||
for (final tag in tags)
|
||||
switch (tag) {
|
||||
final Map<String, Object?> tag
|
||||
when tag['name'] is String && tag['color'] is String =>
|
||||
(
|
||||
name: tag['name']! as String,
|
||||
colorName: tag['color']! as String,
|
||||
fromJellyfin: tag['origin'] == 'jellyfin',
|
||||
),
|
||||
_ => throw const ArchiveUnreadable(ArchiveProblem.unreadable),
|
||||
},
|
||||
],
|
||||
_ => throw const ArchiveUnreadable(ArchiveProblem.unreadable),
|
||||
};
|
||||
|
||||
/// The answers the step [step] lists, none without.
|
||||
List<ArchiveAnswer> _answers(String step, Object? json) => switch (json) {
|
||||
null => const [],
|
||||
final List<Object?> answers => [
|
||||
for (final answer in answers)
|
||||
switch (answer) {
|
||||
final Map<String, Object?> answer
|
||||
when _time(answer['answeredAt']) != null &&
|
||||
answer['outcome'] is String &&
|
||||
(answer['answer'] is String?) =>
|
||||
(
|
||||
step: step,
|
||||
answeredAt: _time(answer['answeredAt'])!,
|
||||
outcome: answer['outcome']! as String,
|
||||
answer: answer['answer'] as String?,
|
||||
),
|
||||
_ => throw const ArchiveUnreadable(ArchiveProblem.unreadable),
|
||||
},
|
||||
],
|
||||
_ => throw const ArchiveUnreadable(ArchiveProblem.unreadable),
|
||||
};
|
||||
|
||||
/// The reading log the PDF [pdf] lists, none without.
|
||||
List<ArchiveReading> _readings(String pdf, Object? json) => switch (json) {
|
||||
null => const [],
|
||||
final List<Object?> readings => [
|
||||
for (final reading in readings)
|
||||
switch (reading) {
|
||||
{
|
||||
'readAt': final at,
|
||||
'kind': final String kind,
|
||||
'page': final int page,
|
||||
}
|
||||
when _time(at) != null && page >= 0 =>
|
||||
(pdf: pdf, readAt: _time(at)!, kind: kind, page: page),
|
||||
_ => throw const ArchiveUnreadable(ArchiveProblem.unreadable),
|
||||
},
|
||||
],
|
||||
_ => throw const ArchiveUnreadable(ArchiveProblem.unreadable),
|
||||
};
|
||||
|
||||
List<ArchiveReview> _reviews(Object? json) {
|
||||
if (json is! List) {
|
||||
throw const ArchiveUnreadable(ArchiveProblem.unreadable);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// What an archive holds, read and not written yet: a studyset with
|
||||
/// everything inside it, its files, and the reviews of its cards (decided
|
||||
/// everything inside it, its files, and the progress made in it (decided
|
||||
/// 2026-10-11, for backups, moves between instances and bringing a library
|
||||
/// from elsewhere).
|
||||
///
|
||||
|
|
@ -15,6 +15,15 @@ import 'dart:typed_data';
|
|||
/// dictionary does. `reviews.json` lists reviews of its cards by their ids
|
||||
/// in it: `{"card", "rating" (1–4), "reviewedAt" (ISO 8601), "durationMs",
|
||||
/// "mode"}`, the last two optional.
|
||||
///
|
||||
/// The rest is optional, kept where the import of v0.17.0, the first, leaves
|
||||
/// it alone (decided 2026-10-15: that import refuses a file it doesn't know,
|
||||
/// so the rest goes into files it reads, as keys it skips, and the format
|
||||
/// stays 1): `archive.json`'s `tags`, `[{"name", "color", "origin"}]` (an
|
||||
/// [ArchiveTag], `origin` only for `jellyfin`); a `studyset.json`'s
|
||||
/// `archived` and `starred` (true); a step's `answers`, `[{"answeredAt",
|
||||
/// "outcome", "answer"}]` (its learning path's log, `answer` optional); a
|
||||
/// PDF's `readings`, `[{"readAt", "kind", "page"}]` (its reading log).
|
||||
final class const ImportArchive({
|
||||
/// The release layout's files by their paths, each read as JSON.
|
||||
required final Map<String, Map<String, Object?>> items,
|
||||
|
|
@ -24,6 +33,21 @@ final class const ImportArchive({
|
|||
|
||||
/// The reviews of its cards.
|
||||
required final List<ArchiveReview> reviews,
|
||||
|
||||
/// The tags its items have, in their colours.
|
||||
final List<ArchiveTag> tags = const [],
|
||||
|
||||
/// The answers to its learning paths' steps.
|
||||
final List<ArchiveAnswer> answers = const [],
|
||||
|
||||
/// The reading logs of its PDFs.
|
||||
final List<ArchiveReading> readings = const [],
|
||||
|
||||
/// The studysets archived, by their ids in it.
|
||||
final Set<String> archived = const {},
|
||||
|
||||
/// The studysets starred, by their ids in it.
|
||||
final Set<String> starred = const {},
|
||||
}) {
|
||||
/// The format this app reads; a newer one is refused whole.
|
||||
static const format = 1;
|
||||
|
|
@ -56,3 +80,20 @@ typedef ArchiveReview = ({
|
|||
int durationMs,
|
||||
String mode,
|
||||
});
|
||||
|
||||
/// A tag of an archive: its name, its colour (a `PaletteColor`'s name), and
|
||||
/// whether it was made for Jellyfin.
|
||||
typedef ArchiveTag = ({String name, String colorName, bool fromJellyfin});
|
||||
|
||||
/// An answer to a step of an archive, by the step's id there, as the log of
|
||||
/// answers holds it.
|
||||
typedef ArchiveAnswer = ({
|
||||
String step,
|
||||
DateTime answeredAt,
|
||||
String outcome,
|
||||
String? answer,
|
||||
});
|
||||
|
||||
/// An entry of the reading log of a PDF of an archive, by the PDF's id
|
||||
/// there, as the log holds it.
|
||||
typedef ArchiveReading = ({String pdf, DateTime readAt, String kind, int page});
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ Future<String?> importArchive(BuildContext context) async {
|
|||
),
|
||||
dependencies.dictionaries,
|
||||
person.reviews,
|
||||
person.pathProgress,
|
||||
person.studysets,
|
||||
person.pdfSources,
|
||||
),
|
||||
)..start(file.bytes),
|
||||
child: const _ArchiveImport(),
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
|
||||
/// How a tag looks: its colour (a `PaletteColor`'s name), and whether it was
|
||||
/// made for Jellyfin.
|
||||
typedef TagLook = ({String colorName, bool fromJellyfin});
|
||||
|
||||
/// A release this app can't make a copy of: one of its items is in a
|
||||
/// schema newer than this app knows, or not what a release holds.
|
||||
|
|
@ -83,11 +88,14 @@ class ReleaseCopier(
|
|||
/// in the release, the id it has now (an archive's progress names its
|
||||
/// items by theirs). Otherwise as [copy].
|
||||
/// Its cards say they came from [cardOrigin] ("archive"), as imported
|
||||
/// ones do.
|
||||
/// ones do; a tag it makes looks as [tags] says (by its
|
||||
/// `canonicalTagName`), an archive keeping its colour and whether it was
|
||||
/// made for Jellyfin.
|
||||
Future<({String top, Map<String, String> ids})> write(
|
||||
PublishedRelease release, {
|
||||
required Future<Uint8List> Function(String sha256) fileOf,
|
||||
required String cardOrigin,
|
||||
Map<String, TagLook> tags = const {},
|
||||
void Function(double done)? onProgress,
|
||||
}) => _copy(
|
||||
release,
|
||||
|
|
@ -95,6 +103,7 @@ class ReleaseCopier(
|
|||
onProgress: onProgress,
|
||||
origin: null,
|
||||
cardOrigin: cardOrigin,
|
||||
tags: tags,
|
||||
);
|
||||
|
||||
Future<({String top, Map<String, String> ids})> _copy(
|
||||
|
|
@ -104,6 +113,7 @@ class ReleaseCopier(
|
|||
required PublishedRef Function(String id)? origin,
|
||||
String? location,
|
||||
String? cardOrigin,
|
||||
Map<String, TagLook> tags = const {},
|
||||
}) async {
|
||||
// Read all of it first: a copy is whole or not made.
|
||||
_read(release, const {});
|
||||
|
|
@ -215,9 +225,18 @@ class ReleaseCopier(
|
|||
}
|
||||
}
|
||||
|
||||
// A tag the person has keeps how it looks.
|
||||
final tagIds = <String, String>{};
|
||||
Future<Set<String>> tagsOf(List<String> names) async => {
|
||||
for (final name in names) tagIds[name] ??= await _tags.create(name),
|
||||
for (final name in names)
|
||||
tagIds[name] ??= switch (tags[canonicalTagName(name)]) {
|
||||
final look? => await _tags.create(
|
||||
name,
|
||||
colorName: look.colorName,
|
||||
fromJellyfin: look.fromJellyfin,
|
||||
),
|
||||
null => await _tags.create(name),
|
||||
},
|
||||
};
|
||||
|
||||
for (final directory in named.directories) {
|
||||
|
|
|
|||
|
|
@ -6,15 +6,104 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.d
|
|||
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/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_exporter.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.
|
||||
/// What a studyset is exported as.
|
||||
enum ExportFormat() {
|
||||
/// An archive, to import again (`ArchiveExporter`).
|
||||
archive,
|
||||
|
||||
/// Markdown, to read elsewhere (`StudysetMarkdownExporter`).
|
||||
markdown,
|
||||
}
|
||||
|
||||
/// What the person chose to export a studyset as, and whether with their
|
||||
/// progress (an archive only).
|
||||
typedef ExportChoice = ({ExportFormat format, bool withProgress});
|
||||
|
||||
/// Exports [studyset] and everything inside it as the person chooses in a
|
||||
/// sheet (decided 2026-10-15): an archive to import again, with their
|
||||
/// progress or without, first; or Markdown to read elsewhere. Kept where
|
||||
/// they choose; says how it went.
|
||||
Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
|
||||
final choice = await showAdaptiveSheet<ExportChoice>(
|
||||
context,
|
||||
builder: (context) => const _ExportSheet(),
|
||||
);
|
||||
if (choice == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
switch (choice.format) {
|
||||
case ExportFormat.archive:
|
||||
await _exportArchive(
|
||||
context,
|
||||
studyset,
|
||||
withProgress: choice.withProgress,
|
||||
);
|
||||
case ExportFormat.markdown:
|
||||
await _exportMarkdown(context, studyset);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exports [studyset] as an archive, as a `.zip` named after it; with
|
||||
/// [withProgress], the person's progress in it too.
|
||||
Future<void> _exportArchive(
|
||||
BuildContext context,
|
||||
Studyset studyset, {
|
||||
required bool withProgress,
|
||||
}) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final person = PersonScope.of(context);
|
||||
final saver = DependenciesScope.of(context).fileSaver;
|
||||
final archiver = DependenciesScope.of(context).exportArchiver;
|
||||
final exporter = ArchiveExporter(
|
||||
person.studysets,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.paths,
|
||||
person.pathProgress,
|
||||
person.pdfSources,
|
||||
person.tags,
|
||||
person.reviews,
|
||||
person.assets.fileOf,
|
||||
);
|
||||
void say(String message) => messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message)));
|
||||
|
||||
say(l10n.exportRunning);
|
||||
final bool saved;
|
||||
try {
|
||||
saved = await saver.save(
|
||||
name: '${_fileStem(studyset.content.title)}.zip',
|
||||
bytes: await archiver.zip(
|
||||
await exporter.export(studyset.id, withProgress: withProgress),
|
||||
),
|
||||
mimeType: 'application/zip',
|
||||
);
|
||||
} on ArchiveFilesMissing catch (missing) {
|
||||
say(l10n.exportArchiveFilesMissing(missing.count));
|
||||
return;
|
||||
} on Object catch (_) {
|
||||
say(l10n.exportFailed);
|
||||
return;
|
||||
}
|
||||
if (!saved) {
|
||||
messenger.hideCurrentSnackBar();
|
||||
return;
|
||||
}
|
||||
say(l10n.exportedArchive);
|
||||
}
|
||||
|
||||
/// Exports [studyset] as Markdown: a `.md` file, or a `.zip` of it and the
|
||||
/// files it links to when it has any.
|
||||
Future<void> _exportMarkdown(BuildContext context, Studyset studyset) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final person = PersonScope.of(context);
|
||||
|
|
@ -111,6 +200,73 @@ StudysetExportTexts exportTexts(AppLocalizations l10n) => (
|
|||
sources: l10n.sources,
|
||||
);
|
||||
|
||||
/// Asks what to export a studyset as: an archive (chosen at first, with
|
||||
/// the progress) or Markdown.
|
||||
class const _ExportSheet() extends StatefulWidget {
|
||||
@override
|
||||
State<_ExportSheet> createState() => _ExportSheetState();
|
||||
}
|
||||
|
||||
class _ExportSheetState() extends State<_ExportSheet> {
|
||||
var _format = ExportFormat.archive;
|
||||
var _withProgress = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return SheetScaffold(
|
||||
title: l10n.exportTitle,
|
||||
footer: FilledButton(
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
).pop<ExportChoice>((format: _format, withProgress: _withProgress)),
|
||||
child: Text(l10n.exportConfirm),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
RadioGroup<ExportFormat>(
|
||||
groupValue: _format,
|
||||
onChanged: (format) => setState(() => _format = format ?? _format),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (format, title, subtitle) in [
|
||||
(
|
||||
ExportFormat.archive,
|
||||
l10n.exportArchive,
|
||||
l10n.exportArchiveSubtitle,
|
||||
),
|
||||
(
|
||||
ExportFormat.markdown,
|
||||
l10n.exportMarkdown,
|
||||
l10n.exportMarkdownSubtitle,
|
||||
),
|
||||
])
|
||||
RadioListTile<ExportFormat>(
|
||||
value: format,
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_format == ExportFormat.archive)
|
||||
CheckboxListTile(
|
||||
value: _withProgress,
|
||||
onChanged: (value) =>
|
||||
setState(() => _withProgress = value ?? _withProgress),
|
||||
title: Text(l10n.exportWithProgress),
|
||||
subtitle: Text(l10n.exportWithProgressSubtitle),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [title] as a file's name: without what file systems refuse, or
|
||||
/// "studyset" when nothing is left.
|
||||
String _fileStem(String title) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ typedef AnswerRow = ({
|
|||
String? answer,
|
||||
});
|
||||
|
||||
/// An answer as the log holds it, without its row's id: what is written.
|
||||
typedef LoggedAnswer = ({
|
||||
String stepId,
|
||||
DateTime answeredAt,
|
||||
String outcome,
|
||||
String? answer,
|
||||
});
|
||||
|
||||
/// The boundary to the answer log of learning paths in this person's local,
|
||||
/// synced database. Tests replace it with a fake.
|
||||
abstract interface class PathAnswerGateway() {
|
||||
|
|
@ -14,10 +22,7 @@ abstract interface class PathAnswerGateway() {
|
|||
Future<List<AnswerRow>> answers({Set<String>? stepIds});
|
||||
|
||||
/// Appends [answers]; the ids given are ignored, each row gets its own.
|
||||
Future<void> insert(
|
||||
List<({String stepId, DateTime answeredAt, String outcome, String? answer})>
|
||||
answers,
|
||||
);
|
||||
Future<void> insert(List<LoggedAnswer> answers);
|
||||
|
||||
/// Deletes the fresh starts of the steps [stepIds] made at [at], to undo
|
||||
/// them.
|
||||
|
|
|
|||
|
|
@ -71,6 +71,28 @@ class PathProgressRepository(final PathAnswerGateway _gateway) {
|
|||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// The answers to the steps [stepIds] as the log holds them, fresh
|
||||
/// starts included, unordered: for an archive.
|
||||
Future<List<LoggedAnswer>> answersOf(Set<String> stepIds) async => [
|
||||
for (final row in await _gateway.answers(stepIds: stepIds))
|
||||
(
|
||||
stepId: row.stepId,
|
||||
answeredAt: row.answeredAt,
|
||||
outcome: row.outcome,
|
||||
answer: row.answer,
|
||||
),
|
||||
];
|
||||
|
||||
/// Appends [answers] as they were logged elsewhere (an archive's), each
|
||||
/// at its own time.
|
||||
Future<void> recordAll(List<LoggedAnswer> answers) async {
|
||||
if (answers.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _gateway.insert(answers);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// When steps were done (answered right, or passed), in UTC and in time
|
||||
/// order, for the dashboard's activity.
|
||||
Future<List<DateTime>> doneTimes() async => [
|
||||
|
|
|
|||
|
|
@ -34,10 +34,7 @@ class SyncPathAnswerGateway(
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> insert(
|
||||
List<({String stepId, DateTime answeredAt, String outcome, String? answer})>
|
||||
answers,
|
||||
) async {
|
||||
Future<void> insert(List<LoggedAnswer> answers) async {
|
||||
if (answers.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,6 +240,36 @@ class PdfSourceRepository(
|
|||
Future<Map<String, int>> furthestPages({Set<String>? pdfIds}) async =>
|
||||
furthestOf(await _gateway.readings(pdfIds: pdfIds));
|
||||
|
||||
/// The reading log of the PDFs [ids], fresh starts included, unordered:
|
||||
/// for an archive.
|
||||
Future<List<PdfReading>> readingsOf(Set<String> ids) async => [
|
||||
for (final row in await _gateway.readings(pdfIds: ids))
|
||||
(
|
||||
pdfId: row.pdfSourceId,
|
||||
readAt: row.readAt,
|
||||
kind: row.kind,
|
||||
page: row.page,
|
||||
),
|
||||
];
|
||||
|
||||
/// Appends [readings] as they were logged elsewhere (an archive's), each
|
||||
/// at its own time.
|
||||
Future<void> recordReadings(List<PdfReading> readings) =>
|
||||
_reading.run(() async {
|
||||
if (readings.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final reading in readings) {
|
||||
await _gateway.insertReading(
|
||||
pdfSourceId: reading.pdfId,
|
||||
readAt: reading.readAt,
|
||||
kind: reading.kind,
|
||||
page: reading.page,
|
||||
);
|
||||
}
|
||||
_changes.add(null);
|
||||
});
|
||||
|
||||
/// Records that [page] of the PDF [id] was reached now, if it is further
|
||||
/// than any reached since the last fresh start.
|
||||
Future<void> reached(String id, int page) => _reading.run(() async {
|
||||
|
|
@ -363,6 +393,10 @@ class PdfSourceRepository(
|
|||
}
|
||||
}
|
||||
|
||||
/// An entry of a PDF's reading log, without its row's id: the PDF, when,
|
||||
/// what it records ([PdfReadingKind], by name) and the page.
|
||||
typedef PdfReading = ({String pdfId, DateTime readAt, String kind, int page});
|
||||
|
||||
/// What an entry of a PDF's reading log records.
|
||||
enum PdfReadingKind() {
|
||||
/// A page reached.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,514 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.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/asset/model/stored_file.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_exporter.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.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/dictionary/data/dictionary_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/export/data/export_archive.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_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/progress.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/pdf_source/model/pdf_source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
||||
import '../../support/fake_asset_gateway.dart';
|
||||
import '../../support/fake_asset_server_gateway.dart';
|
||||
import '../../support/fake_card_gateway.dart';
|
||||
import '../../support/fake_dictionary_gateway.dart';
|
||||
import '../../support/fake_learning_path_gateway.dart';
|
||||
import '../../support/fake_note_gateway.dart';
|
||||
import '../../support/fake_path_answer_gateway.dart';
|
||||
import '../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../support/fake_review_gateway.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
import '../../support/fake_tag_gateway.dart';
|
||||
import '../../support/memory_asset_file_cache.dart';
|
||||
|
||||
/// One person's repositories, on fake gateways of their own.
|
||||
final class _Person() {
|
||||
final studysets = StudysetRepository(FakeStudysetGateway());
|
||||
final cards = CardRepository(FakeCardGateway());
|
||||
final notes = NoteRepository(FakeNoteGateway());
|
||||
final paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
final progress = PathProgressRepository(FakePathAnswerGateway());
|
||||
final reviews = ReviewRepository(FakeReviewGateway());
|
||||
final assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
);
|
||||
late final tags = TagRepository(
|
||||
FakeTagGateway(),
|
||||
cardChanges: cards.changes,
|
||||
noteChanges: notes.changes,
|
||||
pathChanges: paths.changes,
|
||||
);
|
||||
late final pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
|
||||
|
||||
ArchiveExporter exporter({
|
||||
Future<StoredFile?> Function(String assetId)? fileOf,
|
||||
}) => ArchiveExporter(
|
||||
studysets,
|
||||
cards,
|
||||
notes,
|
||||
paths,
|
||||
progress,
|
||||
pdfs,
|
||||
tags,
|
||||
reviews,
|
||||
fileOf ?? assets.fileOf,
|
||||
);
|
||||
|
||||
ArchiveImporter importer() => ArchiveImporter(
|
||||
ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets),
|
||||
DictionaryRepository(FakeDictionaryGateway()),
|
||||
reviews,
|
||||
progress,
|
||||
studysets,
|
||||
pdfs,
|
||||
);
|
||||
}
|
||||
|
||||
/// A person with a studyset of their own, Own, holding one of everything,
|
||||
/// so that nothing an import makes has the id it had where it was exported
|
||||
/// (the fakes number what they store from 1).
|
||||
Future<_Person> _personWithOwn() async {
|
||||
final person = _Person();
|
||||
final own = await person.studysets.create(StudysetContent.titled('Own'));
|
||||
final file = await person.assets.add(
|
||||
Uint8List.fromList(utf8.encode('%PDF-1.7 Own')),
|
||||
fileName: 'own.pdf',
|
||||
mimeType: AssetRepository.pdfMediaType,
|
||||
);
|
||||
await person.pdfs.add(
|
||||
own,
|
||||
PdfSourceContent(title: 'Own', fileName: 'own.pdf', assetId: file),
|
||||
);
|
||||
await person.cards.create(
|
||||
own,
|
||||
CardContent(front: RichContent.plain('x'), back: RichContent.plain('y')),
|
||||
);
|
||||
await person.notes.create(own, NoteContent(body: RichContent.plain('z')));
|
||||
final path = await person.paths.createPath(
|
||||
own,
|
||||
LearningPathContent.titled('Own'),
|
||||
);
|
||||
await person.paths.createStep(
|
||||
path,
|
||||
StepKind.info,
|
||||
StepContent.empty(StepKind.info),
|
||||
);
|
||||
return person;
|
||||
}
|
||||
|
||||
final _png = Uint8List.fromList([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
|
||||
final _pdfBytes = Uint8List.fromList(utf8.encode('%PDF-1.7 Genki'));
|
||||
final _start = DateTime.utc(2026, 10, 1, 8);
|
||||
final _report = CardReport(
|
||||
category: ReportCategory.typo,
|
||||
at: _start,
|
||||
note: 'a, not o',
|
||||
);
|
||||
const _deadline = ReadingDeadline(
|
||||
targetPage: 40,
|
||||
due: (year: 2026, month: 11, day: 30),
|
||||
);
|
||||
final _exam = ExamChoice(plan: 'n5', date: DateTime(2026, 12, 6));
|
||||
|
||||
/// [action] done [minutes] after [_start].
|
||||
Future<T> _at<T>(int minutes, Future<T> Function() action) => withClock(
|
||||
Clock.fixed(_start.add(Duration(minutes: minutes))),
|
||||
action,
|
||||
);
|
||||
|
||||
/// A page tied to [pdf]'s third page: a screenshot of it.
|
||||
RichContent _screenshot(String image, String pdf) => RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl(image), imagePdfKey: pdf, 'page': 3},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/// The screenshot's attributes in [content].
|
||||
Map<String, Object?> _imageOf(RichContent content) =>
|
||||
((((content.toJson()['editor']! as Map)['document']! as Map)['children']!
|
||||
as List)
|
||||
.single
|
||||
as Map)['data']
|
||||
as Map<String, Object?>;
|
||||
|
||||
/// What [_library] made, by name.
|
||||
typedef _Made = ({
|
||||
String japanese,
|
||||
String grammar,
|
||||
String old,
|
||||
String kana,
|
||||
String step,
|
||||
String pdf,
|
||||
String image,
|
||||
});
|
||||
|
||||
/// A copy of a published Japanese studyset: starred, preparing for an exam,
|
||||
/// with Grammar inside (archived; a learning path, its step answered,
|
||||
/// started afresh and answered again), Vocabulary (a PDF, read to page 5,
|
||||
/// started afresh, read to page 2, with a deadline; a card reported and
|
||||
/// reviewed, started afresh and reviewed back to front; a card and a note
|
||||
/// tied to the PDF's third page, the card showing a screenshot of it) and
|
||||
/// Old, waiting in Recently deleted.
|
||||
Future<_Made> _library(_Person a) async {
|
||||
final image = await a.assets.add(
|
||||
_png,
|
||||
fileName: 'page-3.png',
|
||||
mimeType: 'image/png',
|
||||
);
|
||||
final file = await a.assets.add(
|
||||
_pdfBytes,
|
||||
fileName: 'genki.pdf',
|
||||
mimeType: AssetRepository.pdfMediaType,
|
||||
);
|
||||
const origin = PublishedRef(publication: 'jp', item: 'top', version: 3);
|
||||
final japanese = await a.studysets.create(
|
||||
StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.plain('Hiragana first.'),
|
||||
license: 'CC-BY-SA-4.0',
|
||||
published: origin,
|
||||
publishedAt: 'learn.halkyon.eu',
|
||||
),
|
||||
);
|
||||
final grammar = await a.studysets.create(
|
||||
StudysetContent.titled('Grammar'),
|
||||
parentId: japanese,
|
||||
);
|
||||
final vocabulary = await a.studysets.create(
|
||||
StudysetContent.titled('Vocabulary'),
|
||||
parentId: japanese,
|
||||
);
|
||||
final old = await a.studysets.create(
|
||||
StudysetContent.titled('Old'),
|
||||
parentId: japanese,
|
||||
);
|
||||
await a.studysets.markDeleted(old);
|
||||
final top = (await a.studysets.all()).singleWhere((s) => s.id == japanese);
|
||||
await a.studysets.updateContent(
|
||||
japanese,
|
||||
top.content
|
||||
.withExamPlans([
|
||||
ExamPlan(id: 'n5', name: 'JLPT N5', topics: [grammar]),
|
||||
])
|
||||
.withExam(_exam),
|
||||
);
|
||||
await a.studysets.star(japanese);
|
||||
await a.studysets.archive(grammar);
|
||||
|
||||
final path = await a.paths.createPath(
|
||||
grammar,
|
||||
LearningPathContent.titled('Particles'),
|
||||
);
|
||||
final (:stepId, revisionId: _) = await a.paths.createStep(
|
||||
path,
|
||||
StepKind.info,
|
||||
StepContent.empty(
|
||||
StepKind.info,
|
||||
).withField(StepField.body, RichContent.plain('は marks the topic.')),
|
||||
);
|
||||
await _at(1, () => a.progress.record(stepId, AnswerOutcome.continued));
|
||||
await _at(2, () => a.progress.reset({stepId}));
|
||||
await _at(3, () => a.progress.record(stepId, AnswerOutcome.continued));
|
||||
|
||||
final pdf = await a.pdfs.add(
|
||||
vocabulary,
|
||||
PdfSourceContent(
|
||||
title: 'Genki',
|
||||
fileName: 'genki.pdf',
|
||||
assetId: file,
|
||||
pageCount: 40,
|
||||
deadline: _deadline,
|
||||
published: origin,
|
||||
),
|
||||
);
|
||||
await _at(4, () => a.pdfs.reached(pdf, 5));
|
||||
await _at(5, () => a.pdfs.resetReading(pdf));
|
||||
await _at(6, () => a.pdfs.reached(pdf, 2));
|
||||
|
||||
final kana = await a.cards.create(
|
||||
vocabulary,
|
||||
CardContent(
|
||||
front: RichContent.plain('あ'),
|
||||
back: RichContent.plain('a'),
|
||||
report: _report,
|
||||
published: origin,
|
||||
),
|
||||
);
|
||||
final page = await a.cards.create(
|
||||
vocabulary,
|
||||
CardContent(
|
||||
front: RichContent.plain('です'),
|
||||
back: _screenshot(image, pdf),
|
||||
source: (pdfId: pdf, page: 3),
|
||||
),
|
||||
);
|
||||
final note = await a.notes.create(
|
||||
vocabulary,
|
||||
NoteContent(
|
||||
title: 'Lesson 1',
|
||||
body: RichContent.plain('です ends a sentence politely.'),
|
||||
source: (pdfId: pdf, page: 3),
|
||||
),
|
||||
);
|
||||
final kanaTag = await a.tags.create(
|
||||
'Kana',
|
||||
colorName: PaletteColor.copper.name,
|
||||
);
|
||||
final episode = await a.tags.create('あかね噺 S1 E1', fromJellyfin: true);
|
||||
await a.tags.setTagsOf(kana, {kanaTag});
|
||||
await a.tags.setTagsOf(page, {episode});
|
||||
await a.tags.setTagsOfNote(note.noteId, {kanaTag});
|
||||
|
||||
await a.reviews.record(
|
||||
cardId: kana,
|
||||
rating: Rating.good,
|
||||
reviewedAt: _start.add(const Duration(minutes: 7)),
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
await a.reviews.reset({kana}, _start.add(const Duration(minutes: 8)));
|
||||
await a.reviews.record(
|
||||
cardId: kana,
|
||||
rating: Rating.hard,
|
||||
reviewedAt: _start.add(const Duration(minutes: 9)),
|
||||
duration: const Duration(seconds: 2),
|
||||
mode: StudyMode.reverse,
|
||||
);
|
||||
return (
|
||||
japanese: japanese,
|
||||
grammar: grammar,
|
||||
old: old,
|
||||
kana: kana,
|
||||
step: stepId,
|
||||
pdf: pdf,
|
||||
image: image,
|
||||
);
|
||||
}
|
||||
|
||||
/// A review as the log holds it, without its id and card.
|
||||
(Rating, DateTime, Duration, StudyMode) _logged(Review review) =>
|
||||
(review.rating, review.reviewedAt, review.duration, review.mode);
|
||||
|
||||
void main() {
|
||||
test('a studyset comes back from its archive as it was, as the '
|
||||
"person's own, unlinked from Explore: the studysets inside it in "
|
||||
'their order, but those waiting in Recently deleted; starred and '
|
||||
'archived; the exam it prepares for; its cards, notes, paths and '
|
||||
"PDFs with their files, tags in their colours, a card's report and "
|
||||
"a PDF's deadline; what is tied to a PDF's page tied to the PDF "
|
||||
'made of it; the reviews, answers and pages read, fresh starts '
|
||||
'included', () async {
|
||||
final a = _Person();
|
||||
final made = await _library(a);
|
||||
|
||||
final b = await _personWithOwn();
|
||||
final imported = await b.importer().import(
|
||||
readArchive(
|
||||
zipExport(await a.exporter().export(made.japanese, withProgress: true)),
|
||||
),
|
||||
);
|
||||
|
||||
final all = await b.studysets.all();
|
||||
final japanese = all.singleWhere((s) => s.id == imported.studysetId);
|
||||
expect(
|
||||
childrenOf(japanese.id, all).map((s) => s.content.title),
|
||||
['Grammar', 'Vocabulary'],
|
||||
);
|
||||
expect(all, hasLength(4));
|
||||
final grammar = all.singleWhere((s) => s.content.title == 'Grammar');
|
||||
final vocabulary = all.singleWhere((s) => s.content.title == 'Vocabulary');
|
||||
expect(japanese.isStarred, isTrue);
|
||||
expect(grammar.isArchived, isTrue);
|
||||
expect(vocabulary.isStarred || vocabulary.isArchived, isFalse);
|
||||
expect(japanese.content.license, 'CC-BY-SA-4.0');
|
||||
expect(japanese.content.published, isNull);
|
||||
expect(japanese.content.publishedAt, isNull);
|
||||
expect(japanese.content.examPlans.single.topics, [grammar.id]);
|
||||
expect(japanese.content.exam, _exam);
|
||||
|
||||
final pdf = (await b.pdfs.sourcesOf(vocabulary.id)).single;
|
||||
expect(pdf.content.deadline, _deadline);
|
||||
expect(pdf.content.published, isNull);
|
||||
expect((await b.assets.fileOf(pdf.content.assetId))?.bytes, _pdfBytes);
|
||||
expect(await b.pdfs.furthestPages(), {pdf.id: 2});
|
||||
|
||||
final cards = await b.cards.cardsOf(vocabulary.id);
|
||||
expect(cards.map((c) => c.content.front.plainText), ['あ', 'です']);
|
||||
final [kana, page] = cards;
|
||||
expect(kana.content.report, _report);
|
||||
expect(kana.content.published, isNull);
|
||||
expect(page.content.source, (pdfId: pdf.id, page: 3));
|
||||
final screenshot = _imageOf(page.content.back);
|
||||
expect(screenshot[imagePdfKey], pdf.id);
|
||||
final image = await b.assets.fileOf(assetIdOf(screenshot['url'])!);
|
||||
expect(image?.bytes, _png);
|
||||
expect(image?.fileName, 'page-3.png');
|
||||
|
||||
final note = (await b.notes.notesOf(vocabulary.id)).single;
|
||||
expect(note.content.source, (pdfId: pdf.id, page: 3));
|
||||
|
||||
final tagging = await b.tags.read();
|
||||
final kanaTag = tagging.of(kana.id).single;
|
||||
expect(kanaTag.name, 'Kana');
|
||||
expect(kanaTag.colorName, PaletteColor.copper.name);
|
||||
expect(tagging.ofNote(note.id), [kanaTag]);
|
||||
expect(tagging.of(page.id).single.fromJellyfin, isTrue);
|
||||
|
||||
final path = (await b.paths.pathsOf(grammar.id)).single;
|
||||
final step = (await b.paths.stepsOf(path.id)).single;
|
||||
expect(
|
||||
[
|
||||
for (final answer in await b.progress.answersOf({step.id}))
|
||||
(answer.answeredAt, answer.outcome, answer.answer),
|
||||
],
|
||||
unorderedEquals([
|
||||
for (final answer in await a.progress.answersOf({made.step}))
|
||||
(answer.answeredAt, answer.outcome, answer.answer),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await b.reviews.reviewsOf({kana.id})).map(_logged),
|
||||
unorderedEquals((await a.reviews.reviewsOf({made.kana})).map(_logged)),
|
||||
);
|
||||
expect(imported.reviews, 3);
|
||||
});
|
||||
|
||||
test('without the progress it holds the content alone, as a release '
|
||||
'does: no reviews, answers or pages read, no exam prepared for, '
|
||||
'reports or reading deadlines; stars, the archive and tag colours '
|
||||
'stay', () async {
|
||||
final a = _Person();
|
||||
final made = await _library(a);
|
||||
|
||||
final files = await a.exporter().export(
|
||||
made.japanese,
|
||||
withProgress: false,
|
||||
);
|
||||
expect(files.map((file) => file.path), isNot(contains('reviews.json')));
|
||||
final b = await _personWithOwn();
|
||||
await b.importer().import(readArchive(zipExport(files)));
|
||||
|
||||
final all = await b.studysets.all();
|
||||
final japanese = all.singleWhere((s) => s.content.title == 'Japanese');
|
||||
expect(japanese.content.exam, isNull);
|
||||
expect(japanese.content.examPlans, isNotEmpty);
|
||||
expect(japanese.isStarred, isTrue);
|
||||
expect(
|
||||
all.singleWhere((s) => s.content.title == 'Grammar').isArchived,
|
||||
isTrue,
|
||||
);
|
||||
expect(await b.reviews.all(), isEmpty);
|
||||
expect(await b.pdfs.furthestPages(), isEmpty);
|
||||
expect(
|
||||
(await b.pdfs.all()).map((pdf) => pdf.content.deadline),
|
||||
[null, null],
|
||||
);
|
||||
final cards = await b.cards.cardsOf(
|
||||
all.singleWhere((s) => s.content.title == 'Vocabulary').id,
|
||||
);
|
||||
expect(cards.map((card) => card.content.report), [null, null]);
|
||||
expect(
|
||||
(await b.tags.read()).named('Kana')?.colorName,
|
||||
PaletteColor.copper.name,
|
||||
);
|
||||
final grammar = all.singleWhere((s) => s.content.title == 'Grammar');
|
||||
final path = (await b.paths.pathsOf(grammar.id)).single;
|
||||
final step = (await b.paths.stepsOf(path.id)).single;
|
||||
expect(await b.progress.answersOf({step.id}), isEmpty);
|
||||
});
|
||||
|
||||
test('keeps to the files the import of v0.17.0 reads, which refuses a '
|
||||
'file of a kind it does not know: the progress beyond reviews sits '
|
||||
'in them as keys it leaves alone; each studyset inside in a '
|
||||
'directory named by its id', () async {
|
||||
final a = _Person();
|
||||
final made = await _library(a);
|
||||
|
||||
final files = await a.exporter().export(made.japanese, withProgress: true);
|
||||
|
||||
final item = RegExp(
|
||||
r'^([^/]+/)*(studyset\.json|(cards|notes|pdfs)/[^/]+\.json'
|
||||
r'|paths/[^/]+/(path\.json|steps/[^/]+\.json))$',
|
||||
);
|
||||
for (final file in files) {
|
||||
expect(
|
||||
const {'archive.json', 'reviews.json', 'files.json'}.contains(
|
||||
file.path,
|
||||
) ||
|
||||
file.path.startsWith('files/') ||
|
||||
item.hasMatch(file.path),
|
||||
isTrue,
|
||||
reason: file.path,
|
||||
);
|
||||
}
|
||||
Object? jsonOf(String path) => jsonDecode(
|
||||
utf8.decode(files.singleWhere((file) => file.path == path).bytes),
|
||||
);
|
||||
expect(jsonOf('archive.json'), containsPair('format', 1));
|
||||
expect(
|
||||
jsonOf('${made.grammar}/studyset.json'),
|
||||
containsPair('archived', true),
|
||||
);
|
||||
expect(
|
||||
files.where((file) => file.path.startsWith('${made.old}/')),
|
||||
isEmpty,
|
||||
);
|
||||
for (final review in jsonOf('reviews.json')! as List<Object?>) {
|
||||
expect((review! as Map)['rating'], inInclusiveRange(1, 4));
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses to leave out a file this device cannot have now, saying '
|
||||
'how many: an archive missing them would look whole', () async {
|
||||
final a = _Person();
|
||||
final made = await _library(a);
|
||||
|
||||
await expectLater(
|
||||
a
|
||||
.exporter(
|
||||
fileOf: (id) async =>
|
||||
id == made.image ? null : await a.assets.fileOf(id),
|
||||
)
|
||||
.export(made.japanese, withProgress: true),
|
||||
throwsA(
|
||||
isA<ArchiveFilesMissing>().having((e) => e.count, 'count', 1),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.da
|
|||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_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';
|
||||
|
|
@ -34,6 +35,7 @@ import '../../support/fake_card_gateway.dart';
|
|||
import '../../support/fake_dictionary_gateway.dart';
|
||||
import '../../support/fake_learning_path_gateway.dart';
|
||||
import '../../support/fake_note_gateway.dart';
|
||||
import '../../support/fake_path_answer_gateway.dart';
|
||||
import '../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../support/fake_review_gateway.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
|
|
@ -161,6 +163,90 @@ void main() {
|
|||
),
|
||||
refused(ArchiveProblem.unreadable),
|
||||
);
|
||||
for (final unreadable in <Map<String, Object>>[
|
||||
{
|
||||
'archive.json': {'format': 1, 'tags': 'Kana'},
|
||||
},
|
||||
{
|
||||
'pdfs/book.json': {
|
||||
'id': 'book',
|
||||
'readings': [
|
||||
{'readAt': 'yesterday', 'kind': 'reached', 'page': 3},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'paths/drill/steps/go.json': {
|
||||
'id': 'go',
|
||||
'answers': [
|
||||
{'answeredAt': '2026-06-09T10:00:00Z', 'outcome': 1},
|
||||
],
|
||||
},
|
||||
},
|
||||
]) {
|
||||
expect(
|
||||
() => readArchive(zip({...japanese(), ...unreadable})),
|
||||
refused(ArchiveProblem.unreadable),
|
||||
reason: '$unreadable',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('reads the progress kept where v0.17.0 leaves it alone: tags in '
|
||||
'their colours, archived and starred studysets, answers to steps '
|
||||
'and pages read', () {
|
||||
final archive = readArchive(
|
||||
zip({
|
||||
...japanese(),
|
||||
'archive.json': {
|
||||
'format': 1,
|
||||
'tags': [
|
||||
{'name': 'Kana', 'color': 'copper'},
|
||||
{'name': 'あかね噺 S1 E1', 'color': 'cobalt', 'origin': 'jellyfin'},
|
||||
],
|
||||
},
|
||||
'vocabulary/studyset.json': {
|
||||
...japanese()['vocabulary/studyset.json']! as Map<String, Object?>,
|
||||
'archived': true,
|
||||
'starred': true,
|
||||
},
|
||||
'pdfs/book.json': {
|
||||
'id': 'book',
|
||||
'readings': [
|
||||
{'readAt': '2026-06-09T10:00:00Z', 'kind': 'reached', 'page': 3},
|
||||
],
|
||||
},
|
||||
'paths/drill/steps/go.json': {
|
||||
'id': 'go',
|
||||
'answers': [
|
||||
{'answeredAt': '2026-06-09T11:00:00Z', 'outcome': 'continued'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(archive.tags, [
|
||||
(name: 'Kana', colorName: 'copper', fromJellyfin: false),
|
||||
(name: 'あかね噺 S1 E1', colorName: 'cobalt', fromJellyfin: true),
|
||||
]);
|
||||
expect(archive.archived, {'vocabulary'});
|
||||
expect(archive.starred, {'vocabulary'});
|
||||
expect(archive.readings, [
|
||||
(
|
||||
pdf: 'book',
|
||||
readAt: DateTime.utc(2026, 6, 9, 10),
|
||||
kind: 'reached',
|
||||
page: 3,
|
||||
),
|
||||
]);
|
||||
expect(archive.answers, [
|
||||
(
|
||||
step: 'go',
|
||||
answeredAt: DateTime.utc(2026, 6, 9, 11),
|
||||
outcome: 'continued',
|
||||
answer: null,
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -207,9 +293,40 @@ void main() {
|
|||
),
|
||||
),
|
||||
reviews,
|
||||
PathProgressRepository(FakePathAnswerGateway()),
|
||||
studysets,
|
||||
pdfs,
|
||||
);
|
||||
});
|
||||
|
||||
test('an archive this app cannot read all of writes nothing, not even '
|
||||
'the tags it lists', () async {
|
||||
final archive = readArchive(
|
||||
zip({
|
||||
...japanese(),
|
||||
'archive.json': {
|
||||
'format': 1,
|
||||
'tags': [
|
||||
{'name': 'Kana', 'color': 'copper'},
|
||||
],
|
||||
},
|
||||
'cards/future.json': {
|
||||
'id': 'future',
|
||||
'position': 'a9',
|
||||
'tags': ['Kana'],
|
||||
'payload': {'schemaVersion': cardSchemaVersion + 1},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
importer.import(archive),
|
||||
throwsA(isA<ReleaseUnreadable>()),
|
||||
);
|
||||
expect((await tags.read()).all, isEmpty);
|
||||
expect(await studysets.all(), isEmpty);
|
||||
});
|
||||
|
||||
test('content naming a studyset of the archive, as a study button '
|
||||
'does, names it as it is in the library', () async {
|
||||
final archive = {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@ 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_payload.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/export/widget/export_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.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';
|
||||
|
|
@ -16,6 +20,7 @@ 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_review_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
|
|
@ -27,6 +32,7 @@ void main() {
|
|||
late FakeAssetGateway assetGateway;
|
||||
late MemoryAssetFileCache cache;
|
||||
late FakeFileSaver saver;
|
||||
late FakeReviewGateway reviewGateway;
|
||||
|
||||
setUp(() {
|
||||
studysets = FakeStudysetGateway()
|
||||
|
|
@ -38,15 +44,23 @@ void main() {
|
|||
assetGateway = FakeAssetGateway();
|
||||
cache = MemoryAssetFileCache();
|
||||
saver = FakeFileSaver();
|
||||
reviewGateway = FakeReviewGateway();
|
||||
});
|
||||
|
||||
Future<void> card(CardContent content) async {
|
||||
Future<String> card(CardContent content) {
|
||||
final cards = CardRepository(cardGateway);
|
||||
addTearDown(cards.dispose);
|
||||
await cards.create('spanish', content);
|
||||
return cards.create('spanish', content);
|
||||
}
|
||||
|
||||
Future<void> exportFromMenu(WidgetTester tester) async {
|
||||
/// Opens the studyset's Export content, and, unless [choose] is null,
|
||||
/// chooses it (the format's name, as the sheet shows it), sets
|
||||
/// [withProgress] and exports.
|
||||
Future<void> exportFromMenu(
|
||||
WidgetTester tester, {
|
||||
String? choose = 'Markdown',
|
||||
bool withProgress = true,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1200, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
|
@ -58,6 +72,7 @@ void main() {
|
|||
assetGateway: assetGateway,
|
||||
assetFiles: cache,
|
||||
fileSaver: saver,
|
||||
reviewGateway: reviewGateway,
|
||||
),
|
||||
location: RoutePaths.studyset('spanish'),
|
||||
);
|
||||
|
|
@ -72,9 +87,139 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Export content'));
|
||||
await tester.pumpAndSettle();
|
||||
if (choose == null) {
|
||||
return;
|
||||
}
|
||||
await tester.tap(find.text(choose));
|
||||
await tester.pumpAndSettle();
|
||||
if (!withProgress) {
|
||||
await tester.tap(find.text('With my progress'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Export'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('Export content keeps the studyset as a Markdown file, named '
|
||||
/// The archive saved, by its files' paths.
|
||||
Map<String, List<int>> savedArchive() => {
|
||||
for (final file in ZipDecoder().decodeBytes(saver.saved.single.bytes).files)
|
||||
file.name: file.content,
|
||||
};
|
||||
|
||||
testWidgets('Export content offers an archive first, with the progress '
|
||||
'and Markdown below; the progress is only asked about for the '
|
||||
'archive', (tester) async {
|
||||
await exportFromMenu(tester, choose: null);
|
||||
|
||||
expect(
|
||||
tester
|
||||
.widget<RadioGroup<ExportFormat>>(
|
||||
find.byType(RadioGroup<ExportFormat>),
|
||||
)
|
||||
.groupValue,
|
||||
ExportFormat.archive,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<CheckboxListTile>(
|
||||
find.widgetWithText(CheckboxListTile, 'With my progress'),
|
||||
)
|
||||
.value,
|
||||
isTrue,
|
||||
);
|
||||
await tester.tap(find.text('Markdown'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('With my progress'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('an archive keeps the studyset with its reviews as a zip '
|
||||
'named after it, which the import takes back', (tester) async {
|
||||
final hola = await card(
|
||||
CardContent(
|
||||
front: RichContent.plain('hola'),
|
||||
back: RichContent.plain('hello'),
|
||||
),
|
||||
);
|
||||
await ReviewRepository(reviewGateway).record(
|
||||
cardId: hola,
|
||||
rating: Rating.good,
|
||||
reviewedAt: DateTime.utc(2026, 10),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
await exportFromMenu(tester, choose: 'Archive');
|
||||
|
||||
final saved = saver.saved.single;
|
||||
expect(saved.name, 'Spanish vocab.zip');
|
||||
expect(saved.mimeType, 'application/zip');
|
||||
final files = savedArchive();
|
||||
expect(
|
||||
files.keys,
|
||||
containsAll(['archive.json', 'studyset.json', 'cards/$hola.json']),
|
||||
);
|
||||
expect(utf8.decode(files['reviews.json']!), contains('"card": "$hola"'));
|
||||
expect(find.text('Exported as an archive (.zip).'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('without the progress, an archive holds no reviews', (
|
||||
tester,
|
||||
) async {
|
||||
final hola = await card(
|
||||
CardContent(
|
||||
front: RichContent.plain('hola'),
|
||||
back: RichContent.plain('hello'),
|
||||
),
|
||||
);
|
||||
await ReviewRepository(reviewGateway).record(
|
||||
cardId: hola,
|
||||
rating: Rating.good,
|
||||
reviewedAt: DateTime.utc(2026, 10),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
await exportFromMenu(tester, choose: 'Archive', withProgress: false);
|
||||
|
||||
expect(savedArchive().keys, isNot(contains('reviews.json')));
|
||||
});
|
||||
|
||||
testWidgets('an archive missing a file this device cannot have now is '
|
||||
'not kept, and says so', (tester) async {
|
||||
final elsewhere = await assetGateway.insert(
|
||||
sha256: 'f' * 64,
|
||||
mimeType: 'image/png',
|
||||
byteSize: 3,
|
||||
payload: encodeAssetPayload(fileName: 'cat.png'),
|
||||
);
|
||||
await card(
|
||||
CardContent(
|
||||
front: RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl(elsewhere)},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
back: RichContent.plain('gato'),
|
||||
),
|
||||
);
|
||||
|
||||
await exportFromMenu(tester, choose: 'Archive');
|
||||
|
||||
expect(saver.saved, isEmpty);
|
||||
expect(
|
||||
find.text(
|
||||
"1 file isn't on this device and couldn't be downloaded. Connect "
|
||||
'to the internet and try again.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('as Markdown, it keeps the studyset as a Markdown file, named '
|
||||
'after it', (tester) async {
|
||||
await card(
|
||||
CardContent(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue