// lib/remote/remote_repository.dart import 'package:flutter/foundation.dart' show debugPrint; import 'package:sqflite/sqflite.dart'; import 'remote_models.dart'; class RemoteRepository { final Database db; RemoteRepository(this.db); // ========================= // Helpers PRAGMA / schema // ========================= Future _ensureColumns( DatabaseExecutor dbExec, { required String table, required Map 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 le colonne GPS e alcune colonne "remote*" esistano nella tabella `entry`. Future _ensureEntryColumns(DatabaseExecutor dbExec) async { await _ensureColumns(dbExec, table: 'entry', columnsAndTypes: const { // GPS 'latitude': 'REAL', 'longitude': 'REAL', 'altitude': 'REAL', // Campi remoti 'remoteId': 'TEXT', 'remotePath': 'TEXT', 'remoteThumb1': 'TEXT', 'remoteThumb2': 'TEXT', 'origin': 'INTEGER', 'provider': 'TEXT', 'trashed': 'INTEGER', }); // Indice "normale" per velocizzare il lookup su remoteId try { await dbExec.execute( 'CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON entry(remoteId);', ); } 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 _withRetryBusy(Future 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; // 250 → 500 → 1000 ms } } // non dovrebbe arrivare qui return await fn(); } // ========================= // Normalizzazione / Canonicalizzazione // ========================= /// Normalizza gli slash e forza lo slash iniziale. String _normPath(String? p) { if (p == null || p.isEmpty) return ''; var s = p.trim().replaceAll(RegExp(r'/+'), '/'); if (!s.startsWith('/')) s = '/$s'; return s; } /// Inserisce '/original/' dopo '/photos//' se manca, e garantisce filename coerente. String _canonFullPath(String? rawPath, String fileName) { var s = _normPath(rawPath); final seg = s.split('/'); // ['', 'photos', '', maybe 'original', ...] if (seg.length >= 4 && seg[1] == 'photos' && seg[3] != 'original' && seg[3] != 'thumbs') { seg.insert(3, 'original'); } // forza il filename finale (se fornito) if (fileName.isNotEmpty) { seg[seg.length - 1] = fileName; } return seg.join('/'); } // ========================= // 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 _buildEntryRow(RemotePhotoItem it, {int? existingId}) { final canonical = _canonFullPath(it.path, it.name); final thumb = _normPath(it.thub2); return { 'id': existingId, 'contentId': null, 'uri': null, 'path': canonical, // path interno 'sourceMimeType': it.mimeType, 'width': it.width, 'height': it.height, 'sourceRotationDegrees': null, 'sizeBytes': it.sizeBytes, 'title': it.name, 'dateAddedSecs': DateTime.now().millisecondsSinceEpoch ~/ 1000, 'dateModifiedMillis': null, 'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch, 'durationMillis': it.durationMillis, // 👇 REMOTI visibili nella Collection (se la tua Collection include origin=1) 'trashed': 0, 'origin': 1, 'provider': 'json@patachina', // GPS (possono essere null) 'latitude': it.lat, 'longitude': it.lng, 'altitude': it.alt, // campi remoti 'remoteId': it.id, 'remotePath': canonical, // <-- sempre canonico con /original/ 'remoteThumb1': it.thub1, 'remoteThumb2': thumb, }; } Map _buildAddressRow(int newId, RemoteLocation location) { return { 'id': newId, 'addressLine': location.address, 'countryCode': null, 'countryName': location.country, 'adminArea': location.region, 'locality': location.city, }; } // ========================= // Upsert a chunk // ========================= /// Inserisce o aggiorna tutti gli elementi remoti. /// /// - Assicura colonne `entry` (GPS + remote*) /// - Canonicalizza i path (`/photos//original/...`) /// - Lookup robusto: remoteId -> remotePath(canonico) -> remotePath(raw normalizzato) /// - Ordina prima le immagini, poi i video /// - In caso di errore schema su GPS, riprova senza i 3 campi GPS Future upsertAll(List items, {int chunkSize = 200}) async { debugPrint('RemoteRepository.upsertAll: items=${items.length}'); if (items.isEmpty) return; // Garantisco lo schema una volta, poi procedo ai chunk await _withRetryBusy(() => _ensureEntryColumns(db)); // Indici UNIQUE per prevenire futuri duplicati (id + path) await ensureUniqueRemoteId(); await ensureUniqueRemotePath(); // Ordina: prima immagini, poi video final images = []; final videos = []; for (final it in items) { (_isVideoItem(it) ? videos : images).add(it); } final ordered = [...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 { await _withRetryBusy(() => db.transaction((txn) async { final batch = txn.batch(); for (final it in chunk) { // Lookup record esistente per stabilire l'ID (REPLACE mantiene la PK) int? existingId; // 1) prova per remoteId try { final existing = await txn.query( 'entry', columns: ['id'], where: 'origin=1 AND remoteId = ?', whereArgs: [it.id], limit: 1, ); if (existing.isNotEmpty) { existingId = existing.first['id'] as int?; } else { // 2) fallback per remotePath canonico final canonical = _canonFullPath(it.path, it.name); final byCanon = await txn.query( 'entry', columns: ['id'], where: 'origin=1 AND remotePath = ?', whereArgs: [canonical], limit: 1, ); if (byCanon.isNotEmpty) { existingId = byCanon.first['id'] as int?; } else { // 3) ultimo fallback: remotePath "raw normalizzato" (senza forzare /original/) final rawNorm = _normPath(it.path); final byRaw = await txn.query( 'entry', columns: ['id'], where: 'origin=1 AND remotePath = ?', whereArgs: [rawNorm], limit: 1, ); if (byRaw.isNotEmpty) { existingId = byRaw.first['id'] as int?; } } } } catch (e, st) { debugPrint('[RemoteRepository] lookup existingId failed for remoteId=${it.id}: $e\n$st'); } // Riga completa (con path canonico) final row = _buildEntryRow(it, existingId: existingId); // Provo insert/replace con i campi completi (GPS inclusi) try { batch.insert( 'entry', row, conflictAlgorithm: ConflictAlgorithm.replace, ); // Address: lo inseriamo in un secondo pass (post-commit) con PK certa } on DatabaseException catch (e, st) { // Se fallisce per schema GPS (colonne non create o tipo non compatibile), riprovo senza i 3 campi debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st'); final rowNoGps = Map.from(row) ..remove('latitude') ..remove('longitude') ..remove('altitude'); batch.insert( 'entry', rowNoGps, conflictAlgorithm: ConflictAlgorithm.replace, ); } } await batch.commit(noResult: true); // Secondo pass per address, con PK certa for (final it in chunk) { if (it.location == null) continue; try { // cerco per remoteId, altrimenti per path canonico final canonical = _canonFullPath(it.path, it.name); final rows = await txn.query( 'entry', columns: ['id'], where: 'origin=1 AND (remoteId = ? OR remotePath = ?)', whereArgs: [it.id, canonical], 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'); } } })); } catch (e, st) { debugPrint('[RemoteRepository] upsert chunk ${offset}..${end - 1} ERROR: $e\n$st'); rethrow; } } } // ========================= // Unicità & deduplica // ========================= /// Crea un indice UNICO su `remoteId` limitato alle righe remote (origin=1). Future ensureUniqueRemoteId() async { try { await db.execute( 'CREATE UNIQUE INDEX IF NOT EXISTS uq_entry_remote_remoteId ' 'ON entry(remoteId) WHERE origin=1', ); debugPrint('[RemoteRepository] ensured UNIQUE index on entry(remoteId) for origin=1'); } catch (e, st) { debugPrint('[RemoteRepository] ensureUniqueRemoteId error: $e\n$st'); } } /// Crea un indice UNICO su `remotePath` (solo remoti) per prevenire doppi. Future ensureUniqueRemotePath() async { try { await db.execute( 'CREATE UNIQUE INDEX IF NOT EXISTS uq_entry_remote_remotePath ' 'ON entry(remotePath) WHERE origin=1 AND remotePath IS NOT NULL', ); debugPrint('[RemoteRepository] ensured UNIQUE index on entry(remotePath) for origin=1'); } catch (e, st) { debugPrint('[RemoteRepository] ensureUniqueRemotePath error: $e\n$st'); } } /// Rimuove duplicati remoti, tenendo la riga con id MAX per ciascun `remoteId`. Future deduplicateRemotes() async { try { final deleted = await db.rawDelete( 'DELETE FROM entry ' 'WHERE origin=1 AND remoteId IS NOT NULL AND id NOT IN (' ' SELECT MAX(id) FROM entry ' ' WHERE origin=1 AND remoteId IS NOT NULL ' ' GROUP BY remoteId' ')', ); debugPrint('[RemoteRepository] deduplicateRemotes deleted=$deleted'); return deleted; } catch (e, st) { debugPrint('[RemoteRepository] deduplicateRemotes error: $e\n$st'); return 0; } } /// Rimuove duplicati per `remotePath` (exact match), tenendo l'ultima riga. Future deduplicateByRemotePath() async { try { final deleted = await db.rawDelete( 'DELETE FROM entry ' 'WHERE origin=1 AND remotePath IS NOT NULL AND id NOT IN (' ' SELECT MAX(id) FROM entry ' ' WHERE origin=1 AND remotePath IS NOT NULL ' ' GROUP BY remotePath' ')', ); debugPrint('[RemoteRepository] deduplicateByRemotePath deleted=$deleted'); return deleted; } catch (e, st) { debugPrint('[RemoteRepository] deduplicateByRemotePath error: $e\n$st'); return 0; } } /// Helper combinato: prima pulisce i doppioni, poi impone l’unicità. Future sanitizeRemotes() async { await deduplicateRemotes(); await deduplicateByRemotePath(); await ensureUniqueRemoteId(); await ensureUniqueRemotePath(); } // ========================= // Utils // ========================= Future countRemote() async { final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1'); return (rows.first['c'] as int?) ?? 0; } }