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>
This commit is contained in:
parent
47743ced49
commit
ff31652a3a
20 changed files with 1548 additions and 37 deletions
|
|
@ -50,6 +50,21 @@
|
|||
"libraryRefresh": "Aktualisieren",
|
||||
"libraryImport": "Importieren",
|
||||
"libraryImportAnkiHint": "Wird ein eigenes Lernset, aus einer .apkg- oder .colpkg-Datei",
|
||||
"libraryImportArchive": "Halkyon-Archiv",
|
||||
"libraryImportArchiveHint": "Ein Lernset mit allem darin und seinem Fortschritt, aus einer .zip-Datei",
|
||||
"archiveFileType": "Halkyon-Archiv",
|
||||
"archiveImportTitle": "Archiv importieren",
|
||||
"archiveImportReading": "Das Archiv wird gelesen …",
|
||||
"archiveImportHolds": "„{title}“ enthält:",
|
||||
"archiveCountStudysets": "{count, plural, =1{1 Lernset} other{{count} Lernsets}}",
|
||||
"archiveCountReviews": "{count, plural, =1{1 Wiederholung} other{{count} Wiederholungen}}",
|
||||
"archiveImportConfirm": "Importieren",
|
||||
"archiveImported": "Importiert: {cards} und {reviews}.",
|
||||
"archiveImportMissingEntries": "{count, plural, =1{1 Wort fehlt} other{{count} Wörter fehlen}} im Wörterbuch dieser Instanz und {count, plural, =1{blieb} other{blieben}} draußen.",
|
||||
"archiveNotAnArchive": "Diese Datei ist kein Halkyon-Archiv.",
|
||||
"archiveNewerFormat": "Dieses Archiv stammt aus einer neueren Version der App. Aktualisiere die App, um es zu importieren.",
|
||||
"archiveUnreadable": "Dieses Archiv lässt sich nicht lesen.",
|
||||
"archiveImportBrokeOff": "Der Import brach ab. Prüfe die Verbindung und versuche es noch einmal.",
|
||||
"libraryRetry": "Erneut versuchen",
|
||||
"studysetNoContent": "Noch kein Inhalt",
|
||||
"studysetEmpty": "Leer",
|
||||
|
|
|
|||
|
|
@ -56,6 +56,26 @@
|
|||
"libraryRefresh": "Refresh",
|
||||
"libraryImport": "Import",
|
||||
"libraryImportAnkiHint": "Becomes a studyset of its own, from an .apkg or .colpkg file",
|
||||
"libraryImportArchive": "Halkyon archive",
|
||||
"libraryImportArchiveHint": "A studyset with everything in it and its progress, from a .zip file",
|
||||
"archiveFileType": "Halkyon archive",
|
||||
"archiveImportTitle": "Import archive",
|
||||
"archiveImportReading": "Reading the archive…",
|
||||
"archiveImportHolds": "“{title}” holds:",
|
||||
"@archiveImportHolds": {"placeholders": {"title": {"type": "String"}}},
|
||||
"archiveCountStudysets": "{count, plural, =1{1 studyset} other{{count} studysets}}",
|
||||
"@archiveCountStudysets": {"placeholders": {"count": {"type": "int"}}},
|
||||
"archiveCountReviews": "{count, plural, =1{1 review} other{{count} reviews}}",
|
||||
"@archiveCountReviews": {"placeholders": {"count": {"type": "int"}}},
|
||||
"archiveImportConfirm": "Import",
|
||||
"archiveImported": "Imported: {cards} and {reviews}.",
|
||||
"@archiveImported": {"placeholders": {"cards": {"type": "String"}, "reviews": {"type": "String"}}},
|
||||
"archiveImportMissingEntries": "{count, plural, =1{1 word isn't} other{{count} words aren't}} in this instance's dictionary and stayed out.",
|
||||
"@archiveImportMissingEntries": {"placeholders": {"count": {"type": "int"}}},
|
||||
"archiveNotAnArchive": "This file isn't a Halkyon archive.",
|
||||
"archiveNewerFormat": "This archive comes from a newer version of the app. Update the app to import it.",
|
||||
"archiveUnreadable": "This archive can't be read.",
|
||||
"archiveImportBrokeOff": "The import broke off. Check the connection and try again.",
|
||||
"libraryRetry": "Retry",
|
||||
"studysetNoContent": "No content yet",
|
||||
"studysetEmpty": "Empty",
|
||||
|
|
|
|||
|
|
@ -358,6 +358,96 @@ abstract class AppLocalizations {
|
|||
/// **'Becomes a studyset of its own, from an .apkg or .colpkg file'**
|
||||
String get libraryImportAnkiHint;
|
||||
|
||||
/// No description provided for @libraryImportArchive.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Halkyon archive'**
|
||||
String get libraryImportArchive;
|
||||
|
||||
/// No description provided for @libraryImportArchiveHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A studyset with everything in it and its progress, from a .zip file'**
|
||||
String get libraryImportArchiveHint;
|
||||
|
||||
/// No description provided for @archiveFileType.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Halkyon archive'**
|
||||
String get archiveFileType;
|
||||
|
||||
/// No description provided for @archiveImportTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import archive'**
|
||||
String get archiveImportTitle;
|
||||
|
||||
/// No description provided for @archiveImportReading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reading the archive…'**
|
||||
String get archiveImportReading;
|
||||
|
||||
/// No description provided for @archiveImportHolds.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'“{title}” holds:'**
|
||||
String archiveImportHolds(String title);
|
||||
|
||||
/// No description provided for @archiveCountStudysets.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 studyset} other{{count} studysets}}'**
|
||||
String archiveCountStudysets(int count);
|
||||
|
||||
/// No description provided for @archiveCountReviews.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 review} other{{count} reviews}}'**
|
||||
String archiveCountReviews(int count);
|
||||
|
||||
/// No description provided for @archiveImportConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import'**
|
||||
String get archiveImportConfirm;
|
||||
|
||||
/// No description provided for @archiveImported.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Imported: {cards} and {reviews}.'**
|
||||
String archiveImported(String cards, String reviews);
|
||||
|
||||
/// No description provided for @archiveImportMissingEntries.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 word isn\'t} other{{count} words aren\'t}} in this instance\'s dictionary and stayed out.'**
|
||||
String archiveImportMissingEntries(int count);
|
||||
|
||||
/// No description provided for @archiveNotAnArchive.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This file isn\'t a Halkyon archive.'**
|
||||
String get archiveNotAnArchive;
|
||||
|
||||
/// No description provided for @archiveNewerFormat.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This archive comes from a newer version of the app. Update the app to import it.'**
|
||||
String get archiveNewerFormat;
|
||||
|
||||
/// No description provided for @archiveUnreadable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This archive can\'t be read.'**
|
||||
String get archiveUnreadable;
|
||||
|
||||
/// No description provided for @archiveImportBrokeOff.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The import broke off. Check the connection and try again.'**
|
||||
String get archiveImportBrokeOff;
|
||||
|
||||
/// No description provided for @libraryRetry.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -198,6 +198,88 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get libraryImportAnkiHint =>
|
||||
'Wird ein eigenes Lernset, aus einer .apkg- oder .colpkg-Datei';
|
||||
|
||||
@override
|
||||
String get libraryImportArchive => 'Halkyon-Archiv';
|
||||
|
||||
@override
|
||||
String get libraryImportArchiveHint =>
|
||||
'Ein Lernset mit allem darin und seinem Fortschritt, aus einer .zip-Datei';
|
||||
|
||||
@override
|
||||
String get archiveFileType => 'Halkyon-Archiv';
|
||||
|
||||
@override
|
||||
String get archiveImportTitle => 'Archiv importieren';
|
||||
|
||||
@override
|
||||
String get archiveImportReading => 'Das Archiv wird gelesen …';
|
||||
|
||||
@override
|
||||
String archiveImportHolds(String title) {
|
||||
return '„$title“ enthält:';
|
||||
}
|
||||
|
||||
@override
|
||||
String archiveCountStudysets(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Lernsets',
|
||||
one: '1 Lernset',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String archiveCountReviews(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Wiederholungen',
|
||||
one: '1 Wiederholung',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get archiveImportConfirm => 'Importieren';
|
||||
|
||||
@override
|
||||
String archiveImported(String cards, String reviews) {
|
||||
return 'Importiert: $cards und $reviews.';
|
||||
}
|
||||
|
||||
@override
|
||||
String archiveImportMissingEntries(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Wörter fehlen',
|
||||
one: '1 Wort fehlt',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'blieben',
|
||||
one: 'blieb',
|
||||
);
|
||||
return '$_temp0 im Wörterbuch dieser Instanz und $_temp1 draußen.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get archiveNotAnArchive => 'Diese Datei ist kein Halkyon-Archiv.';
|
||||
|
||||
@override
|
||||
String get archiveNewerFormat =>
|
||||
'Dieses Archiv stammt aus einer neueren Version der App. Aktualisiere die App, um es zu importieren.';
|
||||
|
||||
@override
|
||||
String get archiveUnreadable => 'Dieses Archiv lässt sich nicht lesen.';
|
||||
|
||||
@override
|
||||
String get archiveImportBrokeOff =>
|
||||
'Der Import brach ab. Prüfe die Verbindung und versuche es noch einmal.';
|
||||
|
||||
@override
|
||||
String get libraryRetry => 'Erneut versuchen';
|
||||
|
||||
|
|
|
|||
|
|
@ -196,6 +196,82 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get libraryImportAnkiHint =>
|
||||
'Becomes a studyset of its own, from an .apkg or .colpkg file';
|
||||
|
||||
@override
|
||||
String get libraryImportArchive => 'Halkyon archive';
|
||||
|
||||
@override
|
||||
String get libraryImportArchiveHint =>
|
||||
'A studyset with everything in it and its progress, from a .zip file';
|
||||
|
||||
@override
|
||||
String get archiveFileType => 'Halkyon archive';
|
||||
|
||||
@override
|
||||
String get archiveImportTitle => 'Import archive';
|
||||
|
||||
@override
|
||||
String get archiveImportReading => 'Reading the archive…';
|
||||
|
||||
@override
|
||||
String archiveImportHolds(String title) {
|
||||
return '“$title” holds:';
|
||||
}
|
||||
|
||||
@override
|
||||
String archiveCountStudysets(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count studysets',
|
||||
one: '1 studyset',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String archiveCountReviews(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count reviews',
|
||||
one: '1 review',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get archiveImportConfirm => 'Import';
|
||||
|
||||
@override
|
||||
String archiveImported(String cards, String reviews) {
|
||||
return 'Imported: $cards and $reviews.';
|
||||
}
|
||||
|
||||
@override
|
||||
String archiveImportMissingEntries(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count words aren\'t',
|
||||
one: '1 word isn\'t',
|
||||
);
|
||||
return '$_temp0 in this instance\'s dictionary and stayed out.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get archiveNotAnArchive => 'This file isn\'t a Halkyon archive.';
|
||||
|
||||
@override
|
||||
String get archiveNewerFormat =>
|
||||
'This archive comes from a newer version of the app. Update the app to import it.';
|
||||
|
||||
@override
|
||||
String get archiveUnreadable => 'This archive can\'t be read.';
|
||||
|
||||
@override
|
||||
String get archiveImportBrokeOff =>
|
||||
'The import broke off. Check the connection and try again.';
|
||||
|
||||
@override
|
||||
String get libraryRetry => 'Retry';
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/claude_hand_off.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/serverpod_assistant_gateway.dart';
|
||||
|
|
@ -131,6 +132,9 @@ class Dependencies({
|
|||
/// Reads Anki packages to import.
|
||||
required final AnkiPackageReader ankiPackages,
|
||||
|
||||
/// Reads archives to import.
|
||||
required final ArchiveReader archives,
|
||||
|
||||
/// Packs exports with files into a `.zip`.
|
||||
required final ExportArchiver exportArchiver,
|
||||
|
||||
|
|
@ -203,6 +207,7 @@ class Dependencies({
|
|||
videos: IframeYoutubeVideos(),
|
||||
pdfEngine: PdfrxEngine(),
|
||||
ankiPackages: IsolateAnkiPackageReader(),
|
||||
archives: IsolateArchiveReader(),
|
||||
exportArchiver: IsolateExportArchiver(),
|
||||
deviceSettingsStore: device.deviceSettingsStore,
|
||||
// Read anew for each instance: they may have changed on another.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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/archive/model/import_archive.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
|
||||
/// Importing an archive: read, then shown with what it holds, then, once
|
||||
/// confirmed, imported, its progress told; done, or failed with why.
|
||||
class ArchiveImportCubit(
|
||||
final ArchiveReader _reader,
|
||||
final ArchiveImporter _importer,
|
||||
) extends Cubit<ArchiveImportState> {
|
||||
this : super(const ArchiveImportStateReading());
|
||||
|
||||
/// Reads the archive in [bytes].
|
||||
Future<void> start(Uint8List bytes) async {
|
||||
try {
|
||||
final archive = await _reader.read(bytes);
|
||||
final title = switch (archive.items['studyset.json']?['payload']) {
|
||||
final Map<String, Object?> payload => decodeStudysetPayload(
|
||||
ByteData.sublistView(utf8.encode(jsonEncode(payload))),
|
||||
).title,
|
||||
_ => '',
|
||||
};
|
||||
emit(ArchiveImportStateReady(archive: archive, title: title));
|
||||
} on ArchiveUnreadable catch (error) {
|
||||
emit(ArchiveImportStateFailed(error.problem));
|
||||
} on Object {
|
||||
emit(const ArchiveImportStateFailed(ArchiveProblem.unreadable));
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports the archive read.
|
||||
Future<void> confirm() async {
|
||||
if (state case ArchiveImportStateReady(:final archive, :final title)) {
|
||||
emit(
|
||||
ArchiveImportStateRunning(archive: archive, title: title, progress: 0),
|
||||
);
|
||||
try {
|
||||
final result = await _importer.import(
|
||||
archive,
|
||||
onProgress: (done) => emit(
|
||||
ArchiveImportStateRunning(
|
||||
archive: archive,
|
||||
title: title,
|
||||
progress: done,
|
||||
),
|
||||
),
|
||||
);
|
||||
emit(ArchiveImportStateDone(result));
|
||||
} on Object {
|
||||
emit(const ArchiveImportStateFailed(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an import of an archive is.
|
||||
sealed class const ArchiveImportState() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The archive is being read.
|
||||
final class const ArchiveImportStateReading() extends ArchiveImportState;
|
||||
|
||||
/// The archive [archive], its top studyset titled [title], waits to be
|
||||
/// imported.
|
||||
final class const ArchiveImportStateReady({
|
||||
required final ImportArchive archive,
|
||||
required final String title,
|
||||
}) extends ArchiveImportState {
|
||||
@override
|
||||
List<Object?> get props => [archive, title];
|
||||
}
|
||||
|
||||
/// The archive is being imported, [progress] of it done (0 to 1).
|
||||
final class const ArchiveImportStateRunning({
|
||||
required final ImportArchive archive,
|
||||
required final String title,
|
||||
required final double progress,
|
||||
}) extends ArchiveImportState {
|
||||
@override
|
||||
List<Object?> get props => [archive, title, progress];
|
||||
}
|
||||
|
||||
/// The archive was imported: [result].
|
||||
final class const ArchiveImportStateDone(final ArchiveImported result)
|
||||
extends ArchiveImportState {
|
||||
@override
|
||||
List<Object?> get props => [result];
|
||||
}
|
||||
|
||||
/// Reading or importing failed: the archive's [problem], or null for an
|
||||
/// import that broke off (the instance out of reach, say).
|
||||
final class const ArchiveImportStateFailed(final ArchiveProblem? problem)
|
||||
extends ArchiveImportState {
|
||||
@override
|
||||
List<Object?> get props => [problem];
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
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/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/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';
|
||||
|
||||
/// What an import brought: the top studyset made, how many cards and
|
||||
/// reviews, and how many cards were left out, naming dictionary entries the
|
||||
/// instance doesn't hold.
|
||||
typedef ArchiveImported = ({
|
||||
String studysetId,
|
||||
int cards,
|
||||
int reviews,
|
||||
int missingEntries,
|
||||
});
|
||||
|
||||
/// 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).
|
||||
class ArchiveImporter(
|
||||
final ReleaseCopier _copier,
|
||||
final DictionaryRepository _dictionaries,
|
||||
final ReviewRepository _reviews,
|
||||
) {
|
||||
/// 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.
|
||||
static const cardOrigin = 'archive';
|
||||
|
||||
/// Imports [archive]; tells [onProgress] how much is done, from 0 to 1.
|
||||
/// Throws `ReleaseUnreadable` before writing anything when this app
|
||||
/// can't read all of it.
|
||||
Future<ArchiveImported> import(
|
||||
ImportArchive archive, {
|
||||
void Function(double done)? onProgress,
|
||||
}) async {
|
||||
final (:items, :missing) = await _withEntries(archive.items);
|
||||
final (:top, :ids) = await _copier.write(
|
||||
PublishedRelease(publicId: '', version: 0, items: items),
|
||||
fileOf: (sha256) async => archive.files[sha256]!,
|
||||
cardOrigin: cardOrigin,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
final reviews = [
|
||||
for (final review in archive.reviews)
|
||||
if (ids[review.card] case final cardId?)
|
||||
(
|
||||
cardId: cardId,
|
||||
rating: Rating.values.firstWhere(
|
||||
(rating) => rating.value == review.rating,
|
||||
),
|
||||
reviewedAt: review.reviewedAt,
|
||||
duration: Duration(milliseconds: review.durationMs),
|
||||
mode: StudyMode.values.firstWhere(
|
||||
(mode) => mode.value == review.mode,
|
||||
orElse: () => StudyMode.review,
|
||||
),
|
||||
),
|
||||
];
|
||||
await _reviews.recordAll(reviews);
|
||||
return (
|
||||
studysetId: top,
|
||||
cards: archive.cards - missing,
|
||||
reviews: reviews.length,
|
||||
missingEntries: missing,
|
||||
);
|
||||
}
|
||||
|
||||
/// [items] with every card that names a dictionary entry made from it, as
|
||||
/// adding from the dictionary makes it, with the tags that brings beside
|
||||
/// its own; those naming an entry the instance doesn't hold left out and
|
||||
/// counted.
|
||||
Future<({Map<String, Map<String, Object?>> items, int missing})> _withEntries(
|
||||
Map<String, Map<String, Object?>> items,
|
||||
) async {
|
||||
final named = <({String source, String language}), Map<String, String>>{};
|
||||
for (final MapEntry(key: path, value: item) in items.entries) {
|
||||
if (item
|
||||
case {
|
||||
'entry': {
|
||||
'source': final String source,
|
||||
'language': final String language,
|
||||
'id': final String id,
|
||||
},
|
||||
}
|
||||
when !item.containsKey('payload')) {
|
||||
(named[(source: source, language: language)] ??= {})[path] = id;
|
||||
}
|
||||
}
|
||||
if (named.isEmpty) {
|
||||
return (items: items, missing: 0);
|
||||
}
|
||||
final completed = {...items};
|
||||
var missing = 0;
|
||||
final dictionaries = await _dictionaries.dictionaries();
|
||||
for (final MapEntry(key: (:source, :language), value: paths)
|
||||
in named.entries) {
|
||||
final dictionary = dictionaries
|
||||
.where((d) => d.source == source && d.glossLanguage == language)
|
||||
.firstOrNull;
|
||||
final byId = {
|
||||
if (dictionary != null)
|
||||
for (final entry in await _dictionaries.entries(
|
||||
dictionary,
|
||||
{...paths.values}.toList(),
|
||||
))
|
||||
entry.ref.id: entry,
|
||||
};
|
||||
for (final MapEntry(key: path, value: id) in paths.entries) {
|
||||
final entry = byId[id];
|
||||
if (entry == null) {
|
||||
completed.remove(path);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
final item = items[path]!;
|
||||
completed[path] = {
|
||||
...item,
|
||||
'payload': jsonDecode(
|
||||
utf8.decode(Uint8List.sublistView(encodeCardPayload(entry.card))),
|
||||
),
|
||||
'tags': [
|
||||
...{
|
||||
...?(item['tags'] as List<Object?>?)?.whereType<String>(),
|
||||
...entry.tagNames,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
return (items: completed, missing: missing);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart';
|
||||
|
||||
/// Why a file isn't an archive this app can import.
|
||||
enum ArchiveProblem() {
|
||||
/// Not a `.zip`, or one without `archive.json`.
|
||||
notAnArchive,
|
||||
|
||||
/// An archive in a format newer than this app reads.
|
||||
newerFormat,
|
||||
|
||||
/// An archive whose files aren't what its format says.
|
||||
unreadable,
|
||||
}
|
||||
|
||||
/// A file that isn't an archive this app can import, and why.
|
||||
final class const ArchiveUnreadable(final ArchiveProblem problem)
|
||||
implements Exception {
|
||||
@override
|
||||
String toString() => 'ArchiveUnreadable(${problem.name})';
|
||||
}
|
||||
|
||||
/// Reads archives (see [ImportArchive]).
|
||||
abstract interface class ArchiveReader() {
|
||||
/// The archive in [bytes]; throws [ArchiveUnreadable] for a file that
|
||||
/// isn't one this app can import.
|
||||
Future<ImportArchive> read(Uint8List bytes);
|
||||
}
|
||||
|
||||
/// [ArchiveReader] on an isolate of its own: an archive with its PDFs can
|
||||
/// be large, and the app stays responsive while it is unpacked.
|
||||
class IsolateArchiveReader() implements ArchiveReader {
|
||||
@override
|
||||
Future<ImportArchive> read(Uint8List bytes) =>
|
||||
Isolate.run(() => readArchive(bytes), debugName: 'Archive');
|
||||
}
|
||||
|
||||
/// The archive in [bytes]; throws [ArchiveUnreadable] for a file that isn't
|
||||
/// one this app can import.
|
||||
ImportArchive readArchive(Uint8List bytes) {
|
||||
final Archive zip;
|
||||
try {
|
||||
zip = ZipDecoder().decodeBytes(bytes);
|
||||
} on Object {
|
||||
throw const ArchiveUnreadable(ArchiveProblem.notAnArchive);
|
||||
}
|
||||
Object? json(ArchiveFile file) {
|
||||
try {
|
||||
return jsonDecode(utf8.decode(file.content));
|
||||
} on Object {
|
||||
throw const ArchiveUnreadable(ArchiveProblem.unreadable);
|
||||
}
|
||||
}
|
||||
|
||||
final head = zip.findFile('archive.json');
|
||||
if (head == null) {
|
||||
throw const ArchiveUnreadable(ArchiveProblem.notAnArchive);
|
||||
}
|
||||
switch (json(head)) {
|
||||
case {'format': final int format} when format > ImportArchive.format:
|
||||
throw const ArchiveUnreadable(ArchiveProblem.newerFormat);
|
||||
case {'format': int()}:
|
||||
break;
|
||||
default:
|
||||
throw const ArchiveUnreadable(ArchiveProblem.notAnArchive);
|
||||
}
|
||||
|
||||
final items = <String, Map<String, Object?>>{};
|
||||
final files = <String, Uint8List>{};
|
||||
var reviews = const <ArchiveReview>[];
|
||||
for (final file in zip.files) {
|
||||
if (!file.isFile) {
|
||||
continue;
|
||||
}
|
||||
final name = file.name;
|
||||
if (name.startsWith('files/')) {
|
||||
files[name.substring('files/'.length)] = file.content;
|
||||
} else if (name == 'reviews.json') {
|
||||
reviews = _reviews(json(file));
|
||||
} else if (name != 'archive.json' && name.endsWith('.json')) {
|
||||
if (json(file) case final Map<String, Object?> item) {
|
||||
items[name] = item;
|
||||
} else {
|
||||
throw const ArchiveUnreadable(ArchiveProblem.unreadable);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!items.containsKey('studyset.json')) {
|
||||
throw const ArchiveUnreadable(ArchiveProblem.unreadable);
|
||||
}
|
||||
return ImportArchive(items: items, files: files, reviews: reviews);
|
||||
}
|
||||
|
||||
List<ArchiveReview> _reviews(Object? json) {
|
||||
if (json is! List) {
|
||||
throw const ArchiveUnreadable(ArchiveProblem.unreadable);
|
||||
}
|
||||
return [
|
||||
for (final review in json)
|
||||
switch (review) {
|
||||
{
|
||||
'card': final String card,
|
||||
'rating': final int rating,
|
||||
'reviewedAt': final String at,
|
||||
}
|
||||
when rating >= 1 && rating <= 4 && DateTime.tryParse(at) != null =>
|
||||
(
|
||||
card: card,
|
||||
rating: rating,
|
||||
reviewedAt: DateTime.parse(at).toUtc(),
|
||||
durationMs: switch (review['durationMs']) {
|
||||
final int ms when ms >= 0 => ms,
|
||||
_ => 0,
|
||||
},
|
||||
mode: switch (review['mode']) {
|
||||
final String mode => mode,
|
||||
_ => 'review',
|
||||
},
|
||||
),
|
||||
_ => throw const ArchiveUnreadable(ArchiveProblem.unreadable),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
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
|
||||
/// 2026-10-11, for backups, moves between instances and bringing a library
|
||||
/// from elsewhere).
|
||||
///
|
||||
/// An archive is a `.zip`. Its `archive.json` says which format it is in
|
||||
/// (`{"format": 1}`); the studyset is laid out as a release is (ADR 0005,
|
||||
/// rule 3: `studyset.json`, `cards/<id>.json`, `notes/`, `paths/<id>/`,
|
||||
/// `pdfs/`, a directory per studyset inside it, `files.json` with the files
|
||||
/// in `files/<sha256>`). A card may name a dictionary entry in place of its
|
||||
/// payload (`"entry": {"source": "jmdict", "language": "en", "id": "…"}`):
|
||||
/// the import makes it from the instance's dictionary, as adding from the
|
||||
/// 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.
|
||||
final class const ImportArchive({
|
||||
/// The release layout's files by their paths, each read as JSON.
|
||||
required final Map<String, Map<String, Object?>> items,
|
||||
|
||||
/// The files the content names, by their SHA-256.
|
||||
required final Map<String, Uint8List> files,
|
||||
|
||||
/// The reviews of its cards.
|
||||
required final List<ArchiveReview> reviews,
|
||||
}) {
|
||||
/// The format this app reads; a newer one is refused whole.
|
||||
static const format = 1;
|
||||
|
||||
/// How many studysets it holds.
|
||||
int get studysets =>
|
||||
items.keys.where((path) => path.endsWith('studyset.json')).length;
|
||||
|
||||
/// How many flashcards it holds.
|
||||
int get cards => items.keys.where(_isCard).length;
|
||||
|
||||
/// How many learning paths it holds.
|
||||
int get paths =>
|
||||
items.keys.where((path) => path.endsWith('/path.json')).length;
|
||||
|
||||
/// Whether the release layout's [path] is a card's.
|
||||
static bool _isCard(String path) {
|
||||
final segments = path.split('/');
|
||||
return segments.length >= 2 && segments[segments.length - 2] == 'cards';
|
||||
}
|
||||
}
|
||||
|
||||
/// A review of a card of an archive: the card by its id there, the rating
|
||||
/// (1 to 4, as FSRS counts), when, how long it took, and the mode it was
|
||||
/// studied in (as the review log stores it).
|
||||
typedef ArchiveReview = ({
|
||||
String card,
|
||||
int rating,
|
||||
DateTime reviewedAt,
|
||||
int durationMs,
|
||||
String mode,
|
||||
});
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/bloc/archive_import_cubit.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/explore/data/release_copier.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Imports an archive the person picks (see `ImportArchive`): what it
|
||||
/// holds first, then, confirmed, all of it as their own, its progress
|
||||
/// showing and nothing closing it meanwhile; says how it went. Returns the
|
||||
/// top studyset made; null when nothing was imported.
|
||||
Future<String?> importArchive(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final file = await DependenciesScope.of(
|
||||
context,
|
||||
).files.pick(label: l10n.archiveFileType, extensions: const ['zip']);
|
||||
if (file == null || !context.mounted) {
|
||||
return null;
|
||||
}
|
||||
final done = await showAdaptiveSheet<ArchiveImported>(
|
||||
context,
|
||||
// Swiping away would leave an import half done, without asking.
|
||||
swipeable: false,
|
||||
builder: (context) {
|
||||
final dependencies = DependenciesScope.of(context);
|
||||
final person = PersonScope.of(context);
|
||||
return BlocProvider(
|
||||
create: (context) => ArchiveImportCubit(
|
||||
dependencies.archives,
|
||||
ArchiveImporter(
|
||||
ReleaseCopier(
|
||||
person.studysets,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.paths,
|
||||
person.pdfSources,
|
||||
person.tags,
|
||||
person.assets,
|
||||
),
|
||||
dependencies.dictionaries,
|
||||
person.reviews,
|
||||
),
|
||||
)..start(file.bytes),
|
||||
child: const _ArchiveImport(),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (done == null) {
|
||||
return null;
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
[
|
||||
l10n.archiveImported(
|
||||
l10n.studysetCountFlashcards(done.cards),
|
||||
l10n.archiveCountReviews(done.reviews),
|
||||
),
|
||||
if (done.missingEntries > 0)
|
||||
l10n.archiveImportMissingEntries(done.missingEntries),
|
||||
].join(' '),
|
||||
),
|
||||
),
|
||||
);
|
||||
return done.studysetId;
|
||||
}
|
||||
|
||||
class const _ArchiveImport() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return BlocConsumer<ArchiveImportCubit, ArchiveImportState>(
|
||||
listenWhen: (previous, current) => current is ArchiveImportStateDone,
|
||||
listener: (context, state) => Navigator.of(
|
||||
context,
|
||||
).pop((state as ArchiveImportStateDone).result),
|
||||
builder: (context, state) {
|
||||
final running = state is ArchiveImportStateRunning;
|
||||
return SheetScaffold(
|
||||
title: l10n.archiveImportTitle,
|
||||
// Closing would leave half the archive imported.
|
||||
busy: running,
|
||||
footer: switch (state) {
|
||||
ArchiveImportStateReady() ||
|
||||
ArchiveImportStateRunning() => FilledButton(
|
||||
onPressed: running
|
||||
? null
|
||||
: () => context.read<ArchiveImportCubit>().confirm(),
|
||||
child: Text(l10n.archiveImportConfirm),
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
body: switch (state) {
|
||||
ArchiveImportStateReading() || ArchiveImportStateDone() => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
const AppLoadingIndicator(),
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.archiveImportReading),
|
||||
],
|
||||
),
|
||||
),
|
||||
ArchiveImportStateFailed(:final problem) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(switch (problem) {
|
||||
ArchiveProblem.notAnArchive => l10n.archiveNotAnArchive,
|
||||
ArchiveProblem.newerFormat => l10n.archiveNewerFormat,
|
||||
ArchiveProblem.unreadable => l10n.archiveUnreadable,
|
||||
null => l10n.archiveImportBrokeOff,
|
||||
}),
|
||||
),
|
||||
ArchiveImportStateReady(:final archive, :final title) ||
|
||||
ArchiveImportStateRunning(
|
||||
:final archive,
|
||||
:final title,
|
||||
) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.archiveImportHolds(title),
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final line in [
|
||||
l10n.archiveCountStudysets(archive.studysets),
|
||||
l10n.studysetCountFlashcards(archive.cards),
|
||||
if (archive.paths > 0) l10n.studysetCountPaths(archive.paths),
|
||||
if (archive.reviews.isNotEmpty)
|
||||
l10n.archiveCountReviews(archive.reviews.length),
|
||||
])
|
||||
Text('· $line', style: theme.textTheme.bodyMedium),
|
||||
if (state case ArchiveImportStateRunning(:final progress)) ...[
|
||||
const SizedBox(height: 16),
|
||||
LinearProgressIndicator(value: progress),
|
||||
],
|
||||
],
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,10 @@ abstract interface class DictionaryGateway() {
|
|||
Map<String, String> versions,
|
||||
);
|
||||
|
||||
/// The entries of [dictionary] with the ids [ids], in that order; those
|
||||
/// gone from it left out.
|
||||
Future<List<DictionaryItem>> entries(Dictionary dictionary, List<String> ids);
|
||||
|
||||
/// The entries of [dictionary] that carry [label], in its order: [limit]
|
||||
/// of them after the first [offset].
|
||||
Future<List<DictionaryItem>> labelled(
|
||||
|
|
|
|||
|
|
@ -75,6 +75,19 @@ class DictionaryRepository(final DictionaryGateway _gateway) {
|
|||
];
|
||||
}
|
||||
|
||||
/// The entries of [dictionary] with the ids [ids], in that order, those
|
||||
/// gone from it left out; asked [comparedAtOnce] at a time.
|
||||
Future<List<DictionaryItem>> entries(
|
||||
Dictionary dictionary,
|
||||
List<String> ids,
|
||||
) async => [
|
||||
for (var at = 0; at < ids.length; at += comparedAtOnce)
|
||||
...await _gateway.entries(
|
||||
dictionary,
|
||||
ids.sublist(at, math.min(at + comparedAtOnce, ids.length)),
|
||||
),
|
||||
];
|
||||
|
||||
/// How many entries of [dictionary] carry [label] (`jlpt:n5`).
|
||||
Future<int> countLabelled(Dictionary dictionary, String label) =>
|
||||
_gateway.countLabelled(dictionary, label);
|
||||
|
|
|
|||
|
|
@ -57,6 +57,19 @@ class ServerpodDictionaryGateway(final protocol.Client _client)
|
|||
_item(dictionary, hit),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<DictionaryItem>> entries(
|
||||
Dictionary dictionary,
|
||||
List<String> ids,
|
||||
) async => [
|
||||
for (final hit in await _client.dictionary.entries(
|
||||
dictionary.source,
|
||||
dictionary.glossLanguage,
|
||||
ids,
|
||||
))
|
||||
_item(dictionary, hit),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<int> countLabelled(Dictionary dictionary, String label) =>
|
||||
_client.dictionary.countLabelled(
|
||||
|
|
|
|||
|
|
@ -63,6 +63,45 @@ class ReleaseCopier(
|
|||
required String location,
|
||||
required Future<Uint8List> Function(String sha256) fileOf,
|
||||
void Function(double done)? onProgress,
|
||||
}) async => (await _copy(
|
||||
release,
|
||||
fileOf: fileOf,
|
||||
onProgress: onProgress,
|
||||
origin: (id) => PublishedRef(
|
||||
publication: release.publicId,
|
||||
item: id,
|
||||
version: release.version,
|
||||
),
|
||||
location: location,
|
||||
)).top;
|
||||
|
||||
/// Writes [release]'s items into the library as the person's own, linked
|
||||
/// to no publication: an archive's, as a backup or a move brings them
|
||||
/// (`feature/archive/`). Returns the top studyset and, by each item's id
|
||||
/// in the release, the id it has now (the archive's reviews name cards by
|
||||
/// theirs). Otherwise as [copy].
|
||||
/// Its cards say they came from [cardOrigin] ("archive"), as imported
|
||||
/// ones do.
|
||||
Future<({String top, Map<String, String> ids})> write(
|
||||
PublishedRelease release, {
|
||||
required Future<Uint8List> Function(String sha256) fileOf,
|
||||
required String cardOrigin,
|
||||
void Function(double done)? onProgress,
|
||||
}) => _copy(
|
||||
release,
|
||||
fileOf: fileOf,
|
||||
onProgress: onProgress,
|
||||
origin: null,
|
||||
cardOrigin: cardOrigin,
|
||||
);
|
||||
|
||||
Future<({String top, Map<String, String> ids})> _copy(
|
||||
PublishedRelease release, {
|
||||
required Future<Uint8List> Function(String sha256) fileOf,
|
||||
required void Function(double done)? onProgress,
|
||||
required PublishedRef Function(String id)? origin,
|
||||
String? location,
|
||||
String? cardOrigin,
|
||||
}) async {
|
||||
// Read all of it first: a copy is whole or not made.
|
||||
_read(release, const {});
|
||||
|
|
@ -97,17 +136,20 @@ class ReleaseCopier(
|
|||
step();
|
||||
}
|
||||
final read = _read(release, assetIds);
|
||||
PublishedRef origin(String id) => PublishedRef(
|
||||
publication: release.publicId,
|
||||
item: id,
|
||||
version: release.version,
|
||||
);
|
||||
// Each item's id in the release, and the id it has now.
|
||||
final ids = <String, String>{};
|
||||
|
||||
// The studysets, each after the one it is in, in their order.
|
||||
final studysetIds = <String, String>{};
|
||||
final rootItem = read.studysets['']!;
|
||||
studysetIds[''] = await _studysets.create(
|
||||
_withOrigin(rootItem.content, origin(rootItem.id), location: location),
|
||||
studysetIds[''] = ids[rootItem.id] = await _studysets.create(
|
||||
origin == null
|
||||
? rootItem.content
|
||||
: _withOrigin(
|
||||
rootItem.content,
|
||||
origin(rootItem.id),
|
||||
location: location,
|
||||
),
|
||||
);
|
||||
step();
|
||||
for (final directory in read.directories) {
|
||||
|
|
@ -115,8 +157,10 @@ class ReleaseCopier(
|
|||
continue;
|
||||
}
|
||||
final item = read.studysets[directory]!;
|
||||
studysetIds[directory] = await _studysets.create(
|
||||
_withOrigin(item.content, origin(item.id)),
|
||||
studysetIds[directory] = ids[item.id] = await _studysets.create(
|
||||
origin == null
|
||||
? item.content
|
||||
: _withOrigin(item.content, origin(item.id)),
|
||||
parentId: studysetIds[_parentOf(directory)],
|
||||
);
|
||||
step();
|
||||
|
|
@ -132,8 +176,14 @@ class ReleaseCopier(
|
|||
final cards = _ordered(read.cards[directory]);
|
||||
final cardIds = await _cards.createAll(studysetId, [
|
||||
for (final card in cards)
|
||||
card.content.withField(CardField.published, origin(card.id)),
|
||||
]);
|
||||
if (origin == null)
|
||||
card.content
|
||||
else
|
||||
card.content.withField(CardField.published, origin(card.id)),
|
||||
], origin: cardOrigin);
|
||||
for (final (i, card) in cards.indexed) {
|
||||
ids[card.id] = cardIds[i];
|
||||
}
|
||||
await _tags.addTags({
|
||||
for (final (i, card) in cards.indexed)
|
||||
if (card.tags.isNotEmpty) cardIds[i]: await tagsOf(card.tags),
|
||||
|
|
@ -144,8 +194,11 @@ class ReleaseCopier(
|
|||
for (final note in _ordered(read.notes[directory])) {
|
||||
final (:noteId, revisionId: _) = await _notes.create(
|
||||
studysetId,
|
||||
note.content.withField(NoteField.published, origin(note.id)),
|
||||
origin == null
|
||||
? note.content
|
||||
: note.content.withField(NoteField.published, origin(note.id)),
|
||||
);
|
||||
ids[note.id] = noteId;
|
||||
if (note.tags.isNotEmpty) {
|
||||
await _tags.setTagsOfNote(noteId, await tagsOf(note.tags));
|
||||
}
|
||||
|
|
@ -153,9 +206,11 @@ class ReleaseCopier(
|
|||
}
|
||||
|
||||
for (final path in _ordered(read.paths[directory])) {
|
||||
final pathId = await _paths.createPath(
|
||||
final pathId = ids[path.id] = await _paths.createPath(
|
||||
studysetId,
|
||||
_pathWithOrigin(path.content, origin(path.id)),
|
||||
origin == null
|
||||
? path.content
|
||||
: _pathWithOrigin(path.content, origin(path.id)),
|
||||
);
|
||||
if (path.tags.isNotEmpty) {
|
||||
await _tags.setTagsOfPath(pathId, await tagsOf(path.tags));
|
||||
|
|
@ -165,10 +220,12 @@ class ReleaseCopier(
|
|||
await _paths.createStep(
|
||||
pathId,
|
||||
stepItem.kind!,
|
||||
stepItem.content.withField(
|
||||
StepField.published,
|
||||
origin(stepItem.id),
|
||||
),
|
||||
origin == null
|
||||
? stepItem.content
|
||||
: stepItem.content.withField(
|
||||
StepField.published,
|
||||
origin(stepItem.id),
|
||||
),
|
||||
);
|
||||
step();
|
||||
}
|
||||
|
|
@ -177,12 +234,14 @@ class ReleaseCopier(
|
|||
for (final pdf in _ordered(read.pdfs[directory])) {
|
||||
await _pdfs.add(
|
||||
studysetId,
|
||||
_pdfWithOrigin(pdf.content, origin(pdf.id)),
|
||||
origin == null
|
||||
? pdf.content
|
||||
: _pdfWithOrigin(pdf.content, origin(pdf.id)),
|
||||
);
|
||||
step();
|
||||
}
|
||||
}
|
||||
return studysetIds['']!;
|
||||
return (top: studysetIds['']!, ids: ids);
|
||||
}
|
||||
|
||||
/// [release]'s items decoded, each file of the release named by the
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/widget/import_archive.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||
|
|
@ -110,26 +111,50 @@ class _LibraryState() extends State<_Library> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Asks what to import as new studysets, and imports it: for now an Anki
|
||||
/// deck, which becomes a studyset of its own. Learning paths and
|
||||
/// Asks what to import as new studysets, and imports it: an Anki deck,
|
||||
/// which becomes a studyset of its own, or an archive, a studyset with
|
||||
/// everything in it and its progress, opened once in. Learning paths and
|
||||
/// flashcards as text go into a studyset, from its Import content.
|
||||
Future<void> _import(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final anki = await showAdaptiveSheet<bool>(
|
||||
final chosen = await showAdaptiveSheet<_LibraryImport>(
|
||||
context,
|
||||
builder: (sheetContext) => SheetScaffold(
|
||||
title: l10n.libraryImport,
|
||||
body: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.graduationCap),
|
||||
title: Text(l10n.studysetImportAnki),
|
||||
subtitle: Text(l10n.libraryImportAnkiHint),
|
||||
onTap: () => Navigator.of(sheetContext).pop(true),
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.graduationCap),
|
||||
title: Text(l10n.studysetImportAnki),
|
||||
subtitle: Text(l10n.libraryImportAnkiHint),
|
||||
onTap: () => Navigator.of(sheetContext).pop(_LibraryImport.anki),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.archive),
|
||||
title: Text(l10n.libraryImportArchive),
|
||||
subtitle: Text(l10n.libraryImportArchiveHint),
|
||||
onTap: () =>
|
||||
Navigator.of(sheetContext).pop(_LibraryImport.archive),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if ((anki ?? false) && context.mounted) {
|
||||
await importFromAnki(context);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
switch (chosen) {
|
||||
case _LibraryImport.anki:
|
||||
await importFromAnki(context);
|
||||
case _LibraryImport.archive:
|
||||
if (await importArchive(context) case final id? when context.mounted) {
|
||||
context.go(RoutePaths.studyset(id));
|
||||
}
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -589,3 +614,9 @@ class const _SearchRow({
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the library imports as new studysets.
|
||||
enum _LibraryImport() {
|
||||
anki,
|
||||
archive,
|
||||
}
|
||||
|
|
|
|||
247
halkyon_learn_flutter/test/feature/archive/archive_test.dart
Normal file
247
halkyon_learn_flutter/test/feature/archive/archive_test.dart
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
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';
|
||||
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';
|
||||
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/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';
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('importing', () {
|
||||
late CardRepository cards;
|
||||
late StudysetRepository studysets;
|
||||
late TagRepository tags;
|
||||
late ReviewRepository reviews;
|
||||
late ArchiveImporter importer;
|
||||
|
||||
setUp(() {
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
final notes = NoteRepository(FakeNoteGateway());
|
||||
final paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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';
|
||||
|
||||
import '../../support/fake_card_gateway.dart';
|
||||
import '../../support/fake_dictionary_gateway.dart';
|
||||
import '../../support/fake_file_picker.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
import '../../support/pump_app.dart';
|
||||
import '../../support/test_dependencies.dart';
|
||||
import 'archive_test.dart' show japanese, zip;
|
||||
|
||||
void main() {
|
||||
late FakeFilePicker files;
|
||||
late FakeStudysetGateway studysets;
|
||||
|
||||
setUp(() {
|
||||
files = FakeFilePicker();
|
||||
studysets = FakeStudysetGateway();
|
||||
});
|
||||
|
||||
Future<void> show(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: studysets,
|
||||
cardGateway: FakeCardGateway(),
|
||||
files: files,
|
||||
dictionaryGateway: FakeDictionaryGateway(
|
||||
words: [
|
||||
word('1078630', 'テーブル', glosses: ['table']),
|
||||
],
|
||||
),
|
||||
),
|
||||
location: RoutePaths.library,
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> choose(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('Import'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Halkyon archive'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets("the Library's Import takes an archive: says what it holds, "
|
||||
'imports it once confirmed, and opens it', (tester) async {
|
||||
files.next = (name: 'japanese.zip', bytes: zip(japanese()));
|
||||
await show(tester);
|
||||
|
||||
await choose(tester);
|
||||
expect(find.text('“Japanese” holds:'), findsOneWidget);
|
||||
expect(find.text('· 2 studysets'), findsOneWidget);
|
||||
expect(find.text('· 3 reviews'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(StudysetScreen), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('Imported: 2 flashcards and 2 reviews.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining("1 word isn't"), findsOneWidget);
|
||||
final repository = StudysetRepository(studysets);
|
||||
addTearDown(repository.dispose);
|
||||
expect((await repository.all()).length, 2);
|
||||
});
|
||||
|
||||
testWidgets('a file that is no archive says so, and imports nothing', (
|
||||
tester,
|
||||
) async {
|
||||
files.next = (name: 'notes.zip', bytes: zip({'notes.txt': {}}));
|
||||
await show(tester);
|
||||
|
||||
await choose(tester);
|
||||
|
||||
expect(find.text("This file isn't a Halkyon archive."), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Import'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
|
@ -140,6 +140,18 @@ class FakeDictionaryGateway({
|
|||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<DictionaryItem>> entries(
|
||||
Dictionary dictionary,
|
||||
List<String> ids,
|
||||
) async {
|
||||
if (fail case final error?) {
|
||||
throw error;
|
||||
}
|
||||
final byId = {for (final item in _of(dictionary)) item.ref.id: item};
|
||||
return [for (final id in ids) ?byId[id]];
|
||||
}
|
||||
|
||||
/// How often [changed] was asked, and with how many entries each time.
|
||||
final changedAsked = <int>[];
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import 'package:halkyon_learn_flutter/src/feature/about/data/about_gateway.dart'
|
|||
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/claude_hand_off.dart';
|
||||
|
|
@ -169,6 +171,7 @@ Dependencies testDependencies({
|
|||
videos: videos ?? FakeYoutubeVideos(),
|
||||
pdfEngine: pdfEngine ?? FakePdfEngine(),
|
||||
ankiPackages: InlineAnkiPackageReader(),
|
||||
archives: InlineArchiveReader(),
|
||||
exportArchiver: InlineExportArchiver(),
|
||||
deviceSettingsStore:
|
||||
deviceSettingsStore ?? MemoryDeviceSettingsStore(deviceSettings),
|
||||
|
|
@ -325,6 +328,12 @@ class InlineAnkiPackageReader() implements AnkiPackageReader {
|
|||
readAnkiPackage(bytes, temp: Directory.systemTemp);
|
||||
}
|
||||
|
||||
/// Reads archives where it is asked to, as [InlineAnkiPackageReader] reads.
|
||||
class InlineArchiveReader() implements ArchiveReader {
|
||||
@override
|
||||
Future<ImportArchive> read(Uint8List bytes) async => readArchive(bytes);
|
||||
}
|
||||
|
||||
/// Packs exports where it is asked to, as [InlineAnkiPackageReader] reads.
|
||||
class InlineExportArchiver() implements ExportArchiver {
|
||||
@override
|
||||
|
|
|
|||
Loading…
Reference in a new issue