From 60b9efdd52d9c536dd3c9e9aea62eb9bd9788d8a Mon Sep 17 00:00:00 2001 From: FabioMich66 Date: Thu, 5 Mar 2026 17:15:22 +0100 Subject: [PATCH] 2nd --- lib/remote/old/auth_client.dart | 96 ++++ lib/remote/old/remote_client.dart | 93 ++++ lib/remote/old/remote_models.dart | 125 ++++++ lib/remote/old/remote_repository.dart | 413 ++++++++++++++++++ lib/remote/old/remote_settings.dart | 83 ++++ lib/remote/old/remote_settings_page.dart | 94 ++++ .../remote_test_page.dart} | 173 +++++++- lib/remote/old/run_remote_sync.dart | 194 ++++++++ lib/remote/old/url_utils.dart | 7 + lib/remote/remote_client.dart | 32 +- lib/remote/remote_repository.dart | 110 ++++- lib/remote/run_remote_sync.dart | 2 +- 12 files changed, 1375 insertions(+), 47 deletions(-) create mode 100644 lib/remote/old/auth_client.dart create mode 100644 lib/remote/old/remote_client.dart create mode 100644 lib/remote/old/remote_models.dart create mode 100644 lib/remote/old/remote_repository.dart create mode 100644 lib/remote/old/remote_settings.dart create mode 100644 lib/remote/old/remote_settings_page.dart rename lib/remote/{remote_test_page.dart.old => old/remote_test_page.dart} (67%) create mode 100644 lib/remote/old/run_remote_sync.dart create mode 100644 lib/remote/old/url_utils.dart diff --git a/lib/remote/old/auth_client.dart b/lib/remote/old/auth_client.dart new file mode 100644 index 00000000..b093f7f1 --- /dev/null +++ b/lib/remote/old/auth_client.dart @@ -0,0 +1,96 @@ +// lib/remote/auth_client.dart +import 'dart:convert'; +import 'package:http/http.dart' as http; + +/// Gestisce autenticazione remota e caching del Bearer token. +/// - [baseUrl]: URL base del server (con o senza '/') +/// - [email]/[password]: credenziali +/// - [loginPath]: path dell'endpoint di login (default 'auth/login') +/// - [timeout]: timeout per le richieste (default 20s) +class RemoteAuth { + final Uri base; + final String email; + final String password; + final String loginPath; + final Duration timeout; + + String? _token; + + RemoteAuth({ + required String baseUrl, + required this.email, + required this.password, + this.loginPath = 'auth/login', + this.timeout = const Duration(seconds: 20), + }) : base = Uri.parse(baseUrl.endsWith('/') ? baseUrl : '$baseUrl/'); + + Uri get _loginUri => base.resolve(loginPath); + + /// Esegue il login e memorizza il token. + /// Lancia eccezione con messaggio chiaro in caso di errore HTTP, rete o JSON. + Future login() async { + final uri = _loginUri; + final headers = {'Content-Type': 'application/json'}; + final bodyStr = json.encode({'email': email, 'password': password}); + + http.Response res; + try { + res = await http + .post(uri, headers: headers, body: bodyStr) + .timeout(timeout); + } catch (e) { + throw Exception('Login fallito: errore di rete verso $uri: $e'); + } + + // Follow esplicito per redirect POST moderni (307/308) mantenendo metodo e body + if ({307, 308}.contains(res.statusCode) && res.headers['location'] != null) { + final redirectUri = uri.resolve(res.headers['location']!); + try { + res = await http + .post(redirectUri, headers: headers, body: bodyStr) + .timeout(timeout); + } catch (e) { + throw Exception('Login fallito: errore di rete verso $redirectUri: $e'); + } + } + + if (res.statusCode != 200) { + final snippet = utf8.decode(res.bodyBytes.take(200).toList()); + throw Exception( + 'Login fallito: HTTP ${res.statusCode} ${res.reasonPhrase} – $snippet', + ); + } + + // Parsing JSON robusto + Map map; + try { + map = json.decode(utf8.decode(res.bodyBytes)) as Map; + } catch (_) { + throw Exception('Login fallito: risposta non è un JSON valido'); + } + + // Supporto sia 'token' sia 'access_token' + final token = (map['token'] ?? map['access_token']) as String?; + if (token == null || token.isEmpty) { + throw Exception('Login fallito: token assente nella risposta'); + } + + _token = token; + return token; + } + + /// Ritorna gli header con Bearer; se non hai token, esegue login. + Future> authHeaders() async { + _token ??= await login(); + return {'Authorization': 'Bearer $_token'}; + } + + /// Forza il rinnovo del token (es. dopo 401) e ritorna i nuovi header. + Future> refreshAndHeaders() async { + _token = null; + return await authHeaders(); + } + + /// Accesso in sola lettura al token corrente (può essere null). + String? get token => _token; +} \ No newline at end of file diff --git a/lib/remote/old/remote_client.dart b/lib/remote/old/remote_client.dart new file mode 100644 index 00000000..76eb5667 --- /dev/null +++ b/lib/remote/old/remote_client.dart @@ -0,0 +1,93 @@ +// lib/remote/remote_client.dart +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'remote_models.dart'; +import 'auth_client.dart'; + +class RemoteJsonClient { + final Uri indexUri; // es. https://prova.patachina.it/photos/ + final RemoteAuth? auth; // opzionale: se presente, aggiunge Bearer + + RemoteJsonClient( + String baseUrl, + String indexPath, { + this.auth, + }) : indexUri = Uri.parse(baseUrl.endsWith('/') ? baseUrl : '$baseUrl/') + .resolve(indexPath); + + Future> fetchAll() async { + Map headers = {}; + if (auth != null) { + headers = await auth!.authHeaders(); + } + + // DEBUG: stampa la URL precisa + // ignore: avoid_print + print('[remote-client] GET $indexUri'); + + http.Response res; + try { + res = await http.get(indexUri, headers: headers).timeout(const Duration(seconds: 20)); + } catch (e) { + throw Exception('Errore rete su $indexUri: $e'); + } + + // Retry 1 volta in caso di 401 (token scaduto/invalidato) + if (res.statusCode == 401 && auth != null) { + headers = await auth!.refreshAndHeaders(); + res = await http.get(indexUri, headers: headers).timeout(const Duration(seconds: 20)); + } + + // Follow 30x mantenendo Authorization + if ({301, 302, 307, 308}.contains(res.statusCode) && res.headers['location'] != null) { + final loc = res.headers['location']!; + final redirectUri = indexUri.resolve(loc); + res = await http.get(redirectUri, headers: headers).timeout(const Duration(seconds: 20)); + } + if (res.statusCode != 200) { + final snippet = utf8.decode(res.bodyBytes.take(200).toList()); + throw Exception('HTTP ${res.statusCode} ${res.reasonPhrase} su $indexUri. Body: $snippet'); + } + + final body = utf8.decode(res.bodyBytes); + + // Qui siamo espliciti: ci aspettiamo SEMPRE una lista top-level + final dynamic decoded = json.decode(body); + if (decoded is! List) { + throw Exception('JSON inatteso: atteso array top-level, ricevuto ${decoded.runtimeType}'); + } + + final List rawList = decoded; + + // --- DIAGNOSTICA: conteggio pattern dai dati del SERVER (non stampo il JSON intero) + int withOriginal = 0, withoutOriginal = 0, leadingSlash = 0, noLeadingSlash = 0; + for (final e in rawList) { + if (e is Map) { + final p = (e['path'] ?? '').toString(); + if (p.startsWith('/')) { + leadingSlash++; + } else { + noLeadingSlash++; + } + if (p.contains('/original/')) { + withOriginal++; + } else { + withoutOriginal++; + } + } + } + // ignore: avoid_print + print('[remote-client] SERVER paths: withOriginal=$withOriginal ' + 'withoutOriginal=$withoutOriginal leadingSlash=$leadingSlash noLeadingSlash=$noLeadingSlash'); + + // Costruiamo a mano la List, tipizzata esplicitamente + final List items = rawList.map((e) { + if (e is! Map) { + throw Exception('Elemento JSON non è una mappa: ${e.runtimeType} -> $e'); + } + return RemotePhotoItem.fromJson(e); + }).toList(); + + return items; + } +} diff --git a/lib/remote/old/remote_models.dart b/lib/remote/old/remote_models.dart new file mode 100644 index 00000000..aff81802 --- /dev/null +++ b/lib/remote/old/remote_models.dart @@ -0,0 +1,125 @@ +// lib/remote/remote_models.dart +import 'url_utils.dart'; + +class RemotePhotoItem { + final String id; + final String name; + final String path; + final String? thub1, thub2; + final String? mimeType; + final int? width, height, sizeBytes; + final DateTime? takenAtUtc; + final double? lat, lng, alt; + final String? dataExifLegacy; + + final String? user; + final int? durationMillis; + final RemoteLocation? location; + + RemotePhotoItem({ + required this.id, + required this.name, + required this.path, + this.thub1, + this.thub2, + this.mimeType, + this.width, + this.height, + this.sizeBytes, + this.takenAtUtc, + this.lat, + this.lng, + this.alt, + this.dataExifLegacy, + this.user, + this.durationMillis, + this.location, + }); + + // URL completo costruito solo in fase di lettura + // String get uri => "https://prova.patachina.it/$path"; + // Costruzione URL assoluto delegata a utility (in base alle impostazioni) + String absoluteUrl(String baseUrl) => buildAbsoluteUri(baseUrl, path).toString(); + + + static DateTime? _tryParseIsoUtc(dynamic v) { + if (v == null) return null; + try { return DateTime.parse(v.toString()).toUtc(); } catch (_) { return null; } + } + + static double? _toDouble(dynamic v) { + if (v == null) return null; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()); + } + + static int? _toMillis(dynamic v) { + if (v == null) return null; + final num? n = (v is num) ? v : num.tryParse(v.toString()); + if (n == null) return null; + return n >= 1000 ? n.toInt() : (n * 1000).toInt(); + } + + factory RemotePhotoItem.fromJson(Map j) { + final gps = j['gps'] as Map?; + final loc = j['location'] is Map + ? RemoteLocation.fromJson(j['location'] as Map) + : null; + + return RemotePhotoItem( + id: (j['id'] ?? j['name']).toString(), + name: (j['name'] ?? '').toString(), + path: (j['path'] ?? '').toString(), + thub1: j['thub1']?.toString(), + thub2: j['thub2']?.toString(), + mimeType: j['mime_type']?.toString(), + width: (j['width'] as num?)?.toInt(), + height: (j['height'] as num?)?.toInt(), + sizeBytes: (j['size_bytes'] as num?)?.toInt(), + takenAtUtc: _tryParseIsoUtc(j['taken_at']), + dataExifLegacy: j['data']?.toString(), + lat: gps != null ? _toDouble(gps['lat']) : null, + lng: gps != null ? _toDouble(gps['lng']) : null, + alt: gps != null ? _toDouble(gps['alt']) : null, + user: j['user']?.toString(), + durationMillis: _toMillis(j['duration']), + location: loc, + ); + } +} + +class RemoteLocation { + final String? continent; + final String? country; + final String? region; + final String? postcode; + final String? city; + final String? countyCode; + final String? address; + final String? timezone; + final String? timeOffset; + + RemoteLocation({ + this.continent, + this.country, + this.region, + this.postcode, + this.city, + this.countyCode, + this.address, + this.timezone, + this.timeOffset, + }); + + factory RemoteLocation.fromJson(Map j) => RemoteLocation( + continent: j['continent']?.toString(), + country: j['country']?.toString(), + region: j['region']?.toString(), + postcode: j['postcode']?.toString(), + city: j['city']?.toString(), + countyCode:j['county_code']?.toString(), + address: j['address']?.toString(), + timezone: j['timezone']?.toString(), + timeOffset:j['time']?.toString(), + ); +} diff --git a/lib/remote/old/remote_repository.dart b/lib/remote/old/remote_repository.dart new file mode 100644 index 00000000..93a9a0c9 --- /dev/null +++ b/lib/remote/old/remote_repository.dart @@ -0,0 +1,413 @@ +// 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; + } +} diff --git a/lib/remote/old/remote_settings.dart b/lib/remote/old/remote_settings.dart new file mode 100644 index 00000000..7662d4a0 --- /dev/null +++ b/lib/remote/old/remote_settings.dart @@ -0,0 +1,83 @@ +// lib/remote/remote_settings.dart +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class RemoteSettings { + static const _storage = FlutterSecureStorage(); + + // Keys + static const _kEnabled = 'remote_enabled'; + static const _kBaseUrl = 'remote_base_url'; + static const _kIndexPath = 'remote_index_path'; + static const _kEmail = 'remote_email'; + static const _kPassword = 'remote_password'; + + // Default values: + // In DEBUG vogliamo valori pre-compilati; in RELEASE lasciamo vuoti/false. + static final bool defaultEnabled = kDebugMode ? true : false; + static final String defaultBaseUrl = kDebugMode ? 'https://prova.patachina.it' : ''; + static final String defaultIndexPath = kDebugMode ? 'photos/' : ''; + static final String defaultEmail = kDebugMode ? 'fabio@gmail.com' : ''; + static final String defaultPassword = kDebugMode ? 'master66' : ''; + + bool enabled; + String baseUrl; + String indexPath; + String email; + String password; + + RemoteSettings({ + required this.enabled, + required this.baseUrl, + required this.indexPath, + required this.email, + required this.password, + }); + + /// Carica i setting dal secure storage. + /// Se un valore non esiste, usa i default (in debug: quelli precompilati). + static Future load() async { + final enabledStr = await _storage.read(key: _kEnabled); + final baseUrl = await _storage.read(key: _kBaseUrl) ?? defaultBaseUrl; + final indexPath = await _storage.read(key: _kIndexPath) ?? defaultIndexPath; + final email = await _storage.read(key: _kEmail) ?? defaultEmail; + final password = await _storage.read(key: _kPassword) ?? defaultPassword; + + final enabled = (enabledStr ?? (defaultEnabled ? 'true' : 'false')) == 'true'; + return RemoteSettings( + enabled: enabled, + baseUrl: baseUrl, + indexPath: indexPath, + email: email, + password: password, + ); + } + + /// Scrive i setting nel secure storage. + Future save() async { + await _storage.write(key: _kEnabled, value: enabled ? 'true' : 'false'); + await _storage.write(key: _kBaseUrl, value: baseUrl); + await _storage.write(key: _kIndexPath, value: indexPath); + await _storage.write(key: _kEmail, value: email); + await _storage.write(key: _kPassword, value: password); + } + + /// In DEBUG: se un valore non è ancora impostato, inizializzalo con i default. + /// NON sovrascrive valori già presenti (quindi puoi sempre entrare in Settings e cambiare). + static Future debugSeedIfEmpty() async { + if (!kDebugMode) return; + + Future _seed(String key, String value) async { + final existing = await _storage.read(key: key); + if (existing == null) { + await _storage.write(key: key, value: value); + } + } + + await _seed(_kEnabled, defaultEnabled ? 'true' : 'false'); + await _seed(_kBaseUrl, defaultBaseUrl); + await _seed(_kIndexPath, defaultIndexPath); + await _seed(_kEmail, defaultEmail); + await _seed(_kPassword, defaultPassword); + } +} \ No newline at end of file diff --git a/lib/remote/old/remote_settings_page.dart b/lib/remote/old/remote_settings_page.dart new file mode 100644 index 00000000..9be1fe37 --- /dev/null +++ b/lib/remote/old/remote_settings_page.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'remote_settings.dart'; + +class RemoteSettingsPage extends StatefulWidget { + const RemoteSettingsPage({super.key}); + @override + State createState() => _RemoteSettingsPageState(); +} + +class _RemoteSettingsPageState extends State { + final _form = GlobalKey(); + bool _enabled = RemoteSettings.defaultEnabled; + final _baseUrl = TextEditingController(text: RemoteSettings.defaultBaseUrl); + final _indexPath = TextEditingController(text: RemoteSettings.defaultIndexPath); + final _email = TextEditingController(); + final _password = TextEditingController(); + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final s = await RemoteSettings.load(); + setState(() { + _enabled = s.enabled; + _baseUrl.text = s.baseUrl; + _indexPath.text = s.indexPath; + _email.text = s.email; + _password.text = s.password; + }); + } + + Future _save() async { + if (!_form.currentState!.validate()) return; + final s = RemoteSettings( + enabled: _enabled, + baseUrl: _baseUrl.text.trim(), + indexPath: _indexPath.text.trim(), + email: _email.text.trim(), + password: _password.text, + ); + await s.save(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Impostazioni remote salvate'))); + Navigator.of(context).maybePop(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Remote Settings')), + body: Form( + key: _form, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + SwitchListTile( + title: const Text('Abilita sync remoto'), + value: _enabled, + onChanged: (v) => setState(() => _enabled = v), + ), + TextFormField( + controller: _baseUrl, + decoration: const InputDecoration(labelText: 'Base URL (es. https://server.tld)'), + validator: (v) => (v==null || v.isEmpty) ? 'Obbligatorio' : null, + ), + TextFormField( + controller: _indexPath, + decoration: const InputDecoration(labelText: 'Index path (es. photos/)'), + validator: (v) => (v==null || v.isEmpty) ? 'Obbligatorio' : null, + ), + TextFormField( + controller: _email, + decoration: const InputDecoration(labelText: 'User/Email'), + ), + TextFormField( + controller: _password, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _save, + icon: const Icon(Icons.save), + label: const Text('Salva'), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/remote/remote_test_page.dart.old b/lib/remote/old/remote_test_page.dart similarity index 67% rename from lib/remote/remote_test_page.dart.old rename to lib/remote/old/remote_test_page.dart index a51ff2d6..ea26f796 100644 --- a/lib/remote/remote_test_page.dart.old +++ b/lib/remote/old/remote_test_page.dart @@ -1,5 +1,6 @@ // lib/remote/remote_test_page.dart import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:sqflite/sqflite.dart'; // Integrazione impostazioni & auth remota (Fase 1) @@ -101,11 +102,11 @@ class _RemoteTestPageState extends State { extraWhere = ''; } - // Prende le prime 200 entry remote (includiamo il mime e il remoteId) + // Prende le prime 300 entry remote (includiamo il mime e il remoteId) final rows = await widget.db.rawQuery( 'SELECT id, remoteId, title, remotePath, remoteThumb2, sourceMimeType, trashed ' 'FROM entry WHERE origin=1$extraWhere ' - 'ORDER BY id DESC LIMIT 200', + 'ORDER BY id DESC LIMIT 300', ); return rows.map((r) { @@ -121,10 +122,26 @@ class _RemoteTestPageState extends State { }).toList(); } + // Costruzione robusta dell’URL assoluto: + // - se già assoluto → ritorna com’è + // - se relativo → risolve contro _baseUrl (accetta con/senza '/') String _absUrl(String? relativePath) { if (relativePath == null || relativePath.isEmpty) return ''; - if (_baseUrl.isEmpty) return ''; // senza base non possiamo costruire URL - return buildAbsoluteUri(_baseUrl, relativePath).toString(); + final p = relativePath.trim(); + + // URL già assoluto + if (p.startsWith('http://') || p.startsWith('https://')) return p; + + if (_baseUrl.isEmpty) return ''; + try { + final base = Uri.parse(_baseUrl.endsWith('/') ? _baseUrl : '$_baseUrl/'); + // normalizza: se inizia con '/', togliamo per usare resolve coerente + final rel = p.startsWith('/') ? p.substring(1) : p; + final resolved = base.resolve(rel); + return resolved.toString(); + } catch (_) { + return ''; + } } bool _isVideo(String? mime, String? path) { @@ -193,6 +210,34 @@ class _RemoteTestPageState extends State { } } + /// 🔧 Pulisce duplicati per `remotePath` (tiene MAX(id)) e righe senza `remoteId`. + Future _pulisciDuplicatiPath() async { + try { + final delNoId = await widget.db.rawDelete( + "DELETE FROM entry WHERE origin=1 AND (remoteId IS NULL OR TRIM(remoteId)='')", + ); + final delByPath = await widget.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' + ')', + ); + + await _onRefresh(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Pulizia completata: noId=$delNoId, dupPath=$delByPath')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Pulizia fallita: $e')), + ); + } + } + Future _nascondiRemotiInCollection() async { try { final changed = await widget.db.rawUpdate(''' @@ -238,6 +283,11 @@ class _RemoteTestPageState extends State { tooltip: 'Diagnostica DB', onPressed: _diagnosticaDb, ), + IconButton( + icon: const Icon(Icons.cleaning_services_outlined), + tooltip: 'Pulisci duplicati (path)', + onPressed: _pulisciDuplicatiPath, + ), IconButton( icon: const Icon(Icons.visibility_off_outlined), tooltip: 'Nascondi remoti in Collection', @@ -269,12 +319,9 @@ class _RemoteTestPageState extends State { const SizedBox(width: 8), SegmentedButton<_RemoteFilter>( segments: const [ - ButtonSegment( - value: _RemoteFilter.all, label: Text('Tutti')), - ButtonSegment( - value: _RemoteFilter.visibleOnly, label: Text('Visibili')), - ButtonSegment( - value: _RemoteFilter.trashedOnly, label: Text('Cestinati')), + ButtonSegment(value: _RemoteFilter.all, label: Text('Tutti')), + ButtonSegment(value: _RemoteFilter.visibleOnly, label: Text('Visibili')), + ButtonSegment(value: _RemoteFilter.trashedOnly, label: Text('Cestinati')), ], selected: {_filter}, onSelectionChanged: (sel) async { @@ -327,9 +374,71 @@ class _RemoteTestPageState extends State { final isVideo = _isVideo(it.mime, it.remotePath); final thumbUrl = _absUrl(it.remoteThumb2); final fullUrl = _absUrl(it.remotePath); + final hasThumb = thumbUrl.isNotEmpty; + final hasFull = fullUrl.isNotEmpty; final heroTag = 'remote_${it.id}'; return GestureDetector( + onLongPress: () async { + if (!context.mounted) return; + await showModalBottomSheet( + context: context, + builder: (_) => Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyMedium!, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('ID: ${it.id} remoteId: ${it.remoteId} trashed: ${it.trashed}'), + const SizedBox(height: 8), + Text('MIME: ${it.mime}'), + const Divider(), + SelectableText('FULL URL:\n$fullUrl'), + const SizedBox(height: 8), + SelectableText('THUMB URL:\n$thumbUrl'), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: [ + ElevatedButton.icon( + onPressed: hasFull + ? () async { + await Clipboard.setData(ClipboardData(text: fullUrl)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('FULL URL copiato')), + ); + } + } + : null, + icon: const Icon(Icons.copy), + label: const Text('Copia FULL'), + ), + ElevatedButton.icon( + onPressed: hasThumb + ? () async { + await Clipboard.setData(ClipboardData(text: thumbUrl)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('THUMB URL copiato')), + ); + } + } + : null, + icon: const Icon(Icons.copy_all), + label: const Text('Copia THUMB'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, onTap: () async { if (_navigating) return; // debounce _navigating = true; @@ -344,7 +453,7 @@ class _RemoteTestPageState extends State { ); return; } - if (fullUrl.isEmpty) { + if (!hasFull) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('URL non valido')), ); @@ -357,7 +466,7 @@ class _RemoteTestPageState extends State { title: it.title, url: fullUrl, headers: _authHeaders, - heroTag: heroTag, // pairing Hero + heroTag: heroTag, // pairing Hero ), transitionDuration: const Duration(milliseconds: 220), ), @@ -367,7 +476,7 @@ class _RemoteTestPageState extends State { } }, child: Hero( - tag: heroTag, // pairing Hero + tag: heroTag, // pairing Hero child: DecoratedBox( decoration: BoxDecoration(border: Border.all(color: Colors.black12)), child: Stack( @@ -376,7 +485,8 @@ class _RemoteTestPageState extends State { _buildGridTile(isVideo, thumbUrl, fullUrl), // Informazioni utili per capire cosa stiamo vedendo Positioned( - left: 2, bottom: 2, + left: 2, + bottom: 2, child: Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), color: Colors.black54, @@ -386,6 +496,23 @@ class _RemoteTestPageState extends State { ), ), ), + Positioned( + right: 2, + top: 2, + child: Wrap( + spacing: 4, + children: [ + if (hasFull) + const _MiniBadge(label: 'URL') + else + const _MiniBadge(label: 'NOURL', color: Colors.red), + if (hasThumb) + const _MiniBadge(label: 'THUMB') + else + const _MiniBadge(label: 'NOTH', color: Colors.orange), + ], + ), + ), ], ), ), @@ -463,6 +590,24 @@ class _RemoteRow { }); } +class _MiniBadge extends StatelessWidget { + final String label; + final Color color; + const _MiniBadge({super.key, required this.label, this.color = Colors.black54}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(3), + ), + child: Text(label, style: const TextStyle(fontSize: 9, color: Colors.white)), + ); + } +} + class _RemoteFullPage extends StatelessWidget { final String title; final String url; diff --git a/lib/remote/old/run_remote_sync.dart b/lib/remote/old/run_remote_sync.dart new file mode 100644 index 00000000..b5a313f8 --- /dev/null +++ b/lib/remote/old/run_remote_sync.dart @@ -0,0 +1,194 @@ +// lib/remote/run_remote_sync.dart +// +// Esegue un ciclo di sincronizzazione "pull": +// 1) legge le impostazioni (server, path, user, password) da RemoteSettings +// 2) login → Bearer token +// 3) GET dell'indice JSON (array di oggetti foto) +// 4) upsert nel DB 'entry' (e 'address' se presente) tramite RemoteRepository +// +// NOTE: +// - La versione "managed" (runRemoteSyncOnceManaged) apre/chiude il DB ed evita run concorrenti. +// - La versione "plain" (runRemoteSyncOnce) usa un Database già aperto (compatibilità). +// - PRAGMA per concorrenza (WAL, busy_timeout, ...). +// - Non logghiamo contenuti sensibili (password/token/body completi). + +import 'package:path/path.dart' as p; +import 'package:sqflite/sqflite.dart'; + +import 'remote_settings.dart'; +import 'auth_client.dart'; +import 'remote_client.dart'; +import 'remote_repository.dart'; + +// === Guardia anti-concorrenza (single-flight) per la run "managed" === +bool _remoteSyncRunning = false; + +/// Helper: retry esponenziale breve per SQLITE_BUSY. +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) { + final msg = e.toString(); + final isBusy = msg.contains('SQLITE_BUSY') || msg.contains('database is locked'); + if (!isBusy || i == maxAttempts - 1) rethrow; + await Future.delayed(delay); + delay *= 2; // 250 → 500 → 1000 ms + } + } + // non dovrebbe arrivare qui + return await fn(); +} + +/// Versione "managed": +/// - impedisce run concorrenti +/// - apre/chiude da sola la connessione a `metadata.db` (istanza indipendente) +/// - imposta PRAGMA per concorrenza +/// - accetta override opzionali (utile in test) +Future runRemoteSyncOnceManaged({ + String? baseUrl, + String? indexPath, + String? email, + String? password, +}) async { + if (_remoteSyncRunning) { + // ignore: avoid_print + print('[remote-sync] already running, skip'); + return; + } + _remoteSyncRunning = true; + + Database? db; + try { + final dbDir = await getDatabasesPath(); + final dbPath = p.join(dbDir, 'metadata.db'); + + db = await openDatabase( + dbPath, + singleInstance: false, // connessione indipendente (non chiude l’handle di Aves) + onConfigure: (db) async { + try { + // Alcuni PRAGMA ritornano valori → usare SEMPRE rawQuery. + await db.rawQuery('PRAGMA journal_mode=WAL'); + await db.rawQuery('PRAGMA synchronous=NORMAL'); + await db.rawQuery('PRAGMA busy_timeout=3000'); + await db.rawQuery('PRAGMA wal_autocheckpoint=1000'); + await db.rawQuery('PRAGMA foreign_keys=ON'); + + // (Opzionale) verifica del mode corrente + final jm = await db.rawQuery('PRAGMA journal_mode'); + final mode = jm.isNotEmpty ? jm.first.values.first : null; + // ignore: avoid_print + print('[remote-sync] journal_mode=$mode'); // atteso: wal + } catch (e, st) { + // ignore: avoid_print + print('[remote-sync][WARN] PRAGMA setup failed: $e\n$st'); + // Non rilanciare: in estremo, continueremo con journaling di default + } + }, + ); + + await runRemoteSyncOnce( + db: db, + baseUrl: baseUrl, + indexPath: indexPath, + email: email, + password: password, + ); + } finally { + try { + await db?.close(); + } catch (_) { + // In caso di close doppio/già chiuso, ignoro. + } + _remoteSyncRunning = false; + } +} + +/// Versione "plain": +/// Esegue login, scarica /photos e fa upsert nel DB usando una connessione +/// SQLite **già aperta** (non viene chiusa qui). +/// +/// Gli optional [baseUrl], [indexPath], [email], [password] permettono override +/// delle impostazioni salvate in `RemoteSettings` (comodo per test / debug). +Future runRemoteSyncOnce({ + required Database db, + String? baseUrl, + String? indexPath, + String? email, + String? password, +}) async { + try { + // 1) Carica impostazioni sicure (secure storage) + final s = await RemoteSettings.load(); + final bUrl = (baseUrl ?? s.baseUrl).trim(); + final ip = (indexPath ?? s.indexPath).trim(); + final em = (email ?? s.email).trim(); + final pw = (password ?? s.password); + + if (bUrl.isEmpty || ip.isEmpty) { + throw StateError('Impostazioni remote incomplete: baseUrl/indexPath mancanti'); + } + + // 2) Autenticazione (Bearer) + final auth = RemoteAuth(baseUrl: bUrl, email: em, password: pw); + await auth.login(); // Se necessario, RemoteJsonClient può riloggare su 401 + + // 3) Client JSON (segue anche redirect 301/302/307/308) + final client = RemoteJsonClient(bUrl, ip, auth: auth); + + // 4) Scarica l’elenco di elementi remoti (array top-level) + final items = await client.fetchAll(); + + // 5) Upsert nel DB (con retry se incappiamo in SQLITE_BUSY) + final repo = RemoteRepository(db); + await _withRetryBusy(() => repo.upsertAll(items)); + + // 5.b) Impedisci futuri duplicati e ripulisci quelli già presenti + await repo.ensureUniqueRemoteId(); + final removed = await repo.deduplicateRemotes(); + + // 5.c) Paracadute: assicura che i remoti NON siano mostrati nella Collection Aves + //await db.rawUpdate('UPDATE entry SET trashed=1 WHERE origin=1 AND trashed=0;'); + + // 5.d) **CLEANUP LEGACY**: elimina righe remote "orfane" o doppioni su remotePath + // - Righe senza remoteId (NULL o vuoto): non deduplicabili via UNIQUE → vanno rimosse + final purgedNoId = await db.rawDelete( + "DELETE FROM entry WHERE origin=1 AND (remoteId IS NULL OR TRIM(remoteId)='')", + ); + + // - Doppioni per remotePath: tieni solo la riga con id MAX + // (copre i casi in cui in passato siano state create due righe per lo stesso path) + final purgedByPath = 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' + ')', + ); + + // ignore: avoid_print + print('[remote-sync] cleanup: removed dup(remoteId)=$removed, purged(noId)=$purgedNoId, purged(byPath)=$purgedByPath'); + + // 6) Log sintetico + int? c; + try { + c = await repo.countRemote(); + } catch (_) { + c = null; + } + // ignore: avoid_print + if (c == null) { + print('[remote-sync] import completato (conteggio non disponibile)'); + } else { + print('[remote-sync] importati remoti: $c (base=$bUrl, index=$ip)'); + } + } catch (e, st) { + // ignore: avoid_print + print('[remote-sync][ERROR] $e\n$st'); + rethrow; + } +} diff --git a/lib/remote/old/url_utils.dart b/lib/remote/old/url_utils.dart new file mode 100644 index 00000000..9b0450b8 --- /dev/null +++ b/lib/remote/old/url_utils.dart @@ -0,0 +1,7 @@ +// lib/remote/url_utils.dart +Uri buildAbsoluteUri(String baseUrl, String relativePath) { + final base = Uri.parse(baseUrl.endsWith('/') ? baseUrl : '$baseUrl/'); + final cleaned = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath; + final segments = cleaned.split('/').where((s) => s.isNotEmpty).toList(); + return base.replace(pathSegments: [...base.pathSegments, ...segments]); +} diff --git a/lib/remote/remote_client.dart b/lib/remote/remote_client.dart index 647c3935..7d895cf1 100644 --- a/lib/remote/remote_client.dart +++ b/lib/remote/remote_client.dart @@ -38,8 +38,8 @@ class RemoteJsonClient { res = await http.get(indexUri, headers: headers).timeout(const Duration(seconds: 20)); } - // Follow 301/302 mantenendo Authorization - if ({301,302,307,308}.contains(res.statusCode) && res.headers['location'] != null) { + // Follow 30x mantenendo Authorization + if ({301, 302, 307, 308}.contains(res.statusCode) && res.headers['location'] != null) { final loc = res.headers['location']!; final redirectUri = indexUri.resolve(loc); res = await http.get(redirectUri, headers: headers).timeout(const Duration(seconds: 20)); @@ -50,19 +50,37 @@ class RemoteJsonClient { } final body = utf8.decode(res.bodyBytes); - // DEBUG - print('DEBUG JSON: $body'); // Qui siamo espliciti: ci aspettiamo SEMPRE una lista top-level final dynamic decoded = json.decode(body); - if (decoded is! List) { throw Exception('JSON inatteso: atteso array top-level, ricevuto ${decoded.runtimeType}'); } final List rawList = decoded; - // Costruiamo a mano la List, tipizzata esplicitamente + // --- DIAGNOSTICA: conteggio pattern dai dati del SERVER (non stampo il JSON intero) + int withOriginal = 0, withoutOriginal = 0, leadingSlash = 0, noLeadingSlash = 0; + for (final e in rawList) { + if (e is Map) { + final p = (e['path'] ?? '').toString(); + if (p.startsWith('/')) { + leadingSlash++; + } else { + noLeadingSlash++; + } + if (p.contains('/original/')) { + withOriginal++; + } else { + withoutOriginal++; + } + } + } + // ignore: avoid_print + print('[remote-client] SERVER paths -> withOriginal=$withOriginal | withoutOriginal=$withoutOriginal | ' + 'leadingSlash=$leadingSlash | noLeadingSlash=$noLeadingSlash'); + + // Costruiamo List final List items = rawList.map((e) { if (e is! Map) { throw Exception('Elemento JSON non è una mappa: ${e.runtimeType} -> $e'); @@ -71,7 +89,5 @@ class RemoteJsonClient { }).toList(); return items; - - } } diff --git a/lib/remote/remote_repository.dart b/lib/remote/remote_repository.dart index 97218a84..767e31e5 100644 --- a/lib/remote/remote_repository.dart +++ b/lib/remote/remote_repository.dart @@ -53,6 +53,7 @@ class RemoteRepository { 'remoteThumb2': 'TEXT', 'origin': 'INTEGER', 'provider': 'TEXT', + 'trashed': 'INTEGER', }); // Indice "normale" per velocizzare il lookup su remoteId try { @@ -89,6 +90,31 @@ class RemoteRepository { return await fn(); } + // ========================= + // Normalizzazione SOLO per diagnostica (non cambia cosa salvi) + // ========================= + + 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//' + /// se manca). Usato solo per LOG/HINT, NON per scrivere. + String _canonCandidate(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'); + } + if (fileName.isNotEmpty) { + seg[seg.length - 1] = fileName; + } + return seg.join('/'); + } + // ========================= // Utilities // ========================= @@ -105,6 +131,7 @@ class RemoteRepository { } Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { + // ⚠️ NON correggo: salvo esattamente quello che arriva (come ora) return { 'id': existingId, 'contentId': null, @@ -120,15 +147,15 @@ class RemoteRepository { 'dateModifiedMillis': null, 'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch, 'durationMillis': it.durationMillis, - // 👇 REMOTI nascosti nella Collection Aves - 'trashed': 1, + // REMOTI VISIBILI (come nel tuo file attuale) + 'trashed': 0, 'origin': 1, 'provider': 'json@patachina', // GPS (possono essere null) 'latitude': it.lat, 'longitude': it.lng, 'altitude': it.alt, - // campi remoti + // campi remoti (⚠️ path “raw”, senza forzare /original/) 'remoteId': it.id, 'remotePath': it.path, 'remoteThumb1': it.thub1, @@ -148,20 +175,13 @@ class RemoteRepository { } // ========================= - // Upsert a chunk + // Upsert a chunk (DIAGNOSTICA inclusa) // ========================= - /// Inserisce o aggiorna tutti gli elementi remoti. - /// - /// - Assicura colonne `entry` (GPS + remote*) - /// - Ordina prima le immagini, poi i video (i video gravano di più) - /// - Esegue l'upsert per chunk di dimensione fissa (default 200) per rilasciare spesso i lock - /// - In caso di errore di schema sui 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)); // Ordina: prima immagini, poi video @@ -181,13 +201,24 @@ class RemoteRepository { final batch = txn.batch(); for (final it in chunk) { - // Lookup record esistente per stabilire l'ID (così il REPLACE mantiene la PK) + // === DIAGNOSTICA PRE-LOOKUP === + final raw = it.path; + final norm = _normPath(raw); + final cand = _canonCandidate(raw, it.name); + final hasOriginal = raw.contains('/original/'); + final hasLeading = raw.startsWith('/'); + debugPrint( + '[repo-upsert] in: rid=${it.id.substring(0,8)} name=${it.name} ' + 'raw="$raw" (original=${hasOriginal?"Y":"N"}, leading=${hasLeading?"Y":"N"})' + ); + + // Lookup record esistente SOLO per remoteId (comportamento attuale) int? existingId; try { final existing = await txn.query( 'entry', columns: ['id'], - where: 'remoteId = ?', + where: 'origin=1 AND remoteId = ?', whereArgs: [it.id], limit: 1, ); @@ -196,19 +227,54 @@ class RemoteRepository { debugPrint('[RemoteRepository] lookup existingId failed for remoteId=${it.id}: $e\n$st'); } - // Riga completa + // === DIAGNOSTICA HINT: esisterebbe una riga “compatibile” per path? === + // 1) path canonico (con /original/) + try { + final byCanon = await txn.query( + 'entry', + columns: ['id'], + where: 'origin=1 AND remotePath = ?', + whereArgs: [cand], + limit: 1, + ); + if (byCanon.isNotEmpty && existingId == null) { + final idCand = byCanon.first['id']; + debugPrint( + '[repo-upsert][HINT] trovata riga per CAND-remotePath="$cand" -> id=$idCand ' + '(il lookup corrente per remoteId NON la vede: possibile causa duplicato)' + ); + } + } catch (_) {} + + // 2) path raw normalizzato (solo slash) + try { + final byNorm = await txn.query( + 'entry', + columns: ['id'], + where: 'origin=1 AND remotePath = ?', + whereArgs: [norm], + limit: 1, + ); + if (byNorm.isNotEmpty && existingId == null) { + final idNorm = byNorm.first['id']; + debugPrint( + '[repo-upsert][HINT] trovata riga per RAW-NORM-remotePath="$norm" -> id=$idNorm ' + '(il lookup corrente per remoteId NON la vede: possibile causa duplicato)' + ); + } + } catch (_) {} + + // Riga completa (⚠️ salviamo il RAW come stai facendo ora) final row = _buildEntryRow(it, existingId: existingId); - // Provo insert/replace con i campi completi (GPS inclusi) + // Insert/replace 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) @@ -226,7 +292,7 @@ class RemoteRepository { await batch.commit(noResult: true); - // Secondo pass per address, con PK certa + // Secondo pass per address (immutato) for (final it in chunk) { if (it.location == null) continue; @@ -234,7 +300,7 @@ class RemoteRepository { final rows = await txn.query( 'entry', columns: ['id'], - where: 'remoteId = ?', + where: 'origin=1 AND remoteId = ?', whereArgs: [it.id], limit: 1, ); @@ -260,10 +326,9 @@ class RemoteRepository { } // ========================= - // Unicità & deduplica + // Unicità & deduplica (immutato) // ========================= - /// Crea un indice UNICO su `remoteId` limitato alle righe remote (origin=1). Future ensureUniqueRemoteId() async { try { await db.execute( @@ -276,10 +341,8 @@ class RemoteRepository { } } - /// Rimuove duplicati remoti, tenendo la riga con id MAX per ciascun `remoteId`. Future deduplicateRemotes() async { try { - // Subquery per evitare limite placeholder (999) final deleted = await db.rawDelete( 'DELETE FROM entry ' 'WHERE origin=1 AND remoteId IS NOT NULL AND id NOT IN (' @@ -296,7 +359,6 @@ class RemoteRepository { } } - /// Helper combinato: prima pulisce i doppioni, poi impone l’unicità. Future sanitizeRemotes() async { await deduplicateRemotes(); await ensureUniqueRemoteId(); diff --git a/lib/remote/run_remote_sync.dart b/lib/remote/run_remote_sync.dart index a64c92ac..b5a313f8 100644 --- a/lib/remote/run_remote_sync.dart +++ b/lib/remote/run_remote_sync.dart @@ -151,7 +151,7 @@ Future runRemoteSyncOnce({ final removed = await repo.deduplicateRemotes(); // 5.c) Paracadute: assicura che i remoti NON siano mostrati nella Collection Aves - await db.rawUpdate('UPDATE entry SET trashed=1 WHERE origin=1 AND trashed=0;'); + //await db.rawUpdate('UPDATE entry SET trashed=1 WHERE origin=1 AND trashed=0;'); // 5.d) **CLEANUP LEGACY**: elimina righe remote "orfane" o doppioni su remotePath // - Righe senza remoteId (NULL o vuoto): non deduplicabili via UNIQUE → vanno rimosse