import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:drift/drift.dart'; import '../data/database.dart'; import 'webdav_settings.dart'; class WebDavSyncService { WebDavSyncService(this._db); final AppDatabase _db; bool _busy = false; Timer? _debounce; void scheduleSync() { _debounce?.cancel(); _debounce = Timer(const Duration(seconds: 10), () { unawaited(sync()); }); } Future testConnection() async { final settings = await WebDavSettings.load(); if (!settings.isConfigured) { throw StateError('Enter WebDAV URL and username first'); } final dio = _dio(settings); final url = _fileUrl(settings); try { final res = await dio.request( url, options: Options( method: 'PROPFIND', headers: {'Depth': '0'}, validateStatus: (s) => s != null && s < 500, ), ); // Some hosts reject PROPFIND; try GET as fallback. if (res.statusCode == 404 || res.statusCode == 405 || res.statusCode == 501) { final get = await dio.get>( url, options: Options( responseType: ResponseType.bytes, validateStatus: (s) => s != null && (s == 200 || s == 404), ), ); if (get.statusCode == 200 || get.statusCode == 404) { return 'Connection OK'; } throw StateError('HTTP ${get.statusCode}'); } if (res.statusCode != null && res.statusCode! >= 200 && res.statusCode! < 300) { return 'Connection OK'; } if (res.statusCode == 401 || res.statusCode == 403) { throw StateError('Auth failed (HTTP ${res.statusCode})'); } // Parent exists even if file missing if (res.statusCode == 404) return 'Connection OK (file not created yet)'; throw StateError('HTTP ${res.statusCode}'); } on DioException catch (e) { throw StateError(e.message ?? e.toString()); } } Future sync() async { if (_busy) return; final settings = await WebDavSettings.load(); if (!settings.enabled || !settings.isConfigured) return; _busy = true; try { await _syncOnce(settings); await WebDavSettings.setSyncResult( status: 'Synced', error: null, syncedAt: DateTime.now(), ); } catch (e) { await WebDavSettings.setSyncResult( status: 'Failed', error: e.toString(), ); rethrow; } finally { _busy = false; } } Future _syncOnce(WebDavSettings settings, {int attempt = 0}) async { final dio = _dio(settings); final url = _fileUrl(settings); String? etag; Map remote = { 'updatedAt': null, 'titles': [], 'chapters': [], }; final get = await dio.get>( url, options: Options( responseType: ResponseType.bytes, validateStatus: (s) => s != null && (s == 200 || s == 404), ), ); if (get.statusCode == 200 && get.data != null) { etag = get.headers.value('etag'); final text = utf8.decode(get.data!); if (text.trim().isNotEmpty) { remote = jsonDecode(text) as Map; } } final localBundle = await _exportLocal(); final merged = _merge(remote, localBundle); await _applyRemote(merged); final body = utf8.encode(const JsonEncoder.withIndent(' ').convert(merged)); final headers = { 'Content-Type': 'application/json', }; if (etag != null && etag.isNotEmpty) { headers['If-Match'] = etag; } else if (get.statusCode == 404) { // Create only if absent headers['If-None-Match'] = '*'; } try { final put = await dio.put>( url, data: body, options: Options( headers: headers, validateStatus: (s) => s != null && ((s >= 200 && s < 300) || s == 412), ), ); if (put.statusCode == 412) { if (attempt >= 2) { throw StateError('Sync conflict: too many retries'); } await _syncOnce(settings, attempt: attempt + 1); } } on DioException catch (e) { // Weak hosts may ignore precondition; retry once without If-Match. if (attempt == 0 && etag != null) { await dio.put( url, data: body, options: Options(headers: {'Content-Type': 'application/json'}), ); } else { throw StateError(e.message ?? e.toString()); } } } Dio _dio(WebDavSettings settings) { return Dio( BaseOptions( connectTimeout: const Duration(seconds: 20), receiveTimeout: const Duration(seconds: 30), headers: { 'Authorization': 'Basic ${base64Encode(utf8.encode('${settings.username}:${settings.password}'))}', }, ), ); } String _fileUrl(WebDavSettings settings) { final base = settings.url.trim().replaceAll(RegExp(r'/+$'), ''); var path = settings.path.trim().isEmpty ? WebDavSettings.defaultPath : settings.path.trim(); if (!path.startsWith('/')) path = '/$path'; return '$base$path'; } Future> _exportLocal() async { final titles = await _db.select(_db.titles).get(); final chapters = await _db.select(_db.chapters).get(); final titleById = {for (final t in titles) t.id: t}; final titleMaps = >[]; for (final t in titles) { String? chapterKey; if (t.lastReadChapterId != null) { final ch = await _db.getChapter(t.lastReadChapterId!); chapterKey = ch?.chapterKey; } titleMaps.add({ 'source': t.source, 'url': t.url, 'slug': t.slug, 'title': t.title, 'coverUrl': t.coverUrl, 'inLibrary': t.inLibrary, 'readerMode': t.readerMode, 'readingDirection': t.readingDirection, 'lastReadChapterKey': chapterKey, 'lastReadAt': t.lastReadAt?.toUtc().toIso8601String(), 'libraryUpdatedAt': t.libraryUpdatedAt?.toUtc().toIso8601String(), }); } final chapterMaps = >[]; for (final c in chapters) { final t = titleById[c.titleId]; if (t == null) continue; if (c.lastReadAt == null && c.lastPage == 0 && !c.completed) continue; chapterMaps.add({ 'source': t.source, 'seriesUrl': t.url, 'chapterKey': c.chapterKey, 'label': c.label, 'url': c.url, 'chapterNumber': c.chapterNumber, 'lastPage': c.lastPage, 'pageCount': c.pageCount, 'completed': c.completed, 'lastReadAt': c.lastReadAt?.toUtc().toIso8601String(), }); } return { 'updatedAt': DateTime.now().toUtc().toIso8601String(), 'titles': titleMaps, 'chapters': chapterMaps, }; } Map _merge( Map remote, Map local, ) { final remoteTitles = _asMapList(remote['titles']); final localTitles = _asMapList(local['titles']); final titles = >{}; for (final t in [...remoteTitles, ...localTitles]) { final key = '${t['source']}|${t['url']}'; final existing = titles[key]; if (existing == null) { titles[key] = Map.from(t); continue; } titles[key] = _mergeTitle(existing, t); } final remoteChapters = _asMapList(remote['chapters']); final localChapters = _asMapList(local['chapters']); final chapters = >{}; for (final c in [...remoteChapters, ...localChapters]) { final key = '${c['source']}|${c['seriesUrl']}|${c['chapterKey']}'; final existing = chapters[key]; if (existing == null) { chapters[key] = Map.from(c); continue; } chapters[key] = _newerBy(existing, c, 'lastReadAt'); } return { 'updatedAt': DateTime.now().toUtc().toIso8601String(), 'titles': titles.values.toList(), 'chapters': chapters.values.toList(), }; } Map _mergeTitle( Map a, Map b, ) { final out = Map.from(a); // Metadata: prefer non-empty for (final field in ['title', 'coverUrl', 'slug']) { final bv = b[field]; if ((out[field] == null || (out[field] as String?)?.isEmpty == true) && bv is String && bv.isNotEmpty) { out[field] = bv; } } // History / continue by lastReadAt final newerRead = _newerBy(a, b, 'lastReadAt'); out['lastReadAt'] = newerRead['lastReadAt']; out['lastReadChapterKey'] = newerRead['lastReadChapterKey']; out['readerMode'] = newerRead['readerMode'] ?? out['readerMode']; out['readingDirection'] = newerRead['readingDirection'] ?? out['readingDirection']; // Library star by libraryUpdatedAt final newerLib = _newerBy(a, b, 'libraryUpdatedAt'); out['inLibrary'] = newerLib['inLibrary'] ?? false; out['libraryUpdatedAt'] = newerLib['libraryUpdatedAt']; return out; } Map _newerBy( Map a, Map b, String field, ) { final at = _parseTime(a[field]); final bt = _parseTime(b[field]); if (bt == null) return a; if (at == null) return b; return bt.isAfter(at) ? b : a; } DateTime? _parseTime(dynamic v) { if (v is! String || v.isEmpty) return null; return DateTime.tryParse(v)?.toUtc(); } List> _asMapList(dynamic v) { if (v is! List) return const []; return v .whereType() .map((e) => Map.from(e)) .toList(); } Future _applyRemote(Map merged) async { final titleMaps = _asMapList(merged['titles']); final chapterMaps = _asMapList(merged['chapters']); for (final t in titleMaps) { final source = t['source'] as String? ?? ''; final url = t['url'] as String? ?? ''; if (source.isEmpty || url.isEmpty) continue; final existing = await _db.findTitleBySourceUrl(source, url); final lastReadAt = _parseTime(t['lastReadAt']); final libraryUpdatedAt = _parseTime(t['libraryUpdatedAt']); final inLibrary = t['inLibrary'] == true; late final int titleId; if (existing == null) { titleId = await _db.into(_db.titles).insert( TitlesCompanion.insert( source: source, url: url, slug: (t['slug'] as String?) ?? '', title: (t['title'] as String?) ?? url, coverUrl: Value(t['coverUrl'] as String?), readerMode: Value( (t['readerMode'] as String?) ?? '', ), readingDirection: Value(t['readingDirection'] as String?), lastReadAt: Value(lastReadAt), inLibrary: Value(inLibrary), libraryUpdatedAt: Value(libraryUpdatedAt), ), ); } else { titleId = existing.id; await (_db.update(_db.titles)..where((x) => x.id.equals(titleId))) .write( TitlesCompanion( title: Value( (t['title'] as String?)?.isNotEmpty == true ? t['title'] as String : existing.title, ), coverUrl: Value( (t['coverUrl'] as String?) ?? existing.coverUrl, ), slug: Value( (t['slug'] as String?)?.isNotEmpty == true ? t['slug'] as String : existing.slug, ), readerMode: Value( (t['readerMode'] as String?) ?? existing.readerMode, ), readingDirection: Value( (t['readingDirection'] as String?) ?? existing.readingDirection, ), lastReadAt: Value(lastReadAt ?? existing.lastReadAt), inLibrary: Value(inLibrary), libraryUpdatedAt: Value(libraryUpdatedAt ?? existing.libraryUpdatedAt), ), ); } final chapterKey = t['lastReadChapterKey'] as String?; if (chapterKey != null && chapterKey.isNotEmpty) { final rows = await (_db.select(_db.chapters) ..where( (c) => c.titleId.equals(titleId) & c.chapterKey.equals(chapterKey), )) .get(); if (rows.isNotEmpty) { await (_db.update(_db.titles)..where((x) => x.id.equals(titleId))) .write( TitlesCompanion(lastReadChapterId: Value(rows.first.id)), ); } } } for (final c in chapterMaps) { final source = c['source'] as String? ?? ''; final seriesUrl = c['seriesUrl'] as String? ?? ''; final chapterKey = c['chapterKey'] as String? ?? ''; if (source.isEmpty || seriesUrl.isEmpty || chapterKey.isEmpty) continue; var title = await _db.findTitleBySourceUrl(source, seriesUrl); if (title == null) { final titleId = await _db.into(_db.titles).insert( TitlesCompanion.insert( source: source, url: seriesUrl, slug: '', title: seriesUrl, ), ); title = await _db.getTitle(titleId); } if (title == null) continue; final rows = await (_db.select(_db.chapters) ..where( (ch) => ch.titleId.equals(title!.id) & ch.chapterKey.equals(chapterKey), )) .get(); final lastReadAt = _parseTime(c['lastReadAt']); final lastPage = (c['lastPage'] as num?)?.toInt() ?? 0; final pageCount = (c['pageCount'] as num?)?.toInt() ?? 0; final completed = c['completed'] == true; if (rows.isEmpty) { await _db.into(_db.chapters).insert( ChaptersCompanion.insert( titleId: title.id, chapterKey: chapterKey, label: (c['label'] as String?) ?? chapterKey, url: (c['url'] as String?) ?? seriesUrl, chapterNumber: Value(c['chapterNumber'] as String?), lastPage: Value(lastPage), pageCount: Value(pageCount), completed: Value(completed), lastReadAt: Value(lastReadAt), ), ); } else { final local = rows.first; final remoteTime = lastReadAt; final localTime = local.lastReadAt; final takeRemote = remoteTime != null && (localTime == null || remoteTime.isAfter(localTime)); if (takeRemote) { await (_db.update(_db.chapters)..where((x) => x.id.equals(local.id))) .write( ChaptersCompanion( lastPage: Value(lastPage), pageCount: Value(pageCount > 0 ? pageCount : local.pageCount), completed: Value(completed), lastReadAt: Value(lastReadAt), label: Value( (c['label'] as String?)?.isNotEmpty == true ? c['label'] as String : local.label, ), ), ); } } } } }