nc-photos/lib/app_db.dart
2022-02-08 19:35:55 +08:00

500 lines
15 KiB
Dart

import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:idb_shim/idb.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/upgrader.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
import 'package:nc_photos/num_extension.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/type.dart';
import 'package:synchronized/synchronized.dart';
class AppDb {
static const dbName = "app.db";
static const dbVersion = 6;
static const albumStoreName = "albums";
static const file2StoreName = "files2";
static const dirStoreName = "dirs";
static const metaStoreName = "meta";
factory AppDb() => _inst;
AppDb._();
/// Run [fn] with an opened database instance
///
/// This function guarantees that:
/// 1) Database is always closed after [fn] exits, even with an error
/// 2) Only at most 1 database instance being opened at any time
Future<T> use<T>(FutureOr<T> Function(Database db) fn) async {
// make sure only one client is opening the db
return await _lock.synchronized(() async {
final db = await _open();
try {
return await fn(db);
} finally {
db.close();
}
});
}
Future<void> delete() async {
_log.warning("[delete] Deleting database");
return await _lock.synchronized(() async {
final dbFactory = platform.getDbFactory();
await dbFactory.deleteDatabase(dbName);
});
}
/// Open the database
Future<Database> _open() {
if (platform_k.isWeb) {
return _openNative();
} else {
return _openSqflite();
}
}
/// Open the sqflite database
///
/// We can't simply call deleteObjectStore on upgrade failure here, as the
/// package does not remove the corresponding indexes, so when we recreate
/// the indexes later, it'll fail. What we do here, is to delete the whole
/// database instead
Future<Database> _openSqflite() async {
final dbFactory = platform.getDbFactory();
try {
int? fromVersion, toVersion;
final db = await dbFactory.open(
dbName,
version: dbVersion,
onUpgradeNeeded: (event) {
_upgrade(event);
fromVersion = event.oldVersion;
toVersion = event.newVersion;
},
);
if (fromVersion != null && toVersion != null) {
await _onPostUpgrade(db, fromVersion!, toVersion!);
}
return db;
} catch (e, stackTrace) {
_log.shout(
"[_openSqflite] Failed while upgrading database", e, stackTrace);
_log.warning("[_openSqflite] Recreating db");
await dbFactory.deleteDatabase(dbName);
return dbFactory.open(dbName,
version: dbVersion, onUpgradeNeeded: _upgrade);
}
}
/// Open the native IndexedDB database
///
/// Errors thrown in onUpgradeNeeded are not propagated properly to us on web,
/// so the sqflite approach will not work
Future<Database> _openNative() async {
final dbFactory = platform.getDbFactory();
int? fromVersion, toVersion;
final db = await dbFactory.open(
dbName,
version: dbVersion,
onUpgradeNeeded: (event) {
try {
_upgrade(event);
fromVersion = event.oldVersion;
toVersion = event.newVersion;
} catch (e, stackTrace) {
_log.shout(
"[_openNative] Failed while upgrading database", e, stackTrace);
// drop the db and rebuild a new one instead
try {
event.database.deleteObjectStore(albumStoreName);
} catch (_) {}
try {
event.database.deleteObjectStore(file2StoreName);
} catch (_) {}
try {
event.database.deleteObjectStore(dirStoreName);
} catch (_) {}
try {
event.database.deleteObjectStore(metaStoreName);
} catch (_) {}
try {
event.database.deleteObjectStore(_fileDbStoreName);
} catch (_) {}
try {
event.database.deleteObjectStore(_fileStoreName);
} catch (_) {}
_log.warning("[_openNative] Recreating db");
_upgrade(_DummyVersionChangeEvent(
0,
event.newVersion,
event.transaction,
event.target,
event.currentTarget,
event.database));
}
},
);
if (fromVersion != null && toVersion != null) {
await _onPostUpgrade(db, fromVersion!, toVersion!);
}
return db;
}
void _upgrade(VersionChangeEvent event) {
_log.info("[_upgrade] Upgrade database: ${event.oldVersion} -> $dbVersion");
final db = event.database;
// ignore: unused_local_variable
ObjectStore? albumStore, file2Store, dirStore, metaStore;
if (event.oldVersion < 2) {
// version 2 store things in a new way, just drop all
try {
db.deleteObjectStore(albumStoreName);
} catch (_) {}
albumStore = db.createObjectStore(albumStoreName);
albumStore.createIndex(
AppDbAlbumEntry.indexName, AppDbAlbumEntry.keyPath);
}
if (event.oldVersion < 3) {
// new object store in v3
// no longer relevant in v4
// recreate file store from scratch
// no longer relevant in v4
}
if (event.oldVersion < 4) {
try {
db.deleteObjectStore(_fileDbStoreName);
} catch (_) {}
try {
db.deleteObjectStore(_fileStoreName);
} catch (_) {}
file2Store = db.createObjectStore(file2StoreName);
file2Store.createIndex(AppDbFile2Entry.strippedPathIndexName,
AppDbFile2Entry.strippedPathKeyPath);
dirStore = db.createObjectStore(dirStoreName);
}
file2Store ??= event.transaction.objectStore(file2StoreName);
if (event.oldVersion < 5) {
file2Store.createIndex(AppDbFile2Entry.dateTimeEpochMsIndexName,
AppDbFile2Entry.dateTimeEpochMsKeyPath);
metaStore =
db.createObjectStore(metaStoreName, keyPath: AppDbMetaEntry.keyPath);
}
if (event.oldVersion < 6) {
file2Store.createIndex(AppDbFile2Entry.fileIsFavoriteIndexName,
AppDbFile2Entry.fileIsFavoriteKeyPath);
}
}
Future<void> _onPostUpgrade(
Database db, int fromVersion, int toVersion) async {
if (fromVersion.inRange(1, 4) && toVersion >= 5) {
final transaction = db.transaction(AppDb.metaStoreName, idbModeReadWrite);
final metaStore = transaction.objectStore(AppDb.metaStoreName);
await metaStore
.put(const AppDbMetaEntryDbCompatV5(false).toEntry().toJson());
await transaction.completed;
}
}
static late final _inst = AppDb._();
final _lock = Lock(reentrant: true);
static const _fileDbStoreName = "filesDb";
static const _fileStoreName = "files";
static final _log = Logger("app_db.AppDb");
}
class AppDbAlbumEntry {
static const indexName = "albumStore_path_index";
static const keyPath = ["path", "index"];
static const maxDataSize = 160;
AppDbAlbumEntry(this.path, this.index, this.album);
JsonObj toJson() {
return {
"path": path,
"index": index,
"album": album.toAppDbJson(),
};
}
factory AppDbAlbumEntry.fromJson(JsonObj json, Account account) {
return AppDbAlbumEntry(
json["path"],
json["index"],
Album.fromJson(
json["album"].cast<String, dynamic>(),
upgraderFactory: DefaultAlbumUpgraderFactory(
account: account,
logFilePath: json["path"],
),
)!,
);
}
static String toPath(Account account, String filePath) =>
"${account.url}/$filePath";
static String toPathFromFile(Account account, File albumFile) =>
toPath(account, albumFile.path);
static String toPrimaryKey(Account account, File albumFile, int index) =>
"${toPathFromFile(account, albumFile)}[$index]";
final String path;
final int index;
// properties other than Album.items is undefined when index > 0
final Album album;
}
class AppDbFile2Entry with EquatableMixin {
static const strippedPathIndexName = "server_userId_strippedPath";
static const strippedPathKeyPath = ["server", "userId", "strippedPath"];
static const dateTimeEpochMsIndexName = "server_userId_dateTimeEpochMs";
static const dateTimeEpochMsKeyPath = ["server", "userId", "dateTimeEpochMs"];
static const fileIsFavoriteIndexName = "server_userId_fileIsFavorite";
static const fileIsFavoriteKeyPath = ["server", "userId", "file.isFavorite"];
AppDbFile2Entry(this.server, this.userId, this.strippedPath,
this.dateTimeEpochMs, this.file);
factory AppDbFile2Entry.fromFile(Account account, File file) =>
AppDbFile2Entry(account.url, account.username, file.strippedPathWithEmpty,
file.bestDateTime.millisecondsSinceEpoch, file);
factory AppDbFile2Entry.fromJson(JsonObj json) => AppDbFile2Entry(
json["server"],
(json["userId"] as String).toCi(),
json["strippedPath"],
json["dateTimeEpochMs"],
File.fromJson(json["file"].cast<String, dynamic>()),
);
JsonObj toJson() => {
"server": server,
"userId": userId.toCaseInsensitiveString(),
"strippedPath": strippedPath,
"dateTimeEpochMs": dateTimeEpochMs,
"file": file.toJson(),
};
static String toPrimaryKey(Account account, int fileId) =>
"${account.url}/${account.username.toCaseInsensitiveString()}/$fileId";
static String toPrimaryKeyForFile(Account account, File file) =>
toPrimaryKey(account, file.fileId!);
static List<Object> toStrippedPathIndexKey(
Account account, String strippedPath) =>
[
account.url,
account.username.toCaseInsensitiveString(),
strippedPath == "." ? "" : strippedPath
];
static List<Object> toStrippedPathIndexKeyForFile(
Account account, File file) =>
toStrippedPathIndexKey(account, file.strippedPathWithEmpty);
/// Return the lower bound key used to query files under [dir] and its sub
/// dirs
static List<Object> toStrippedPathIndexLowerKeyForDir(
Account account, File dir) =>
[
account.url,
account.username.toCaseInsensitiveString(),
dir.strippedPath.run((p) => p == "." ? "" : "$p/")
];
/// Return the upper bound key used to query files under [dir] and its sub
/// dirs
static List<Object> toStrippedPathIndexUpperKeyForDir(
Account account, File dir) {
return toStrippedPathIndexLowerKeyForDir(account, dir).run((k) {
k[2] = (k[2] as String) + "\uffff";
return k;
});
}
static List<Object> toDateTimeEpochMsIndexKey(Account account, int epochMs) =>
[
account.url,
account.username.toCaseInsensitiveString(),
epochMs,
];
static List<Object> toFileIsFavoriteIndexKey(
Account account, bool isFavorite) =>
[
account.url,
account.username.toCaseInsensitiveString(),
isFavorite ? 1 : 0,
];
@override
get props => [
server,
userId,
strippedPath,
dateTimeEpochMs,
file,
];
/// Server URL where this file belongs to
final String server;
final CiString userId;
final String strippedPath;
final int dateTimeEpochMs;
final File file;
}
class AppDbDirEntry with EquatableMixin {
AppDbDirEntry._(
this.server, this.userId, this.strippedPath, this.dir, this.children);
factory AppDbDirEntry.fromFiles(
Account account, File dir, List<File> children) =>
AppDbDirEntry._(
account.url,
account.username,
dir.strippedPathWithEmpty,
dir,
children.map((f) => f.fileId!).toList(),
);
factory AppDbDirEntry.fromJson(JsonObj json) => AppDbDirEntry._(
json["server"],
(json["userId"] as String).toCi(),
json["strippedPath"],
File.fromJson((json["dir"] as Map).cast<String, dynamic>()),
json["children"].cast<int>(),
);
JsonObj toJson() => {
"server": server,
"userId": userId.toCaseInsensitiveString(),
"strippedPath": strippedPath,
"dir": dir.toJson(),
"children": children,
};
static String toPrimaryKeyForDir(Account account, File dir) =>
"${account.url}/${account.username.toCaseInsensitiveString()}/${dir.strippedPathWithEmpty}";
/// Return the lower bound key used to query dirs under [root] and its sub
/// dirs
static String toPrimaryLowerKeyForSubDirs(Account account, File root) {
final strippedPath = root.strippedPath.run((p) => p == "." ? "" : "$p/");
return "${account.url}/${account.username.toCaseInsensitiveString()}/$strippedPath";
}
/// Return the upper bound key used to query dirs under [root] and its sub
/// dirs
static String toPrimaryUpperKeyForSubDirs(Account account, File root) =>
toPrimaryLowerKeyForSubDirs(account, root) + "\uffff";
@override
get props => [
server,
userId,
strippedPath,
dir,
children,
];
/// Server URL where this file belongs to
final String server;
final CiString userId;
final String strippedPath;
final File dir;
final List<int> children;
}
class AppDbMetaEntry with EquatableMixin {
static const keyPath = "key";
const AppDbMetaEntry(this.key, this.obj);
factory AppDbMetaEntry.fromJson(JsonObj json) => AppDbMetaEntry(
json["key"],
json["obj"].cast<String, dynamic>(),
);
JsonObj toJson() => {
"key": key,
"obj": obj,
};
@override
get props => [
key,
obj,
];
final String key;
final JsonObj obj;
}
class AppDbMetaEntryDbCompatV5 {
static const key = "dbCompatV5";
const AppDbMetaEntryDbCompatV5(this.isMigrated);
factory AppDbMetaEntryDbCompatV5.fromJson(JsonObj json) =>
AppDbMetaEntryDbCompatV5(json["isMigrated"]);
AppDbMetaEntry toEntry() => AppDbMetaEntry(key, {
"isMigrated": isMigrated,
});
final bool isMigrated;
}
class AppDbMetaEntryCompatV37 {
static const key = "compatV37";
const AppDbMetaEntryCompatV37(this.isMigrated);
factory AppDbMetaEntryCompatV37.fromJson(JsonObj json) =>
AppDbMetaEntryCompatV37(json["isMigrated"]);
AppDbMetaEntry toEntry() => AppDbMetaEntry(key, {
"isMigrated": isMigrated,
});
final bool isMigrated;
}
class _DummyVersionChangeEvent implements VersionChangeEvent {
const _DummyVersionChangeEvent(this.oldVersion, this.newVersion,
this.transaction, this.target, this.currentTarget, this.database);
@override
final int oldVersion;
@override
final int newVersion;
@override
final Transaction transaction;
@override
final Object target;
@override
final Object currentTarget;
@override
final Database database;
}