ok
This commit is contained in:
parent
60b9efdd52
commit
5e112be16b
11 changed files with 1822 additions and 70 deletions
96
lib/remote/new/auth_client.dart
Normal file
96
lib/remote/new/auth_client.dart
Normal file
|
|
@ -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<String> 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<String, dynamic> map;
|
||||
try {
|
||||
map = json.decode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
|
||||
} 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<Map<String, String>> authHeaders() async {
|
||||
_token ??= await login();
|
||||
return {'Authorization': 'Bearer $_token'};
|
||||
}
|
||||
|
||||
/// Forza il rinnovo del token (es. dopo 401) e ritorna i nuovi header.
|
||||
Future<Map<String, String>> refreshAndHeaders() async {
|
||||
_token = null;
|
||||
return await authHeaders();
|
||||
}
|
||||
|
||||
/// Accesso in sola lettura al token corrente (può essere null).
|
||||
String? get token => _token;
|
||||
}
|
||||
93
lib/remote/new/remote_client.dart
Normal file
93
lib/remote/new/remote_client.dart
Normal file
|
|
@ -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<List<RemotePhotoItem>> fetchAll() async {
|
||||
Map<String, String> 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<dynamic> 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<String, dynamic>) {
|
||||
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<RemotePhotoItem>
|
||||
final List<RemotePhotoItem> items = rawList.map<RemotePhotoItem>((e) {
|
||||
if (e is! Map<String, dynamic>) {
|
||||
throw Exception('Elemento JSON non è una mappa: ${e.runtimeType} -> $e');
|
||||
}
|
||||
return RemotePhotoItem.fromJson(e);
|
||||
}).toList();
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
125
lib/remote/new/remote_models.dart
Normal file
125
lib/remote/new/remote_models.dart
Normal file
|
|
@ -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<String, dynamic> j) {
|
||||
final gps = j['gps'] as Map<String, dynamic>?;
|
||||
final loc = j['location'] is Map<String, dynamic>
|
||||
? RemoteLocation.fromJson(j['location'] as Map<String, dynamic>)
|
||||
: 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<String, dynamic> 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(),
|
||||
);
|
||||
}
|
||||
375
lib/remote/new/remote_repository.dart
Normal file
375
lib/remote/new/remote_repository.dart
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
// 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<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 le colonne GPS e alcune colonne "remote*" esistano nella tabella `entry`.
|
||||
Future<void> _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<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; // 250 → 500 → 1000 ms
|
||||
}
|
||||
}
|
||||
// non dovrebbe arrivare qui
|
||||
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/<User>/'
|
||||
/// 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', '<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('/');
|
||||
}
|
||||
|
||||
// =========================
|
||||
// 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}) {
|
||||
// ⚠️ NON correggo: salvo esattamente quello che arriva (come ora)
|
||||
return <String, Object?>{
|
||||
'id': existingId,
|
||||
'contentId': null,
|
||||
'uri': null,
|
||||
'path': it.path,
|
||||
'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 (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 (⚠️ path “raw”, senza forzare /original/)
|
||||
'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': null,
|
||||
'countryName': location.country,
|
||||
'adminArea': location.region,
|
||||
'locality': location.city,
|
||||
};
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Upsert a chunk (DIAGNOSTICA inclusa)
|
||||
// =========================
|
||||
|
||||
Future<void> upsertAll(List<RemotePhotoItem> items, {int chunkSize = 200}) async {
|
||||
debugPrint('RemoteRepository.upsertAll: items=${items.length}');
|
||||
if (items.isEmpty) return;
|
||||
|
||||
await _withRetryBusy(() => _ensureEntryColumns(db));
|
||||
|
||||
// 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 {
|
||||
await _withRetryBusy(() => db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
|
||||
for (final it in chunk) {
|
||||
// === 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: 'origin=1 AND remoteId = ?',
|
||||
whereArgs: [it.id],
|
||||
limit: 1,
|
||||
);
|
||||
existingId = existing.isNotEmpty ? (existing.first['id'] as int?) : null;
|
||||
} catch (e, st) {
|
||||
debugPrint('[RemoteRepository] lookup existingId failed for remoteId=${it.id}: $e\n$st');
|
||||
}
|
||||
|
||||
// === 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);
|
||||
|
||||
// Insert/replace
|
||||
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');
|
||||
|
||||
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 (immutato)
|
||||
for (final it in chunk) {
|
||||
if (it.location == null) continue;
|
||||
|
||||
try {
|
||||
final rows = await txn.query(
|
||||
'entry',
|
||||
columns: ['id'],
|
||||
where: 'origin=1 AND remoteId = ?',
|
||||
whereArgs: [it.id],
|
||||
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 (immutato)
|
||||
// =========================
|
||||
|
||||
Future<void> 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');
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sanitizeRemotes() async {
|
||||
await deduplicateRemotes();
|
||||
await ensureUniqueRemoteId();
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Utils
|
||||
// =========================
|
||||
|
||||
Future<int> countRemote() async {
|
||||
final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1');
|
||||
return (rows.first['c'] as int?) ?? 0;
|
||||
}
|
||||
}
|
||||
83
lib/remote/new/remote_settings.dart
Normal file
83
lib/remote/new/remote_settings.dart
Normal file
|
|
@ -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<RemoteSettings> 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<void> 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<void> debugSeedIfEmpty() async {
|
||||
if (!kDebugMode) return;
|
||||
|
||||
Future<void> _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);
|
||||
}
|
||||
}
|
||||
94
lib/remote/new/remote_settings_page.dart
Normal file
94
lib/remote/new/remote_settings_page.dart
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'remote_settings.dart';
|
||||
|
||||
class RemoteSettingsPage extends StatefulWidget {
|
||||
const RemoteSettingsPage({super.key});
|
||||
@override
|
||||
State<RemoteSettingsPage> createState() => _RemoteSettingsPageState();
|
||||
}
|
||||
|
||||
class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
|
||||
final _form = GlobalKey<FormState>();
|
||||
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<void> _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<void> _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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
647
lib/remote/new/remote_test_page.dart
Normal file
647
lib/remote/new/remote_test_page.dart
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
// 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)
|
||||
import 'remote_settings.dart';
|
||||
import 'auth_client.dart';
|
||||
import 'url_utils.dart';
|
||||
|
||||
enum _RemoteFilter { all, visibleOnly, trashedOnly }
|
||||
|
||||
class RemoteTestPage extends StatefulWidget {
|
||||
final Database db;
|
||||
|
||||
/// Base URL preferita (es. https://prova.patachina.it).
|
||||
/// Se non la passi o è vuota, verrà usata quella in RemoteSettings.
|
||||
final String? baseUrl;
|
||||
|
||||
const RemoteTestPage({
|
||||
super.key,
|
||||
required this.db,
|
||||
this.baseUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RemoteTestPage> createState() => _RemoteTestPageState();
|
||||
}
|
||||
|
||||
class _RemoteTestPageState extends State<RemoteTestPage> {
|
||||
Future<List<_RemoteRow>>? _future;
|
||||
String _baseUrl = '';
|
||||
Map<String, String>? _authHeaders;
|
||||
bool _navigating = false; // debounce del tap
|
||||
_RemoteFilter _filter = _RemoteFilter.all;
|
||||
|
||||
// contatori diagnostici
|
||||
int _countAll = 0;
|
||||
int _countVisible = 0; // trashed=0
|
||||
int _countTrashed = 0; // trashed=1
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init(); // prepara baseUrl + header auth (se necessari), poi carica i dati
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
// 1) Base URL: parametro > settings
|
||||
final s = await RemoteSettings.load();
|
||||
final candidate = (widget.baseUrl ?? '').trim();
|
||||
_baseUrl = candidate.isNotEmpty ? candidate : s.baseUrl.trim();
|
||||
|
||||
// 2) Header Authorization (opzionale)
|
||||
_authHeaders = null;
|
||||
try {
|
||||
if (_baseUrl.isNotEmpty && (s.email.isNotEmpty || s.password.isNotEmpty)) {
|
||||
final auth = RemoteAuth(baseUrl: _baseUrl, email: s.email, password: s.password);
|
||||
final token = await auth.login();
|
||||
_authHeaders = {'Authorization': 'Bearer $token'};
|
||||
}
|
||||
} catch (_) {
|
||||
// In debug non bloccare la pagina se il login immagini fallisce
|
||||
_authHeaders = null;
|
||||
}
|
||||
|
||||
// 3) Carica contatori e lista
|
||||
await _refreshCounters();
|
||||
_future = _load();
|
||||
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _refreshCounters() async {
|
||||
// Totale remoti (origin=1), visibili e cestinati
|
||||
final all = await widget.db.rawQuery(
|
||||
"SELECT COUNT(*) AS c FROM entry WHERE origin=1",
|
||||
);
|
||||
final vis = await widget.db.rawQuery(
|
||||
"SELECT COUNT(*) AS c FROM entry WHERE origin=1 AND trashed=0",
|
||||
);
|
||||
final tra = await widget.db.rawQuery(
|
||||
"SELECT COUNT(*) AS c FROM entry WHERE origin=1 AND trashed=1",
|
||||
);
|
||||
_countAll = (all.first['c'] as int?) ?? 0;
|
||||
_countVisible = (vis.first['c'] as int?) ?? 0;
|
||||
_countTrashed = (tra.first['c'] as int?) ?? 0;
|
||||
}
|
||||
|
||||
Future<List<_RemoteRow>> _load() async {
|
||||
// Filtro WHERE in base al toggle
|
||||
String extraWhere = '';
|
||||
switch (_filter) {
|
||||
case _RemoteFilter.visibleOnly:
|
||||
extraWhere = ' AND trashed=0';
|
||||
break;
|
||||
case _RemoteFilter.trashedOnly:
|
||||
extraWhere = ' AND trashed=1';
|
||||
break;
|
||||
case _RemoteFilter.all:
|
||||
default:
|
||||
extraWhere = '';
|
||||
}
|
||||
|
||||
// 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 300',
|
||||
);
|
||||
|
||||
return rows.map((r) {
|
||||
return _RemoteRow(
|
||||
id: r['id'] as int,
|
||||
remoteId: (r['remoteId'] as String?) ?? '',
|
||||
title: (r['title'] as String?) ?? '',
|
||||
remotePath: r['remotePath'] as String?,
|
||||
remoteThumb2: r['remoteThumb2'] as String?,
|
||||
mime: r['sourceMimeType'] as String?,
|
||||
trashed: (r['trashed'] as int?) ?? 0,
|
||||
);
|
||||
}).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 '';
|
||||
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) {
|
||||
final m = (mime ?? '').toLowerCase();
|
||||
final p = (path ?? '').toLowerCase();
|
||||
return m.startsWith('video/') ||
|
||||
p.endsWith('.mp4') ||
|
||||
p.endsWith('.mov') ||
|
||||
p.endsWith('.m4v') ||
|
||||
p.endsWith('.mkv') ||
|
||||
p.endsWith('.webm');
|
||||
}
|
||||
|
||||
Future<void> _onRefresh() async {
|
||||
await _refreshCounters();
|
||||
_future = _load();
|
||||
if (mounted) setState(() {});
|
||||
await _future;
|
||||
}
|
||||
|
||||
Future<void> _diagnosticaDb() async {
|
||||
try {
|
||||
final dup = await widget.db.rawQuery('''
|
||||
SELECT remoteId, COUNT(*) AS cnt
|
||||
FROM entry
|
||||
WHERE origin=1 AND remoteId IS NOT NULL
|
||||
GROUP BY remoteId
|
||||
HAVING cnt > 1
|
||||
''');
|
||||
final vis = await widget.db.rawQuery('''
|
||||
SELECT COUNT(*) AS visible_remotes
|
||||
FROM entry
|
||||
WHERE origin=1 AND trashed=0
|
||||
''');
|
||||
final idx = await widget.db.rawQuery("PRAGMA index_list('entry')");
|
||||
|
||||
if (!mounted) return;
|
||||
await showModalBottomSheet<void>(
|
||||
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: [
|
||||
const Text('Diagnostica DB', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
Text('Duplicati per remoteId:\n${dup.isEmpty ? "nessuno" : dup.map((e)=>e.toString()).join('\n')}'),
|
||||
const SizedBox(height: 12),
|
||||
Text('Remoti visibili in Aves (trashed=0): ${vis.first.values.first}'),
|
||||
const SizedBox(height: 12),
|
||||
Text('Indici su entry:\n${idx.map((e)=>e.toString()).join('\n')}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Diagnostica DB fallita: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔧 Pulisce duplicati per `remotePath` (tiene MAX(id)) e righe senza `remoteId`.
|
||||
Future<void> _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<void> _nascondiRemotiInCollection() async {
|
||||
try {
|
||||
final changed = await widget.db.rawUpdate('''
|
||||
UPDATE entry SET trashed=1
|
||||
WHERE origin=1 AND trashed=0
|
||||
''');
|
||||
if (!mounted) return;
|
||||
await _onRefresh();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Remoti nascosti dalla Collection: $changed')),
|
||||
);
|
||||
} on DatabaseException catch (e) {
|
||||
final msg = e.toString();
|
||||
if (!mounted) return;
|
||||
// Probabile connessione R/O: istruisci a riaprire il DB in R/W
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text(
|
||||
'UPDATE fallito (DB in sola lettura?): $msg\n'
|
||||
'Apri il DB in R/W in HomePage._openRemoteTestPage (no readOnly).',
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Errore UPDATE: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ready = (_baseUrl.isNotEmpty && _future != null);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('[DEBUG] Remote Test'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
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',
|
||||
onPressed: _nascondiRemotiInCollection,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: !ready
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
children: [
|
||||
// Header contatori + filtro
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: -6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Chip(label: Text('Tot: $_countAll')),
|
||||
Chip(label: Text('Visibili: $_countVisible')),
|
||||
Chip(label: Text('Cestinati: $_countTrashed')),
|
||||
],
|
||||
),
|
||||
),
|
||||
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')),
|
||||
],
|
||||
selected: {_filter},
|
||||
onSelectionChanged: (sel) async {
|
||||
setState(() => _filter = sel.first);
|
||||
await _onRefresh();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
child: FutureBuilder<List<_RemoteRow>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * .6,
|
||||
child: Center(child: Text('Errore: ${snap.error}')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final items = snap.data ?? const <_RemoteRow>[];
|
||||
if (items.isEmpty) {
|
||||
return SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * .6,
|
||||
child: const Center(child: Text('Nessuna entry remota (origin=1)')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3, mainAxisSpacing: 4, crossAxisSpacing: 4,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, i) {
|
||||
final it = items[i];
|
||||
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<void>(
|
||||
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;
|
||||
|
||||
try {
|
||||
if (isVideo) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Video remoto: anteprima full non disponibile (thumb richiesta).'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!hasFull) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('URL non valido')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (_, __, ___) => _RemoteFullPage(
|
||||
title: it.title,
|
||||
url: fullUrl,
|
||||
headers: _authHeaders,
|
||||
heroTag: heroTag, // pairing Hero
|
||||
),
|
||||
transitionDuration: const Duration(milliseconds: 220),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_navigating = false;
|
||||
}
|
||||
},
|
||||
child: Hero(
|
||||
tag: heroTag, // pairing Hero
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(border: Border.all(color: Colors.black12)),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildGridTile(isVideo, thumbUrl, fullUrl),
|
||||
// Informazioni utili per capire cosa stiamo vedendo
|
||||
Positioned(
|
||||
left: 2,
|
||||
bottom: 2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
color: Colors.black54,
|
||||
child: Text(
|
||||
'id:${it.id} rid:${it.remoteId}${it.trashed==1 ? " (T)" : ""}',
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridTile(bool isVideo, String thumbUrl, String fullUrl) {
|
||||
if (isVideo) {
|
||||
// Per i video: NON usiamo Image.network(fullUrl).
|
||||
// Usiamo la thumb se c'è, altrimenti placeholder con icona "play".
|
||||
final base = thumbUrl.isEmpty
|
||||
? const ColoredBox(color: Colors.black12)
|
||||
: Image.network(
|
||||
thumbUrl,
|
||||
fit: BoxFit.cover,
|
||||
headers: _authHeaders,
|
||||
errorBuilder: (_, __, ___) => const Center(child: Icon(Icons.broken_image)),
|
||||
);
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
base,
|
||||
const Align(
|
||||
alignment: Alignment.center,
|
||||
child: Icon(Icons.play_circle_fill, color: Colors.white70, size: 48),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Per le immagini: se non c'è thumb, posso usare direttamente l'URL full.
|
||||
final displayUrl = thumbUrl.isEmpty ? fullUrl : thumbUrl;
|
||||
|
||||
if (displayUrl.isEmpty) {
|
||||
return const ColoredBox(color: Colors.black12);
|
||||
}
|
||||
|
||||
return Image.network(
|
||||
displayUrl,
|
||||
fit: BoxFit.cover,
|
||||
headers: _authHeaders,
|
||||
errorBuilder: (_, __, ___) => const Center(child: Icon(Icons.broken_image)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoteRow {
|
||||
final int id;
|
||||
final String remoteId;
|
||||
final String title;
|
||||
final String? remotePath;
|
||||
final String? remoteThumb2;
|
||||
final String? mime;
|
||||
final int trashed;
|
||||
|
||||
_RemoteRow({
|
||||
required this.id,
|
||||
required this.remoteId,
|
||||
required this.title,
|
||||
this.remotePath,
|
||||
this.remoteThumb2,
|
||||
this.mime,
|
||||
required this.trashed,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
final Map<String, String>? headers;
|
||||
final String heroTag; // pairing Hero
|
||||
|
||||
const _RemoteFullPage({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.heroTag,
|
||||
this.headers,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = url.isEmpty
|
||||
? const Text('URL non valido')
|
||||
: Hero(
|
||||
tag: heroTag, // pairing con la griglia
|
||||
child: InteractiveViewer(
|
||||
maxScale: 5,
|
||||
child: Image.network(
|
||||
url,
|
||||
fit: BoxFit.contain,
|
||||
headers: headers, // Authorization se il server lo richiede
|
||||
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image, size: 64),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title.isEmpty ? 'Remote' : title)),
|
||||
body: Center(child: body),
|
||||
);
|
||||
}
|
||||
}
|
||||
194
lib/remote/new/run_remote_sync.dart
Normal file
194
lib/remote/new/run_remote_sync.dart
Normal file
|
|
@ -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<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) {
|
||||
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<void> 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<void> 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;
|
||||
}
|
||||
}
|
||||
7
lib/remote/new/url_utils.dart
Normal file
7
lib/remote/new/url_utils.dart
Normal file
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -77,10 +77,10 @@ class RemoteJsonClient {
|
|||
}
|
||||
}
|
||||
// ignore: avoid_print
|
||||
print('[remote-client] SERVER paths -> withOriginal=$withOriginal | withoutOriginal=$withoutOriginal | '
|
||||
'leadingSlash=$leadingSlash | noLeadingSlash=$noLeadingSlash');
|
||||
print('[remote-client] SERVER paths: withOriginal=$withOriginal '
|
||||
'withoutOriginal=$withoutOriginal leadingSlash=$leadingSlash noLeadingSlash=$noLeadingSlash');
|
||||
|
||||
// Costruiamo List<RemotePhotoItem>
|
||||
// Costruiamo a mano la List<RemotePhotoItem>, tipizzata esplicitamente
|
||||
final List<RemotePhotoItem> items = rawList.map<RemotePhotoItem>((e) {
|
||||
if (e is! Map<String, dynamic>) {
|
||||
throw Exception('Elemento JSON non è una mappa: ${e.runtimeType} -> $e');
|
||||
|
|
|
|||
|
|
@ -91,9 +91,10 @@ class RemoteRepository {
|
|||
}
|
||||
|
||||
// =========================
|
||||
// Normalizzazione SOLO per diagnostica (non cambia cosa salvi)
|
||||
// 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'/+'), '/');
|
||||
|
|
@ -101,14 +102,14 @@ class RemoteRepository {
|
|||
return s;
|
||||
}
|
||||
|
||||
/// Candidato "canonico" (inserisce '/original/' dopo '/photos/<User>/'
|
||||
/// se manca). Usato solo per LOG/HINT, NON per scrivere.
|
||||
String _canonCandidate(String? rawPath, String fileName) {
|
||||
/// Inserisce '/original/' dopo '/photos/<User>/' se manca, e garantisce filename coerente.
|
||||
String _canonFullPath(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');
|
||||
}
|
||||
// forza il filename finale (se fornito)
|
||||
if (fileName.isNotEmpty) {
|
||||
seg[seg.length - 1] = fileName;
|
||||
}
|
||||
|
|
@ -131,12 +132,14 @@ class RemoteRepository {
|
|||
}
|
||||
|
||||
Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||
// ⚠️ NON correggo: salvo esattamente quello che arriva (come ora)
|
||||
final canonical = _canonFullPath(it.path, it.name);
|
||||
final thumb = _normPath(it.thub2);
|
||||
|
||||
return <String, Object?>{
|
||||
'id': existingId,
|
||||
'contentId': null,
|
||||
'uri': null,
|
||||
'path': it.path,
|
||||
'path': canonical, // path interno
|
||||
'sourceMimeType': it.mimeType,
|
||||
'width': it.width,
|
||||
'height': it.height,
|
||||
|
|
@ -147,7 +150,7 @@ class RemoteRepository {
|
|||
'dateModifiedMillis': null,
|
||||
'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch,
|
||||
'durationMillis': it.durationMillis,
|
||||
// REMOTI VISIBILI (come nel tuo file attuale)
|
||||
// 👇 REMOTI visibili nella Collection (se la tua Collection include origin=1)
|
||||
'trashed': 0,
|
||||
'origin': 1,
|
||||
'provider': 'json@patachina',
|
||||
|
|
@ -155,11 +158,11 @@ class RemoteRepository {
|
|||
'latitude': it.lat,
|
||||
'longitude': it.lng,
|
||||
'altitude': it.alt,
|
||||
// campi remoti (⚠️ path “raw”, senza forzare /original/)
|
||||
// campi remoti
|
||||
'remoteId': it.id,
|
||||
'remotePath': it.path,
|
||||
'remotePath': canonical, // <-- sempre canonico con /original/
|
||||
'remoteThumb1': it.thub1,
|
||||
'remoteThumb2': it.thub2,
|
||||
'remoteThumb2': thumb,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -175,15 +178,27 @@ class RemoteRepository {
|
|||
}
|
||||
|
||||
// =========================
|
||||
// Upsert a chunk (DIAGNOSTICA inclusa)
|
||||
// Upsert a chunk
|
||||
// =========================
|
||||
|
||||
/// Inserisce o aggiorna tutti gli elementi remoti.
|
||||
///
|
||||
/// - Assicura colonne `entry` (GPS + remote*)
|
||||
/// - Canonicalizza i path (`/photos/<User>/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<void> upsertAll(List<RemotePhotoItem> 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 = <RemotePhotoItem>[];
|
||||
final videos = <RemotePhotoItem>[];
|
||||
|
|
@ -201,19 +216,10 @@ class RemoteRepository {
|
|||
final batch = txn.batch();
|
||||
|
||||
for (final it in chunk) {
|
||||
// === 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)
|
||||
// Lookup record esistente per stabilire l'ID (REPLACE mantiene la PK)
|
||||
int? existingId;
|
||||
|
||||
// 1) prova per remoteId
|
||||
try {
|
||||
final existing = await txn.query(
|
||||
'entry',
|
||||
|
|
@ -222,59 +228,52 @@ class RemoteRepository {
|
|||
whereArgs: [it.id],
|
||||
limit: 1,
|
||||
);
|
||||
existingId = existing.isNotEmpty ? (existing.first['id'] as int?) : null;
|
||||
} catch (e, st) {
|
||||
debugPrint('[RemoteRepository] lookup existingId failed for remoteId=${it.id}: $e\n$st');
|
||||
}
|
||||
|
||||
// === DIAGNOSTICA HINT: esisterebbe una riga “compatibile” per path? ===
|
||||
// 1) path canonico (con /original/)
|
||||
try {
|
||||
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: [cand],
|
||||
whereArgs: [canonical],
|
||||
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(
|
||||
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: [norm],
|
||||
whereArgs: [rawNorm],
|
||||
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)'
|
||||
);
|
||||
if (byRaw.isNotEmpty) {
|
||||
existingId = byRaw.first['id'] as int?;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('[RemoteRepository] lookup existingId failed for remoteId=${it.id}: $e\n$st');
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Riga completa (⚠️ salviamo il RAW come stai facendo ora)
|
||||
// Riga completa (con path canonico)
|
||||
final row = _buildEntryRow(it, existingId: existingId);
|
||||
|
||||
// Insert/replace
|
||||
// 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<String, Object?>.from(row)
|
||||
|
|
@ -292,16 +291,18 @@ class RemoteRepository {
|
|||
|
||||
await batch.commit(noResult: true);
|
||||
|
||||
// Secondo pass per address (immutato)
|
||||
// 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 = ?',
|
||||
whereArgs: [it.id],
|
||||
where: 'origin=1 AND (remoteId = ? OR remotePath = ?)',
|
||||
whereArgs: [it.id, canonical],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) continue;
|
||||
|
|
@ -326,9 +327,10 @@ class RemoteRepository {
|
|||
}
|
||||
|
||||
// =========================
|
||||
// Unicità & deduplica (immutato)
|
||||
// Unicità & deduplica
|
||||
// =========================
|
||||
|
||||
/// Crea un indice UNICO su `remoteId` limitato alle righe remote (origin=1).
|
||||
Future<void> ensureUniqueRemoteId() async {
|
||||
try {
|
||||
await db.execute(
|
||||
|
|
@ -341,6 +343,20 @@ class RemoteRepository {
|
|||
}
|
||||
}
|
||||
|
||||
/// Crea un indice UNICO su `remotePath` (solo remoti) per prevenire doppi.
|
||||
Future<void> 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<int> deduplicateRemotes() async {
|
||||
try {
|
||||
final deleted = await db.rawDelete(
|
||||
|
|
@ -359,9 +375,31 @@ class RemoteRepository {
|
|||
}
|
||||
}
|
||||
|
||||
/// Rimuove duplicati per `remotePath` (exact match), tenendo l'ultima riga.
|
||||
Future<int> 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<void> sanitizeRemotes() async {
|
||||
await deduplicateRemotes();
|
||||
await deduplicateByRemotePath();
|
||||
await ensureUniqueRemoteId();
|
||||
await ensureUniqueRemotePath();
|
||||
}
|
||||
|
||||
// =========================
|
||||
|
|
|
|||
Loading…
Reference in a new issue