feat(archive): import a studyset with everything in it and its progress
Your call (2026-10-11): an archive, imported from the Library's Import, to
bring a library from elsewhere as one's own, and later to restore backups
and move studysets between instances before federation.
An archive is a .zip: archive.json says its format; the studyset is laid
out as a release (ADR 0005, rule 3), so the release copier writes it, now
also unlinked from any publication (ReleaseCopier.write), its cards marked
"archive" as imported ones are, handing back each item's new id. A card may
name a dictionary entry instead of its payload: the import makes it from
the instance's dictionary as adding from it does, with today's entry,
examples and tags, and leaves out (counting) one the instance lacks; for
that, DictionaryEndpoint.entries gives entries by their ids, at most 1,000
at once. reviews.json lists reviews of the archive's cards; they go into
the person's review log once the cards are in, so every card's schedule
goes on from them.
The sheet says what the archive holds ("Japanese" holds: 12 studysets,
7,745 flashcards, 3 paths, 20,317 reviews), imports once confirmed, busy
meanwhile, and opens the studyset made. Not an archive, a newer format or
an unreadable one says so and imports nothing. No export into the format
yet.
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>
2026-10-11 16:39:07 +02:00
|
|
|
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';
|
2026-10-11 16:50:26 +02:00
|
|
|
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
feat(archive): import a studyset with everything in it and its progress
Your call (2026-10-11): an archive, imported from the Library's Import, to
bring a library from elsewhere as one's own, and later to restore backups
and move studysets between instances before federation.
An archive is a .zip: archive.json says its format; the studyset is laid
out as a release (ADR 0005, rule 3), so the release copier writes it, now
also unlinked from any publication (ReleaseCopier.write), its cards marked
"archive" as imported ones are, handing back each item's new id. A card may
name a dictionary entry instead of its payload: the import makes it from
the instance's dictionary as adding from it does, with today's entry,
examples and tags, and leaves out (counting) one the instance lacks; for
that, DictionaryEndpoint.entries gives entries by their ids, at most 1,000
at once. reviews.json lists reviews of the archive's cards; they go into
the person's review log once the cards are in, so every card's schedule
goes on from them.
The sheet says what the archive holds ("Japanese" holds: 12 studysets,
7,745 flashcards, 3 paths, 20,317 reviews), imports once confirmed, busy
meanwhile, and opens the studyset made. Not an archive, a newer format or
an unreadable one says so and imports nothing. No export into the format
yet.
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>
2026-10-11 16:39:07 +02:00
|
|
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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_payload.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';
|
2026-10-11 16:50:26 +02:00
|
|
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
|
feat(archive): import a studyset with everything in it and its progress
Your call (2026-10-11): an archive, imported from the Library's Import, to
bring a library from elsewhere as one's own, and later to restore backups
and move studysets between instances before federation.
An archive is a .zip: archive.json says its format; the studyset is laid
out as a release (ADR 0005, rule 3), so the release copier writes it, now
also unlinked from any publication (ReleaseCopier.write), its cards marked
"archive" as imported ones are, handing back each item's new id. A card may
name a dictionary entry instead of its payload: the import makes it from
the instance's dictionary as adding from it does, with today's entry,
examples and tags, and leaves out (counting) one the instance lacks; for
that, DictionaryEndpoint.entries gives entries by their ids, at most 1,000
at once. reviews.json lists reviews of the archive's cards; they go into
the person's review log once the cards are in, so every card's schedule
goes on from them.
The sheet says what the archive holds ("Japanese" holds: 12 studysets,
7,745 flashcards, 3 paths, 20,317 reviews), imports once confirmed, busy
meanwhile, and opens the studyset made. Not an archive, a newer format or
an unreadable one says so and imports nothing. No export into the format
yet.
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>
2026-10-11 16:39:07 +02:00
|
|
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
2026-10-11 16:50:26 +02:00
|
|
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
|
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>
2026-10-15 18:57:16 +02:00
|
|
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
2026-10-11 16:50:26 +02:00
|
|
|
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';
|
feat(archive): import a studyset with everything in it and its progress
Your call (2026-10-11): an archive, imported from the Library's Import, to
bring a library from elsewhere as one's own, and later to restore backups
and move studysets between instances before federation.
An archive is a .zip: archive.json says its format; the studyset is laid
out as a release (ADR 0005, rule 3), so the release copier writes it, now
also unlinked from any publication (ReleaseCopier.write), its cards marked
"archive" as imported ones are, handing back each item's new id. A card may
name a dictionary entry instead of its payload: the import makes it from
the instance's dictionary as adding from it does, with today's entry,
examples and tags, and leaves out (counting) one the instance lacks; for
that, DictionaryEndpoint.entries gives entries by their ids, at most 1,000
at once. reviews.json lists reviews of the archive's cards; they go into
the person's review log once the cards are in, so every card's schedule
goes on from them.
The sheet says what the archive holds ("Japanese" holds: 12 studysets,
7,745 flashcards, 3 paths, 20,317 reviews), imports once confirmed, busy
meanwhile, and opens the studyset made. Not an archive, a newer format or
an unreadable one says so and imports nothing. No export into the format
yet.
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>
2026-10-11 16:39:07 +02:00
|
|
|
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/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/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 '../../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';
|
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>
2026-10-15 18:57:16 +02:00
|
|
|
import '../../support/fake_path_answer_gateway.dart';
|
feat(archive): import a studyset with everything in it and its progress
Your call (2026-10-11): an archive, imported from the Library's Import, to
bring a library from elsewhere as one's own, and later to restore backups
and move studysets between instances before federation.
An archive is a .zip: archive.json says its format; the studyset is laid
out as a release (ADR 0005, rule 3), so the release copier writes it, now
also unlinked from any publication (ReleaseCopier.write), its cards marked
"archive" as imported ones are, handing back each item's new id. A card may
name a dictionary entry instead of its payload: the import makes it from
the instance's dictionary as adding from it does, with today's entry,
examples and tags, and leaves out (counting) one the instance lacks; for
that, DictionaryEndpoint.entries gives entries by their ids, at most 1,000
at once. reviews.json lists reviews of the archive's cards; they go into
the person's review log once the cards are in, so every card's schedule
goes on from them.
The sheet says what the archive holds ("Japanese" holds: 12 studysets,
7,745 flashcards, 3 paths, 20,317 reviews), imports once confirmed, busy
meanwhile, and opens the studyset made. Not an archive, a newer format or
an unreadable one says so and imports nothing. No export into the format
yet.
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>
2026-10-11 16:39:07 +02:00
|
|
|
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';
|
|
|
|
|
|
|
|
|
|
/// The JSON a synced [payload] holds.
|
|
|
|
|
Map<String, Object?> json(ByteData payload) =>
|
|
|
|
|
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
|
|
|
|
as Map<String, Object?>;
|
|
|
|
|
|
|
|
|
|
/// [files] (paths and their JSON or bytes) as a `.zip`.
|
|
|
|
|
Uint8List zip(Map<String, Object> files) {
|
|
|
|
|
final archive = Archive();
|
|
|
|
|
for (final MapEntry(key: path, value: content) in files.entries) {
|
|
|
|
|
archive.add(
|
|
|
|
|
ArchiveFile.bytes(
|
|
|
|
|
path,
|
|
|
|
|
content is Uint8List ? content : utf8.encode(jsonEncode(content)),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return Uint8List.fromList(ZipEncoder().encodeBytes(archive));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A Japanese studyset with Vocabulary inside: a card of its own, a word
|
|
|
|
|
/// named by its JMdict entry, one naming an entry the instance doesn't
|
|
|
|
|
/// hold, and reviews of the first two.
|
|
|
|
|
Map<String, Object> japanese({int format = 1}) => {
|
|
|
|
|
'archive.json': {'format': format},
|
|
|
|
|
'studyset.json': {
|
|
|
|
|
'id': 'japanese',
|
|
|
|
|
'position': 'a0',
|
|
|
|
|
'payload': json(
|
|
|
|
|
encodeStudysetPayload(
|
|
|
|
|
StudysetContent(
|
|
|
|
|
title: 'Japanese',
|
|
|
|
|
description: RichContent.empty(),
|
|
|
|
|
license: 'CC-BY-NC-SA-4.0',
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
'vocabulary/studyset.json': {
|
|
|
|
|
'id': 'vocabulary',
|
|
|
|
|
'position': 'a0',
|
|
|
|
|
'payload': json(
|
|
|
|
|
encodeStudysetPayload(StudysetContent.titled('Vocabulary')),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
'cards/own.json': {
|
|
|
|
|
'id': 'own',
|
|
|
|
|
'position': 'a0',
|
|
|
|
|
'tags': ['Kana'],
|
|
|
|
|
'payload': json(
|
|
|
|
|
encodeCardPayload(
|
|
|
|
|
CardContent(
|
|
|
|
|
front: RichContent.plain('あ'),
|
|
|
|
|
back: RichContent.plain('a'),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
'vocabulary/cards/table.json': {
|
|
|
|
|
'id': 'table',
|
|
|
|
|
'position': 'a0',
|
|
|
|
|
'entry': {'source': 'jmdict', 'language': 'en', 'id': '1078630'},
|
|
|
|
|
},
|
|
|
|
|
'vocabulary/cards/gone.json': {
|
|
|
|
|
'id': 'gone',
|
|
|
|
|
'position': 'a1',
|
|
|
|
|
'entry': {'source': 'jmdict', 'language': 'en', 'id': '404'},
|
|
|
|
|
},
|
|
|
|
|
'reviews.json': [
|
|
|
|
|
{'card': 'own', 'rating': 3, 'reviewedAt': '2026-06-09T11:11:08Z'},
|
|
|
|
|
{
|
|
|
|
|
'card': 'table',
|
|
|
|
|
'rating': 1,
|
|
|
|
|
'reviewedAt': '2026-06-10T08:00:00Z',
|
|
|
|
|
'durationMs': 4200,
|
|
|
|
|
},
|
|
|
|
|
{'card': 'gone', 'rating': 4, 'reviewedAt': '2026-06-11T08:00:00Z'},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
group('reading', () {
|
|
|
|
|
test('an archive says what it holds', () {
|
|
|
|
|
final archive = readArchive(zip(japanese()));
|
|
|
|
|
|
|
|
|
|
expect(archive.studysets, 2);
|
|
|
|
|
expect(archive.cards, 3);
|
|
|
|
|
expect(archive.reviews, hasLength(3));
|
|
|
|
|
expect(archive.reviews[1].durationMs, 4200);
|
|
|
|
|
expect(archive.reviews[1].reviewedAt, DateTime.utc(2026, 6, 10, 8));
|
|
|
|
|
expect(archive.reviews.first.mode, 'review');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('refuses a file that is no zip, a zip without archive.json, a '
|
|
|
|
|
'newer format and reviews it cannot read', () {
|
|
|
|
|
Matcher refused(ArchiveProblem problem) => throwsA(
|
|
|
|
|
isA<ArchiveUnreadable>().having((e) => e.problem, 'problem', problem),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
() => readArchive(Uint8List.fromList(utf8.encode('not a zip'))),
|
|
|
|
|
refused(ArchiveProblem.notAnArchive),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => readArchive(zip({...japanese()}..remove('archive.json'))),
|
|
|
|
|
refused(ArchiveProblem.notAnArchive),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => readArchive(zip(japanese(format: 2))),
|
|
|
|
|
refused(ArchiveProblem.newerFormat),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => readArchive(
|
|
|
|
|
zip({
|
|
|
|
|
...japanese(),
|
|
|
|
|
'reviews.json': [
|
|
|
|
|
{'card': 'own', 'rating': 7, 'reviewedAt': '2026-06-09'},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
refused(ArchiveProblem.unreadable),
|
|
|
|
|
);
|
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>
2026-10-15 18:57:16 +02:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
]);
|
feat(archive): import a studyset with everything in it and its progress
Your call (2026-10-11): an archive, imported from the Library's Import, to
bring a library from elsewhere as one's own, and later to restore backups
and move studysets between instances before federation.
An archive is a .zip: archive.json says its format; the studyset is laid
out as a release (ADR 0005, rule 3), so the release copier writes it, now
also unlinked from any publication (ReleaseCopier.write), its cards marked
"archive" as imported ones are, handing back each item's new id. A card may
name a dictionary entry instead of its payload: the import makes it from
the instance's dictionary as adding from it does, with today's entry,
examples and tags, and leaves out (counting) one the instance lacks; for
that, DictionaryEndpoint.entries gives entries by their ids, at most 1,000
at once. reviews.json lists reviews of the archive's cards; they go into
the person's review log once the cards are in, so every card's schedule
goes on from them.
The sheet says what the archive holds ("Japanese" holds: 12 studysets,
7,745 flashcards, 3 paths, 20,317 reviews), imports once confirmed, busy
meanwhile, and opens the studyset made. Not an archive, a newer format or
an unreadable one says so and imports nothing. No export into the format
yet.
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>
2026-10-11 16:39:07 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('importing', () {
|
|
|
|
|
late CardRepository cards;
|
|
|
|
|
late StudysetRepository studysets;
|
|
|
|
|
late TagRepository tags;
|
|
|
|
|
late ReviewRepository reviews;
|
2026-10-11 16:50:26 +02:00
|
|
|
late LearningPathRepository paths;
|
feat(archive): import a studyset with everything in it and its progress
Your call (2026-10-11): an archive, imported from the Library's Import, to
bring a library from elsewhere as one's own, and later to restore backups
and move studysets between instances before federation.
An archive is a .zip: archive.json says its format; the studyset is laid
out as a release (ADR 0005, rule 3), so the release copier writes it, now
also unlinked from any publication (ReleaseCopier.write), its cards marked
"archive" as imported ones are, handing back each item's new id. A card may
name a dictionary entry instead of its payload: the import makes it from
the instance's dictionary as adding from it does, with today's entry,
examples and tags, and leaves out (counting) one the instance lacks; for
that, DictionaryEndpoint.entries gives entries by their ids, at most 1,000
at once. reviews.json lists reviews of the archive's cards; they go into
the person's review log once the cards are in, so every card's schedule
goes on from them.
The sheet says what the archive holds ("Japanese" holds: 12 studysets,
7,745 flashcards, 3 paths, 20,317 reviews), imports once confirmed, busy
meanwhile, and opens the studyset made. Not an archive, a newer format or
an unreadable one says so and imports nothing. No export into the format
yet.
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>
2026-10-11 16:39:07 +02:00
|
|
|
late ArchiveImporter importer;
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
studysets = StudysetRepository(FakeStudysetGateway());
|
|
|
|
|
cards = CardRepository(FakeCardGateway());
|
|
|
|
|
final notes = NoteRepository(FakeNoteGateway());
|
2026-10-11 16:50:26 +02:00
|
|
|
paths = LearningPathRepository(FakeLearningPathGateway());
|
feat(archive): import a studyset with everything in it and its progress
Your call (2026-10-11): an archive, imported from the Library's Import, to
bring a library from elsewhere as one's own, and later to restore backups
and move studysets between instances before federation.
An archive is a .zip: archive.json says its format; the studyset is laid
out as a release (ADR 0005, rule 3), so the release copier writes it, now
also unlinked from any publication (ReleaseCopier.write), its cards marked
"archive" as imported ones are, handing back each item's new id. A card may
name a dictionary entry instead of its payload: the import makes it from
the instance's dictionary as adding from it does, with today's entry,
examples and tags, and leaves out (counting) one the instance lacks; for
that, DictionaryEndpoint.entries gives entries by their ids, at most 1,000
at once. reviews.json lists reviews of the archive's cards; they go into
the person's review log once the cards are in, so every card's schedule
goes on from them.
The sheet says what the archive holds ("Japanese" holds: 12 studysets,
7,745 flashcards, 3 paths, 20,317 reviews), imports once confirmed, busy
meanwhile, and opens the studyset made. Not an archive, a newer format or
an unreadable one says so and imports nothing. No export into the format
yet.
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>
2026-10-11 16:39:07 +02:00
|
|
|
tags = TagRepository(
|
|
|
|
|
FakeTagGateway(),
|
|
|
|
|
cardChanges: cards.changes,
|
|
|
|
|
noteChanges: notes.changes,
|
|
|
|
|
pathChanges: paths.changes,
|
|
|
|
|
);
|
|
|
|
|
final assets = AssetRepository(
|
|
|
|
|
FakeAssetGateway(),
|
|
|
|
|
FakeAssetServerGateway(),
|
|
|
|
|
MemoryAssetFileCache(),
|
|
|
|
|
);
|
|
|
|
|
final pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
|
|
|
|
|
reviews = ReviewRepository(FakeReviewGateway());
|
|
|
|
|
importer = ArchiveImporter(
|
|
|
|
|
ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets),
|
|
|
|
|
DictionaryRepository(
|
|
|
|
|
FakeDictionaryGateway(
|
|
|
|
|
words: [
|
|
|
|
|
word(
|
|
|
|
|
'1078630',
|
|
|
|
|
'テーブル',
|
|
|
|
|
glosses: ['table'],
|
|
|
|
|
labels: ['common', 'jlpt:n5'],
|
|
|
|
|
pos: ['n'],
|
|
|
|
|
version: 'v1',
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
reviews,
|
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>
2026-10-15 18:57:16 +02:00
|
|
|
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>()),
|
feat(archive): import a studyset with everything in it and its progress
Your call (2026-10-11): an archive, imported from the Library's Import, to
bring a library from elsewhere as one's own, and later to restore backups
and move studysets between instances before federation.
An archive is a .zip: archive.json says its format; the studyset is laid
out as a release (ADR 0005, rule 3), so the release copier writes it, now
also unlinked from any publication (ReleaseCopier.write), its cards marked
"archive" as imported ones are, handing back each item's new id. A card may
name a dictionary entry instead of its payload: the import makes it from
the instance's dictionary as adding from it does, with today's entry,
examples and tags, and leaves out (counting) one the instance lacks; for
that, DictionaryEndpoint.entries gives entries by their ids, at most 1,000
at once. reviews.json lists reviews of the archive's cards; they go into
the person's review log once the cards are in, so every card's schedule
goes on from them.
The sheet says what the archive holds ("Japanese" holds: 12 studysets,
7,745 flashcards, 3 paths, 20,317 reviews), imports once confirmed, busy
meanwhile, and opens the studyset made. Not an archive, a newer format or
an unreadable one says so and imports nothing. No export into the format
yet.
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>
2026-10-11 16:39:07 +02:00
|
|
|
);
|
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>
2026-10-15 18:57:16 +02:00
|
|
|
expect((await tags.read()).all, isEmpty);
|
|
|
|
|
expect(await studysets.all(), isEmpty);
|
feat(archive): import a studyset with everything in it and its progress
Your call (2026-10-11): an archive, imported from the Library's Import, to
bring a library from elsewhere as one's own, and later to restore backups
and move studysets between instances before federation.
An archive is a .zip: archive.json says its format; the studyset is laid
out as a release (ADR 0005, rule 3), so the release copier writes it, now
also unlinked from any publication (ReleaseCopier.write), its cards marked
"archive" as imported ones are, handing back each item's new id. A card may
name a dictionary entry instead of its payload: the import makes it from
the instance's dictionary as adding from it does, with today's entry,
examples and tags, and leaves out (counting) one the instance lacks; for
that, DictionaryEndpoint.entries gives entries by their ids, at most 1,000
at once. reviews.json lists reviews of the archive's cards; they go into
the person's review log once the cards are in, so every card's schedule
goes on from them.
The sheet says what the archive holds ("Japanese" holds: 12 studysets,
7,745 flashcards, 3 paths, 20,317 reviews), imports once confirmed, busy
meanwhile, and opens the studyset made. Not an archive, a newer format or
an unreadable one says so and imports nothing. No export into the format
yet.
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>
2026-10-11 16:39:07 +02:00
|
|
|
});
|
|
|
|
|
|
2026-10-11 16:50:26 +02:00
|
|
|
test('content naming a studyset of the archive, as a study button '
|
|
|
|
|
'does, names it as it is in the library', () async {
|
|
|
|
|
final archive = {
|
|
|
|
|
...japanese(),
|
|
|
|
|
'paths/drill/path.json': {
|
|
|
|
|
'id': 'drill',
|
|
|
|
|
'position': 'a0',
|
|
|
|
|
'payload': json(
|
|
|
|
|
encodeLearningPathPayload(LearningPathContent.titled('Drill')),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
'paths/drill/steps/go.json': {
|
|
|
|
|
'id': 'go',
|
|
|
|
|
'position': 'a0',
|
|
|
|
|
'kind': StepKind.info.value,
|
|
|
|
|
'payload': json(
|
|
|
|
|
encodeStepPayload(
|
|
|
|
|
StepContent.empty(StepKind.info).withField(
|
|
|
|
|
StepField.body,
|
|
|
|
|
RichContent({
|
|
|
|
|
'document': {
|
|
|
|
|
'type': 'page',
|
|
|
|
|
'children': [
|
|
|
|
|
{
|
|
|
|
|
'type': studyButtonBlockType,
|
|
|
|
|
'data': {studyButtonStudysetKey: 'vocabulary'},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await importer.import(readArchive(zip(archive)));
|
|
|
|
|
|
|
|
|
|
final vocabulary = (await studysets.all()).singleWhere(
|
|
|
|
|
(s) => s.content.title == 'Vocabulary',
|
|
|
|
|
);
|
|
|
|
|
final path = (await paths.pathsOf(
|
|
|
|
|
(await studysets.all()).singleWhere((s) => s.parentId == null).id,
|
|
|
|
|
)).single;
|
|
|
|
|
final step = (await paths.stepsOf(path.id)).single;
|
|
|
|
|
expect(
|
|
|
|
|
jsonEncode(step.content.body.toJson()),
|
|
|
|
|
contains('"$studyButtonStudysetKey":"${vocabulary.id}"'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(archive): import a studyset with everything in it and its progress
Your call (2026-10-11): an archive, imported from the Library's Import, to
bring a library from elsewhere as one's own, and later to restore backups
and move studysets between instances before federation.
An archive is a .zip: archive.json says its format; the studyset is laid
out as a release (ADR 0005, rule 3), so the release copier writes it, now
also unlinked from any publication (ReleaseCopier.write), its cards marked
"archive" as imported ones are, handing back each item's new id. A card may
name a dictionary entry instead of its payload: the import makes it from
the instance's dictionary as adding from it does, with today's entry,
examples and tags, and leaves out (counting) one the instance lacks; for
that, DictionaryEndpoint.entries gives entries by their ids, at most 1,000
at once. reviews.json lists reviews of the archive's cards; they go into
the person's review log once the cards are in, so every card's schedule
goes on from them.
The sheet says what the archive holds ("Japanese" holds: 12 studysets,
7,745 flashcards, 3 paths, 20,317 reviews), imports once confirmed, busy
meanwhile, and opens the studyset made. Not an archive, a newer format or
an unreadable one says so and imports nothing. No export into the format
yet.
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>
2026-10-11 16:39:07 +02:00
|
|
|
test("brings the studysets and cards in as the person's own, a word "
|
|
|
|
|
'from the dictionary as adding it makes it, the reviews onto the '
|
|
|
|
|
'cards they were of; an entry the instance lacks is left out and '
|
|
|
|
|
'counted', () async {
|
|
|
|
|
final imported = await importer.import(readArchive(zip(japanese())));
|
|
|
|
|
|
|
|
|
|
expect(imported.cards, 2);
|
|
|
|
|
expect(imported.reviews, 2);
|
|
|
|
|
expect(imported.missingEntries, 1);
|
|
|
|
|
final all = await studysets.all();
|
|
|
|
|
expect(all.map((s) => s.content.title), ['Japanese', 'Vocabulary']);
|
|
|
|
|
expect(all.every((s) => s.content.published == null), isTrue);
|
|
|
|
|
|
|
|
|
|
final vocabulary = all.singleWhere(
|
|
|
|
|
(s) => s.content.title == 'Vocabulary',
|
|
|
|
|
);
|
|
|
|
|
final word = (await cards.cardsOf(vocabulary.id)).single;
|
|
|
|
|
expect(word.content.entry, (source: 'jmdict', id: '1078630'));
|
|
|
|
|
expect(word.content.entryVersion, 'v1');
|
|
|
|
|
final own = (await cards.cardsOf(imported.studysetId)).single;
|
|
|
|
|
expect(own.content.published, isNull);
|
|
|
|
|
|
|
|
|
|
final tagging = await tags.read();
|
|
|
|
|
expect(
|
|
|
|
|
{for (final tag in tagging.of(word.id)) tag.name},
|
|
|
|
|
containsAll(['JLPT-N5']),
|
|
|
|
|
);
|
|
|
|
|
expect({for (final tag in tagging.of(own.id)) tag.name}, {'Kana'});
|
|
|
|
|
|
|
|
|
|
final logged = await reviews.all();
|
|
|
|
|
expect(
|
|
|
|
|
{for (final review in logged) review.cardId: review.rating},
|
|
|
|
|
{own.id: Rating.good, word.id: Rating.again},
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(await cards.allCards()).every((card) => card.imported),
|
|
|
|
|
isTrue,
|
|
|
|
|
reason: 'counted as imported, not written',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|