Sync tags on startup

This commit is contained in:
Ming Ming 2022-08-05 17:28:28 +08:00
parent 8b1ed216b0
commit 711516439e
12 changed files with 769 additions and 3 deletions

View file

@ -178,6 +178,8 @@ Future<void> _initDiContainer(InitIsolateType isolateType) async {
c.shareeRepo = ShareeRepo(ShareeRemoteDataSource());
c.favoriteRepo = const FavoriteRepo(FavoriteRemoteDataSource());
c.tagRepo = const TagRepo(TagRemoteDataSource());
c.tagRepoRemote = const TagRepo(TagRemoteDataSource());
c.tagRepoLocal = TagRepo(TagSqliteDbDataSource(c.sqliteDb));
c.taggedFileRepo = const TaggedFileRepo(TaggedFileRemoteDataSource());
if (platform_k.isAndroid) {

View file

@ -24,6 +24,8 @@ enum DiType {
shareeRepo,
favoriteRepo,
tagRepo,
tagRepoRemote,
tagRepoLocal,
taggedFileRepo,
localFileRepo,
pref,
@ -43,6 +45,8 @@ class DiContainer {
ShareeRepo? shareeRepo,
FavoriteRepo? favoriteRepo,
TagRepo? tagRepo,
TagRepo? tagRepoRemote,
TagRepo? tagRepoLocal,
TaggedFileRepo? taggedFileRepo,
LocalFileRepo? localFileRepo,
Pref? pref,
@ -58,6 +62,8 @@ class DiContainer {
_shareeRepo = shareeRepo,
_favoriteRepo = favoriteRepo,
_tagRepo = tagRepo,
_tagRepoRemote = tagRepoRemote,
_tagRepoLocal = tagRepoLocal,
_taggedFileRepo = taggedFileRepo,
_localFileRepo = localFileRepo,
_pref = pref,
@ -89,6 +95,10 @@ class DiContainer {
return contianer._favoriteRepo != null;
case DiType.tagRepo:
return contianer._tagRepo != null;
case DiType.tagRepoRemote:
return contianer._tagRepoRemote != null;
case DiType.tagRepoLocal:
return contianer._tagRepoLocal != null;
case DiType.taggedFileRepo:
return contianer._taggedFileRepo != null;
case DiType.localFileRepo:
@ -142,6 +152,8 @@ class DiContainer {
ShareeRepo get shareeRepo => _shareeRepo!;
FavoriteRepo get favoriteRepo => _favoriteRepo!;
TagRepo get tagRepo => _tagRepo!;
TagRepo get tagRepoRemote => _tagRepoRemote!;
TagRepo get tagRepoLocal => _tagRepoLocal!;
TaggedFileRepo get taggedFileRepo => _taggedFileRepo!;
LocalFileRepo get localFileRepo => _localFileRepo!;
@ -203,6 +215,16 @@ class DiContainer {
_tagRepo = v;
}
set tagRepoRemote(TagRepo v) {
assert(_tagRepoRemote == null);
_tagRepoRemote = v;
}
set tagRepoLocal(TagRepo v) {
assert(_tagRepoLocal == null);
_tagRepoLocal = v;
}
set taggedFileRepo(TaggedFileRepo v) {
assert(_taggedFileRepo == null);
_taggedFileRepo = v;
@ -237,6 +259,8 @@ class DiContainer {
ShareeRepo? _shareeRepo;
FavoriteRepo? _favoriteRepo;
TagRepo? _tagRepo;
TagRepo? _tagRepoRemote;
TagRepo? _tagRepoLocal;
TaggedFileRepo? _taggedFileRepo;
LocalFileRepo? _localFileRepo;
@ -250,4 +274,6 @@ extension DiContainerExtension on DiContainer {
DiContainer withRemoteFileRepo() =>
copyWith(fileRepo: OrNull(fileRepoRemote));
DiContainer withLocalFileRepo() => copyWith(fileRepo: OrNull(fileRepoLocal));
DiContainer withRemoteTagRepo() => copyWith(tagRepo: OrNull(tagRepoRemote));
DiContainer withLocalTagRepo() => copyWith(tagRepo: OrNull(tagRepoLocal));
}

View file

@ -1,4 +1,5 @@
import 'package:drift/drift.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
@ -147,6 +148,21 @@ class AlbumShares extends Table {
get primaryKey => {album, userId};
}
class Tags extends Table {
IntColumn get rowId => integer().autoIncrement()();
IntColumn get server =>
integer().references(Servers, #rowId, onDelete: KeyAction.cascade)();
IntColumn get tagId => integer()();
TextColumn get displayName => text()();
BoolColumn get userVisible => boolean().nullable()();
BoolColumn get userAssignable => boolean().nullable()();
@override
get uniqueKeys => [
{server, tagId},
];
}
// remember to also update the truncate method after adding a new table
@DriftDatabase(
tables: [
@ -159,6 +175,7 @@ class AlbumShares extends Table {
DirFiles,
Albums,
AlbumShares,
Tags,
],
)
class SqliteDb extends _$SqliteDb {
@ -169,7 +186,7 @@ class SqliteDb extends _$SqliteDb {
SqliteDb.connect(DatabaseConnection connection) : super.connect(connection);
@override
get schemaVersion => 1;
get schemaVersion => 2;
@override
get migration => MigrationStrategy(
@ -198,6 +215,22 @@ class SqliteDb extends _$SqliteDb {
await m.createIndex(Index("album_shares_album_index",
"CREATE INDEX album_shares_album_index ON album_shares(album);"));
await _createIndexV2(m);
},
onUpgrade: (m, from, to) async {
_log.info("[onUpgrade] $from -> $to");
try {
await transaction(() async {
if (from < 2) {
await m.createTable(tags);
await _createIndexV2(m);
}
});
} catch (e, stackTrace) {
_log.shout("[onUpgrade] Failed upgrading sqlite db", e, stackTrace);
rethrow;
}
},
beforeOpen: (details) async {
await customStatement("PRAGMA foreign_keys = ON;");
@ -208,6 +241,13 @@ class SqliteDb extends _$SqliteDb {
await customStatement("PRAGMA busy_timeout = 5000;");
},
);
Future<void> _createIndexV2(Migrator m) async {
await m.createIndex(Index("tags_server_index",
"CREATE INDEX tags_server_index ON tags(server);"));
}
static final _log = Logger("entity.sqlite_table.SqliteDb");
}
class _DateTimeConverter extends TypeConverter<DateTime, DateTime> {

View file

@ -3070,6 +3070,350 @@ class $AlbumSharesTable extends AlbumShares
const _DateTimeConverter();
}
class Tag extends DataClass implements Insertable<Tag> {
final int rowId;
final int server;
final int tagId;
final String displayName;
final bool? userVisible;
final bool? userAssignable;
Tag(
{required this.rowId,
required this.server,
required this.tagId,
required this.displayName,
this.userVisible,
this.userAssignable});
factory Tag.fromData(Map<String, dynamic> data, {String? prefix}) {
final effectivePrefix = prefix ?? '';
return Tag(
rowId: const IntType()
.mapFromDatabaseResponse(data['${effectivePrefix}row_id'])!,
server: const IntType()
.mapFromDatabaseResponse(data['${effectivePrefix}server'])!,
tagId: const IntType()
.mapFromDatabaseResponse(data['${effectivePrefix}tag_id'])!,
displayName: const StringType()
.mapFromDatabaseResponse(data['${effectivePrefix}display_name'])!,
userVisible: const BoolType()
.mapFromDatabaseResponse(data['${effectivePrefix}user_visible']),
userAssignable: const BoolType()
.mapFromDatabaseResponse(data['${effectivePrefix}user_assignable']),
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['row_id'] = Variable<int>(rowId);
map['server'] = Variable<int>(server);
map['tag_id'] = Variable<int>(tagId);
map['display_name'] = Variable<String>(displayName);
if (!nullToAbsent || userVisible != null) {
map['user_visible'] = Variable<bool?>(userVisible);
}
if (!nullToAbsent || userAssignable != null) {
map['user_assignable'] = Variable<bool?>(userAssignable);
}
return map;
}
TagsCompanion toCompanion(bool nullToAbsent) {
return TagsCompanion(
rowId: Value(rowId),
server: Value(server),
tagId: Value(tagId),
displayName: Value(displayName),
userVisible: userVisible == null && nullToAbsent
? const Value.absent()
: Value(userVisible),
userAssignable: userAssignable == null && nullToAbsent
? const Value.absent()
: Value(userAssignable),
);
}
factory Tag.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return Tag(
rowId: serializer.fromJson<int>(json['rowId']),
server: serializer.fromJson<int>(json['server']),
tagId: serializer.fromJson<int>(json['tagId']),
displayName: serializer.fromJson<String>(json['displayName']),
userVisible: serializer.fromJson<bool?>(json['userVisible']),
userAssignable: serializer.fromJson<bool?>(json['userAssignable']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'rowId': serializer.toJson<int>(rowId),
'server': serializer.toJson<int>(server),
'tagId': serializer.toJson<int>(tagId),
'displayName': serializer.toJson<String>(displayName),
'userVisible': serializer.toJson<bool?>(userVisible),
'userAssignable': serializer.toJson<bool?>(userAssignable),
};
}
Tag copyWith(
{int? rowId,
int? server,
int? tagId,
String? displayName,
Value<bool?> userVisible = const Value.absent(),
Value<bool?> userAssignable = const Value.absent()}) =>
Tag(
rowId: rowId ?? this.rowId,
server: server ?? this.server,
tagId: tagId ?? this.tagId,
displayName: displayName ?? this.displayName,
userVisible: userVisible.present ? userVisible.value : this.userVisible,
userAssignable:
userAssignable.present ? userAssignable.value : this.userAssignable,
);
@override
String toString() {
return (StringBuffer('Tag(')
..write('rowId: $rowId, ')
..write('server: $server, ')
..write('tagId: $tagId, ')
..write('displayName: $displayName, ')
..write('userVisible: $userVisible, ')
..write('userAssignable: $userAssignable')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
rowId, server, tagId, displayName, userVisible, userAssignable);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is Tag &&
other.rowId == this.rowId &&
other.server == this.server &&
other.tagId == this.tagId &&
other.displayName == this.displayName &&
other.userVisible == this.userVisible &&
other.userAssignable == this.userAssignable);
}
class TagsCompanion extends UpdateCompanion<Tag> {
final Value<int> rowId;
final Value<int> server;
final Value<int> tagId;
final Value<String> displayName;
final Value<bool?> userVisible;
final Value<bool?> userAssignable;
const TagsCompanion({
this.rowId = const Value.absent(),
this.server = const Value.absent(),
this.tagId = const Value.absent(),
this.displayName = const Value.absent(),
this.userVisible = const Value.absent(),
this.userAssignable = const Value.absent(),
});
TagsCompanion.insert({
this.rowId = const Value.absent(),
required int server,
required int tagId,
required String displayName,
this.userVisible = const Value.absent(),
this.userAssignable = const Value.absent(),
}) : server = Value(server),
tagId = Value(tagId),
displayName = Value(displayName);
static Insertable<Tag> custom({
Expression<int>? rowId,
Expression<int>? server,
Expression<int>? tagId,
Expression<String>? displayName,
Expression<bool?>? userVisible,
Expression<bool?>? userAssignable,
}) {
return RawValuesInsertable({
if (rowId != null) 'row_id': rowId,
if (server != null) 'server': server,
if (tagId != null) 'tag_id': tagId,
if (displayName != null) 'display_name': displayName,
if (userVisible != null) 'user_visible': userVisible,
if (userAssignable != null) 'user_assignable': userAssignable,
});
}
TagsCompanion copyWith(
{Value<int>? rowId,
Value<int>? server,
Value<int>? tagId,
Value<String>? displayName,
Value<bool?>? userVisible,
Value<bool?>? userAssignable}) {
return TagsCompanion(
rowId: rowId ?? this.rowId,
server: server ?? this.server,
tagId: tagId ?? this.tagId,
displayName: displayName ?? this.displayName,
userVisible: userVisible ?? this.userVisible,
userAssignable: userAssignable ?? this.userAssignable,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (rowId.present) {
map['row_id'] = Variable<int>(rowId.value);
}
if (server.present) {
map['server'] = Variable<int>(server.value);
}
if (tagId.present) {
map['tag_id'] = Variable<int>(tagId.value);
}
if (displayName.present) {
map['display_name'] = Variable<String>(displayName.value);
}
if (userVisible.present) {
map['user_visible'] = Variable<bool?>(userVisible.value);
}
if (userAssignable.present) {
map['user_assignable'] = Variable<bool?>(userAssignable.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('TagsCompanion(')
..write('rowId: $rowId, ')
..write('server: $server, ')
..write('tagId: $tagId, ')
..write('displayName: $displayName, ')
..write('userVisible: $userVisible, ')
..write('userAssignable: $userAssignable')
..write(')'))
.toString();
}
}
class $TagsTable extends Tags with TableInfo<$TagsTable, Tag> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$TagsTable(this.attachedDatabase, [this._alias]);
final VerificationMeta _rowIdMeta = const VerificationMeta('rowId');
@override
late final GeneratedColumn<int?> rowId = GeneratedColumn<int?>(
'row_id', aliasedName, false,
type: const IntType(),
requiredDuringInsert: false,
defaultConstraints: 'PRIMARY KEY AUTOINCREMENT');
final VerificationMeta _serverMeta = const VerificationMeta('server');
@override
late final GeneratedColumn<int?> server = GeneratedColumn<int?>(
'server', aliasedName, false,
type: const IntType(),
requiredDuringInsert: true,
defaultConstraints: 'REFERENCES servers (row_id) ON DELETE CASCADE');
final VerificationMeta _tagIdMeta = const VerificationMeta('tagId');
@override
late final GeneratedColumn<int?> tagId = GeneratedColumn<int?>(
'tag_id', aliasedName, false,
type: const IntType(), requiredDuringInsert: true);
final VerificationMeta _displayNameMeta =
const VerificationMeta('displayName');
@override
late final GeneratedColumn<String?> displayName = GeneratedColumn<String?>(
'display_name', aliasedName, false,
type: const StringType(), requiredDuringInsert: true);
final VerificationMeta _userVisibleMeta =
const VerificationMeta('userVisible');
@override
late final GeneratedColumn<bool?> userVisible = GeneratedColumn<bool?>(
'user_visible', aliasedName, true,
type: const BoolType(),
requiredDuringInsert: false,
defaultConstraints: 'CHECK (user_visible IN (0, 1))');
final VerificationMeta _userAssignableMeta =
const VerificationMeta('userAssignable');
@override
late final GeneratedColumn<bool?> userAssignable = GeneratedColumn<bool?>(
'user_assignable', aliasedName, true,
type: const BoolType(),
requiredDuringInsert: false,
defaultConstraints: 'CHECK (user_assignable IN (0, 1))');
@override
List<GeneratedColumn> get $columns =>
[rowId, server, tagId, displayName, userVisible, userAssignable];
@override
String get aliasedName => _alias ?? 'tags';
@override
String get actualTableName => 'tags';
@override
VerificationContext validateIntegrity(Insertable<Tag> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('row_id')) {
context.handle(
_rowIdMeta, rowId.isAcceptableOrUnknown(data['row_id']!, _rowIdMeta));
}
if (data.containsKey('server')) {
context.handle(_serverMeta,
server.isAcceptableOrUnknown(data['server']!, _serverMeta));
} else if (isInserting) {
context.missing(_serverMeta);
}
if (data.containsKey('tag_id')) {
context.handle(
_tagIdMeta, tagId.isAcceptableOrUnknown(data['tag_id']!, _tagIdMeta));
} else if (isInserting) {
context.missing(_tagIdMeta);
}
if (data.containsKey('display_name')) {
context.handle(
_displayNameMeta,
displayName.isAcceptableOrUnknown(
data['display_name']!, _displayNameMeta));
} else if (isInserting) {
context.missing(_displayNameMeta);
}
if (data.containsKey('user_visible')) {
context.handle(
_userVisibleMeta,
userVisible.isAcceptableOrUnknown(
data['user_visible']!, _userVisibleMeta));
}
if (data.containsKey('user_assignable')) {
context.handle(
_userAssignableMeta,
userAssignable.isAcceptableOrUnknown(
data['user_assignable']!, _userAssignableMeta));
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {rowId};
@override
List<Set<GeneratedColumn>> get uniqueKeys => [
{server, tagId},
];
@override
Tag map(Map<String, dynamic> data, {String? tablePrefix}) {
return Tag.fromData(data,
prefix: tablePrefix != null ? '$tablePrefix.' : null);
}
@override
$TagsTable createAlias(String alias) {
return $TagsTable(attachedDatabase, alias);
}
}
abstract class _$SqliteDb extends GeneratedDatabase {
_$SqliteDb(QueryExecutor e) : super(SqlTypeSystem.defaultInstance, e);
_$SqliteDb.connect(DatabaseConnection c) : super.connect(c);
@ -3082,6 +3426,7 @@ abstract class _$SqliteDb extends GeneratedDatabase {
late final $DirFilesTable dirFiles = $DirFilesTable(this);
late final $AlbumsTable albums = $AlbumsTable(this);
late final $AlbumSharesTable albumShares = $AlbumSharesTable(this);
late final $TagsTable tags = $TagsTable(this);
@override
Iterable<TableInfo> get allTables => allSchemaEntities.whereType<TableInfo>();
@override
@ -3094,6 +3439,7 @@ abstract class _$SqliteDb extends GeneratedDatabase {
trashes,
dirFiles,
albums,
albumShares
albumShares,
tags
];
}

View file

@ -10,8 +10,26 @@ import 'package:nc_photos/entity/exif.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/object_extension.dart';
extension SqlTagListExtension on List<sql.Tag> {
Future<List<Tag>> convertToAppTag() {
return computeAll(SqliteTagConverter.fromSql);
}
}
extension AppTagListExtension on List<Tag> {
Future<List<sql.TagsCompanion>> convertToTagCompanion(
sql.Account? dbAccount) {
return map((t) => {
"account": dbAccount,
"tag": t,
}).computeAll(_convertAppTag);
}
}
class SqliteAlbumConverter {
static Album fromSql(
sql.Album album, File albumFile, List<sql.AlbumShare> shares) {
@ -142,3 +160,29 @@ class SqliteFileConverter {
return sql.CompleteFileCompanion(dbFile, dbAccountFile, dbImage, dbTrash);
}
}
class SqliteTagConverter {
static Tag fromSql(sql.Tag tag) => Tag(
id: tag.tagId,
displayName: tag.displayName,
userVisible: tag.userVisible,
userAssignable: tag.userAssignable,
);
static sql.TagsCompanion toSql(sql.Account? dbAccount, Tag tag) =>
sql.TagsCompanion(
server:
dbAccount == null ? const Value.absent() : Value(dbAccount.server),
tagId: Value(tag.id),
displayName: Value(tag.displayName),
userVisible: Value(tag.userVisible),
userAssignable: Value(tag.userAssignable),
);
}
sql.TagsCompanion _convertAppTag(Map map) {
final account = map["account"] as sql.Account?;
final tag = map["tag"] as Tag;
return SqliteTagConverter.toSql(account, tag);
}

View file

@ -394,6 +394,25 @@ extension SqliteDbExtension on SqliteDb {
.get();
}
Future<List<Tag>> allTags({
Account? sqlAccount,
app.Account? appAccount,
}) {
assert((sqlAccount != null) != (appAccount != null));
if (sqlAccount != null) {
final query = select(tags)
..where((t) => t.server.equals(sqlAccount.server));
return query.get();
} else {
final query = select(tags).join([
innerJoin(servers, servers.rowId.equalsExp(tags.server),
useColumns: false),
])
..where(servers.address.equals(appAccount!.url));
return query.map((r) => r.readTable(tags)).get();
}
}
Future<void> truncate() async {
await delete(servers).go();
// technically deleting Servers table is enough to clear the followings, but
@ -406,6 +425,7 @@ extension SqliteDbExtension on SqliteDb {
await delete(dirFiles).go();
await delete(albums).go();
await delete(albumShares).go();
await delete(tags).go();
// reset the auto increment counter
await customStatement("UPDATE sqlite_sequence SET seq=0;");

View file

@ -2,6 +2,9 @@ import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_converter.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/entity/webdav_response_parser.dart';
import 'package:nc_photos/exception.dart';
@ -55,3 +58,26 @@ class TagRemoteDataSource implements TagDataSource {
static final _log = Logger("entity.tag.data_source.TagRemoteDataSource");
}
class TagSqliteDbDataSource implements TagDataSource {
const TagSqliteDbDataSource(this.sqliteDb);
@override
list(Account account) async {
_log.info("[list] $account");
final dbTags = await sqliteDb.use((db) async {
return await db.allTags(appAccount: account);
});
return dbTags.convertToAppTag();
}
@override
listByFile(Account account, File file) async {
_log.info("[listByFile] ${file.path}");
throw UnimplementedError();
}
final sql.SqliteDb sqliteDb;
static final _log = Logger("entity.tag.data_source.TagSqliteDbDataSource");
}

View file

@ -11,12 +11,14 @@ import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/type.dart';
import 'package:nc_photos/use_case/sync_favorite.dart';
import 'package:nc_photos/use_case/sync_tag.dart';
/// Sync various properties with server during startup
class StartupSync {
StartupSync(this._c)
: assert(require(_c)),
assert(SyncFavorite.require(_c));
assert(SyncFavorite.require(_c)),
assert(SyncTag.require(_c));
static bool require(DiContainer c) => true;
@ -49,6 +51,11 @@ class StartupSync {
_log.shout("[_run] Failed while SyncFavorite", e, stackTrace);
syncFavoriteCount = -1;
}
try {
await SyncTag(_c)(account);
} catch (e, stackTrace) {
_log.shout("[_run] Failed while SyncTag", e, stackTrace);
}
_log.info("[_run] Elapsed time: ${stopwatch.elapsedMilliseconds}ms");
return SyncResult(syncFavoriteCount);
}

View file

@ -0,0 +1,72 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' as sql;
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_converter.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/list_util.dart' as list_util;
class SyncTag {
SyncTag(this._c) : assert(require(_c));
static bool require(DiContainer c) =>
DiContainer.has(c, DiType.tagRepoRemote) &&
DiContainer.has(c, DiType.tagRepoLocal);
/// Sync tags in cache db with remote server
Future<void> call(Account account) async {
_log.info("[call] Sync tags with remote");
int tagSorter(Tag a, Tag b) => a.id.compareTo(b.id);
final remote = (await _c.tagRepoRemote.list(account))..sort(tagSorter);
final cache = (await _c.tagRepoLocal.list(account))..sort(tagSorter);
final diff = list_util.diffWith<Tag>(cache, remote, tagSorter);
final inserts = diff.item1;
_log.info("[call] New tags: ${inserts.toReadableString()}");
final deletes = diff.item2;
_log.info("[call] Removed tags: ${deletes.toReadableString()}");
final updates = remote.where((r) {
final c = cache.firstWhereOrNull((c) => c.id == r.id);
return c != null && c != r;
}).toList();
_log.info("[call] Updated tags: ${updates.toReadableString()}");
if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) {
await _c.sqliteDb.use((db) async {
final dbAccount = await db.accountOf(account);
await db.batch((batch) {
for (final d in deletes) {
batch.deleteWhere(
db.tags,
(sql.$TagsTable t) =>
t.server.equals(dbAccount.server) & t.tagId.equals(d.id),
);
}
for (final u in updates) {
batch.update(
db.tags,
sql.TagsCompanion(
displayName: sql.Value(u.displayName),
userVisible: sql.Value(u.userVisible),
userAssignable: sql.Value(u.userAssignable),
),
where: (sql.$TagsTable t) =>
t.server.equals(dbAccount.server) & t.tagId.equals(u.id),
);
}
for (final i in inserts) {
batch.insert(db.tags, SqliteTagConverter.toSql(dbAccount, i),
mode: sql.InsertMode.insertOrIgnore);
}
});
});
}
}
final DiContainer _c;
static final _log = Logger("use_case.sync_tag.SyncTag");
}

View file

@ -318,6 +318,7 @@ Future<void> _truncate() async {
"dir_files",
"albums",
"album_shares",
"tags",
});
for (final t in tables) {
expect(

View file

@ -13,6 +13,7 @@ import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/sharee.dart';
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/exception_event.dart';
import 'package:nc_photos/future_util.dart' as future_util;
import 'package:nc_photos/or_null.dart';
@ -422,6 +423,34 @@ class MockShareeMemoryRepo extends MockShareeRepo {
final List<Sharee> sharees;
}
class MockTagRepo implements TagRepo {
@override
TagDataSource get dataSrc => throw UnimplementedError();
@override
Future<List<Tag>> list(Account account) {
throw UnimplementedError();
}
@override
Future<List<Tag>> listByFile(Account account, File file) {
throw UnimplementedError();
}
}
class MockTagMemoryRepo extends MockTagRepo {
MockTagMemoryRepo([
Map<String, List<Tag>> initialData = const {},
]) : tags = initialData.map((key, value) => MapEntry(key, List.of(value)));
@override
list(Account account) async {
return tags[account.url]!;
}
final Map<String, List<Tag>> tags;
}
extension MockDiContainerExtension on DiContainer {
MockAlbumMemoryRepo get albumMemoryRepo => albumRepo as MockAlbumMemoryRepo;
MockFileMemoryRepo get fileMemoryRepo => fileRepo as MockFileMemoryRepo;

View file

@ -0,0 +1,153 @@
import 'package:drift/drift.dart' as sql;
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_converter.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/entity/tag/data_source.dart';
import 'package:nc_photos/use_case/sync_tag.dart';
import 'package:test/test.dart';
import 'package:tuple/tuple.dart';
import '../mock_type.dart';
import '../test_util.dart' as util;
void main() {
group("SyncTag", () {
test("new", _new);
test("remove", _remove);
test("update", _update);
});
}
/// Sync with remote where there are new tags
///
/// Remote: [tag0, tag1, tag2]
/// Local: [tag0]
/// Expect: [tag0, tag1, tag2]
Future<void> _new() async {
final account = util.buildAccount();
final c = DiContainer.late();
c.sqliteDb = util.buildTestDb();
addTearDown(() => c.sqliteDb.close());
c.tagRepoRemote = MockTagMemoryRepo({
account.url: [
const Tag(id: 10, displayName: "tag0"),
const Tag(id: 11, displayName: "tag1"),
const Tag(id: 12, displayName: "tag2"),
],
});
c.tagRepoLocal = TagRepo(TagSqliteDbDataSource(c.sqliteDb));
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.batch((batch) {
batch.insert(c.sqliteDb.tags,
sql.TagsCompanion.insert(server: 1, tagId: 10, displayName: "tag0"));
});
});
await SyncTag(c)(account);
expect(
await _listSqliteDbTags(c.sqliteDb),
{
account.url: {
const Tag(id: 10, displayName: "tag0"),
const Tag(id: 11, displayName: "tag1"),
const Tag(id: 12, displayName: "tag2"),
},
},
);
}
/// Sync with remote where there are removed tags
///
/// Remote: [tag0]
/// Local: [tag0, tag1, tag2]
/// Expect: [tag0]
Future<void> _remove() async {
final account = util.buildAccount();
final c = DiContainer.late();
c.sqliteDb = util.buildTestDb();
addTearDown(() => c.sqliteDb.close());
c.tagRepoRemote = MockTagMemoryRepo({
account.url: [
const Tag(id: 10, displayName: "tag0"),
],
});
c.tagRepoLocal = TagRepo(TagSqliteDbDataSource(c.sqliteDb));
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.batch((batch) {
batch.insertAll(c.sqliteDb.tags, [
sql.TagsCompanion.insert(server: 1, tagId: 10, displayName: "tag0"),
sql.TagsCompanion.insert(server: 1, tagId: 11, displayName: "tag1"),
sql.TagsCompanion.insert(server: 1, tagId: 12, displayName: "tag2"),
]);
});
});
await SyncTag(c)(account);
expect(
await _listSqliteDbTags(c.sqliteDb),
{
account.url: {
const Tag(id: 10, displayName: "tag0"),
},
},
);
}
/// Sync with remote where there are updated tags (i.e, same id, different
/// properties)
///
/// Remote: [tag0, new tag1]
/// Local: [tag0, tag1]
/// Expect: [tag0, new tag1]
Future<void> _update() async {
final account = util.buildAccount();
final c = DiContainer.late();
c.sqliteDb = util.buildTestDb();
addTearDown(() => c.sqliteDb.close());
c.tagRepoRemote = MockTagMemoryRepo({
account.url: [
const Tag(id: 10, displayName: "tag0"),
const Tag(id: 11, displayName: "new tag1"),
],
});
c.tagRepoLocal = TagRepo(TagSqliteDbDataSource(c.sqliteDb));
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.batch((batch) {
batch.insertAll(c.sqliteDb.tags, [
sql.TagsCompanion.insert(server: 1, tagId: 10, displayName: "tag0"),
sql.TagsCompanion.insert(server: 1, tagId: 11, displayName: "tag1"),
]);
});
});
await SyncTag(c)(account);
expect(
await _listSqliteDbTags(c.sqliteDb),
{
account.url: {
const Tag(id: 10, displayName: "tag0"),
const Tag(id: 11, displayName: "new tag1"),
},
},
);
}
Future<Map<String, Set<Tag>>> _listSqliteDbTags(sql.SqliteDb db) async {
final query = db.select(db.tags).join([
sql.innerJoin(db.servers, db.servers.rowId.equalsExp(db.tags.server)),
]);
final result = await query
.map((r) => Tuple2(r.readTable(db.servers), r.readTable(db.tags)))
.get();
final product = <String, Set<Tag>>{};
for (final r in result) {
(product[r.item1.address] ??= {}).add(SqliteTagConverter.fromSql(r.item2));
}
return product;
}