- Introduced a new Flutter project for the cxyz Android manga/comic reader, including essential files such as pubspec.yaml, AndroidManifest.xml, and Gradle configuration. - Added .gitignore files to exclude unnecessary files and directories for both the main project and Android-specific files. - Implemented initial README.md with project description, features, setup instructions, and API details for ComicLand, MangaRead, and MangaTown. - Configured analysis options and dependencies in pubspec.yaml to support Flutter development. - Established a pnpm workspace and lockfile for dependency management. - Included various Android resources such as icons, styles, and launch backgrounds for a complete application setup.
475 lines
15 KiB
Dart
475 lines
15 KiB
Dart
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<String> 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<List<int>>(
|
|
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<void> 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<void> _syncOnce(WebDavSettings settings, {int attempt = 0}) async {
|
|
final dio = _dio(settings);
|
|
final url = _fileUrl(settings);
|
|
|
|
String? etag;
|
|
Map<String, dynamic> remote = {
|
|
'updatedAt': null,
|
|
'titles': <dynamic>[],
|
|
'chapters': <dynamic>[],
|
|
};
|
|
|
|
final get = await dio.get<List<int>>(
|
|
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<String, dynamic>;
|
|
}
|
|
}
|
|
|
|
final localBundle = await _exportLocal();
|
|
final merged = _merge(remote, localBundle);
|
|
await _applyRemote(merged);
|
|
|
|
final body = utf8.encode(const JsonEncoder.withIndent(' ').convert(merged));
|
|
final headers = <String, dynamic>{
|
|
'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<List<int>>(
|
|
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<Map<String, dynamic>> _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 = <Map<String, dynamic>>[];
|
|
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 = <Map<String, dynamic>>[];
|
|
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<String, dynamic> _merge(
|
|
Map<String, dynamic> remote,
|
|
Map<String, dynamic> local,
|
|
) {
|
|
final remoteTitles = _asMapList(remote['titles']);
|
|
final localTitles = _asMapList(local['titles']);
|
|
final titles = <String, Map<String, dynamic>>{};
|
|
for (final t in [...remoteTitles, ...localTitles]) {
|
|
final key = '${t['source']}|${t['url']}';
|
|
final existing = titles[key];
|
|
if (existing == null) {
|
|
titles[key] = Map<String, dynamic>.from(t);
|
|
continue;
|
|
}
|
|
titles[key] = _mergeTitle(existing, t);
|
|
}
|
|
|
|
final remoteChapters = _asMapList(remote['chapters']);
|
|
final localChapters = _asMapList(local['chapters']);
|
|
final chapters = <String, Map<String, dynamic>>{};
|
|
for (final c in [...remoteChapters, ...localChapters]) {
|
|
final key = '${c['source']}|${c['seriesUrl']}|${c['chapterKey']}';
|
|
final existing = chapters[key];
|
|
if (existing == null) {
|
|
chapters[key] = Map<String, dynamic>.from(c);
|
|
continue;
|
|
}
|
|
chapters[key] = _newerBy(existing, c, 'lastReadAt');
|
|
}
|
|
|
|
return {
|
|
'updatedAt': DateTime.now().toUtc().toIso8601String(),
|
|
'titles': titles.values.toList(),
|
|
'chapters': chapters.values.toList(),
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> _mergeTitle(
|
|
Map<String, dynamic> a,
|
|
Map<String, dynamic> b,
|
|
) {
|
|
final out = Map<String, dynamic>.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<String, dynamic> _newerBy(
|
|
Map<String, dynamic> a,
|
|
Map<String, dynamic> 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<Map<String, dynamic>> _asMapList(dynamic v) {
|
|
if (v is! List) return const [];
|
|
return v
|
|
.whereType<Map>()
|
|
.map((e) => Map<String, dynamic>.from(e))
|
|
.toList();
|
|
}
|
|
|
|
Future<void> _applyRemote(Map<String, dynamic> 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|