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
2 changed files with 345 additions and 60 deletions

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

View file

@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_editor/halkyon_editor.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/card/data/card_repository.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';
@ -118,6 +120,30 @@ void main() {
expect(await cards.cardsOf('japanese'), hasLength(4));
});
testWidgets('shows its steps under way, and Abort deletes what came '
'(decided 9 Nov 2026)', (tester) async {
final hold = cardGateway.holdWrites = Completer<void>();
await startImport(tester, fixture('anki_latest.apkg'));
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
await tester.pump();
await tester.pump();
expect(find.text('Step 2 of 4: Writing flashcards'), 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,
);
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
expect(await cards.cardsOf('japanese'), isEmpty);
});
testWidgets("the Library's Import makes a deck a studyset of its own, "
'named after its top deck, its subdecks inside as chosen', (
tester,