890 lines
28 KiB
Dart
890 lines
28 KiB
Dart
// lib/remote/remote_repository.dart
|
|
import 'package:flutter/foundation.dart' show debugPrint;
|
|
import 'package:sqflite/sqflite.dart';
|
|
|
|
import 'remote_models.dart';
|
|
import 'remote_db_uris.dart';
|
|
import 'remote_content_ids.dart';
|
|
import 'remote_origin.dart';
|
|
|
|
class RemoteRepository {
|
|
final Database db;
|
|
RemoteRepository(this.db);
|
|
|
|
/// Assicura che le folders esistano per le entries remote fornite.
|
|
/// Usa accesso dinamico ai campi di RemotePhotoItem per compatibilità con diverse versioni.
|
|
Future<void> ensureFoldersFromEntries(List<dynamic> items) async {
|
|
final dirs = <String>{};
|
|
|
|
for (final it in items) {
|
|
try {
|
|
String? dir;
|
|
|
|
// 1) se l'oggetto è già una Map
|
|
if (it is Map) {
|
|
dir = (it['directory'] ?? it['dir'] ?? it['folder'] ?? it['path'])?.toString();
|
|
if (dir == null || dir.isEmpty) {
|
|
final rp = (it['remotePath'] ?? it['remote_path'] ?? it['path_remote'])?.toString();
|
|
if (rp != null && rp.isNotEmpty) dir = _extractDirFromRemotePath(rp);
|
|
}
|
|
} else {
|
|
// 2) oggetto custom: prova a leggere proprietà dinamicamente
|
|
try {
|
|
final dynamic d = it;
|
|
|
|
try {
|
|
dir = d.directory as String?;
|
|
} catch (_) {}
|
|
|
|
if (dir == null || dir.isEmpty) {
|
|
try {
|
|
dir = d.dir as String?;
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (dir == null || dir.isEmpty) {
|
|
try {
|
|
dir = d.folder as String?;
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (dir == null || dir.isEmpty) {
|
|
try {
|
|
final rp = d.remotePath as String?;
|
|
if (rp != null && rp.isNotEmpty) dir = _extractDirFromRemotePath(rp);
|
|
} catch (_) {}
|
|
}
|
|
|
|
// 3) fallback: se l'oggetto espone toJson(), usa quello
|
|
if ((dir == null || dir.isEmpty) && (d as dynamic).toJson is Function) {
|
|
try {
|
|
final m = (d as dynamic).toJson();
|
|
if (m is Map) {
|
|
dir = (m['directory'] ?? m['dir'] ?? m['folder'] ?? m['path'])?.toString();
|
|
if (dir == null || dir.isEmpty) {
|
|
final rp = (m['remotePath'] ?? m['remote_path'] ?? m['path_remote'])?.toString();
|
|
if (rp != null && rp.isNotEmpty) dir = _extractDirFromRemotePath(rp);
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (dir != null && dir.isNotEmpty) dirs.add(dir);
|
|
} catch (_) {
|
|
// ignora item malformati
|
|
}
|
|
}
|
|
|
|
if (dirs.isEmpty) return;
|
|
|
|
// Ottieni l'istanza DB dinamicamente (rawDb, db, database)
|
|
final dynamic dynDb = (this as dynamic).rawDb ?? (this as dynamic).db ?? (this as dynamic).database;
|
|
if (dynDb == null) {
|
|
// Se non trovi il DB, esci silenziosamente.
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await dynDb.transaction((txn) async {
|
|
for (final d in dirs) {
|
|
try {
|
|
await txn.insert(
|
|
'folders',
|
|
{
|
|
'path': d,
|
|
'display_name': d.split('/').isNotEmpty ? d.split('/').last : d,
|
|
'is_remote': 1,
|
|
'album_type': 0,
|
|
'priority': 0,
|
|
},
|
|
conflictAlgorithm: ConflictAlgorithm.ignore,
|
|
);
|
|
} catch (_) {
|
|
// ignora errori su singolo insert
|
|
}
|
|
}
|
|
});
|
|
} catch (e, st) {
|
|
debugPrint('[remote][repo] ensureFoldersFromEntries transaction failed: $e\n$st');
|
|
}
|
|
}
|
|
|
|
/// Estrae la directory da un remotePath, es. "/a/b/c/file.jpg" -> "/a/b/c".
|
|
String _extractDirFromRemotePath(String remotePath) {
|
|
try {
|
|
final idx = remotePath.lastIndexOf('/');
|
|
if (idx <= 0) return remotePath;
|
|
return remotePath.substring(0, idx);
|
|
} catch (_) {
|
|
return remotePath;
|
|
}
|
|
}
|
|
|
|
// =========================
|
|
// Helpers PRAGMA / schema
|
|
// =========================
|
|
|
|
Future<void> _ensureColumns(
|
|
DatabaseExecutor dbExec, {
|
|
required String table,
|
|
required Map<String, String> columnsAndTypes,
|
|
}) async {
|
|
try {
|
|
final rows = await dbExec.rawQuery('PRAGMA table_info($table);');
|
|
final existing = rows.map((r) => r['name'] as String).toSet();
|
|
|
|
for (final entry in columnsAndTypes.entries) {
|
|
final col = entry.key;
|
|
final typ = entry.value;
|
|
if (!existing.contains(col)) {
|
|
final sql = 'ALTER TABLE $table ADD COLUMN $col $typ;';
|
|
try {
|
|
await dbExec.execute(sql);
|
|
debugPrint('[RemoteRepository] executed: $sql');
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] failed to execute $sql: $e\n$st');
|
|
}
|
|
}
|
|
}
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] _ensureColumns($table) error: $e\n$st');
|
|
}
|
|
}
|
|
|
|
/// Assicura che tutte le entry remote legacy abbiano una uri costruita da remoteId.
|
|
/// Per il nuovo modello, uri è la chiave canonica.
|
|
Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
|
|
try {
|
|
await dbExec.execute('''
|
|
UPDATE entry
|
|
SET uri = 'aves-remote://rid/' || replace(remoteId, ' ', '')
|
|
WHERE origin = ${RemoteOrigin.value}
|
|
AND remoteId IS NOT NULL
|
|
AND trim(remoteId) != ''
|
|
AND (uri IS NULL OR trim(uri) = '' OR uri NOT LIKE 'aves-remote://%');
|
|
''');
|
|
debugPrint('[RemoteRepository] ensureRemoteUris: migration applied');
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] ensureRemoteUris error: $e\n$st');
|
|
}
|
|
}
|
|
|
|
/// Assicura che le colonne necessarie esistano nella tabella `entry`.
|
|
Future<void> _ensureEntryColumns(DatabaseExecutor dbExec) async {
|
|
await _ensureColumns(
|
|
dbExec,
|
|
table: 'entry',
|
|
columnsAndTypes: const {
|
|
// Core
|
|
'uri': 'TEXT',
|
|
|
|
// GPS
|
|
'latitude': 'REAL',
|
|
'longitude': 'REAL',
|
|
'altitude': 'REAL',
|
|
|
|
// Campi remoti legacy / compatibilità
|
|
'remoteId': 'TEXT',
|
|
'remotePath': 'TEXT',
|
|
'remoteThumb1': 'TEXT',
|
|
'remoteThumb2': 'TEXT',
|
|
'origin': 'INTEGER',
|
|
'provider': 'TEXT',
|
|
'trashed': 'INTEGER',
|
|
|
|
// Durata video
|
|
'durationMillis': 'INTEGER',
|
|
|
|
// 0 locale o remoto - 1 sia remoto che locale
|
|
'hybrid_file': 'INTEGER DEFAULT 0',
|
|
},
|
|
);
|
|
|
|
try {
|
|
await dbExec.execute('CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON entry(remoteId);');
|
|
await dbExec.execute('CREATE INDEX IF NOT EXISTS idx_entry_remote_uri ON entry(uri);');
|
|
await dbExec.execute('CREATE INDEX IF NOT EXISTS idx_entry_origin_uri ON entry(origin, uri);');
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] create index error: $e\n$st');
|
|
}
|
|
}
|
|
|
|
// =========================
|
|
// Retry su SQLITE_BUSY
|
|
// =========================
|
|
|
|
bool _isBusy(Object e) {
|
|
final s = e.toString();
|
|
return s.contains('SQLITE_BUSY') || s.contains('database is locked');
|
|
}
|
|
|
|
Future<T> _withRetryBusy<T>(Future<T> Function() fn) async {
|
|
const maxAttempts = 3;
|
|
var delay = const Duration(milliseconds: 250);
|
|
|
|
for (var i = 0; i < maxAttempts; i++) {
|
|
try {
|
|
return await fn();
|
|
} catch (e) {
|
|
if (!_isBusy(e) || i == maxAttempts - 1) rethrow;
|
|
await Future.delayed(delay);
|
|
delay *= 2;
|
|
}
|
|
}
|
|
|
|
return await fn();
|
|
}
|
|
|
|
// =========================
|
|
// Normalizzazione path
|
|
// =========================
|
|
|
|
String _normPath(String? p) {
|
|
if (p == null || p.isEmpty) return '';
|
|
var s = p.trim().replaceAll(RegExp(r'/+'), '/');
|
|
if (!s.startsWith('/')) s = '/$s';
|
|
return s;
|
|
}
|
|
|
|
/// Candidato canonico: inserisce `/original/` dopo `/photos/<User>/`
|
|
/// se manca. Usato per lookup/fallback.
|
|
String _canonCandidate(String? rawPath, String fileName) {
|
|
var s = _normPath(rawPath);
|
|
final seg = s.split('/'); // ['', 'photos', '<User>', maybe 'original', ...]
|
|
if (seg.length >= 4 && seg[1] == 'photos' && seg[3] != 'original' && seg[3] != 'thumbs') {
|
|
seg.insert(3, 'original');
|
|
}
|
|
|
|
if (fileName.isNotEmpty) {
|
|
seg[seg.length - 1] = fileName;
|
|
}
|
|
|
|
return seg.join('/');
|
|
}
|
|
|
|
// =========================
|
|
// Remote identity helpers
|
|
// =========================
|
|
|
|
String _remoteUriForId(String remoteId, {String? remotePath}) {
|
|
return RemoteDbUris.make(remoteId: remoteId, remotePath: remotePath);
|
|
}
|
|
|
|
/* String? _remoteIdFromRow(Map<String, Object?> row) {
|
|
final rid = (row['remoteId'] as String?)?.trim();
|
|
if (rid != null && rid.isNotEmpty) return rid;
|
|
|
|
final uri = row['uri'] as String?;
|
|
return RemoteDbUris.remoteIdFromUri(uri);
|
|
}
|
|
*/
|
|
|
|
Future<int?> _findExistingEntryIdByRemoteIdentity(
|
|
DatabaseExecutor exec,
|
|
RemotePhotoItem it,
|
|
) async {
|
|
final remoteUri = _remoteUriForId(it.id, remotePath: it.path);
|
|
|
|
try {
|
|
final rows = await exec.query(
|
|
'entry',
|
|
columns: ['id'],
|
|
where: 'origin=? AND (remoteId=? OR uri=?)',
|
|
whereArgs: [RemoteOrigin.value, it.id, remoteUri],
|
|
limit: 1,
|
|
);
|
|
|
|
if (rows.isNotEmpty) {
|
|
return rows.first['id'] as int?;
|
|
}
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] lookup by remote identity failed for remoteId=${it.id} uri=$remoteUri: $e\n$st');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// =========================
|
|
// Utilities
|
|
// =========================
|
|
|
|
bool _isVideoItem(RemotePhotoItem it) {
|
|
final mt = (it.mimeType ?? '').toLowerCase();
|
|
final p = it.path.toLowerCase();
|
|
|
|
return mt.startsWith('video/') || p.endsWith('.mp4') || p.endsWith('.mov') || p.endsWith('.m4v') || p.endsWith('.mkv') || p.endsWith('.webm');
|
|
}
|
|
|
|
Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
|
final syntheticUri = RemoteDbUris.make(remoteId: it.id, remotePath: it.path);
|
|
|
|
int _makeContentId() {
|
|
final source = syntheticUri.isNotEmpty ? syntheticUri : it.path;
|
|
return RemoteContentIds.make(source);
|
|
}
|
|
|
|
final syntheticContentId = _makeContentId();
|
|
|
|
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
|
|
|
// dateModifiedMillis: preferisci mtimeMs, poi updatedAtUtc, poi takenAtUtc, poi now
|
|
final dateModMs = it.mtimeMs ?? it.updatedAtUtc?.millisecondsSinceEpoch ?? it.takenAtUtc?.millisecondsSinceEpoch ?? nowMs;
|
|
|
|
// trashed: soft delete dal server (deleted_at != null)
|
|
final trashed = it.deletedAtUtc != null ? 1 : 0;
|
|
|
|
// provider: tieni il tuo default ma se c'è user dal server, salvalo
|
|
final provider = (it.user != null && it.user!.trim().isNotEmpty) ? it.user!.trim() : 'json@patachina';
|
|
|
|
return <String, Object?>{
|
|
'id': existingId,
|
|
'contentId': syntheticContentId,
|
|
|
|
// Chiave remota canonica
|
|
'uri': syntheticUri,
|
|
|
|
'path': it.path,
|
|
|
|
// fallback MIME video se mancante
|
|
'sourceMimeType': it.mimeType ?? (_isVideoItem(it) ? 'video/mp4' : 'image/jpeg'),
|
|
|
|
'width': it.width ?? 0,
|
|
'height': it.height ?? 0,
|
|
'sourceRotationDegrees': it.rotation ?? 0,
|
|
|
|
'sizeBytes': it.sizeBytes,
|
|
|
|
'title': it.name,
|
|
'dateAddedSecs': nowMs ~/ 1000,
|
|
'dateModifiedMillis': dateModMs,
|
|
'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch,
|
|
|
|
// durata video in ms
|
|
'durationMillis': it.durationMillis,
|
|
|
|
'trashed': trashed,
|
|
'origin': RemoteOrigin.value,
|
|
'provider': provider,
|
|
'hybrid_file': 0,
|
|
|
|
'latitude': it.lat,
|
|
'longitude': it.lng,
|
|
'altitude': it.alt,
|
|
|
|
// Nuovo modello: remoteId non è più la sorgente canonica.
|
|
// Lo lasciamo NULL per testare/forzare l'uso di uri.
|
|
'remoteId': it.id,
|
|
|
|
'remotePath': it.path,
|
|
'remoteThumb1': it.thub1,
|
|
'remoteThumb2': it.thub2,
|
|
};
|
|
}
|
|
|
|
Map<String, Object?> _buildAddressRow(int newId, RemoteLocation location) {
|
|
return <String, Object?>{
|
|
'id': newId,
|
|
'addressLine': location.address,
|
|
'countryCode': location.countryCode,
|
|
'countryName': location.country,
|
|
'adminArea': location.region,
|
|
'locality': location.city,
|
|
};
|
|
}
|
|
|
|
// =========================
|
|
// Upsert a chunk
|
|
// =========================
|
|
|
|
Future<void> upsertAll(List<RemotePhotoItem> items, {int chunkSize = 200}) async {
|
|
final t0 = DateTime.now();
|
|
debugPrint('[RemoteRepository] upsertAll start items=${items.length}');
|
|
|
|
if (items.isEmpty) {
|
|
debugPrint('[RemoteRepository] upsertAll: no items, returning');
|
|
return;
|
|
}
|
|
|
|
await _withRetryBusy(() => _ensureEntryColumns(db));
|
|
|
|
// Protezione DB: crea indici unici dove mancano
|
|
await ensureUniqueRemoteUri();
|
|
await ensureUniqueRemoteId();
|
|
await ensureUniqueRemotePath();
|
|
|
|
// Ordina: prima immagini, poi video
|
|
final images = <RemotePhotoItem>[];
|
|
final videos = <RemotePhotoItem>[];
|
|
|
|
for (final it in items) {
|
|
(_isVideoItem(it) ? videos : images).add(it);
|
|
}
|
|
|
|
final ordered = <RemotePhotoItem>[...images, ...videos];
|
|
|
|
for (var offset = 0; offset < ordered.length; offset += chunkSize) {
|
|
final end = (offset + chunkSize < ordered.length) ? offset + chunkSize : ordered.length;
|
|
final chunk = ordered.sublist(offset, end);
|
|
|
|
try {
|
|
final chunkT0 = DateTime.now();
|
|
|
|
await _withRetryBusy(
|
|
() => db.transaction((txn) async {
|
|
final batch = txn.batch();
|
|
|
|
for (final it in chunk) {
|
|
final raw = it.path;
|
|
final norm = _normPath(raw);
|
|
final cand = _canonCandidate(raw, it.name);
|
|
final safeRid = it.id.length >= 8 ? it.id.substring(0, 8) : it.id;
|
|
|
|
debugPrint('[repo-upsert] in: rid=$safeRid name=${it.name} raw="$raw"');
|
|
|
|
int? existingId;
|
|
|
|
// 1) lookup per identità remota: remoteId legacy OR uri canonica
|
|
existingId = await _findExistingEntryIdByRemoteIdentity(txn, it);
|
|
|
|
// 2) fallback per remotePath canonico
|
|
if (existingId == null && norm.isNotEmpty) {
|
|
try {
|
|
final byCanon = await txn.query(
|
|
'entry',
|
|
columns: ['id'],
|
|
where: 'origin=? AND remotePath = ?',
|
|
whereArgs: [RemoteOrigin.value, cand],
|
|
limit: 1,
|
|
);
|
|
|
|
if (byCanon.isNotEmpty) {
|
|
existingId = byCanon.first['id'] as int?;
|
|
}
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] lookup by canonical path failed "$cand": $e\n$st');
|
|
}
|
|
}
|
|
|
|
// 3) fallback per remotePath normalizzato
|
|
if (existingId == null) {
|
|
try {
|
|
final byNorm = await txn.query(
|
|
'entry',
|
|
columns: ['id'],
|
|
where: 'origin=? AND remotePath = ?',
|
|
whereArgs: [RemoteOrigin.value, norm],
|
|
limit: 1,
|
|
);
|
|
|
|
if (byNorm.isNotEmpty) {
|
|
existingId = byNorm.first['id'] as int?;
|
|
}
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] lookup by normalized path failed "$norm": $e\n$st');
|
|
}
|
|
}
|
|
|
|
final row = _buildEntryRow(it, existingId: existingId);
|
|
|
|
try {
|
|
batch.insert(
|
|
'entry',
|
|
row,
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
} on DatabaseException catch (e, st) {
|
|
debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st');
|
|
|
|
// fallback: rimuovi GPS se causa problemi
|
|
final rowNoGps = Map<String, Object?>.from(row)
|
|
..remove('latitude')
|
|
..remove('longitude')
|
|
..remove('altitude');
|
|
|
|
batch.insert(
|
|
'entry',
|
|
rowNoGps,
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
}
|
|
}
|
|
|
|
await batch.commit(noResult: true);
|
|
|
|
// Secondo pass per address
|
|
for (final it in chunk) {
|
|
if (it.location == null) continue;
|
|
|
|
try {
|
|
final rows = await txn.query(
|
|
'entry',
|
|
columns: ['id'],
|
|
where: 'origin=? AND (remoteId=? OR uri=?)',
|
|
whereArgs: [
|
|
RemoteOrigin.value,
|
|
it.id,
|
|
_remoteUriForId(it.id, remotePath: it.path),
|
|
],
|
|
limit: 1,
|
|
);
|
|
|
|
if (rows.isEmpty) continue;
|
|
|
|
final newId = rows.first['id'] as int;
|
|
final addr = _buildAddressRow(newId, it.location!);
|
|
|
|
await txn.insert(
|
|
'address',
|
|
addr,
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] insert address failed for remoteId=${it.id}: $e\n$st');
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
debugPrint(
|
|
'[RemoteRepository] upsert chunk $offset..${end - 1} '
|
|
'committed size=${chunk.length} took=${DateTime.now().difference(chunkT0)}',
|
|
);
|
|
|
|
await Future.delayed(Duration.zero);
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] upsert chunk $offset..${end - 1} ERROR: $e\n$st');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
debugPrint('[RemoteRepository] upsertAll completed totalItems=${items.length} totalTime=${DateTime.now().difference(t0)}');
|
|
}
|
|
|
|
// =========================
|
|
// Hard delete mirato
|
|
// =========================
|
|
|
|
/// Cancella hard-delete le entry remote con remote id nei [remoteIds].
|
|
/// Compatibile con nuovo modello:
|
|
/// - record legacy: remoteId IN (...)
|
|
/// - record nuovo: uri IN (aves-remote://rid/...)
|
|
Future<int> deleteRemotesByRemoteIds(Set<String> remoteIds, {int chunkSize = 400}) async {
|
|
if (remoteIds.isEmpty) return 0;
|
|
|
|
int deletedTotal = 0;
|
|
final ids = remoteIds.where((s) => s.trim().isNotEmpty).map((s) => s.trim()).toList(growable: false);
|
|
|
|
for (var i = 0; i < ids.length; i += chunkSize) {
|
|
final chunk = ids.sublist(i, (i + chunkSize > ids.length) ? ids.length : i + chunkSize);
|
|
final uris = chunk.map((id) => _remoteUriForId(id)).toList(growable: false);
|
|
|
|
final idPlaceholders = List.filled(chunk.length, '?').join(',');
|
|
final uriPlaceholders = List.filled(uris.length, '?').join(',');
|
|
|
|
final deleted = await db.rawDelete(
|
|
'DELETE FROM entry '
|
|
'WHERE origin=? '
|
|
'AND (remoteId IN ($idPlaceholders) OR uri IN ($uriPlaceholders))',
|
|
[RemoteOrigin.value, ...chunk, ...uris],
|
|
);
|
|
|
|
deletedTotal += deleted;
|
|
}
|
|
|
|
debugPrint('[RemoteRepository] deleteRemotesByRemoteIds deleted=$deletedTotal');
|
|
return deletedTotal;
|
|
}
|
|
|
|
// =========================
|
|
// Unicità & deduplica
|
|
// =========================
|
|
|
|
Future<void> ensureUniqueRemoteUri() async {
|
|
try {
|
|
await db.execute(
|
|
'CREATE UNIQUE INDEX IF NOT EXISTS uq_entry_remote_uri '
|
|
'ON entry(uri) '
|
|
'WHERE origin=${RemoteOrigin.value} '
|
|
"AND uri IS NOT NULL AND trim(uri) != ''",
|
|
);
|
|
|
|
debugPrint('[RemoteRepository] ensured UNIQUE index on entry(uri) for origin=${RemoteOrigin.value}');
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] ensureUniqueRemoteUri error: $e\n$st');
|
|
}
|
|
}
|
|
|
|
Future<void> ensureUniqueRemoteId() async {
|
|
try {
|
|
await db.execute(
|
|
'CREATE UNIQUE INDEX IF NOT EXISTS uq_entry_remote_remoteId '
|
|
'ON entry(remoteId) '
|
|
'WHERE origin=${RemoteOrigin.value} '
|
|
"AND remoteId IS NOT NULL AND trim(remoteId) != ''",
|
|
);
|
|
|
|
debugPrint('[RemoteRepository] ensured UNIQUE index on entry(remoteId) for origin=${RemoteOrigin.value}');
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] ensureUniqueRemoteId error: $e\n$st');
|
|
}
|
|
}
|
|
|
|
Future<void> ensureUniqueRemotePath() async {
|
|
try {
|
|
await db.execute(
|
|
'CREATE UNIQUE INDEX IF NOT EXISTS uq_entry_remote_remotePath '
|
|
'ON entry(remotePath) '
|
|
'WHERE origin=${RemoteOrigin.value} '
|
|
"AND remotePath IS NOT NULL AND trim(remotePath) != ''",
|
|
);
|
|
|
|
debugPrint('[RemoteRepository] ensured UNIQUE index on entry(remotePath) for origin=${RemoteOrigin.value}');
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] ensureUniqueRemotePath error: $e\n$st');
|
|
}
|
|
}
|
|
|
|
/// Deduplica principale per uri, perché uri è la chiave remota canonica.
|
|
///
|
|
/// Mantiene MAX(id).
|
|
Future<int> deduplicateRemotes() async {
|
|
try {
|
|
final deleted = await db.rawDelete(
|
|
'DELETE FROM entry '
|
|
'WHERE origin=${RemoteOrigin.value} '
|
|
"AND uri IS NOT NULL AND trim(uri) != '' "
|
|
'AND id NOT IN ('
|
|
' SELECT MAX(id) FROM entry '
|
|
' WHERE origin=${RemoteOrigin.value} '
|
|
" AND uri IS NOT NULL AND trim(uri) != '' "
|
|
' GROUP BY uri'
|
|
')',
|
|
);
|
|
|
|
debugPrint('[RemoteRepository] deduplicateRemotes by uri deleted=$deleted');
|
|
return deleted;
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] deduplicateRemotes error: $e\n$st');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
Future<int> deduplicateByRemotePath() async {
|
|
try {
|
|
final deleted = await db.rawDelete(
|
|
'DELETE FROM entry '
|
|
'WHERE origin=${RemoteOrigin.value} '
|
|
"AND remotePath IS NOT NULL AND trim(remotePath) != '' "
|
|
'AND id NOT IN ('
|
|
' SELECT MAX(id) FROM entry '
|
|
' WHERE origin=${RemoteOrigin.value} '
|
|
" AND remotePath IS NOT NULL AND trim(remotePath) != '' "
|
|
' GROUP BY remotePath'
|
|
')',
|
|
);
|
|
|
|
debugPrint('[RemoteRepository] deduplicateByRemotePath deleted=$deleted');
|
|
return deleted;
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] deduplicateByRemotePath error: $e\n$st');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// =========================
|
|
// Bootstrap / prune remoti
|
|
// =========================
|
|
|
|
/// Bootstrap: cancella TUTTI i remoti.
|
|
Future<int> deleteAllRemotes() async {
|
|
try {
|
|
final deleted = await db.rawDelete('DELETE FROM entry WHERE origin=${RemoteOrigin.value}');
|
|
debugPrint('[RemoteRepository] deleteAllRemotes deleted=$deleted');
|
|
return deleted;
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] deleteAllRemotes error: $e\n$st');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/// FULL sync: elimina i remoti NON più presenti nel serverRemoteIds.
|
|
///
|
|
/// Compatibile con:
|
|
/// record legacy con remoteId
|
|
/// record nuovi con uri `aves-remote://rid/<id>`
|
|
Future<int> pruneMissingRemotes(Set<String> serverRemoteIds) async {
|
|
if (serverRemoteIds.isEmpty) return 0;
|
|
|
|
try {
|
|
final deleted = await db.transaction((txn) async {
|
|
await txn.execute('CREATE TEMP TABLE IF NOT EXISTS tmp_remote_ids(remoteId TEXT PRIMARY KEY);');
|
|
await txn.execute('CREATE TEMP TABLE IF NOT EXISTS tmp_remote_uris(uri TEXT PRIMARY KEY);');
|
|
|
|
await txn.execute('DELETE FROM tmp_remote_ids;');
|
|
await txn.execute('DELETE FROM tmp_remote_uris;');
|
|
|
|
final batch = txn.batch();
|
|
|
|
for (final rawId in serverRemoteIds) {
|
|
final id = rawId.trim();
|
|
if (id.isEmpty) continue;
|
|
|
|
batch.insert(
|
|
'tmp_remote_ids',
|
|
{'remoteId': id},
|
|
conflictAlgorithm: ConflictAlgorithm.ignore,
|
|
);
|
|
|
|
batch.insert(
|
|
'tmp_remote_uris',
|
|
{'uri': _remoteUriForId(id)},
|
|
conflictAlgorithm: ConflictAlgorithm.ignore,
|
|
);
|
|
}
|
|
|
|
await batch.commit(noResult: true);
|
|
|
|
final deleted = await txn.rawDelete('''
|
|
DELETE FROM entry
|
|
WHERE origin=${RemoteOrigin.value}
|
|
AND (
|
|
uri IS NULL
|
|
OR trim(uri) = ''
|
|
OR uri NOT IN (SELECT uri FROM tmp_remote_uris)
|
|
)
|
|
AND (
|
|
remoteId IS NULL
|
|
OR trim(remoteId) = ''
|
|
OR remoteId NOT IN (SELECT remoteId FROM tmp_remote_ids)
|
|
)
|
|
''');
|
|
|
|
return deleted;
|
|
});
|
|
|
|
debugPrint('[RemoteRepository] pruneMissingRemotes deleted=$deleted');
|
|
return deleted;
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] pruneMissingRemotes error: $e\n$st');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// =========================
|
|
// Backfill URI fittizi per remoti legacy
|
|
// =========================
|
|
|
|
Future<void> backfillRemoteUris() async {
|
|
try {
|
|
final updated = await db.rawUpdate(
|
|
'UPDATE entry '
|
|
"SET uri = 'aves-remote://rid/' || replace(remoteId, ' ', '') "
|
|
"WHERE origin=${RemoteOrigin.value} "
|
|
"AND (uri IS NULL OR trim(uri)='') "
|
|
"AND remoteId IS NOT NULL "
|
|
"AND trim(remoteId) != ''",
|
|
);
|
|
|
|
debugPrint('[RemoteRepository] backfill URIs via SQL remoteId updated=$updated');
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] backfill URIs SQL error: $e\n$st');
|
|
}
|
|
|
|
try {
|
|
final rows = await db.rawQuery(
|
|
'SELECT id, remotePath FROM entry '
|
|
"WHERE origin=${RemoteOrigin.value} "
|
|
"AND (uri IS NULL OR trim(uri)='') "
|
|
"AND (remoteId IS NULL OR trim(remoteId)='') "
|
|
'AND remotePath IS NOT NULL',
|
|
);
|
|
|
|
if (rows.isNotEmpty) {
|
|
for (final r in rows) {
|
|
final id = (r['id'] as num).toInt();
|
|
final rp = (r['remotePath'] as String?) ?? '';
|
|
final synthetic = RemoteDbUris.make(remotePath: rp);
|
|
|
|
await db.update(
|
|
'entry',
|
|
{'uri': synthetic},
|
|
where: 'id=?',
|
|
whereArgs: [id],
|
|
conflictAlgorithm: ConflictAlgorithm.ignore,
|
|
);
|
|
}
|
|
|
|
debugPrint('[RemoteRepository] backfill URIs via Dart remotePath updated=${rows.length}');
|
|
}
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] backfill URIs Dart error: $e\n$st');
|
|
}
|
|
}
|
|
|
|
// =========================
|
|
// Backfill essentials per remoti legacy
|
|
// =========================
|
|
|
|
Future<void> backfillRemoteEssentials() async {
|
|
try {
|
|
final rows = await db.rawQuery(
|
|
'SELECT id, uri, remoteId, remotePath FROM entry '
|
|
'WHERE origin=${RemoteOrigin.value} AND (contentId IS NULL OR contentId<=0)',
|
|
);
|
|
|
|
if (rows.isNotEmpty) {
|
|
for (final r in rows) {
|
|
final id = (r['id'] as num).toInt();
|
|
|
|
final uri = (r['uri'] as String?)?.trim() ?? '';
|
|
final rpath = (r['remotePath'] as String?)?.trim() ?? '';
|
|
|
|
final source = uri.isNotEmpty ? uri : rpath;
|
|
final cid = RemoteContentIds.make(source);
|
|
|
|
await db.update(
|
|
'entry',
|
|
{'contentId': cid},
|
|
where: 'id=?',
|
|
whereArgs: [id],
|
|
);
|
|
}
|
|
|
|
debugPrint('[RemoteRepository] backfill contentId updated=${rows.length}');
|
|
}
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] backfill contentId error: $e\n$st');
|
|
}
|
|
|
|
try {
|
|
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
|
|
|
final updated = await db.rawUpdate(
|
|
'UPDATE entry SET dateModifiedMillis = COALESCE(sourceDateTakenMillis, ?) '
|
|
'WHERE origin=${RemoteOrigin.value} AND (dateModifiedMillis IS NULL OR dateModifiedMillis=0)',
|
|
[nowMs],
|
|
);
|
|
|
|
debugPrint('[RemoteRepository] backfill dateModifiedMillis updated=$updated');
|
|
} catch (e, st) {
|
|
debugPrint('[RemoteRepository] backfill dateModifiedMillis error: $e\n$st');
|
|
}
|
|
}
|
|
|
|
Future<void> sanitizeRemotes() async {
|
|
await backfillRemoteUris();
|
|
await deduplicateRemotes();
|
|
await deduplicateByRemotePath();
|
|
await ensureUniqueRemoteUri();
|
|
await ensureUniqueRemoteId();
|
|
await ensureUniqueRemotePath();
|
|
await backfillRemoteEssentials();
|
|
}
|
|
|
|
Future<int> countRemote() async {
|
|
final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=${RemoteOrigin.value}');
|
|
return (rows.first['c'] as int?) ?? 0;
|
|
}
|
|
}
|