feat(learn): an archive's import shows its steps, counts and percent, with Abort

Importing a Halkyon archive showed one bar. It now says which of its three
steps it is in (copying files; writing studysets, flashcards and notes;
bringing over the progress), how many of the step's items are done and the
percent of all of it, and offers Abort (decided 9 Nov 2026).

The copier checks between items, never leaving one half written, and
keeps a trail of what it wrote: the top studyset once made, and the files'
rows it made (not those the person had, found before each file is added).
Aborted, the importer deletes the top studyset for good with everything
inside it, as Delete now does (the reviews, answers and pages read going
with their cards, steps and PDFs), and those rows; the tags it made stay,
as names the person may well use anyway. Once all of it is written, Abort
no longer undoes it.

WorkProgress, WorkStages and AbortSignal (core/work/) and
WorkProgressView (core/widget/) are shared with the Anki import and the
exports, which follow.

Whats-New: Importing an archive shows its steps and how far it has come, and Abort stops it, keeping nothing of it.
Whats-New-De: Der Import eines Archivs zeigt seine Schritte und wie weit er ist, und „Abbrechen“ beendet ihn, ohne etwas davon zu behalten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 15:53:31 +01:00
commit 5f7cb70c3c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 901 additions and 53 deletions

View file

@ -0,0 +1,35 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
enum _Stage() {
files,
items,
progress,
}
/// How far long work has come, and its abort (decided 9 Nov 2026).
void main() {
test('a stage counts as its share of the whole, by weight', () {
final stages = WorkStages(
_Stage.values,
weights: const {_Stage.items: 2},
);
expect(stages.at(_Stage.files).fraction, 0);
expect(stages.at(_Stage.files, done: 1, total: 2).fraction, 0.125);
final items = stages.at(_Stage.items, done: 3, total: 4);
expect(items.step, 2);
expect(items.steps, 3);
expect((items.done, items.total), (3, 4));
expect(items.fraction, 0.625);
expect(items.percent, 62);
expect(stages.at(_Stage.progress, done: 1, total: 1).percent, 100);
});
test('an abort is thrown at the next check, not before', () {
final signal = AbortSignal()..check();
expect(signal.aborted, isFalse);
signal.abort();
expect(signal.aborted, isTrue);
expect(signal.check, throwsA(isA<WorkAborted>()));
});
}

View file

@ -28,6 +28,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.da
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_deletions.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';
@ -93,6 +94,8 @@ final class _Person() {
progress,
studysets,
pdfs,
StudysetDeletions(studysets, cards, notes, paths, pdfs),
assets,
);
}

View file

