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

Importing an Anki deck showed one bar and "Importing…". It now says which
of its four steps it is in (copying files, writing flashcards, tagging,
bringing over the review history), how many of the step's items are done
and the percent of all of it, and offers Abort (decided 9 Nov 2026), as
the archive's import does.

The importer checks between files and batches of cards. Aborted, it
deletes what it wrote: a studyset it made for the deck for good with all
of it; importing into an existing studyset, the cards it wrote there and
the topics it made for subdecks, leaving what the studyset held before;
and the files' rows it made, not those the person had. Tags it made stay.
Once everything is written, Abort no longer undoes it.

Whats-New: Importing an Anki deck shows its steps and how far it has come, and Abort stops it, keeping nothing of it.
Whats-New-De: Der Import eines Anki-Decks 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 16:17:00 +01:00
commit 7cba684513
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,14 +1,21 @@
import 'dart:io';
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/anki/data/anki_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/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/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/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
@ -17,6 +24,9 @@ import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_sealing.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
@ -49,7 +59,20 @@ void main() {
MemoryAssetFileCache(),
FakeAssetSealing(),
);
importer = AnkiImporter(studysets, cards, tags, reviews, assets);
importer = AnkiImporter(
studysets,
cards,
tags,
reviews,
assets,
StudysetDeletions(
studysets,
cards,
NoteRepository(FakeNoteGateway()),
LearningPathRepository(FakeLearningPathGateway()),
PdfSourceRepository(FakePdfSourceGateway(), assets),
),
);
japanese = await studysets.create(StudysetContent.titled('Japanese'));
});
tearDown(() async {
@ -253,8 +276,8 @@ void main() {
expect(namesOf(list), {'Animals'});
});
test('tells how far it got', () async {
final progress = <double>[];
test('tells how far it got, step by step (decided 9 Nov 2026)', () async {
final progress = <WorkProgress<AnkiImportStage>>[];
await importer.import(
package,
@ -265,8 +288,98 @@ void main() {
onProgress: progress.add,
);
expect(progress, isNotEmpty);
expect(progress.last, 1);
expect(progress, orderedEquals([...progress]..sort()));
expect(
{for (final each in progress) each.stage}.toList(),
AnkiImportStage.values,
);
expect(progress.last.percent, 100);
final fractions = [for (final each in progress) each.fraction];
expect(fractions, orderedEquals([...fractions]..sort()));
});
group('aborted (decided 9 Nov 2026)', () {
/// Imports as [import] does, aborting once [when] holds.
Future<void> abortedImport(
bool Function(WorkProgress<AnkiImportStage> progress) when, {
required String studysetId,
AnkiImportMode mode = AnkiImportMode.flatten,
bool madeForIt = false,
}) async {
final abort = AbortSignal();
await expectLater(
importer.import(
package,
studysetId: studysetId,
mode: mode,
keepProgress: true,
includeMedia: true,
abort: abort,
madeForIt: madeForIt,
onProgress: (progress) {
if (when(progress)) {
abort.abort();
}
},
),
throwsA(isA<WorkAborted>()),
);
}
test('deletes the cards, the studysets made for decks and the files it '
'added, keeping what the studyset held before', () async {
final own = await cards.create(
japanese,
CardContent(
front: RichContent.plain('mine'),
back: RichContent.plain('mine'),
),
);
await abortedImport(
(progress) =>
progress.stage == AnkiImportStage.cards && progress.done > 0,
studysetId: japanese,
mode: AnkiImportMode.subStudysets,
);
expect(
[for (final s in await studysets.withDeleted()) s.id],
[
japanese,
],
);
expect([for (final card in await cards.allCards()) card.id], [own]);
expect(await assetRows.all(), isEmpty);
});
test('in its last step too', () async {
await abortedImport(
(progress) => progress.stage == AnkiImportStage.tags,
studysetId: japanese,
);
expect(await cards.allCards(), isEmpty);
});
test('a studyset made for it goes for good with it', () async {
final made = await importer.newStudysetFor(
package,
fallbackTitle: 'x',
);
await abortedImport(
(progress) => progress.stage == AnkiImportStage.files,
studysetId: made,
madeForIt: true,
);
expect(
[for (final s in await studysets.withDeleted()) s.id],
[
japanese,
],
);
expect(await cards.allCards(), isEmpty);
});
});
}