import 'dart:io'; import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata_db_upgrade.dart'; import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:sqflite/sqflite.dart'; abstract class MetadataDb { Future init(); Future dbFileSize(); Future reset(); Future removeIds(Set contentIds, {required bool metadataOnly}); // entries Future clearEntries(); Future> loadEntries(); Future saveEntries(Iterable entries); Future updateEntryId(int oldId, AvesEntry entry); Future> searchEntries(String query, {int? limit}); // date taken Future clearDates(); Future> loadDates(); // catalog metadata Future clearMetadataEntries(); Future> loadMetadataEntries(); Future saveMetadata(Set metadataEntries); Future updateMetadataId(int oldId, CatalogMetadata? metadata); // address Future clearAddresses(); Future> loadAddresses(); Future saveAddresses(Set addresses); Future updateAddressId(int oldId, AddressDetails? address); // favourites Future clearFavourites(); Future> loadFavourites(); Future addFavourites(Iterable rows); Future updateFavouriteId(int oldId, FavouriteRow row); Future removeFavourites(Iterable rows); // covers Future clearCovers(); Future> loadCovers(); Future addCovers(Iterable rows); Future updateCoverEntryId(int oldId, CoverRow row); Future removeCovers(Set filters); } class SqfliteMetadataDb implements MetadataDb { late Future _database; Future get path async => pContext.join(await getDatabasesPath(), 'metadata.db'); static const entryTable = 'entry'; static const dateTakenTable = 'dateTaken'; static const metadataTable = 'metadata'; static const addressTable = 'address'; static const favouriteTable = 'favourites'; static const coverTable = 'covers'; @override Future init() async { debugPrint('$runtimeType init'); _database = openDatabase( await path, onCreate: (db, version) async { await db.execute('CREATE TABLE $entryTable(' 'contentId INTEGER PRIMARY KEY' ', uri TEXT' ', path TEXT' ', sourceMimeType TEXT' ', width INTEGER' ', height INTEGER' ', sourceRotationDegrees INTEGER' ', sizeBytes INTEGER' ', title TEXT' ', dateModifiedSecs INTEGER' ', sourceDateTakenMillis INTEGER' ', durationMillis INTEGER' ')'); await db.execute('CREATE TABLE $dateTakenTable(' 'contentId INTEGER PRIMARY KEY' ', dateMillis INTEGER' ')'); await db.execute('CREATE TABLE $metadataTable(' 'contentId INTEGER PRIMARY KEY' ', mimeType TEXT' ', dateMillis INTEGER' ', flags INTEGER' ', rotationDegrees INTEGER' ', xmpSubjects TEXT' ', xmpTitleDescription TEXT' ', latitude REAL' ', longitude REAL' ')'); await db.execute('CREATE TABLE $addressTable(' 'contentId INTEGER PRIMARY KEY' ', addressLine TEXT' ', countryCode TEXT' ', countryName TEXT' ', adminArea TEXT' ', locality TEXT' ')'); await db.execute('CREATE TABLE $favouriteTable(' 'contentId INTEGER PRIMARY KEY' ', path TEXT' ')'); await db.execute('CREATE TABLE $coverTable(' 'filter TEXT PRIMARY KEY' ', contentId INTEGER' ')'); }, onUpgrade: MetadataDbUpgrader.upgradeDb, version: 4, ); } @override Future dbFileSize() async { final file = File(await path); return await file.exists() ? await file.length() : 0; } @override Future reset() async { debugPrint('$runtimeType reset'); await (await _database).close(); await deleteDatabase(await path); await init(); } @override Future removeIds(Set contentIds, {required bool metadataOnly}) async { if (contentIds.isEmpty) return; // final stopwatch = Stopwatch()..start(); final db = await _database; // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead final batch = db.batch(); const where = 'contentId = ?'; contentIds.forEach((id) { final whereArgs = [id]; batch.delete(entryTable, where: where, whereArgs: whereArgs); batch.delete(dateTakenTable, where: where, whereArgs: whereArgs); batch.delete(metadataTable, where: where, whereArgs: whereArgs); batch.delete(addressTable, where: where, whereArgs: whereArgs); if (!metadataOnly) { batch.delete(favouriteTable, where: where, whereArgs: whereArgs); batch.delete(coverTable, where: where, whereArgs: whereArgs); } }); await batch.commit(noResult: true); // debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries'); } // entries @override Future clearEntries() async { final db = await _database; final count = await db.delete(entryTable, where: '1'); debugPrint('$runtimeType clearEntries deleted $count entries'); } @override Future> loadEntries() async { // final stopwatch = Stopwatch()..start(); final db = await _database; final maps = await db.query(entryTable); final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet(); // debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); return entries; } @override Future saveEntries(Iterable entries) async { if (entries.isEmpty) return; final stopwatch = Stopwatch()..start(); final db = await _database; final batch = db.batch(); entries.forEach((entry) => _batchInsertEntry(batch, entry)); await batch.commit(noResult: true); debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); } @override Future updateEntryId(int oldId, AvesEntry entry) async { final db = await _database; final batch = db.batch(); batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]); _batchInsertEntry(batch, entry); await batch.commit(noResult: true); } void _batchInsertEntry(Batch batch, AvesEntry entry) { batch.insert( entryTable, entry.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } @override Future> searchEntries(String query, {int? limit}) async { final db = await _database; final maps = await db.query( entryTable, where: 'title LIKE ?', whereArgs: ['%$query%'], orderBy: 'sourceDateTakenMillis DESC', limit: limit, ); return maps.map((map) => AvesEntry.fromMap(map)).toSet(); } // date taken @override Future clearDates() async { final db = await _database; final count = await db.delete(dateTakenTable, where: '1'); debugPrint('$runtimeType clearDates deleted $count entries'); } @override Future> loadDates() async { final db = await _database; final maps = await db.query(dateTakenTable); final metadataEntries = Map.fromEntries(maps.map((map) => MapEntry(map['contentId'] as int, (map['dateMillis'] ?? 0) as int))); return metadataEntries; } // catalog metadata @override Future clearMetadataEntries() async { final db = await _database; final count = await db.delete(metadataTable, where: '1'); debugPrint('$runtimeType clearMetadataEntries deleted $count entries'); } @override Future> loadMetadataEntries() async { final db = await _database; final maps = await db.query(metadataTable); final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList(); return metadataEntries; } @override Future saveMetadata(Set metadataEntries) async { if (metadataEntries.isEmpty) return; final stopwatch = Stopwatch()..start(); try { final db = await _database; final batch = db.batch(); metadataEntries.forEach((metadata) => _batchInsertMetadata(batch, metadata)); await batch.commit(noResult: true); debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); } catch (error, stack) { debugPrint('$runtimeType failed to save metadata with error=$error\n$stack'); } } @override Future updateMetadataId(int oldId, CatalogMetadata? metadata) async { final db = await _database; final batch = db.batch(); batch.delete(dateTakenTable, where: 'contentId = ?', whereArgs: [oldId]); batch.delete(metadataTable, where: 'contentId = ?', whereArgs: [oldId]); _batchInsertMetadata(batch, metadata); await batch.commit(noResult: true); } void _batchInsertMetadata(Batch batch, CatalogMetadata? metadata) { if (metadata == null) return; if (metadata.dateMillis != 0) { batch.insert( dateTakenTable, { 'contentId': metadata.contentId, 'dateMillis': metadata.dateMillis, }, conflictAlgorithm: ConflictAlgorithm.replace, ); } batch.insert( metadataTable, metadata.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } // address @override Future clearAddresses() async { final db = await _database; final count = await db.delete(addressTable, where: '1'); debugPrint('$runtimeType clearAddresses deleted $count entries'); } @override Future> loadAddresses() async { final db = await _database; final maps = await db.query(addressTable); final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList(); return addresses; } @override Future saveAddresses(Set addresses) async { if (addresses.isEmpty) return; final stopwatch = Stopwatch()..start(); final db = await _database; final batch = db.batch(); addresses.forEach((address) => _batchInsertAddress(batch, address)); await batch.commit(noResult: true); debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries'); } @override Future updateAddressId(int oldId, AddressDetails? address) async { final db = await _database; final batch = db.batch(); batch.delete(addressTable, where: 'contentId = ?', whereArgs: [oldId]); _batchInsertAddress(batch, address); await batch.commit(noResult: true); } void _batchInsertAddress(Batch batch, AddressDetails? address) { if (address == null) return; batch.insert( addressTable, address.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } // favourites @override Future clearFavourites() async { final db = await _database; final count = await db.delete(favouriteTable, where: '1'); debugPrint('$runtimeType clearFavourites deleted $count entries'); } @override Future> loadFavourites() async { final db = await _database; final maps = await db.query(favouriteTable); final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet(); return rows; } @override Future addFavourites(Iterable rows) async { if (rows.isEmpty) return; final db = await _database; final batch = db.batch(); rows.forEach((row) => _batchInsertFavourite(batch, row)); await batch.commit(noResult: true); } @override Future updateFavouriteId(int oldId, FavouriteRow row) async { final db = await _database; final batch = db.batch(); batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [oldId]); _batchInsertFavourite(batch, row); await batch.commit(noResult: true); } void _batchInsertFavourite(Batch batch, FavouriteRow row) { batch.insert( favouriteTable, row.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } @override Future removeFavourites(Iterable rows) async { if (rows.isEmpty) return; final ids = rows.map((row) => row.contentId); if (ids.isEmpty) return; final db = await _database; // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead final batch = db.batch(); ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id])); await batch.commit(noResult: true); } // covers @override Future clearCovers() async { final db = await _database; final count = await db.delete(coverTable, where: '1'); debugPrint('$runtimeType clearCovers deleted $count entries'); } @override Future> loadCovers() async { final db = await _database; final maps = await db.query(coverTable); final rows = maps.map(CoverRow.fromMap).whereNotNull().toSet(); return rows; } @override Future addCovers(Iterable rows) async { if (rows.isEmpty) return; final db = await _database; final batch = db.batch(); rows.forEach((row) => _batchInsertCover(batch, row)); await batch.commit(noResult: true); } @override Future updateCoverEntryId(int oldId, CoverRow row) async { final db = await _database; final batch = db.batch(); batch.delete(coverTable, where: 'contentId = ?', whereArgs: [oldId]); _batchInsertCover(batch, row); await batch.commit(noResult: true); } void _batchInsertCover(Batch batch, CoverRow row) { batch.insert( coverTable, row.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } @override Future removeCovers(Set filters) async { if (filters.isEmpty) return; final db = await _database; // using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead final batch = db.batch(); filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()])); await batch.commit(noResult: true); } }