@ -2,9 +2,11 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.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';
@ -23,6 +25,7 @@ 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_deletions.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';
@ -257,12 +260,16 @@ void main() {
late TagRepository tags;
late ReviewRepository reviews;
late LearningPathRepository paths;
late NoteRepository notes;
late PdfSourceRepository pdfs;
late AssetRepository assets;
late FakeAssetGateway assetRows;
late ArchiveImporter importer;
setUp(() {
studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(FakeCardGateway());
final notes = NoteRepository(FakeNoteGateway());
notes = NoteRepository(FakeNoteGateway());
paths = LearningPathRepository(FakeLearningPathGateway());
tags = TagRepository(
FakeTagGateway(),
@ -270,13 +277,14 @@ void main() {
noteChanges: notes.changes,
pathChanges: paths.changes,
);
final assets = AssetRepository(
FakeAssetGateway(),
assetRows = FakeAssetGateway();
assets = AssetRepository(
assetRows,
FakeAssetServerGateway(),
MemoryAssetFileCache(),
FakeAssetSealing(),
);
final pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
reviews = ReviewRepository(FakeReviewGateway());
importer = ArchiveImporter(
ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets),
@ -299,9 +307,139 @@ void main() {
PathProgressRepository(FakePathAnswerGateway()),
studysets,
pdfs,
StudysetDeletions(studysets, cards, notes, paths, pdfs),
assets,
);
});
group('aborted (decided 9 Nov 2026)', () {
final kept = Uint8List.fromList(utf8.encode('a picture the person has'));
final brought = Uint8List.fromList(utf8.encode('a picture it brings'));
String hashOf(Uint8List bytes) => sha256.convert(bytes).toString();
/// [japanese] with both pictures among its files.
Map<String, Object> withFiles() => {
...japanese(),
'files.json': {
'kept': {
'sha256': hashOf(kept),
'mimeType': 'image/png',
'payload': {'schemaVersion': 1, 'fileName': 'kept.png'},
},
'brought': {
'sha256': hashOf(brought),
'mimeType': 'image/png',
'payload': {'schemaVersion': 1, 'fileName': 'brought.png'},
},
},
'files/${hashOf(kept)}': kept,
'files/${hashOf(brought)}': brought,
};
test('goes through its steps in order, to the whole', () async {
final seen = <WorkProgress<ArchiveImportStage>>[];
await importer.import(
readArchive(zip(withFiles())),
onProgress: seen.add,
);
expect(
{for (final progress in seen) progress.stage}.toList(),
ArchiveImportStage.values,
);
expect(seen.every((progress) => progress.steps == 3), isTrue);
for (final (i, progress) in seen.indexed.skip(1)) {
expect(
progress.fraction,
greaterThanOrEqualTo(seen[i - 1].fraction),
reason: '$progress after ${seen[i - 1]}',
);
}
final files = seen.lastWhere(
(progress) => progress.stage == ArchiveImportStage.files,
);
expect((files.done, files.total), (2, 2));
expect(seen.last.percent, 100);
});
test(
'deletes everything it wrote, not the file the person had',
() async {
final own = await studysets.create(StudysetContent.titled('Own'));
final ownFile = await assets.add(
kept,
fileName: 'mine.png',
mimeType: 'image/png',
studysetId: own,
);
final abort = AbortSignal();
await expectLater(
importer.import(
readArchive(zip(withFiles())),
abort: abort,
onProgress: (progress) {
if (progress.stage == ArchiveImportStage.content &&
progress.done >= 2) {
abort.abort();
}
},
),
throwsA(isA<WorkAborted>()),
);
expect([for (final s in await studysets.withDeleted()) s.id], [own]);
expect(await cards.allCards(), isEmpty);
expect([for (final row in await assetRows.all()) row.id], [ownFile]);
},
);
test(
'in its last step, deletes the cards its reviews were written for',
() async {
final abort = AbortSignal();
await expectLater(
importer.import(
readArchive(zip(japanese())),
abort: abort,
onProgress: (progress) {
if (progress.stage == ArchiveImportStage.progress &&
progress.done == 1) {
abort.abort();
}
},
),
throwsA(isA<WorkAborted>()),
);
// The reviews written go with their cards: the database
// cascades (`review_log.card`), which the fakes don't.
expect(await studysets.withDeleted(), isEmpty);
expect(await cards.allCards(), isEmpty);
},
);
test('asked once all of it is written, it is done', () async {
final abort = AbortSignal();
final imported = await importer.import(
readArchive(zip(japanese())),
abort: abort,
onProgress: (progress) {
if (progress.percent == 100) {
abort.abort();
}
},
);
expect(
[for (final s in await studysets.all()) s.id],
contains(imported.studysetId),
);
});
});
test('an archive this app cannot read all of writes nothing, not even '
'the tags it lists', () async {
final archive = readArchive(

View file

@ -1,6 +1,9 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/work_progress_view.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
import 'package:material_ui/material_ui.dart';
@ -15,10 +18,12 @@ import 'archive_test.dart' show japanese, zip;
void main() {
late FakeFilePicker files;
late FakeStudysetGateway studysets;
late FakeCardGateway cards;
setUp(() {
files = FakeFilePicker();
studysets = FakeStudysetGateway();
cards = FakeCardGateway();
});
Future<void> show(WidgetTester tester) async {
@ -29,7 +34,7 @@ void main() {
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: FakeCardGateway(),
cardGateway: cards,
files: files,
dictionaryGateway: FakeDictionaryGateway(
words: [
@ -77,6 +82,38 @@ void main() {
expect((await repository.all()).length, 2);
});
testWidgets('shows its steps under way, and Abort deletes what it wrote '
'(decided 9 Nov 2026)', (tester) async {
files.next = (name: 'japanese.zip', bytes: zip(japanese()));
final hold = cards.holdWrites = Completer<void>();
await show(tester);
await choose(tester);
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
await tester.pump();
await tester.pump();
expect(
find.text('Step 2 of 3: Writing studysets, flashcards and notes'),
findsOneWidget,
);
expect(find.textContaining('%'), findsOneWidget);
await tester.tap(find.byKey(WorkProgressView.abortKey));
await tester.pump();
expect(find.text('Aborting…'), findsOneWidget);
hold.complete();
await tester.pumpAndSettle();
expect(
find.text('Import aborted: nothing of it was kept.'),
findsOneWidget,
);
expect(find.byType(StudysetScreen), findsNothing);
final repository = StudysetRepository(studysets);
addTearDown(repository.dispose);
expect(await repository.withDeleted(), isEmpty);
});
testWidgets('a file that is no archive says so, and imports nothing', (
tester,
) async {

View file

@ -38,6 +38,9 @@ class FakeCardGateway() implements CardGateway {
/// When set, deleting a card waits until it completes.
Completer<void>? holdDeletes;
/// When set, storing cards in a batch waits until it completes.
Completer<void>? holdWrites;
/// The payload each revision holds now, by revision id.
Map<String, ByteData> get payloads => {
for (final revision in _revisions.values) revision.id: revision.payload,
@ -86,14 +89,19 @@ class FakeCardGateway() implements CardGateway {
Future<List<String>> insertCards(
String studysetId,
List<NewCardRow> cards,
) async => [
for (final card in cards)
await insertCard(
studysetId: studysetId,
position: card.position,
payload: card.payload,
),
];
) async {
if (holdWrites case final hold?) {
await hold.future;
}
return [
for (final card in cards)
await insertCard(
studysetId: studysetId,
position: card.position,
payload: card.payload,
),
];
}
@override
Future<String> insertCard({