generated from dellevin/template
465 lines
16 KiB
Dart
465 lines
16 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'backup_service.dart';
|
|
|
|
/// WebDAV 同步结果
|
|
class SyncResult {
|
|
final bool success;
|
|
final String message;
|
|
final DateTime? lastSyncTime;
|
|
final int uploadedFiles;
|
|
final int downloadedFiles;
|
|
final int uploadedImages;
|
|
final int downloadedImages;
|
|
final bool needReload;
|
|
|
|
SyncResult({
|
|
required this.success,
|
|
required this.message,
|
|
this.lastSyncTime,
|
|
this.uploadedFiles = 0,
|
|
this.downloadedFiles = 0,
|
|
this.uploadedImages = 0,
|
|
this.downloadedImages = 0,
|
|
this.needReload = false,
|
|
});
|
|
}
|
|
|
|
/// 同步方向
|
|
enum SyncDirection {
|
|
upload, // 仅上传
|
|
download, // 仅下载
|
|
bidirectional, // 双向同步
|
|
}
|
|
|
|
/// WebDAV 服务类 - 完整备份 zip 同步
|
|
class WebDAVService {
|
|
static final WebDAVService _instance = WebDAVService._internal();
|
|
static WebDAVService get instance => _instance;
|
|
|
|
WebDAVService._internal();
|
|
|
|
static const String _configKey = 'webdav_config';
|
|
static const String _lastSyncKey = 'webdav_last_sync';
|
|
static const String _autoSyncKey = 'webdav_auto_sync';
|
|
static const String _autoSyncIntervalKey = 'webdav_auto_sync_interval';
|
|
|
|
// 默认自动同步间隔(分钟)
|
|
static const int _defaultAutoSyncInterval = 5;
|
|
|
|
Map<String, String>? _cachedConfig;
|
|
Timer? _autoSyncTimer;
|
|
bool _isAutoSyncEnabled = false;
|
|
int _autoSyncIntervalMinutes = _defaultAutoSyncInterval;
|
|
|
|
/// 获取配置
|
|
Future<Map<String, String>?> getConfig() async {
|
|
if (_cachedConfig != null) {
|
|
return _cachedConfig;
|
|
}
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final configJson = prefs.getString(_configKey);
|
|
if (configJson != null) {
|
|
try {
|
|
final config = Map<String, String>.from(jsonDecode(configJson));
|
|
_cachedConfig = config;
|
|
return config;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// 保存配置
|
|
Future<void> saveConfig({
|
|
required String url,
|
|
required String username,
|
|
required String password,
|
|
required String path,
|
|
}) async {
|
|
final config = {
|
|
'url': url,
|
|
'username': username,
|
|
'password': password,
|
|
'path': path,
|
|
};
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_configKey, jsonEncode(config));
|
|
_cachedConfig = config;
|
|
}
|
|
|
|
/// 清除配置
|
|
Future<void> clearConfig() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove(_configKey);
|
|
await prefs.remove(_lastSyncKey);
|
|
await prefs.remove(_autoSyncKey);
|
|
await prefs.remove(_autoSyncIntervalKey);
|
|
_cachedConfig = null;
|
|
stopAutoSync();
|
|
}
|
|
|
|
/// 测试连接
|
|
Future<Map<String, dynamic>> testConnection({
|
|
required String url,
|
|
required String username,
|
|
required String password,
|
|
required String path,
|
|
}) async {
|
|
try {
|
|
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
|
var davUrl = '$baseUrl$path';
|
|
|
|
final client = http.Client();
|
|
try {
|
|
var propfindRequest = http.Request('PROPFIND', Uri.parse(davUrl));
|
|
propfindRequest.headers['Authorization'] = _basicAuth(username, password);
|
|
propfindRequest.headers['Depth'] = '0';
|
|
propfindRequest.body = '''<?xml version="1.0" encoding="utf-8"?>
|
|
<D:propfind xmlns:D="DAV:">
|
|
<D:prop>
|
|
<D:resourcetype/>
|
|
</D:prop>
|
|
</D:propfind>''';
|
|
|
|
var propfindResponse = await client.send(propfindRequest);
|
|
|
|
if (propfindResponse.statusCode == 301 ||
|
|
propfindResponse.statusCode == 302 ||
|
|
propfindResponse.statusCode == 307 ||
|
|
propfindResponse.statusCode == 308) {
|
|
final location = propfindResponse.headers['location'];
|
|
if (location != null) {
|
|
davUrl = location;
|
|
propfindRequest = http.Request('PROPFIND', Uri.parse(davUrl));
|
|
propfindRequest.headers['Authorization'] = _basicAuth(username, password);
|
|
propfindRequest.headers['Depth'] = '0';
|
|
propfindRequest.body = '''<?xml version="1.0" encoding="utf-8"?>
|
|
<D:propfind xmlns:D="DAV:">
|
|
<D:prop>
|
|
<D:resourcetype/>
|
|
</D:prop>
|
|
</D:propfind>''';
|
|
propfindResponse = await client.send(propfindRequest);
|
|
}
|
|
}
|
|
|
|
if (propfindResponse.statusCode == 207) {
|
|
return {'success': true, 'message': '连接成功'};
|
|
} else if (propfindResponse.statusCode == 401) {
|
|
return {'success': false, 'message': '认证失败,请检查用户名和密码'};
|
|
} else if (propfindResponse.statusCode == 404) {
|
|
// 目录不存在,尝试创建
|
|
} else {
|
|
return {'success': false, 'message': '服务器返回错误: ${propfindResponse.statusCode}'};
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
|
|
try {
|
|
final mkcolRequest = http.Request('MKCOL', Uri.parse(davUrl));
|
|
mkcolRequest.headers['Authorization'] = _basicAuth(username, password);
|
|
|
|
final mkcolResponse = await client.send(mkcolRequest);
|
|
|
|
if (mkcolResponse.statusCode == 201) {
|
|
return {'success': true, 'message': '连接成功,已创建目录'};
|
|
} else if (mkcolResponse.statusCode == 405) {
|
|
return {'success': true, 'message': '连接成功,目录已存在'};
|
|
} else if (mkcolResponse.statusCode == 401) {
|
|
return {'success': false, 'message': '认证失败,请检查用户名和密码'};
|
|
} else if (mkcolResponse.statusCode == 409) {
|
|
return {'success': false, 'message': '父目录不存在,请检查路径'};
|
|
} else {
|
|
return {'success': false, 'message': '创建目录失败: ${mkcolResponse.statusCode}'};
|
|
}
|
|
} catch (e) {
|
|
return {'success': false, 'message': '连接失败: $e'};
|
|
} finally {
|
|
client.close();
|
|
}
|
|
} catch (e) {
|
|
return {'success': false, 'message': '连接失败: $e'};
|
|
}
|
|
}
|
|
|
|
/// 同步数据 — 完整备份 zip 格式,与本地备份完全一致
|
|
Future<SyncResult> syncData({SyncDirection direction = SyncDirection.bidirectional}) async {
|
|
final config = await getConfig();
|
|
if (config == null) {
|
|
return SyncResult(success: false, message: '未配置 WebDAV');
|
|
}
|
|
|
|
try {
|
|
final url = config['url']!;
|
|
final username = config['username']!;
|
|
final password = config['password']!;
|
|
final path = config['path']!;
|
|
|
|
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
|
final zipUrl = '$baseUrl$path/mooknote_backup.zip';
|
|
|
|
final client = http.Client();
|
|
int uploadedFiles = 0;
|
|
int downloadedFiles = 0;
|
|
int uploadedImages = 0;
|
|
int downloadedImages = 0;
|
|
bool needReload = false;
|
|
|
|
try {
|
|
if (direction == SyncDirection.upload) {
|
|
final exportResult = await BackupService.instance.exportDataForAutoBackup();
|
|
if (!exportResult.success || exportResult.zipBytes == null) {
|
|
return SyncResult(success: false, message: exportResult.errorMessage ?? '创建备份失败');
|
|
}
|
|
|
|
final success = await _uploadBytes(client, zipUrl, username, password, exportResult.zipBytes!);
|
|
if (success) {
|
|
uploadedFiles = 1;
|
|
uploadedImages = exportResult.imageCount;
|
|
print('[WebDAV] 备份上传成功 (影视${exportResult.movieCount} 书籍${exportResult.bookCount} 笔记${exportResult.noteCount} 图片${exportResult.imageCount})');
|
|
} else {
|
|
return SyncResult(success: false, message: '上传备份文件失败');
|
|
}
|
|
|
|
} else if (direction == SyncDirection.download) {
|
|
final tempDir = await getTemporaryDirectory();
|
|
final tempZip = File(p.join(tempDir.path, 'mooknote_download.zip'));
|
|
final success = await _downloadFile(client, zipUrl, username, password, tempZip);
|
|
|
|
if (success && await tempZip.exists()) {
|
|
final bytes = await tempZip.readAsBytes();
|
|
final importResult = await BackupService.instance.restoreFromZipBytes(bytes);
|
|
await tempZip.delete();
|
|
|
|
if (importResult.success) {
|
|
downloadedFiles = 1;
|
|
downloadedImages = importResult.stats?['图片'] ?? 0;
|
|
needReload = true;
|
|
print('[WebDAV] 备份恢复成功: ${importResult.statsText}');
|
|
} else {
|
|
return SyncResult(success: false, message: importResult.errorMessage ?? '恢复备份失败');
|
|
}
|
|
} else {
|
|
return SyncResult(success: false, message: '服务器上没有备份文件,请先从其他设备上传');
|
|
}
|
|
|
|
} else if (direction == SyncDirection.bidirectional) {
|
|
final tempDir = await getTemporaryDirectory();
|
|
final tempZip = File(p.join(tempDir.path, 'mooknote_bidir.zip'));
|
|
|
|
final downloadSuccess = await _downloadFile(client, zipUrl, username, password, tempZip);
|
|
if (downloadSuccess && await tempZip.exists()) {
|
|
final bytes = await tempZip.readAsBytes();
|
|
final importResult = await BackupService.instance.restoreFromZipBytes(bytes);
|
|
await tempZip.delete();
|
|
|
|
if (importResult.success) {
|
|
downloadedFiles = 1;
|
|
downloadedImages = importResult.stats?['图片'] ?? 0;
|
|
needReload = true;
|
|
print('[WebDAV] 远程备份已恢复: ${importResult.statsText}');
|
|
}
|
|
} else {
|
|
try { await tempZip.delete(); } catch (_) {}
|
|
print('[WebDAV] 服务器无备份,仅上传本地数据');
|
|
}
|
|
|
|
// 始终上传本地,确保远程是最新的
|
|
final exportResult = await BackupService.instance.exportDataForAutoBackup();
|
|
if (exportResult.success && exportResult.zipBytes != null) {
|
|
final uploadSuccess = await _uploadBytes(client, zipUrl, username, password, exportResult.zipBytes!);
|
|
if (uploadSuccess) {
|
|
uploadedFiles = 1;
|
|
uploadedImages = exportResult.imageCount;
|
|
print('[WebDAV] 本地备份已上传 (影视${exportResult.movieCount} 书籍${exportResult.bookCount} 笔记${exportResult.noteCount} 图片${exportResult.imageCount})');
|
|
}
|
|
}
|
|
}
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String());
|
|
|
|
return SyncResult(
|
|
success: true,
|
|
message: '同步完成',
|
|
lastSyncTime: DateTime.now(),
|
|
uploadedFiles: uploadedFiles,
|
|
downloadedFiles: downloadedFiles,
|
|
uploadedImages: uploadedImages,
|
|
downloadedImages: downloadedImages,
|
|
needReload: needReload,
|
|
);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
} catch (e) {
|
|
return SyncResult(success: false, message: '同步失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 获取自动同步间隔(分钟)
|
|
Future<int> getAutoSyncInterval() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
return prefs.getInt(_autoSyncIntervalKey) ?? _defaultAutoSyncInterval;
|
|
}
|
|
|
|
/// 设置自动同步间隔(分钟)
|
|
Future<void> setAutoSyncInterval(int minutes) async {
|
|
if (minutes < 1) minutes = 1;
|
|
if (minutes > 60) minutes = 60;
|
|
|
|
_autoSyncIntervalMinutes = minutes;
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setInt(_autoSyncIntervalKey, minutes);
|
|
|
|
if (_isAutoSyncEnabled) {
|
|
await startAutoSync();
|
|
}
|
|
}
|
|
|
|
/// 启动自动同步
|
|
Future<void> startAutoSync() async {
|
|
await stopAutoSync();
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
_isAutoSyncEnabled = true;
|
|
_autoSyncIntervalMinutes = await getAutoSyncInterval();
|
|
await prefs.setBool(_autoSyncKey, true);
|
|
|
|
// 立即执行一次同步
|
|
print('[WebDAV] 自动同步已启动,间隔 $_autoSyncIntervalMinutes 分钟');
|
|
await syncData(direction: SyncDirection.bidirectional);
|
|
|
|
// 设置定时器
|
|
_autoSyncTimer = Timer.periodic(
|
|
Duration(minutes: _autoSyncIntervalMinutes),
|
|
(timer) async {
|
|
if (_isAutoSyncEnabled) {
|
|
await syncData(direction: SyncDirection.bidirectional);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 停止自动同步
|
|
Future<void> stopAutoSync() async {
|
|
_autoSyncTimer?.cancel();
|
|
_autoSyncTimer = null;
|
|
_isAutoSyncEnabled = false;
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setBool(_autoSyncKey, false);
|
|
}
|
|
|
|
/// 检查自动同步状态
|
|
Future<bool> isAutoSyncEnabled() async {
|
|
if (_autoSyncTimer != null) {
|
|
return _isAutoSyncEnabled;
|
|
}
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
return prefs.getBool(_autoSyncKey) ?? false;
|
|
}
|
|
|
|
/// 获取上次同步时间
|
|
Future<String?> getLastSyncTime() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
return prefs.getString(_lastSyncKey);
|
|
}
|
|
|
|
/// 上传字节数据到 WebDAV
|
|
Future<bool> _uploadBytes(
|
|
http.Client client,
|
|
String url,
|
|
String username,
|
|
String password,
|
|
Uint8List bytes,
|
|
) async {
|
|
try {
|
|
var request = http.Request('PUT', Uri.parse(url));
|
|
request.headers['Authorization'] = _basicAuth(username, password);
|
|
request.headers['Content-Type'] = 'application/zip';
|
|
request.bodyBytes = bytes;
|
|
|
|
var response = await client.send(request);
|
|
|
|
// 处理重定向
|
|
if (response.statusCode == 301 || response.statusCode == 302 ||
|
|
response.statusCode == 307 || response.statusCode == 308) {
|
|
final location = response.headers['location'];
|
|
if (location != null) {
|
|
request = http.Request('PUT', Uri.parse(location));
|
|
request.headers['Authorization'] = _basicAuth(username, password);
|
|
request.headers['Content-Type'] = 'application/zip';
|
|
request.bodyBytes = bytes;
|
|
response = await client.send(request);
|
|
}
|
|
}
|
|
|
|
print('[WebDAV] PUT $url -> ${response.statusCode}');
|
|
return response.statusCode == 200 || response.statusCode == 201 || response.statusCode == 204;
|
|
} catch (e) {
|
|
print('[WebDAV] _uploadBytes error: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 下载文件到本地
|
|
Future<bool> _downloadFile(
|
|
http.Client client,
|
|
String url,
|
|
String username,
|
|
String password,
|
|
File localFile,
|
|
) async {
|
|
try {
|
|
var request = http.Request('GET', Uri.parse(url));
|
|
request.headers['Authorization'] = _basicAuth(username, password);
|
|
|
|
var response = await client.send(request);
|
|
|
|
// 处理重定向
|
|
if (response.statusCode == 301 || response.statusCode == 302 ||
|
|
response.statusCode == 307 || response.statusCode == 308) {
|
|
final location = response.headers['location'];
|
|
if (location != null) {
|
|
request = http.Request('GET', Uri.parse(location));
|
|
request.headers['Authorization'] = _basicAuth(username, password);
|
|
response = await client.send(request);
|
|
}
|
|
}
|
|
|
|
print('[WebDAV] GET $url -> ${response.statusCode}');
|
|
|
|
if (response.statusCode == 200) {
|
|
await localFile.parent.create(recursive: true);
|
|
final bytes = await response.stream.toBytes();
|
|
await localFile.writeAsBytes(bytes);
|
|
print('[WebDAV] Downloaded ${bytes.length} bytes');
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
// ignore
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Basic Auth 编码
|
|
String _basicAuth(String username, String password) {
|
|
final credentials = base64Encode(utf8.encode('$username:$password'));
|
|
return 'Basic $credentials';
|
|
}
|
|
}
|