services/database: rewrite upgrade/reset logic

This commit is contained in:
2026-06-01 15:55:25 +09:00
parent fe00301526
commit adc905e820
15 changed files with 883 additions and 291 deletions
+4 -8
View File
@@ -1,9 +1,10 @@
import 'package:mugiten/services/database/database.dart';
import 'package:mugiten/services/database/schemas/v2/table_names.dart';
import 'package:sqflite/sqflite.dart';
Future<void> verifyMugitenTablesWithDbConnection(
final DatabaseExecutor db,
) async {
final DatabaseExecutor db, {
final Set<String> expectedTables = allSchemaV2TableNames,
}) async {
final Set<String> tables = await db
.query(
'sqlite_master',
@@ -15,11 +16,6 @@ Future<void> verifyMugitenTablesWithDbConnection(
return result.map((final row) => row['name'] as String).toSet();
});
final Set<String> expectedTables = {
...HistoryTableNames.allTables,
...LibraryListTableNames.allTables,
};
final missingTables = expectedTables.difference(tables);
if (missingTables.isNotEmpty) {
+10 -12
View File
@@ -3,7 +3,7 @@ import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:mugiten/services/archive/v2/format.dart';
import 'package:mugiten/services/archive/archive_dispatcher.dart';
import 'package:sqflite/sqlite_api.dart';
/// The archive controller is a singleton-like class that keeps track of whether the
@@ -25,7 +25,7 @@ class ArchiveController extends Cubit<ArchiveState> {
final database = GetIt.instance.get<Database>();
final totalChunks = totalAmountOfChunksFromArchive(archive);
final totalChunks = await totalAmountOfChunksFromArchive(archive);
emit(
ImportingState(
@@ -36,12 +36,11 @@ class ArchiveController extends Cubit<ArchiveState> {
);
int i = 0;
await for (final event in importData(database, archive)) {
await for (final event in importBackupArchive(database, archive)) {
i += 1;
final status = switch (event) {
ArchiveV2StreamEvent(type: 'history') =>
'Importing history: ${event.progress}/${event.total}',
ArchiveV2StreamEvent(type: 'library') =>
final status = switch (event.type) {
'history' => 'Importing history: ${event.progress}/${event.total}',
'library' =>
'Importing library list "${event.name}": ${event.subProgress}/${event.subTotal}',
_ => 'Importing unknown data: ${event.progress}/${event.total}',
};
@@ -74,12 +73,11 @@ class ArchiveController extends Cubit<ArchiveState> {
);
int i = 0;
await for (final event in exportData(database, archive)) {
await for (final event in exportBackupArchive(database, archive)) {
i += 1;
final status = switch (event) {
ArchiveV2StreamEvent(type: 'history') =>
'Exporting history: ${event.progress}/${event.total}',
ArchiveV2StreamEvent(type: 'library') =>
final status = switch (event.type) {
'history' => 'Exporting history: ${event.progress}/${event.total}',
'library' =>
'Exporting library list "${event.name}": ${event.subProgress}/${event.subTotal}',
_ => 'Exporting unknown data: ${event.progress}/${event.total}',
};
+319 -3
View File
@@ -1,5 +1,321 @@
// TODO: generate export functions for all database schema versions
import 'dart:io';
// TODO: dispatch export based on current database schema version
import 'package:archive/archive_io.dart';
import 'package:mugiten/services/archive/sql/export_db_v1.dart' as schema_v1;
import 'package:mugiten/services/archive/sql/export_db_v2.dart' as schema_v2;
import 'package:mugiten/services/archive/v1/format.dart' as archive_v1;
import 'package:mugiten/services/archive/v2/format.dart' as archive_v2;
import 'package:sqflite/sqlite_api.dart';
// TODO: dispatch import based on detected archive version.
class ArchiveTransferEvent {
final String type;
final int progress;
final int total;
final String? name;
final int? subProgress;
final int? subTotal;
const ArchiveTransferEvent({
required this.type,
required this.progress,
required this.total,
this.name,
this.subProgress,
this.subTotal,
}) : assert(
progress > 0 && total > 0 && progress <= total,
'0 < progress <= total must hold',
),
assert(
(subProgress == null && subTotal == null) ||
(subProgress != null &&
subTotal != null &&
subProgress > 0 &&
subTotal > 0 &&
subProgress <= subTotal),
'subProgress and subTotal must both be null or both be positive integers with subProgress <= subTotal',
);
bool get hasSubProgress => subProgress != null && subTotal != null;
factory ArchiveTransferEvent.fromArchiveV2StreamEvent(
final archive_v2.ArchiveV2StreamEvent event,
) => ArchiveTransferEvent(
type: event.type,
progress: event.progress,
total: event.total,
name: event.name,
subProgress: event.subProgress,
subTotal: event.subTotal,
);
@override
String toString() {
if (!hasSubProgress) {
return 'ArchiveTransferEvent(type: $type, progress: $progress/$total)';
}
return 'ArchiveTransferEvent(type: $type, name: $name, progress: $progress/$total, subProgress: $subProgress/$subTotal)';
}
}
abstract interface class SchemaArchiveExportStrategy {
int get schemaVersion;
int get archiveFormatVersion;
Future<int> totalAmountOfChunks(final Database db);
Stream<ArchiveTransferEvent> export(
final Database db,
final File archiveFile,
);
}
abstract base class ArchiveV2SchemaExportStrategy
implements SchemaArchiveExportStrategy {
const ArchiveV2SchemaExportStrategy();
archive_v2.ArchiveV2ExportAdapter get adapter;
@override
int get archiveFormatVersion => archive_v2.expectedDataFormatVersion;
@override
Future<int> totalAmountOfChunks(final Database db) {
return archive_v2.totalAmountOfChunksFromDatabaseWithAdapter(
db,
adapter: adapter,
);
}
@override
Stream<ArchiveTransferEvent> export(
final Database db,
final File archiveFile,
) {
return archive_v2
.exportDataWithAdapter(db, archiveFile, adapter: adapter)
.map(ArchiveTransferEvent.fromArchiveV2StreamEvent);
}
}
final class SchemaV1ToArchiveV2ExportStrategy
extends ArchiveV2SchemaExportStrategy {
const SchemaV1ToArchiveV2ExportStrategy();
@override
int get schemaVersion => 1;
@override
archive_v2.ArchiveV2ExportAdapter get adapter =>
schema_v1.archiveExportAdapter;
}
final class SchemaV2ToArchiveV2ExportStrategy
extends ArchiveV2SchemaExportStrategy {
const SchemaV2ToArchiveV2ExportStrategy();
@override
int get schemaVersion => 2;
@override
archive_v2.ArchiveV2ExportAdapter get adapter =>
schema_v2.archiveExportAdapter;
}
abstract interface class ArchiveImportStrategy {
int get archiveFormatVersion;
Stream<ArchiveTransferEvent> importArchive(
final DatabaseExecutor db,
final File archiveFile,
);
}
final class ArchiveV1ImportStrategy implements ArchiveImportStrategy {
const ArchiveV1ImportStrategy();
@override
int get archiveFormatVersion => archive_v1.expectedDataFormatVersion;
@override
Stream<ArchiveTransferEvent> importArchive(
final DatabaseExecutor db,
final File archiveFile,
) async* {
if (db is! Database) {
throw ArgumentError.value(
db,
'db',
'Archive V1 import requires a Database instance',
);
}
await archive_v1.importData(db, archiveFile);
}
}
final class ArchiveV2ImportStrategy implements ArchiveImportStrategy {
const ArchiveV2ImportStrategy();
@override
int get archiveFormatVersion => archive_v2.expectedDataFormatVersion;
@override
Stream<ArchiveTransferEvent> importArchive(
final DatabaseExecutor db,
final File archiveFile,
) {
return archive_v2
.importData(db, archiveFile)
.map(ArchiveTransferEvent.fromArchiveV2StreamEvent);
}
}
final Map<int, SchemaArchiveExportStrategy> _latestExportStrategyBySchema = {
1: const SchemaV1ToArchiveV2ExportStrategy(),
2: const SchemaV2ToArchiveV2ExportStrategy(),
};
final Map<int, ArchiveImportStrategy> _importStrategiesByArchiveVersion = {
archive_v1.expectedDataFormatVersion: const ArchiveV1ImportStrategy(),
archive_v2.expectedDataFormatVersion: const ArchiveV2ImportStrategy(),
};
Future<int?> _detectLegacySchemaVersionFromTables(
final DatabaseExecutor db,
) async {
final columns = await db.rawQuery('PRAGMA table_info("Mugiten_LibraryList")');
final columnNames = columns
.map((final row) => row['name'])
.whereType<String>()
.toSet();
if (columnNames.contains('orderNum')) {
return 2;
}
if (columnNames.contains('prevList')) {
return 1;
}
return null;
}
/// Extracts Mugiten's schema version from SQLite's `user_version`.
///
/// Older development databases may still store a plain integer in
/// `user_version` instead of the packed Mugiten+jadb version. Some of those
/// databases also have an incorrect plain value, so legacy values are validated
/// against the actual Mugiten tables before being trusted.
Future<int> detectMugitenSchemaVersion(final Database db) async {
final userVersion = await db.getVersion();
if (userVersion > 0xFFFF) {
return userVersion >> 16;
}
final inferredVersion = await _detectLegacySchemaVersionFromTables(db);
return inferredVersion ?? userVersion;
}
SchemaArchiveExportStrategy _exportStrategyForSchema(final int schemaVersion) {
final strategy = _latestExportStrategyBySchema[schemaVersion];
if (strategy == null) {
throw UnsupportedError(
'No archive export strategy registered for Mugiten schema version $schemaVersion',
);
}
return strategy;
}
Future<int> totalAmountOfChunksFromDatabase(final Database db) async {
final schemaVersion = await detectMugitenSchemaVersion(db);
final strategy = _exportStrategyForSchema(schemaVersion);
return strategy.totalAmountOfChunks(db);
}
Future<int> totalAmountOfChunksFromArchive(final File archiveFile) async {
final archiveVersion = await detectArchiveVersion(archiveFile);
return switch (archiveVersion) {
archive_v2.expectedDataFormatVersion =>
archive_v2.totalAmountOfChunksFromArchive(archiveFile),
archive_v1.expectedDataFormatVersion => 1,
_ => throw UnsupportedError(
'No archive chunk counter registered for archive version $archiveVersion',
),
};
}
Future<int> detectArchiveVersion(final File archiveFile) async {
if (!archiveFile.existsSync()) {
throw Exception('Archive file does not exist: ${archiveFile.path}');
}
final archive = ZipDecoder().decodeStream(InputFileStream(archiveFile.path));
try {
for (final file in archive) {
if (!file.isFile || file.name != 'version.txt') {
continue;
}
final bytes = file.readBytes();
if (bytes == null) {
throw FormatException(
'Archive version file is empty: ${archiveFile.path}',
);
}
return int.parse(String.fromCharCodes(bytes).trim());
}
} finally {
for (final file in archive) {
file.closeSync();
}
}
return archive_v1.expectedDataFormatVersion;
}
ArchiveImportStrategy _importStrategyForArchiveVersion(
final int archiveVersion,
) {
final strategy = _importStrategiesByArchiveVersion[archiveVersion];
if (strategy == null) {
throw UnsupportedError(
'No archive import strategy registered for archive version $archiveVersion',
);
}
return strategy;
}
/// Exports a migration backup using the newest archive format that the source
/// database schema supports.
Stream<ArchiveTransferEvent> exportBackupArchive(
final Database db,
final File archiveFile,
) async* {
final schemaVersion = await detectMugitenSchemaVersion(db);
final strategy = _exportStrategyForSchema(schemaVersion);
yield* strategy.export(db, archiveFile);
}
/// Imports an archive into the latest database schema.
///
/// Migration backups are normally written using the latest archive format, but
/// older archive versions are also accepted here so that old user archives can
/// be restored through the same dispatcher.
Stream<ArchiveTransferEvent> importBackupArchive(
final DatabaseExecutor db,
final File archiveFile,
) async* {
final archiveVersion = await detectArchiveVersion(archiveFile);
final strategy = _importStrategyForArchiveVersion(archiveVersion);
yield* strategy.importArchive(db, archiveFile);
}
+19 -10
View File
@@ -1,6 +1,14 @@
import 'package:mugiten/services/archive/v2/format.dart';
import 'package:mugiten/services/database/schemas/v1/table_names.dart';
import 'package:sqflite/sqflite.dart';
import 'package:sqflite/sqlite_api.dart';
const archiveExportAdapter = ArchiveV2ExportAdapter(
historyEntryCount: historyEntryCount,
historyEntryGetAll: historyEntryGetAll,
libraryListGetLibraryMetadata: libraryListGetLibraryMetadata,
libraryListGetTotalCounts: libraryListGetTotalCounts,
libraryListGetEntries: libraryListGetEntries,
);
Future<int> historyEntryCount(final DatabaseExecutor db) async {
final result = await db.rawQuery('''
@@ -55,10 +63,10 @@ Future<List<ArchiveV2LibraryListMetadata>> libraryListGetLibraryMetadata({
required final DatabaseExecutor db,
}) async {
final result = await db.query(
LibraryListTableNames.libraryList,
LibraryListTableNames.libraryListOrdered,
columns: ['name'],
orderBy: '"name" ASC',
);
return result
.map(
(final row) =>
@@ -72,13 +80,13 @@ Future<Map<String, int>> libraryListGetTotalCounts({
}) async {
final result = await db.rawQuery('''
SELECT
"listName",
"lists"."name" AS "listName",
(
SELECT COUNT(*)
FROM "${LibraryListTableNames.libraryListEntry}"
WHERE "${LibraryListTableNames.libraryListEntry}"."listName" = "${LibraryListTableNames.libraryList}"."name"
FROM "${LibraryListTableNames.libraryListEntry}" AS "entries"
WHERE "entries"."listName" = "lists"."name"
) AS "count"
FROM "${LibraryListTableNames.libraryList}"
FROM "${LibraryListTableNames.libraryListOrdered}" AS "lists"
''');
final counts = {
@@ -92,7 +100,6 @@ Future<List<ArchiveV2LibraryListEntry>> libraryListGetEntries({
required final DatabaseExecutor db,
required final String listName,
required final int page,
required final int pageSize,
}) async {
final result = await db.rawQuery(
'''
@@ -132,13 +139,15 @@ Future<List<ArchiveV2LibraryListEntry>> libraryListGetEntries({
LIMIT ?
OFFSET ?
''',
[listName, listName, pageSize, page * pageSize],
[listName, listName, libraryListChunkSize, page * libraryListChunkSize],
);
final entries = result
.map(
(final entry) => ArchiveV2LibraryListEntry(
lastModified: DateTime.parse(entry['lastModified'] as String),
lastModified: DateTime.fromMillisecondsSinceEpoch(
entry['lastModified'] as int,
),
jmdictEntryId: entry['jmdictEntryId'] as int?,
kanji: entry['kanji'] as String?,
),
+20 -12
View File
@@ -1,9 +1,14 @@
import 'package:mugiten/services/archive/v2/format.dart';
import 'package:mugiten/services/database/schemas/v2/table_names.dart';
import 'package:sqflite/sqflite.dart';
import 'package:sqflite/sqlite_api.dart';
const int basicListOrderNumInterval = 100;
const int defaultLibraryListPageSize = 100;
const archiveExportAdapter = ArchiveV2ExportAdapter(
historyEntryCount: historyEntryCount,
historyEntryGetAll: historyEntryGetAll,
libraryListGetLibraryMetadata: libraryListGetLibraryMetadata,
libraryListGetTotalCounts: libraryListGetTotalCounts,
libraryListGetEntries: libraryListGetEntries,
);
Future<int> historyEntryCount(final DatabaseExecutor db) async {
final result = await db.rawQuery('''
@@ -60,8 +65,9 @@ Future<List<ArchiveV2LibraryListMetadata>> libraryListGetLibraryMetadata({
final result = await db.query(
LibraryListTableNames.libraryList,
columns: ['name'],
orderBy: '"name" ASC',
orderBy: '"orderNum" ASC',
);
return result
.map(
(final row) =>
@@ -75,13 +81,14 @@ Future<Map<String, int>> libraryListGetTotalCounts({
}) async {
final result = await db.rawQuery('''
SELECT
"listName",
"lists"."name" AS "listName",
(
SELECT COUNT(*)
FROM "${LibraryListTableNames.libraryListEntry}"
WHERE "${LibraryListTableNames.libraryListEntry}"."listName" = "${LibraryListTableNames.libraryList}"."name"
FROM "${LibraryListTableNames.libraryListEntry}" AS "entries"
WHERE "entries"."listName" = "lists"."name"
) AS "count"
FROM "${LibraryListTableNames.libraryList}"
FROM "${LibraryListTableNames.libraryList}" AS "lists"
ORDER BY "lists"."orderNum" ASC
''');
final counts = {
@@ -96,9 +103,8 @@ Future<List<ArchiveV2LibraryListEntry>> libraryListGetEntries({
required final String listName,
required final int page,
}) async {
final offset = basicListOrderNumInterval * (basicListOrderNumInterval * page);
final limit =
offset + (basicListOrderNumInterval * defaultLibraryListPageSize);
final offset = libraryListChunkSize * libraryListChunkSize * page;
final limit = offset + (libraryListChunkSize * libraryListChunkSize);
final result = await db.rawQuery(
'''
@@ -118,7 +124,9 @@ Future<List<ArchiveV2LibraryListEntry>> libraryListGetEntries({
final entries = result
.map(
(final entry) => ArchiveV2LibraryListEntry(
lastModified: DateTime.parse(entry['lastModified'] as String),
lastModified: DateTime.fromMillisecondsSinceEpoch(
entry['lastModified'] as int,
),
jmdictEntryId: entry['jmdictEntryId'] as int?,
kanji: entry['kanji'] as String?,
),
+128 -17
View File
@@ -20,6 +20,92 @@ const int expectedDataFormatVersion = 2;
const int historyChunkSize = 100;
const int libraryListChunkSize = defaultLibraryListPageSize;
typedef ArchiveV2HistoryEntryCountQuery =
Future<int> Function(DatabaseExecutor db);
typedef ArchiveV2HistoryEntryGetAllQuery =
Future<List<ArchiveV2HistoryEntry>> Function({
required DatabaseExecutor db,
required int page,
required int pageSize,
});
typedef ArchiveV2LibraryListGetLibraryMetadataQuery =
Future<List<ArchiveV2LibraryListMetadata>> Function({
required DatabaseExecutor db,
});
typedef ArchiveV2LibraryListGetTotalCountsQuery =
Future<Map<String, int>> Function({required DatabaseExecutor db});
typedef ArchiveV2LibraryListGetEntriesQuery =
Future<List<ArchiveV2LibraryListEntry>> Function({
required DatabaseExecutor db,
required String listName,
required int page,
});
/// An adapter that provides the necessary functions to export data from the database
/// in the format expected by version 2 of the data archive.
class ArchiveV2ExportAdapter {
final ArchiveV2HistoryEntryCountQuery historyEntryCount;
final ArchiveV2HistoryEntryGetAllQuery historyEntryGetAll;
final ArchiveV2LibraryListGetLibraryMetadataQuery
libraryListGetLibraryMetadata;
final ArchiveV2LibraryListGetTotalCountsQuery libraryListGetTotalCounts;
final ArchiveV2LibraryListGetEntriesQuery libraryListGetEntries;
const ArchiveV2ExportAdapter({
required this.historyEntryCount,
required this.historyEntryGetAll,
required this.libraryListGetLibraryMetadata,
required this.libraryListGetTotalCounts,
required this.libraryListGetEntries,
});
}
final ArchiveV2ExportAdapter latestSchemaExportAdapter = ArchiveV2ExportAdapter(
historyEntryCount: (final db) => db.historyEntryAmount(),
historyEntryGetAll:
({
required final DatabaseExecutor db,
required final int page,
required final int pageSize,
}) async {
return (await db.historyEntryGetAll(
page: page,
pageSize: pageSize,
)).map(ArchiveV2HistoryEntry.fromHistoryEntry).toList();
},
libraryListGetLibraryMetadata: ({required final DatabaseExecutor db}) async {
return (await db.libraryListGetLists())
.map((final list) => ArchiveV2LibraryListMetadata(name: list.name))
.toList();
},
libraryListGetTotalCounts: ({required final DatabaseExecutor db}) async {
final lists = await db.libraryListGetLists();
return {for (final list in lists) list.name: list.totalCount};
},
libraryListGetEntries:
({
required final DatabaseExecutor db,
required final String listName,
required final int page,
}) async {
final entryPage = await db.libraryListGetListEntries(
listName,
page: page,
);
if (entryPage == null) {
return <ArchiveV2LibraryListEntry>[];
}
return entryPage.entries
.map(ArchiveV2LibraryListEntry.fromLibraryListEntry)
.toList();
},
);
/// Functions and properties that makes up the format of version 2 of the data archive.
/// This archive is used to back up user data and optionally to transfer data between devices.
/// The main difference to version 1 is that the data is split into chunks, so that it can be
@@ -77,15 +163,16 @@ extension ArchiveFormatV2 on Directory {
File get libraryMetadataFile =>
File(libraryDir.uri.resolve('metadata.json').toFilePath());
/// The metadata of all library lists
/// The metadata of all library lists.
///
/// This is expected to be a list of objects, containing:
/// - *order*: implicitly from the order of the json list, the index of the library list
/// - name: the original name of the library list
/// - slug: the slugified name of the library list, used for the directory name
Map<String, Object?> get libraryMetadata =>
jsonDecode(libraryMetadataFile.readAsStringSync())
as Map<String, Object?>;
List<Map<String, Object?>> get libraryMetadata =>
(jsonDecode(libraryMetadataFile.readAsStringSync()) as List<dynamic>)
.map((final entry) => entry as Map<String, Object?>)
.toList();
List<Directory> get libraryListDirs =>
libraryDir.listSync().whereType<Directory>().toList();
@@ -102,24 +189,36 @@ extension ArchiveFormatV2 on Directory {
);
List<int> get libraryListEntryCounts => libraryListDirs
.map(
(final d) =>
d.listSync().whereType<File>().length -
1, // Subtract 1 for metadata.json
)
.map((final d) => d.listSync().whereType<File>().length)
.toList();
}
String slugifyLibraryListFileName(final String name) =>
name.toLowerCase().replaceAll(RegExp(r'\s+'), '_');
Future<int> totalAmountOfChunksFromDatabase(final DatabaseExecutor db) async {
final historyCount = await db.historyEntryAmount();
final libraryListCounts = (await db.libraryListGetLists())
.map((final list) => (list.totalCount / libraryListChunkSize).ceil())
Future<int> totalAmountOfChunksFromDatabase(final DatabaseExecutor db) {
return totalAmountOfChunksFromDatabaseWithAdapter(
db,
adapter: latestSchemaExportAdapter,
);
}
Future<int> totalAmountOfChunksFromDatabaseWithAdapter(
final DatabaseExecutor db, {
required final ArchiveV2ExportAdapter adapter,
}) async {
final historyCount = await adapter.historyEntryCount(db);
final libraryMetadata = await adapter.libraryListGetLibraryMetadata(db: db);
final libraryListCounts = await adapter.libraryListGetTotalCounts(db: db);
final libraryChunks = libraryMetadata
.map(
(final list) =>
((libraryListCounts[list.name] ?? 0) / libraryListChunkSize).ceil(),
)
.sum;
return (historyCount / historyChunkSize).ceil() + libraryListCounts;
return (historyCount / historyChunkSize).ceil() + libraryChunks;
}
// TODO: skip counting chunks where the library list already exists
@@ -196,7 +295,19 @@ class ArchiveV2StreamEvent {
Stream<ArchiveV2StreamEvent> exportData(
final DatabaseExecutor db,
final File archiveFile,
) async* {
) {
return exportDataWithAdapter(
db,
archiveFile,
adapter: latestSchemaExportAdapter,
);
}
Stream<ArchiveV2StreamEvent> exportDataWithAdapter(
final DatabaseExecutor db,
final File archiveFile, {
required final ArchiveV2ExportAdapter adapter,
}) async* {
if (!archiveFile.existsSync()) {
archiveFile.createSync();
}
@@ -207,8 +318,8 @@ Stream<ArchiveV2StreamEvent> exportData(
archiveRoot,
).versionFile.writeAsString(expectedDataFormatVersion.toString());
yield* exportHistory(db, archiveRoot);
yield* exportLibraryLists(db, archiveRoot);
yield* exportHistory(db, archiveRoot, adapter: adapter);
yield* exportLibraryLists(db, archiveRoot, adapter: adapter);
await packZip(archiveRoot, outputFile: archiveFile);
+20 -9
View File
@@ -91,26 +91,37 @@ class ArchiveV2HistorySearchInstance {
/// Calculate the total number of chunks needed to export the history,
/// needed for progress tracking during export.
Future<int> exportHistoryChunkCount(final DatabaseExecutor db) async =>
(await db.historyEntryAmount() / historyChunkSize).ceil();
Future<int> exportHistoryChunkCount(
final DatabaseExecutor db, {
final ArchiveV2ExportAdapter? adapter,
}) async =>
(await (adapter ?? latestSchemaExportAdapter).historyEntryCount(db) /
historyChunkSize)
.ceil();
/// Exports the history into json files in the given directory.
///
/// Streams back the number of chunks that have been exported so far.
Stream<ArchiveV2StreamEvent> exportHistory(
final DatabaseExecutor db,
final Directory archiveRoot,
) async* {
final int chunkCount = await exportHistoryChunkCount(db);
final Directory archiveRoot, {
final ArchiveV2ExportAdapter? adapter,
}) async* {
final exportAdapter = adapter ?? latestSchemaExportAdapter;
final int chunkCount = await exportHistoryChunkCount(
db,
adapter: exportAdapter,
);
archiveRoot.historyDir.createSync();
for (int i = 0; i < chunkCount; i++) {
final List<Map<String, Object?>> jsonEntries =
(await db.historyEntryGetAll(page: i, pageSize: historyChunkSize))
.map(ArchiveV2HistoryEntry.fromHistoryEntry)
.map((final e) => e.toJson())
.toList();
(await exportAdapter.historyEntryGetAll(
db: db,
page: i,
pageSize: historyChunkSize,
)).map((final e) => e.toJson()).toList();
archiveRoot.historyChunkFile(i)
..createSync()
+75 -35
View File
@@ -53,23 +53,46 @@ class ArchiveV2LibraryListEntry {
);
}
/// Exports metadata about library lists, such as their names and order, into the archive.
Future<void> exportLibraryMetadata(
final DatabaseExecutor db,
final Directory archiveRoot,
) async {
final libraryLists = await db.libraryListGetLists();
final List<ArchiveV2LibraryListMetadata> metadataList = libraryLists
class ArchiveV2ExportLibraryList {
final ArchiveV2LibraryListMetadata metadata;
final int totalCount;
const ArchiveV2ExportLibraryList({
required this.metadata,
required this.totalCount,
});
}
Future<List<ArchiveV2ExportLibraryList>> _getExportLibraryLists(
final DatabaseExecutor db, {
required final ArchiveV2ExportAdapter adapter,
}) async {
final metadata = await adapter.libraryListGetLibraryMetadata(db: db);
final counts = await adapter.libraryListGetTotalCounts(db: db);
return metadata
.map(
(final libraryList) => ArchiveV2LibraryListMetadata(
name: libraryList.name,
slug: slugifyLibraryListFileName(libraryList.name),
(final meta) => ArchiveV2ExportLibraryList(
metadata: meta,
totalCount: counts[meta.name] ?? 0,
),
)
.toList();
}
/// Exports metadata about library lists, such as their names and order, into the archive.
Future<void> exportLibraryMetadata(
final Directory archiveRoot,
final List<ArchiveV2ExportLibraryList> libraryLists,
) async {
final metadataFile = archiveRoot.libraryMetadataFile..createSync();
await metadataFile.writeAsString(jsonEncode(metadataList));
await metadataFile.writeAsString(
jsonEncode(
libraryLists
.map((final libraryList) => libraryList.metadata.toJson())
.toList(),
),
);
}
List<ArchiveV2LibraryListMetadata> importLibraryMetadata(
@@ -89,13 +112,19 @@ List<ArchiveV2LibraryListMetadata> importLibraryMetadata(
/// Calculate the total number of chunks needed to export all library lists,
/// needed for progress tracking during export.
Future<int> exportLibraryListChunkCount(final DatabaseExecutor db) async =>
(await db.libraryListGetLists())
Future<int> exportLibraryListChunkCount(
final DatabaseExecutor db, {
final ArchiveV2ExportAdapter? adapter,
}) async =>
(await _getExportLibraryLists(
db,
adapter: adapter ?? latestSchemaExportAdapter,
))
.map(
(final libraryList) =>
(libraryList.totalCount / libraryListChunkSize).ceil(),
)
.reduce((final a, final b) => a + b);
.sum;
/// Exports all library lists into json files in the given directory.
///
@@ -103,13 +132,16 @@ Future<int> exportLibraryListChunkCount(final DatabaseExecutor db) async =>
/// See also [exportLibraryListChunkCount].
Stream<ArchiveV2StreamEvent> exportLibraryLists(
final DatabaseExecutor db,
final Directory archiveRoot,
) async* {
final Directory archiveRoot, {
final ArchiveV2ExportAdapter? adapter,
}) async* {
final exportAdapter = adapter ?? latestSchemaExportAdapter;
archiveRoot.libraryDir.createSync();
await exportLibraryMetadata(db, archiveRoot);
final libraryLists = await _getExportLibraryLists(db, adapter: exportAdapter);
final libraryLists = await db.libraryListGetLists();
await exportLibraryMetadata(archiveRoot, libraryLists);
for (final (i, libraryList) in libraryLists.indexed) {
yield* exportLibraryList(
@@ -118,6 +150,7 @@ Stream<ArchiveV2StreamEvent> exportLibraryLists(
libraryList,
i + 1,
libraryLists.length,
adapter: exportAdapter,
);
}
}
@@ -128,34 +161,35 @@ Stream<ArchiveV2StreamEvent> exportLibraryLists(
Stream<ArchiveV2StreamEvent> exportLibraryList(
final DatabaseExecutor db,
final Directory archiveRoot,
final LibraryList libraryList,
final ArchiveV2ExportLibraryList libraryList,
final int index,
final int total,
) async* {
final int totalEntries = libraryList.totalCount;
final int chunkCount = (totalEntries / libraryListChunkSize).ceil();
final int total, {
final ArchiveV2ExportAdapter? adapter,
}) async* {
final exportAdapter = adapter ?? latestSchemaExportAdapter;
final listName = libraryList.metadata.name;
final int chunkCount = (libraryList.totalCount / libraryListChunkSize).ceil();
archiveRoot.libraryListDir(libraryList.name).createSync();
archiveRoot.libraryListDir(listName).createSync();
for (int i = 0; i < chunkCount; i++) {
final entryPage = (await db.libraryListGetListEntries(
libraryList.name,
final archiveEntries = await exportAdapter.libraryListGetEntries(
db: db,
listName: listName,
page: i,
))!;
);
final archiveEntries = entryPage.entries
.map(ArchiveV2LibraryListEntry.fromLibraryListEntry)
.toList();
archiveRoot.libraryListChunkFile(libraryList.name, i)
archiveRoot.libraryListChunkFile(listName, i)
..createSync()
..writeAsStringSync(jsonEncode(archiveEntries));
..writeAsStringSync(
jsonEncode(archiveEntries.map((final e) => e.toJson()).toList()),
);
yield ArchiveV2StreamEvent(
type: 'library',
progress: index,
total: total,
name: libraryList.name,
name: listName,
subProgress: i + 1,
subTotal: chunkCount,
);
@@ -210,7 +244,13 @@ Stream<ArchiveV2StreamEvent> importLibraryList(
final int index,
final int total,
) async* {
final chunkFiles = libraryListDir.listSync().whereType<File>();
final chunkFiles = libraryListDir.listSync().whereType<File>().sortedBy(
(final file) =>
int.tryParse(
file.uri.pathSegments.last.replaceFirst(RegExp(r'\.json$'), ''),
) ??
0,
);
for (final (i, chunkFile) in chunkFiles.indexed) {
final chunkContent = chunkFile.readAsStringSync();
+14 -127
View File
@@ -1,18 +1,21 @@
import 'dart:developer';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:get_it/get_it.dart';
import 'package:jadb/search.dart';
import 'package:jadb/version.dart';
import 'package:mugiten/models/verify_tables.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
export 'package:mugiten/services/database/database_reset.dart'
show resetDatabase;
export 'package:mugiten/services/database/schemas/v2/table_names.dart'
show HistoryTableNames, LibraryListTableNames;
const int expectedDatabaseVersion = 2;
const int mugitenSchemaVersion = 2;
const int schemaVersion = mugitenSchemaVersion << 16 | jadbSchemaVersion;
/// Returns the directory where mugiten's database file is stored.
Future<Directory> _databaseDir() async {
@@ -26,7 +29,10 @@ Future<String> databasePath() async {
return join((await _databaseDir()).path, 'mugiten.sqlite');
}
Future<bool> databaseNeedsInitialization() async {
/// Checks if the database needs to be reset.
///
/// This is the case if the database does not yet exist, or if it's using an old schema version.
Future<bool> databaseNeedsReset() async {
final String dbPath = await databasePath();
if (!File(dbPath).existsSync()) {
@@ -41,7 +47,7 @@ Future<bool> databaseNeedsInitialization() async {
final databaseVersion = await database.getVersion();
await database.close();
if (databaseVersion < expectedDatabaseVersion) {
if (databaseVersion < schemaVersion) {
return true;
}
@@ -53,76 +59,6 @@ Future<void> quickInitializeDatabase() async {
await setupDatabase();
}
// Migration logic and heavy initialization
class DatabaseMigration {
final String path;
final String content;
const DatabaseMigration({required this.path, required this.content});
int get version {
final String fileName = basenameWithoutExtension(path);
return int.parse(fileName.split('_')[0]);
}
@override
String toString() {
return 'DatabaseMigration(path: $path, content: ${content.length} chars)';
}
}
Future<List<DatabaseMigration>> readMigrationsFromAssets() async {
log('Reading migrations from assets...');
final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final List<String> migrations = assetManifest
.listAssets()
.where(
(final assetPath) =>
RegExp(r'^migrations\/\d{4}.*\.sql$').hasMatch(assetPath),
)
.toList();
assert(migrations.isNotEmpty, 'No migration files found in assets');
migrations.sort();
log('Found ${migrations.length} migration files:');
for (final migration in migrations) {
log(' - $migration');
}
return Future.wait(
migrations.map((final migration) async {
final content = await rootBundle.loadString(migration, cache: false);
return DatabaseMigration(path: migration, content: content);
}),
);
}
/// Migrates the database from version `oldVersion` to `newVersion`.
Future<void> migrate(
final Database db,
final Iterable<DatabaseMigration> migrations,
) async {
for (final migration in migrations) {
log('Running migration ${migration.version} from ${migration.path}');
migration.content
.split(';')
.map(
(final s) => s
.split('\n')
.where((final l) => !l.startsWith(RegExp(r'\s*--')))
.join('\n')
.trim(),
)
.where((final s) => s != '')
.forEach(db.execute);
}
}
Future<Database> openDatabaseWithoutMigrations(
final String dbPath, {
final bool readOnly = false,
@@ -131,7 +67,7 @@ Future<Database> openDatabaseWithoutMigrations(
log('Opening database at $dbPath');
final Database database = await openDatabase(
dbPath,
version: expectedDatabaseVersion,
version: schemaVersion,
readOnly: readOnly,
onConfigure: (final db) async {
// Enable foreign key constraints
@@ -152,44 +88,6 @@ Future<Database> openDatabaseWithoutMigrations(
return database;
}
Future<Database> openAndMigrateDatabase(
final String dbPath,
final Iterable<DatabaseMigration> migrations,
) async {
log('Opening database at $dbPath');
final Database database = await openDatabase(
dbPath,
version: expectedDatabaseVersion,
readOnly: false,
onUpgrade: (final db, final oldVersion, final newVersion) async {
log('Migrating database from v$oldVersion to v$newVersion...');
final migrationsToRun = migrations
.where(
(final migration) =>
migration.version > oldVersion &&
migration.version <= newVersion,
)
.toList();
await migrate(db, migrationsToRun);
},
onConfigure: (final db) async {
// Enable foreign key constraints
await db.execute('PRAGMA foreign_keys=ON');
},
onOpen: (final db) async {
log('Verifying jadb tables...');
await db.jadbVerifyTables();
log('Verifying jadb tables...');
await verifyMugitenTablesWithDbConnection(db);
log('Database tables verified successfully');
},
);
return database;
}
/// Sets up the database, creating it if it does not exist.
Future<void> setupDatabase() async {
log('Setting up database...');
@@ -205,8 +103,8 @@ Future<void> setupDatabase() async {
);
assert(
await database.getVersion() == expectedDatabaseVersion,
'Database version should be $expectedDatabaseVersion',
await database.getVersion() == schemaVersion,
'Database version should be $schemaVersion',
);
log('Registering database in GetIt...');
@@ -214,7 +112,7 @@ Future<void> setupDatabase() async {
}
/// Resets the database by closing it, deleting the file, and setting it up again.
Future<void> resetDatabase() async {
Future<void> resetGetItDatabase() async {
log('Closing database...');
await GetIt.instance.get<Database>().close();
@@ -227,14 +125,3 @@ Future<void> resetDatabase() async {
log('Setting up database again...');
await setupDatabase();
}
/// Extracts the `jadb.sqlite` file from the assets into a writable directory
/// and returns its path.
Future<void> extractJadbFromAssets(final String path) async {
final File jadbFile = File(path)..createSync();
final ByteData data = await rootBundle.load('assets/jadb.sqlite');
await jadbFile.writeAsBytes(
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
);
}
+159
View File
@@ -0,0 +1,159 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:jadb/search.dart';
import 'package:mugiten/services/database/database.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
/// Extracts the `jadb.sqlite` file from the assets into a writable directory
/// and returns its path.
Future<void> extractJadbFromAssets(final String path) async {
final File jadbFile = File(path)..createSync();
final ByteData data = await rootBundle.load('assets/jadb.sqlite');
await jadbFile.writeAsBytes(
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
);
}
/// Dataclass representing a raw SQL migration file, containing its path and content.
class RawSQLMigration {
final String path;
final String content;
const RawSQLMigration({required this.path, required this.content});
String get name {
final String fileName = basenameWithoutExtension(path);
return fileName.split('_').sublist(1).join('_');
}
int get version {
final String fileName = basenameWithoutExtension(path);
return int.parse(fileName.split('_')[0]);
}
@override
String toString() {
return 'RawSQLMigration(version: $version, name: $name, size: ${content.length})';
}
}
/// Reads all migration files for the given database version from the assets and returns them as a list of [RawSQLMigration]s.
Future<List<RawSQLMigration>> readMigrationsForDatabaseVersionFromAssets(
final int databaseVersion,
) async {
log(
'Reading migrations for database version $databaseVersion from assets...',
);
final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final List<String> migrations = assetManifest
.listAssets()
.where(
(final assetPath) => RegExp(
'^lib/services/database/schemas/v$databaseVersion/\\d{4}.*\\.sql\$',
).hasMatch(assetPath),
)
.toList();
assert(
migrations.isNotEmpty,
'No migration files found in assets for database version $databaseVersion',
);
migrations.sort();
log('Found ${migrations.length} migration files:');
for (final migration in migrations) {
log(' - $migration');
}
return Future.wait(
migrations.map((final migration) async {
final content = await rootBundle.loadString(migration, cache: false);
return RawSQLMigration(path: migration, content: content);
}),
);
}
/// Applies the given list of [RawSQLMigration]s to the provided [Database] in a single transaction.
Future<void> applyMigrations(
final Database db,
final List<RawSQLMigration> migrations,
) async {
for (final migration in migrations) {
log('Applying migration $migration');
await db.transaction((final txn) async {
migration.content
.split(';')
.map(
(final s) => s
.split('\n')
.where((final l) => !l.startsWith(RegExp(r'\s*--')))
.join('\n')
.trim(),
)
.where((final s) => s != '')
.forEach(txn.execute);
});
}
}
/// Resets the database at the given path by:
///
/// - Deleting the database file (if it exists)
/// - Extracting a fresh copy of `jadb.sqlite` from the assets
/// - Applying all schema migrations for the current schema version
Future<Database> resetDatabase(final String dbPath) async {
log('Resetting database at $dbPath...');
final File dbFile = File(dbPath);
if (dbFile.existsSync()) {
dbFile.delete();
log('Deleted existing database file at $dbPath');
}
await extractJadbFromAssets(dbPath);
log('Extracted jadb.sqlite to $dbPath');
final migrations = await readMigrationsForDatabaseVersionFromAssets(
mugitenSchemaVersion,
);
final Database database = await openDatabase(
dbPath,
version: schemaVersion,
readOnly: false,
onUpgrade: (final db, final oldVersion, final newVersion) async {
assert(
oldVersion == 0,
'Expected oldVersion to be 0 during database reset, but got $oldVersion',
);
log('Setting up new database with schema version $newVersion...');
await applyMigrations(db, migrations);
log('Database upgrade complete');
},
onConfigure: (final db) async {
// Enable foreign key constraints
await db.execute('PRAGMA foreign_keys=ON');
},
onOpen: (final db) async {
log('Verifying jadb tables...');
await db.jadbVerifyTables();
log('Verifying mugiten tables...');
// TODO: verify mugiten tables for the exact schema version.
// await verifyMugitenTablesWithDbConnection(db);
log('Database tables verified successfully');
},
);
return database;
}
@@ -32,7 +32,7 @@ abstract class HistoryTableNames {
static const String historyEntryOrderedByTimestamp =
'Mugiten_HistoryEntry_orderedByTimestamp';
static Set<String> get allTables => {
static const Set<String> allTables = {
historyEntry,
historyEntryKanji,
historyEntryTimestamp,
@@ -64,9 +64,14 @@ abstract class LibraryListTableNames {
/// - name TEXT
static const String libraryListOrdered = 'Mugiten_LibraryList_Ordered';
static Set<String> get allTables => {
static const Set<String> allTables = {
libraryList,
libraryListEntry,
libraryListOrdered,
};
}
const Set<String> allSchemaV1TableNames = {
...HistoryTableNames.allTables,
...LibraryListTableNames.allTables,
};
@@ -32,7 +32,7 @@ abstract class HistoryTableNames {
static const String historyEntryOrderedByTimestamp =
'Mugiten_HistoryEntry_orderedByTimestamp';
static Set<String> get allTables => {
static const Set<String> allTables = {
historyEntry,
historyEntryKanji,
historyEntryTimestamp,
@@ -59,5 +59,10 @@ abstract class LibraryListTableNames {
// VIEWS //
///////////
static Set<String> get allTables => {libraryList, libraryListEntry};
static const Set<String> allTables = {libraryList, libraryListEntry};
}
const Set<String> allSchemaV2TableNames = {
...HistoryTableNames.allTables,
...LibraryListTableNames.allTables,
};
@@ -2,25 +2,73 @@ import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_mlkit_digital_ink_recognition/google_mlkit_digital_ink_recognition.dart';
import 'package:mugiten/services/archive/v1/format.dart'
show exportData, importData;
import 'package:mugiten/services/archive/archive_dispatcher.dart'
as archive_dispatcher;
import 'package:mugiten/services/database/database.dart'
show
DatabaseMigration,
databaseNeedsInitialization,
databasePath,
extractJadbFromAssets,
openAndMigrateDatabase,
openDatabaseWithoutMigrations,
readMigrationsFromAssets;
show databaseNeedsReset, databasePath, resetDatabase;
import 'package:mugiten/services/initialization/initialization_status.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
class InitializationCubit extends Cubit<InitializationStatus> {
final bool deleteDatabase;
InitializationCubit(this.deleteDatabase) : super(InitializationNotStarted());
Future<void> _backupUserData(
final Database database,
final File archiveFile,
) async {
final int rawTotal = await archive_dispatcher
.totalAmountOfChunksFromDatabase(database);
final int total = rawTotal > 0 ? rawTotal : 1;
emit(BackupUserData(progress: 0, total: total));
int progress = 0;
await for (final _ in archive_dispatcher.exportBackupArchive(
database,
archiveFile,
)) {
progress += 1;
emit(
BackupUserData(
progress: progress <= total ? progress : total,
total: total,
),
);
}
emit(BackupUserData(progress: total, total: total));
}
Future<void> _restoreUserData(
final Database database,
final File archiveFile,
) async {
final int rawTotal = await archive_dispatcher
.totalAmountOfChunksFromArchive(archiveFile);
final int total = rawTotal > 0 ? rawTotal : 1;
emit(RestoreUserData(progress: 0, total: total));
int progress = 0;
await for (final _ in archive_dispatcher.importBackupArchive(
database,
archiveFile,
)) {
progress += 1;
emit(
RestoreUserData(
progress: progress <= total ? progress : total,
total: total,
),
);
}
emit(RestoreUserData(progress: total, total: total));
}
Future<void> start() async {
emit(InitializationPending());
@@ -36,51 +84,49 @@ class InitializationCubit extends Cubit<InitializationStatus> {
emit(FinishDownloadMLKitDigitalInkModel());
emit(CheckDatabase());
if (deleteDatabase || await databaseNeedsInitialization()) {
if (deleteDatabase || await databaseNeedsReset()) {
final String dbPath = await databasePath();
final databaseAlreadyExists = File(dbPath).existsSync();
final bool databaseAlreadyExists = File(dbPath).existsSync();
late final File? tmpdirDataDump;
File? backupArchive;
Database? migratedDatabase;
if (!databaseAlreadyExists) {
await extractJadbFromAssets(dbPath);
} else {
emit(BackupUserData(total: 2, progress: 1));
final tempDir = await getTemporaryDirectory();
final database = await openDatabaseWithoutMigrations(dbPath);
try {
if (databaseAlreadyExists) {
final tempDir = await getTemporaryDirectory();
backupArchive = File('${tempDir.path}/mugiten_data_backup.zip');
if (backupArchive.existsSync()) {
await backupArchive.delete();
}
final dataDump = await exportData(database);
final existingDatabase = await openDatabase(
dbPath,
readOnly: true,
singleInstance: false,
);
try {
await _backupUserData(existingDatabase, backupArchive);
} finally {
await existingDatabase.close();
}
}
await database.close();
emit(MigrateDatabase(total: 2, progress: 1));
migratedDatabase = await resetDatabase(dbPath);
emit(MigrateDatabase(total: 2, progress: 2));
tmpdirDataDump = await dataDump.copy(
'${tempDir.path}/mugiten_data_backup.zip',
);
emit(BackupUserData(total: 2, progress: 2));
if (databaseAlreadyExists) {
await _restoreUserData(migratedDatabase, backupArchive!);
}
} finally {
if (migratedDatabase != null) {
await migratedDatabase.close();
}
if (backupArchive != null && backupArchive.existsSync()) {
await backupArchive.delete();
}
}
if (deleteDatabase) {
await File(dbPath).delete();
await extractJadbFromAssets(dbPath);
}
emit(MigrateDatabase(total: 2, progress: 1));
final List<DatabaseMigration> migrations =
await readMigrationsFromAssets();
final database = await openAndMigrateDatabase(dbPath, migrations);
emit(MigrateDatabase(total: 2, progress: 2));
if (databaseAlreadyExists) {
emit(RestoreUserData(total: 2, progress: 1));
await importData(database, tmpdirDataDump!);
emit(RestoreUserData(total: 2, progress: 2));
}
database.close();
}
emit(DatabaseUpdateFinished());
@@ -15,7 +15,7 @@ Future<bool> needsInitialization() async {
return true;
}
if (await databaseNeedsInitialization()) {
if (await databaseNeedsReset()) {
return true;
}
+6 -5
View File
@@ -5,9 +5,9 @@ import 'package:flutter/material.dart';
import 'package:jadb/search.dart';
import 'package:jadb/table_names/jmdict.dart';
import 'package:jadb/table_names/kanjidic.dart';
import 'package:mugiten/services/database/database.dart';
import 'package:mugiten/models/history_entry.dart';
import 'package:mugiten/models/library_list.dart';
import 'package:mugiten/services/database/database.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
Future<Database> createDatabaseCopy({
@@ -37,10 +37,11 @@ Future<Database> createDatabaseCopy({
WidgetsFlutterBinding.ensureInitialized();
return await openAndMigrateDatabase(
jadbCopyPath,
await readMigrationsFromAssets(),
);
final database = await resetDatabase(jadbCopyPath);
assert(database.isOpen, 'Failed to open database copy at $jadbCopyPath');
return database;
}
Future<List<LibraryListEntry>> createRandomLibraryListEntries({