generated from dellevin/template
优化webdav恢复
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
"allow": [
|
||||
"Bash(flutter analyze *)",
|
||||
"Bash(python _fix_script.py)",
|
||||
"Bash(dart analyze *)"
|
||||
"Bash(dart analyze *)",
|
||||
"Bash(dart run *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +264,14 @@ class _MainContentPageState extends State<MainContentPage> {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
// 下载了数据则刷新界面
|
||||
if (result.success && result.needReload && context.mounted) {
|
||||
final provider = context.read<AppProvider>();
|
||||
await provider.loadMovies();
|
||||
await provider.loadBooks();
|
||||
await provider.loadNotes();
|
||||
}
|
||||
|
||||
// 显示结果
|
||||
if (context.mounted) {
|
||||
final isSuccess = result.success;
|
||||
|
||||
@@ -233,38 +233,12 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
final notes = provider.notes;
|
||||
|
||||
final movieCount = movies.where((m) => !m.isDeleted).length;
|
||||
final watchedCount = movies.where((m) => m.status == 'watched' && !m.isDeleted).length;
|
||||
final watchingCount = movies.where((m) => m.status == 'watching' && !m.isDeleted).length;
|
||||
final wantToWatchCount = movies.where((m) => m.status == 'want_to_watch' && !m.isDeleted).length;
|
||||
|
||||
final bookCount = books.length;
|
||||
final readCount = books.where((b) => b.status == 'read').length;
|
||||
final readingCount = books.where((b) => b.status == 'reading').length;
|
||||
final wantToReadCount = books.where((b) => b.status == 'want_to_read').length;
|
||||
|
||||
final noteCount = notes.length;
|
||||
|
||||
final movieRatings = movies
|
||||
.where((m) => m.rating != null && !m.isDeleted)
|
||||
.map((m) => m.rating!);
|
||||
final avgMovieRating = movieRatings.isNotEmpty
|
||||
? movieRatings.reduce((a, b) => a + b) / movieRatings.length
|
||||
: 0.0;
|
||||
|
||||
final bookRatings = books
|
||||
.where((b) => b.rating != null)
|
||||
.map((b) => b.rating!);
|
||||
final avgBookRating = bookRatings.isNotEmpty
|
||||
? bookRatings.reduce((a, b) => a + b) / bookRatings.length
|
||||
: 0.0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 主统计卡片
|
||||
Container(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8F8F8),
|
||||
@@ -279,31 +253,6 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 详情统计
|
||||
_buildSectionTitle('观影详情'),
|
||||
const SizedBox(height: 12),
|
||||
_buildDetailGrid([
|
||||
_buildDetailItem('已看', watchedCount, Icons.check_circle_outline),
|
||||
_buildDetailItem('在看', watchingCount, Icons.play_circle_outline),
|
||||
_buildDetailItem('想看', wantToWatchCount, Icons.bookmark_border),
|
||||
_buildDetailItem('均分', avgMovieRating > 0 ? avgMovieRating.toStringAsFixed(1) : '-', Icons.star_outline),
|
||||
]),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
_buildSectionTitle('阅读详情'),
|
||||
const SizedBox(height: 12),
|
||||
_buildDetailGrid([
|
||||
_buildDetailItem('已读', readCount, Icons.check_circle_outline),
|
||||
_buildDetailItem('在读', readingCount, Icons.play_circle_outline),
|
||||
_buildDetailItem('想读', wantToReadCount, Icons.bookmark_border),
|
||||
_buildDetailItem('均分', avgBookRating > 0 ? avgBookRating.toStringAsFixed(1) : '-', Icons.star_outline),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -354,63 +303,6 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 详情网格
|
||||
Widget _buildDetailGrid(List<Widget> children) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAFAFA),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 详情项
|
||||
Widget _buildDetailItem(String label, dynamic value, IconData icon) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: const Color(0xFF999999),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'$value',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 区块标题
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 功能菜单
|
||||
Widget _buildMenuSection() {
|
||||
return Container(
|
||||
|
||||
@@ -96,6 +96,16 @@ class _StatisticsPageState extends State<StatisticsPage> {
|
||||
final readingBooks = books.where((b) => b.status == 'reading').length;
|
||||
final wantToReadBooks = books.where((b) => b.status == 'want_to_read').length;
|
||||
|
||||
final movieRatings = movies.where((m) => m.rating != null && !m.isDeleted).map((m) => m.rating!);
|
||||
final avgMovieRating = movieRatings.isNotEmpty
|
||||
? movieRatings.reduce((a, b) => a + b) / movieRatings.length
|
||||
: null;
|
||||
|
||||
final bookRatings = books.where((b) => b.rating != null).map((b) => b.rating!);
|
||||
final avgBookRating = bookRatings.isNotEmpty
|
||||
? bookRatings.reduce((a, b) => a + b) / bookRatings.length
|
||||
: null;
|
||||
|
||||
final children = <Widget>[];
|
||||
|
||||
// 数据总览
|
||||
@@ -106,25 +116,25 @@ class _StatisticsPageState extends State<StatisticsPage> {
|
||||
|
||||
// 影视状态分布
|
||||
if (_showMovies) {
|
||||
children.add(_buildSectionTitle('影视状态分布'));
|
||||
children.add(_buildSectionTitle('影视'));
|
||||
children.add(const SizedBox(height: 16));
|
||||
children.add(_buildStatusDistribution([
|
||||
_StatusData('已看', watchedMovies, const Color(0xFF1A1A1A)),
|
||||
_StatusData('在看', watchingMovies, const Color(0xFF666666)),
|
||||
_StatusData('想看', wantToWatchMovies, const Color(0xFF999999)),
|
||||
]));
|
||||
], avgRating: avgMovieRating));
|
||||
children.add(const SizedBox(height: 32));
|
||||
}
|
||||
|
||||
// 书籍状态分布
|
||||
if (_showBooks) {
|
||||
children.add(_buildSectionTitle('书籍状态分布'));
|
||||
children.add(_buildSectionTitle('书籍'));
|
||||
children.add(const SizedBox(height: 16));
|
||||
children.add(_buildStatusDistribution([
|
||||
_StatusData('已读', readBooks, const Color(0xFF1A1A1A)),
|
||||
_StatusData('在读', readingBooks, const Color(0xFF666666)),
|
||||
_StatusData('想读', wantToReadBooks, const Color(0xFF999999)),
|
||||
]));
|
||||
], avgRating: avgBookRating));
|
||||
children.add(const SizedBox(height: 32));
|
||||
}
|
||||
|
||||
@@ -295,7 +305,7 @@ class _StatisticsPageState extends State<StatisticsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusDistribution(List<_StatusData> data) {
|
||||
Widget _buildStatusDistribution(List<_StatusData> data, {double? avgRating}) {
|
||||
final total = data.fold(0, (sum, item) => sum + item.count);
|
||||
|
||||
return Container(
|
||||
@@ -305,7 +315,8 @@ class _StatisticsPageState extends State<StatisticsPage> {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: data.map((item) {
|
||||
children: [
|
||||
...data.map((item) {
|
||||
final percentage = total > 0 ? (item.count / total * 100).toStringAsFixed(1) : '0';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
@@ -324,7 +335,21 @@ class _StatisticsPageState extends State<StatisticsPage> {
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
if (avgRating != null) ...[
|
||||
const Divider(height: 1, color: Color(0xFFE8E8E8)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.star, size: 16, color: Color(0xFF999999)),
|
||||
const SizedBox(width: 10),
|
||||
const Text('平均评分', style: TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
|
||||
const Spacer(),
|
||||
Text(avgRating.toStringAsFixed(1), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -524,6 +524,144 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 ZIP 字节数据恢复(供 WebDAV 同步等场景使用)
|
||||
Future<ImportResult> restoreFromZipBytes(Uint8List zipBytes) async {
|
||||
try {
|
||||
final archive = ZipDecoder().decodeBytes(zipBytes);
|
||||
|
||||
// 查找 data.json
|
||||
final dataFile = archive.findFile('data.json');
|
||||
if (dataFile == null) {
|
||||
return ImportResult.error('备份文件中没有找到数据文件');
|
||||
}
|
||||
|
||||
final jsonString = utf8.decode(dataFile.content as List<int>);
|
||||
final backupData = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
|
||||
// 记录图片文件名到新路径的映射
|
||||
final imagePathMap = <String, String>{};
|
||||
int imageCount = 0;
|
||||
|
||||
// 解压图片到应用目录,保持目录结构
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesDir = Directory(path.join(appDir.path, 'images'));
|
||||
if (!await imagesDir.exists()) {
|
||||
await imagesDir.create(recursive: true);
|
||||
}
|
||||
|
||||
for (final archiveFile in archive) {
|
||||
if (archiveFile.name.startsWith('images/')) {
|
||||
final relativePath = archiveFile.name.substring(7); // 去掉 'images/' 前缀
|
||||
final outputFile = File(path.join(imagesDir.path, relativePath));
|
||||
final parentDir = outputFile.parent;
|
||||
if (!await parentDir.exists()) {
|
||||
await parentDir.create(recursive: true);
|
||||
}
|
||||
await outputFile.writeAsBytes(archiveFile.content as List<int>);
|
||||
final fileName = path.basename(archiveFile.name);
|
||||
imagePathMap[fileName] = outputFile.path;
|
||||
imageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证备份格式
|
||||
if (!backupData.containsKey('data')) {
|
||||
return ImportResult.error('无效的备份文件格式');
|
||||
}
|
||||
|
||||
// 导入数据
|
||||
final data = backupData['data'] as Map<String, dynamic>;
|
||||
final db = await DatabaseHelper.instance.database;
|
||||
|
||||
await db.transaction((txn) async {
|
||||
// 清空现有数据
|
||||
await txn.delete('movie_reviews');
|
||||
await txn.delete('movie_posters');
|
||||
await txn.delete('movies');
|
||||
await txn.delete('books');
|
||||
await txn.delete('notes');
|
||||
|
||||
if (data.containsKey('movies')) {
|
||||
final movies = data['movies'] as List<dynamic>;
|
||||
for (final movie in movies) {
|
||||
final movieMap = _convertToDbMap(movie);
|
||||
final updatedMap = _updateImagePath(movieMap, 'poster_path', imagePathMap);
|
||||
await txn.insert('movies', updatedMap);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.containsKey('books')) {
|
||||
final books = data['books'] as List<dynamic>;
|
||||
for (final book in books) {
|
||||
final bookMap = _convertToDbMap(book);
|
||||
final updatedMap = _updateImagePath(bookMap, 'cover_path', imagePathMap);
|
||||
await txn.insert('books', updatedMap);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.containsKey('notes')) {
|
||||
final notes = data['notes'] as List<dynamic>;
|
||||
for (final note in notes) {
|
||||
final noteMap = _convertToDbMap(note);
|
||||
final updatedMap = _updateNoteImagesPath(noteMap, imagePathMap);
|
||||
await txn.insert('notes', updatedMap);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.containsKey('movie_reviews')) {
|
||||
final reviews = data['movie_reviews'] as List<dynamic>;
|
||||
for (final review in reviews) {
|
||||
await txn.insert('movie_reviews', _convertToDbMap(review));
|
||||
}
|
||||
}
|
||||
|
||||
if (data.containsKey('movie_posters')) {
|
||||
final posters = data['movie_posters'] as List<dynamic>;
|
||||
for (final poster in posters) {
|
||||
final posterMap = _convertToDbMap(poster);
|
||||
final updatedMap = _updateImagePath(posterMap, 'poster_path', imagePathMap);
|
||||
await txn.insert('movie_posters', updatedMap);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 恢复用户个人信息
|
||||
if (backupData.containsKey('userInfo')) {
|
||||
final userInfo = backupData['userInfo'] as Map<String, dynamic>;
|
||||
final userPrefs = UserPrefs();
|
||||
|
||||
if (userInfo.containsKey('nickname')) {
|
||||
await userPrefs.setNickname(userInfo['nickname'] as String);
|
||||
}
|
||||
if (userInfo.containsKey('motto')) {
|
||||
await userPrefs.setMotto(userInfo['motto'] as String);
|
||||
}
|
||||
if (userInfo.containsKey('avatarPath')) {
|
||||
final avatarPath = userInfo['avatarPath'] as String?;
|
||||
if (avatarPath != null && avatarPath.isNotEmpty) {
|
||||
final fileName = path.basename(avatarPath);
|
||||
if (imagePathMap.containsKey(fileName)) {
|
||||
await userPrefs.setAvatarPath(imagePathMap[fileName]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 统计导入数量
|
||||
final stats = <String, int>{};
|
||||
if (data.containsKey('movies')) stats['影视'] = (data['movies'] as List).length;
|
||||
if (data.containsKey('books')) stats['书籍'] = (data['books'] as List).length;
|
||||
if (data.containsKey('notes')) stats['笔记'] = (data['notes'] as List).length;
|
||||
if (data.containsKey('movie_reviews')) stats['影评'] = (data['movie_reviews'] as List).length;
|
||||
if (data.containsKey('movie_posters')) stats['海报'] = (data['movie_posters'] as List).length;
|
||||
if (imageCount > 0) stats['图片'] = imageCount;
|
||||
|
||||
return ImportResult.success(stats);
|
||||
} catch (e) {
|
||||
return ImportResult.error('恢复失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 将动态类型转换为数据库可用的 Map
|
||||
Map<String, dynamic> _convertToDbMap(dynamic item) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
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:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:archive/archive_io.dart';
|
||||
import '../database_helper.dart';
|
||||
import '../user_prefs.dart';
|
||||
import 'backup_service.dart';
|
||||
|
||||
/// WebDAV 同步结果
|
||||
class SyncResult {
|
||||
@@ -40,14 +38,7 @@ enum SyncDirection {
|
||||
bidirectional, // 双向同步
|
||||
}
|
||||
|
||||
/// 图片同步结果
|
||||
class _ImageSyncResult {
|
||||
final int uploaded;
|
||||
final int downloaded;
|
||||
_ImageSyncResult({required this.uploaded, required this.downloaded});
|
||||
}
|
||||
|
||||
/// WebDAV 服务类 - 支持实时同步(数据+图片分离存储)
|
||||
/// WebDAV 服务类 - 完整备份 zip 同步
|
||||
class WebDAVService {
|
||||
static final WebDAVService _instance = WebDAVService._internal();
|
||||
static WebDAVService get instance => _instance;
|
||||
@@ -58,7 +49,6 @@ class WebDAVService {
|
||||
static const String _lastSyncKey = 'webdav_last_sync';
|
||||
static const String _autoSyncKey = 'webdav_auto_sync';
|
||||
static const String _autoSyncIntervalKey = 'webdav_auto_sync_interval';
|
||||
static const String _lastDbModifiedKey = 'webdav_last_db_modified';
|
||||
|
||||
// 默认自动同步间隔(分钟)
|
||||
static const int _defaultAutoSyncInterval = 5;
|
||||
@@ -68,12 +58,6 @@ class WebDAVService {
|
||||
bool _isAutoSyncEnabled = false;
|
||||
int _autoSyncIntervalMinutes = _defaultAutoSyncInterval;
|
||||
|
||||
// 文件系统监听
|
||||
StreamSubscription<FileSystemEvent>? _imagesDirWatcher;
|
||||
final Set<String> _pendingImageUploads = {};
|
||||
Timer? _debounceTimer;
|
||||
static const Duration _debounceDelay = Duration(seconds: 3);
|
||||
|
||||
/// 获取配置
|
||||
Future<Map<String, String>?> getConfig() async {
|
||||
if (_cachedConfig != null) {
|
||||
@@ -120,7 +104,6 @@ class WebDAVService {
|
||||
await prefs.remove(_lastSyncKey);
|
||||
await prefs.remove(_autoSyncKey);
|
||||
await prefs.remove(_autoSyncIntervalKey);
|
||||
await prefs.remove(_lastDbModifiedKey);
|
||||
_cachedConfig = null;
|
||||
stopAutoSync();
|
||||
}
|
||||
@@ -136,8 +119,6 @@ class WebDAVService {
|
||||
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
var davUrl = '$baseUrl$path';
|
||||
|
||||
// print('WebDAV: Testing connection to $davUrl');
|
||||
|
||||
final client = http.Client();
|
||||
try {
|
||||
var propfindRequest = http.Request('PROPFIND', Uri.parse(davUrl));
|
||||
@@ -151,7 +132,6 @@ class WebDAVService {
|
||||
</D:propfind>''';
|
||||
|
||||
var propfindResponse = await client.send(propfindRequest);
|
||||
// print('WebDAV: PROPFIND status ${propfindResponse.statusCode}');
|
||||
|
||||
if (propfindResponse.statusCode == 301 ||
|
||||
propfindResponse.statusCode == 302 ||
|
||||
@@ -178,12 +158,12 @@ class WebDAVService {
|
||||
} else if (propfindResponse.statusCode == 401) {
|
||||
return {'success': false, 'message': '认证失败,请检查用户名和密码'};
|
||||
} else if (propfindResponse.statusCode == 404) {
|
||||
// print('WebDAV: Directory not found, trying to create...');
|
||||
// 目录不存在,尝试创建
|
||||
} else {
|
||||
return {'success': false, 'message': '服务器返回错误: ${propfindResponse.statusCode}'};
|
||||
}
|
||||
} catch (e) {
|
||||
// print('WebDAV: PROPFIND error: $e');
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -191,7 +171,6 @@ class WebDAVService {
|
||||
mkcolRequest.headers['Authorization'] = _basicAuth(username, password);
|
||||
|
||||
final mkcolResponse = await client.send(mkcolRequest);
|
||||
// print('WebDAV: MKCOL status ${mkcolResponse.statusCode}');
|
||||
|
||||
if (mkcolResponse.statusCode == 201) {
|
||||
return {'success': true, 'message': '连接成功,已创建目录'};
|
||||
@@ -205,18 +184,16 @@ class WebDAVService {
|
||||
return {'success': false, 'message': '创建目录失败: ${mkcolResponse.statusCode}'};
|
||||
}
|
||||
} catch (e) {
|
||||
// print('WebDAV: MKCOL error: $e');
|
||||
return {'success': false, 'message': '连接失败: $e'};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
} catch (e) {
|
||||
// print('WebDAV test connection error: $e');
|
||||
return {'success': false, 'message': '连接失败: $e'};
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步数据(数据+图片分离存储,非压缩包方式)
|
||||
/// 同步数据 — 完整备份 zip 格式,与本地备份完全一致
|
||||
Future<SyncResult> syncData({SyncDirection direction = SyncDirection.bidirectional}) async {
|
||||
final config = await getConfig();
|
||||
if (config == null) {
|
||||
@@ -229,18 +206,8 @@ class WebDAVService {
|
||||
final password = config['password']!;
|
||||
final path = config['path']!;
|
||||
|
||||
final dbPath = await getDatabasesPath();
|
||||
final dbFile = File(p.join(dbPath, 'mooknote.db'));
|
||||
|
||||
if (!await dbFile.exists()) {
|
||||
return SyncResult(success: false, message: '本地数据库不存在');
|
||||
}
|
||||
|
||||
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
final dbUrl = '$baseUrl$path/mooknote.db';
|
||||
final imagesUrl = '$baseUrl$path/images';
|
||||
final avatarsUrl = '$baseUrl$path/avatars';
|
||||
final userConfigUrl = '$baseUrl$path/user_config.json';
|
||||
final zipUrl = '$baseUrl$path/mooknote_backup.zip';
|
||||
|
||||
final client = http.Client();
|
||||
int uploadedFiles = 0;
|
||||
@@ -251,70 +218,73 @@ class WebDAVService {
|
||||
|
||||
try {
|
||||
if (direction == SyncDirection.upload) {
|
||||
// 上传数据库文件
|
||||
final dbSuccess = await _uploadFile(client, dbUrl, username, password, dbFile);
|
||||
if (dbSuccess) {
|
||||
uploadedFiles = 1;
|
||||
final exportResult = await BackupService.instance.exportDataForAutoBackup();
|
||||
if (!exportResult.success || exportResult.zipBytes == null) {
|
||||
return SyncResult(success: false, message: exportResult.errorMessage ?? '创建备份失败');
|
||||
}
|
||||
|
||||
// 上传所有图片
|
||||
final imageResult = await _syncImages(client, imagesUrl, username, password, SyncDirection.upload);
|
||||
uploadedImages = imageResult.uploaded;
|
||||
|
||||
// 上传头像
|
||||
final avatarResult = await _syncAvatars(client, avatarsUrl, username, password, SyncDirection.upload);
|
||||
uploadedImages += avatarResult.uploaded;
|
||||
|
||||
// 上传用户配置
|
||||
await _uploadUserConfig(client, userConfigUrl, username, password);
|
||||
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 tempDbFile = File('${dbFile.parent.path}/mooknote_download.db');
|
||||
final dbSuccess = await _downloadFile(client, dbUrl, username, password, tempDbFile);
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempZip = File(p.join(tempDir.path, 'mooknote_download.zip'));
|
||||
final success = await _downloadFile(client, zipUrl, username, password, tempZip);
|
||||
|
||||
if (dbSuccess && await tempDbFile.exists()) {
|
||||
// 替换本地数据库
|
||||
await tempDbFile.copy(dbFile.path);
|
||||
await tempDbFile.delete();
|
||||
await DatabaseHelper.instance.reopenDatabase();
|
||||
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: '服务器上没有备份文件,请先从其他设备上传');
|
||||
}
|
||||
|
||||
// 下载所有图片
|
||||
final imageResult = await _syncImages(client, imagesUrl, username, password, SyncDirection.download);
|
||||
downloadedImages = imageResult.downloaded;
|
||||
|
||||
// 下载头像
|
||||
final avatarResult = await _syncAvatars(client, avatarsUrl, username, password, SyncDirection.download);
|
||||
downloadedImages += avatarResult.downloaded;
|
||||
|
||||
// 下载并恢复用户配置
|
||||
await _downloadUserConfig(client, userConfigUrl, username, password);
|
||||
|
||||
} else if (direction == SyncDirection.bidirectional) {
|
||||
// 双向同步:分别同步数据库和图片
|
||||
final dbResult = await _syncDatabaseFile(client, dbUrl, username, password, dbFile);
|
||||
if (dbResult['uploaded'] == true) uploadedFiles = 1;
|
||||
if (dbResult['downloaded'] == true) {
|
||||
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 imageResult = await _syncImagesBidirectional(client, imagesUrl, username, password);
|
||||
uploadedImages = imageResult.uploaded;
|
||||
downloadedImages = imageResult.downloaded;
|
||||
|
||||
// 双向同步头像
|
||||
final avatarResult = await _syncAvatarsBidirectional(client, avatarsUrl, username, password);
|
||||
uploadedImages += avatarResult.uploaded;
|
||||
downloadedImages += avatarResult.downloaded;
|
||||
|
||||
// 双向同步用户配置:先下载远程,再上传本地
|
||||
await _downloadUserConfig(client, userConfigUrl, username, password);
|
||||
await _uploadUserConfig(client, userConfigUrl, username, password);
|
||||
// 始终上传本地,确保远程是最新的
|
||||
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();
|
||||
@@ -338,271 +308,6 @@ class WebDAVService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步数据库文件(双向)
|
||||
Future<Map<String, bool>> _syncDatabaseFile(
|
||||
http.Client client,
|
||||
String dbUrl,
|
||||
String username,
|
||||
String password,
|
||||
File localDbFile,
|
||||
) async {
|
||||
final result = <String, bool>{};
|
||||
|
||||
try {
|
||||
final remoteInfo = await _getRemoteFileInfo(client, dbUrl, username, password);
|
||||
final localModified = await localDbFile.lastModified();
|
||||
|
||||
if (remoteInfo == null) {
|
||||
// 远程不存在,上传本地
|
||||
result['uploaded'] = await _uploadFile(client, dbUrl, username, password, localDbFile);
|
||||
} else {
|
||||
final remoteModified = remoteInfo['modified'] as DateTime;
|
||||
final timeDiff = localModified.difference(remoteModified).inSeconds;
|
||||
|
||||
if (timeDiff > 10) {
|
||||
// 本地较新,上传
|
||||
result['uploaded'] = await _uploadFile(client, dbUrl, username, password, localDbFile);
|
||||
} else if (timeDiff < -10) {
|
||||
// 远程较新,下载
|
||||
final tempFile = File('${localDbFile.parent.path}/mooknote_temp.db');
|
||||
final success = await _downloadFile(client, dbUrl, username, password, tempFile);
|
||||
if (success) {
|
||||
await tempFile.copy(localDbFile.path);
|
||||
await tempFile.delete();
|
||||
await DatabaseHelper.instance.reopenDatabase();
|
||||
result['downloaded'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 双向同步图片(下载远程 zip 合并 -> 打包上传本地)
|
||||
Future<_ImageSyncResult> _syncImagesBidirectional(
|
||||
http.Client client,
|
||||
String imagesUrl,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
int uploaded = 0;
|
||||
int downloaded = 0;
|
||||
|
||||
try {
|
||||
final zipUrl = '${imagesUrl.substring(0, imagesUrl.lastIndexOf('/'))}/images.zip';
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempZip = File(p.join(tempDir.path, 'images_bidir.zip'));
|
||||
|
||||
final downloadSuccess = await _downloadFile(client, zipUrl, username, password, tempZip);
|
||||
if (downloadSuccess) {
|
||||
await _extractImagesZip(tempZip);
|
||||
downloaded = 1;
|
||||
try { await tempZip.delete(); } catch (_) {}
|
||||
}
|
||||
|
||||
final zipFile = await _createImagesZip();
|
||||
if (await zipFile.exists()) {
|
||||
final uploadSuccess = await _uploadFile(client, zipUrl, username, password, zipFile);
|
||||
if (uploadSuccess) uploaded = 1;
|
||||
}
|
||||
try { await zipFile.delete(); } catch (_) {}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return _ImageSyncResult(uploaded: uploaded, downloaded: downloaded);
|
||||
}
|
||||
|
||||
|
||||
/// 同步头像目录(zip 打包传输)
|
||||
Future<_ImageSyncResult> _syncAvatars(
|
||||
http.Client client,
|
||||
String avatarsUrl,
|
||||
String username,
|
||||
String password,
|
||||
SyncDirection direction,
|
||||
) async {
|
||||
int uploaded = 0;
|
||||
int downloaded = 0;
|
||||
|
||||
try {
|
||||
final zipUrl = '${avatarsUrl.substring(0, avatarsUrl.lastIndexOf('/'))}/avatars.zip';
|
||||
|
||||
if (direction == SyncDirection.upload) {
|
||||
final zipFile = await _createAvatarsZip();
|
||||
if (await zipFile.exists()) {
|
||||
final success = await _uploadFile(client, zipUrl, username, password, zipFile);
|
||||
if (success) uploaded = 1;
|
||||
}
|
||||
try { await zipFile.delete(); } catch (_) {}
|
||||
} else if (direction == SyncDirection.download) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempZip = File(p.join(tempDir.path, 'avatars_dl.zip'));
|
||||
final success = await _downloadFile(client, zipUrl, username, password, tempZip);
|
||||
if (success) {
|
||||
await _extractAvatarsZip(tempZip);
|
||||
downloaded = 1;
|
||||
}
|
||||
try { await tempZip.delete(); } catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return _ImageSyncResult(uploaded: uploaded, downloaded: downloaded);
|
||||
}
|
||||
|
||||
|
||||
/// 双向同步头像目录(下载远程 zip 合并 -> 打包上传本地)
|
||||
Future<_ImageSyncResult> _syncAvatarsBidirectional(
|
||||
http.Client client,
|
||||
String avatarsUrl,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
int uploaded = 0;
|
||||
int downloaded = 0;
|
||||
|
||||
try {
|
||||
final zipUrl = '${avatarsUrl.substring(0, avatarsUrl.lastIndexOf('/'))}/avatars.zip';
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempZip = File(p.join(tempDir.path, 'avatars_bidir.zip'));
|
||||
|
||||
final downloadSuccess = await _downloadFile(client, zipUrl, username, password, tempZip);
|
||||
if (downloadSuccess) {
|
||||
await _extractAvatarsZip(tempZip);
|
||||
downloaded = 1;
|
||||
try { await tempZip.delete(); } catch (_) {}
|
||||
}
|
||||
|
||||
final zipFile = await _createAvatarsZip();
|
||||
if (await zipFile.exists()) {
|
||||
final uploadSuccess = await _uploadFile(client, zipUrl, username, password, zipFile);
|
||||
if (uploadSuccess) uploaded = 1;
|
||||
}
|
||||
try { await zipFile.delete(); } catch (_) {}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return _ImageSyncResult(uploaded: uploaded, downloaded: downloaded);
|
||||
}
|
||||
|
||||
|
||||
/// 上传用户配置
|
||||
Future<void> _uploadUserConfig(
|
||||
http.Client client,
|
||||
String userConfigUrl,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
try {
|
||||
final userPrefs = UserPrefs();
|
||||
final config = {
|
||||
'nickname': userPrefs.nickname,
|
||||
'motto': userPrefs.motto,
|
||||
'avatarPath': userPrefs.avatarPath,
|
||||
'updatedAt': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
final request = http.Request('PUT', Uri.parse(userConfigUrl));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
request.headers['Content-Type'] = 'application/json';
|
||||
request.body = jsonEncode(config);
|
||||
|
||||
await client.send(request);
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载并恢复用户配置
|
||||
Future<void> _downloadUserConfig(
|
||||
http.Client client,
|
||||
String userConfigUrl,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
try {
|
||||
final request = http.Request('GET', Uri.parse(userConfigUrl));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
|
||||
final response = await client.send(request);
|
||||
if (response.statusCode == 200) {
|
||||
final body = await response.stream.bytesToString();
|
||||
final config = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
final userPrefs = UserPrefs();
|
||||
if (config.containsKey('nickname')) {
|
||||
await userPrefs.setNickname(config['nickname'] as String);
|
||||
}
|
||||
if (config.containsKey('motto')) {
|
||||
await userPrefs.setMotto(config['motto'] as String);
|
||||
}
|
||||
if (config.containsKey('avatarPath')) {
|
||||
final avatarPath = config['avatarPath'] as String?;
|
||||
if (avatarPath != null && avatarPath.isNotEmpty) {
|
||||
final fileName = p.basename(avatarPath);
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final newAvatarPath = p.join(appDir.path, 'avatars', fileName);
|
||||
if (await File(newAvatarPath).exists()) {
|
||||
await userPrefs.setAvatarPath(newAvatarPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取远程文件信息
|
||||
Future<Map<String, dynamic>?> _getRemoteFileInfo(
|
||||
http.Client client,
|
||||
String url,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
try {
|
||||
var request = http.Request('PROPFIND', Uri.parse(url));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
request.headers['Depth'] = '0';
|
||||
|
||||
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) {
|
||||
url = location;
|
||||
request = http.Request('PROPFIND', Uri.parse(url));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
request.headers['Depth'] = '0';
|
||||
response = await client.send(request);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.statusCode == 207) {
|
||||
final body = await response.stream.bytesToString();
|
||||
final modifiedMatch = RegExp(r'<d:getlastmodified>([^<]+)</d:getlastmodified>', caseSensitive: false)
|
||||
.firstMatch(body);
|
||||
if (modifiedMatch != null) {
|
||||
final modifiedStr = modifiedMatch.group(1)!;
|
||||
final modified = HttpDate.parse(modifiedStr);
|
||||
return {'modified': modified, 'url': url};
|
||||
}
|
||||
return {'modified': DateTime.now(), 'url': url};
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
// print('WebDAV: Get remote file info error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取自动同步间隔(分钟)
|
||||
Future<int> getAutoSyncInterval() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -618,15 +323,13 @@ class WebDAVService {
|
||||
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();
|
||||
@@ -635,20 +338,18 @@ class WebDAVService {
|
||||
await prefs.setBool(_autoSyncKey, true);
|
||||
|
||||
// 立即执行一次同步
|
||||
await _performIncrementalSync();
|
||||
print('[WebDAV] 自动同步已启动,间隔 $_autoSyncIntervalMinutes 分钟');
|
||||
await syncData(direction: SyncDirection.bidirectional);
|
||||
|
||||
// 设置定时器进行定期同步
|
||||
// 设置定时器
|
||||
_autoSyncTimer = Timer.periodic(
|
||||
Duration(minutes: _autoSyncIntervalMinutes),
|
||||
(timer) async {
|
||||
if (_isAutoSyncEnabled) {
|
||||
await _performIncrementalSync();
|
||||
await syncData(direction: SyncDirection.bidirectional);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 启动文件系统监听
|
||||
await _startFileWatcher();
|
||||
}
|
||||
|
||||
/// 停止自动同步
|
||||
@@ -657,12 +358,6 @@ class WebDAVService {
|
||||
_autoSyncTimer = null;
|
||||
_isAutoSyncEnabled = false;
|
||||
|
||||
// 停止文件监听
|
||||
await _imagesDirWatcher?.cancel();
|
||||
_imagesDirWatcher = null;
|
||||
_debounceTimer?.cancel();
|
||||
_pendingImageUploads.clear();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_autoSyncKey, false);
|
||||
}
|
||||
@@ -677,316 +372,25 @@ class WebDAVService {
|
||||
return prefs.getBool(_autoSyncKey) ?? false;
|
||||
}
|
||||
|
||||
/// 启动文件系统监听
|
||||
Future<void> _startFileWatcher() async {
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesDir = Directory('${appDir.path}/images');
|
||||
|
||||
if (!await imagesDir.exists()) {
|
||||
await imagesDir.create(recursive: true);
|
||||
}
|
||||
|
||||
// 监听图片目录的变化
|
||||
_imagesDirWatcher = imagesDir.watch(recursive: true).listen((event) {
|
||||
if (event is FileSystemCreateEvent || event is FileSystemModifyEvent) {
|
||||
final path = event.path;
|
||||
if (_isImageFile(path)) {
|
||||
_pendingImageUploads.add(path);
|
||||
_debounceUpload();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// 文件监听可能不支持某些平台,忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否是图片文件
|
||||
bool _isImageFile(String path) {
|
||||
final ext = p.extension(path).toLowerCase();
|
||||
return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'].contains(ext);
|
||||
}
|
||||
|
||||
/// 防抖上传
|
||||
void _debounceUpload() {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(_debounceDelay, () async {
|
||||
if (_pendingImageUploads.isNotEmpty && _isAutoSyncEnabled) {
|
||||
await _uploadPendingImages();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 上传待处理的图片(重新打包 zip 上传)
|
||||
Future<void> _uploadPendingImages() async {
|
||||
final config = await getConfig();
|
||||
if (config == null) return;
|
||||
|
||||
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/images.zip';
|
||||
|
||||
_pendingImageUploads.clear();
|
||||
|
||||
final client = http.Client();
|
||||
try {
|
||||
final zipFile = await _createImagesZip();
|
||||
if (await zipFile.exists()) {
|
||||
await _uploadFile(client, zipUrl, username, password, zipFile);
|
||||
}
|
||||
try { await zipFile.delete(); } catch (_) {}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行增量同步(检查变更并上传)
|
||||
Future<SyncResult> _performIncrementalSync() 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 dbPath = await getDatabasesPath();
|
||||
final dbFile = File(p.join(dbPath, 'mooknote.db'));
|
||||
|
||||
if (!await dbFile.exists()) {
|
||||
return SyncResult(success: false, message: '本地数据库不存在');
|
||||
}
|
||||
|
||||
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
final dbUrl = '$baseUrl$path/mooknote.db';
|
||||
final imagesUrl = '$baseUrl$path/images';
|
||||
final avatarsUrl = '$baseUrl$path/avatars';
|
||||
final userConfigUrl = '$baseUrl$path/user_config.json';
|
||||
|
||||
final client = http.Client();
|
||||
int uploadedImages = 0;
|
||||
int downloadedImages = 0;
|
||||
bool dbUploaded = false;
|
||||
|
||||
try {
|
||||
// 检查数据库是否需要同步
|
||||
/// 获取上次同步时间
|
||||
Future<String?> getLastSyncTime() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastDbModifiedStr = prefs.getString(_lastDbModifiedKey);
|
||||
final currentDbModified = await dbFile.lastModified();
|
||||
|
||||
bool needDbSync = true;
|
||||
if (lastDbModifiedStr != null) {
|
||||
final lastDbModified = DateTime.parse(lastDbModifiedStr);
|
||||
// 如果数据库修改时间在3秒内,认为没有变化
|
||||
if (currentDbModified.difference(lastDbModified).inSeconds.abs() < 3) {
|
||||
needDbSync = false;
|
||||
}
|
||||
return prefs.getString(_lastSyncKey);
|
||||
}
|
||||
|
||||
if (needDbSync) {
|
||||
// 上传数据库
|
||||
dbUploaded = await _uploadFile(client, dbUrl, username, password, dbFile);
|
||||
if (dbUploaded) {
|
||||
await prefs.setString(_lastDbModifiedKey, currentDbModified.toIso8601String());
|
||||
}
|
||||
}
|
||||
|
||||
// 同步图片(双向)
|
||||
final imageResult = await _syncImagesBidirectional(client, imagesUrl, username, password);
|
||||
uploadedImages = imageResult.uploaded;
|
||||
downloadedImages = imageResult.downloaded;
|
||||
|
||||
// 同步头像(双向)
|
||||
final avatarResult = await _syncAvatarsBidirectional(client, avatarsUrl, username, password);
|
||||
uploadedImages += avatarResult.uploaded;
|
||||
downloadedImages += avatarResult.downloaded;
|
||||
|
||||
// 同步用户配置:下载远程并上传本地
|
||||
await _downloadUserConfig(client, userConfigUrl, username, password);
|
||||
await _uploadUserConfig(client, userConfigUrl, username, password);
|
||||
|
||||
await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String());
|
||||
|
||||
return SyncResult(
|
||||
success: true,
|
||||
message: '自动同步完成',
|
||||
lastSyncTime: DateTime.now(),
|
||||
uploadedFiles: dbUploaded ? 1 : 0,
|
||||
uploadedImages: uploadedImages,
|
||||
downloadedImages: downloadedImages,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
} catch (e) {
|
||||
return SyncResult(success: false, message: '自动同步失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// 将本地 images 目录打包为 zip 文件,返回临时文件
|
||||
Future<File> _createImagesZip() async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesDir = Directory(p.join(appDir.path, 'images'));
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final zipFile = File(p.join(tempDir.path, 'images.zip'));
|
||||
|
||||
final archive = Archive();
|
||||
if (await imagesDir.exists()) {
|
||||
await for (final entity in imagesDir.list(recursive: true)) {
|
||||
if (entity is File) {
|
||||
final bytes = await entity.readAsBytes();
|
||||
final relativePath = p.relative(entity.path, from: imagesDir.path);
|
||||
archive.addFile(ArchiveFile(relativePath, bytes.length, bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final zipBytes = ZipEncoder().encode(archive)!;
|
||||
await zipFile.writeAsBytes(zipBytes);
|
||||
return zipFile;
|
||||
}
|
||||
|
||||
/// 解压 images.zip 到本地 images 目录(合并模式)
|
||||
Future<void> _extractImagesZip(File zipFile) async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesDir = Directory(p.join(appDir.path, 'images'));
|
||||
|
||||
final inputStream = InputFileStream(zipFile.path);
|
||||
final archive = ZipDecoder().decodeBuffer(inputStream);
|
||||
await inputStream.close();
|
||||
|
||||
for (final file in archive) {
|
||||
if (file.isFile) {
|
||||
final targetFile = File(p.join(imagesDir.path, file.name));
|
||||
await targetFile.parent.create(recursive: true);
|
||||
await targetFile.writeAsBytes(file.content!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将本地 avatars 目录打包为 zip 文件
|
||||
Future<File> _createAvatarsZip() async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final avatarsDir = Directory(p.join(appDir.path, 'avatars'));
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final zipFile = File(p.join(tempDir.path, 'avatars.zip'));
|
||||
|
||||
final archive = Archive();
|
||||
if (await avatarsDir.exists()) {
|
||||
await for (final entity in avatarsDir.list(recursive: true)) {
|
||||
if (entity is File) {
|
||||
final bytes = await entity.readAsBytes();
|
||||
final relativePath = p.relative(entity.path, from: avatarsDir.path);
|
||||
archive.addFile(ArchiveFile(relativePath, bytes.length, bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final zipBytes = ZipEncoder().encode(archive)!;
|
||||
await zipFile.writeAsBytes(zipBytes);
|
||||
return zipFile;
|
||||
}
|
||||
|
||||
/// 解压 avatars.zip 到本地 avatars 目录(合并模式)
|
||||
Future<void> _extractAvatarsZip(File zipFile) async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final avatarsDir = Directory(p.join(appDir.path, 'avatars'));
|
||||
|
||||
final inputStream = InputFileStream(zipFile.path);
|
||||
final archive = ZipDecoder().decodeBuffer(inputStream);
|
||||
await inputStream.close();
|
||||
|
||||
for (final file in archive) {
|
||||
if (file.isFile) {
|
||||
final targetFile = File(p.join(avatarsDir.path, file.name));
|
||||
await targetFile.parent.create(recursive: true);
|
||||
await targetFile.writeAsBytes(file.content!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步图片(zip 打包传输,一次请求完成)
|
||||
Future<_ImageSyncResult> _syncImages(
|
||||
http.Client client,
|
||||
String imagesUrl,
|
||||
String username,
|
||||
String password,
|
||||
SyncDirection direction,
|
||||
) async {
|
||||
int uploaded = 0;
|
||||
int downloaded = 0;
|
||||
|
||||
try {
|
||||
final zipUrl = '${imagesUrl.substring(0, imagesUrl.lastIndexOf('/'))}/images.zip';
|
||||
|
||||
if (direction == SyncDirection.upload) {
|
||||
final zipFile = await _createImagesZip();
|
||||
if (await zipFile.exists()) {
|
||||
final success = await _uploadFile(client, zipUrl, username, password, zipFile);
|
||||
if (success) uploaded = 1;
|
||||
}
|
||||
try { await zipFile.delete(); } catch (_) {}
|
||||
} else if (direction == SyncDirection.download) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempZip = File(p.join(tempDir.path, 'images_dl.zip'));
|
||||
final success = await _downloadFile(client, zipUrl, username, password, tempZip);
|
||||
if (success) {
|
||||
await _extractImagesZip(tempZip);
|
||||
downloaded = 1;
|
||||
}
|
||||
try { await tempZip.delete(); } catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return _ImageSyncResult(uploaded: uploaded, downloaded: downloaded);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/// 上传文件(数据库文件上传前自动 VACUUM 压缩)
|
||||
Future<bool> _uploadFile(
|
||||
/// 上传字节数据到 WebDAV
|
||||
Future<bool> _uploadBytes(
|
||||
http.Client client,
|
||||
String url,
|
||||
String username,
|
||||
String password,
|
||||
File file,
|
||||
Uint8List bytes,
|
||||
) async {
|
||||
try {
|
||||
// 数据库文件:上传前 VACUUM 压缩
|
||||
if (p.basename(url).endsWith('.db')) {
|
||||
try {
|
||||
final db = await DatabaseHelper.instance.database;
|
||||
await db.execute('VACUUM');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final fileBytes = await file.readAsBytes();
|
||||
|
||||
var request = http.Request('PUT', Uri.parse(url));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
request.headers['Content-Type'] = 'application/octet-stream';
|
||||
request.bodyBytes = fileBytes;
|
||||
request.headers['Content-Type'] = 'application/zip';
|
||||
request.bodyBytes = bytes;
|
||||
|
||||
var response = await client.send(request);
|
||||
|
||||
@@ -997,20 +401,21 @@ class WebDAVService {
|
||||
if (location != null) {
|
||||
request = http.Request('PUT', Uri.parse(location));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
request.headers['Content-Type'] = 'application/octet-stream';
|
||||
request.bodyBytes = fileBytes;
|
||||
request.headers['Content-Type'] = 'application/zip';
|
||||
request.bodyBytes = bytes;
|
||||
response = await client.send(request);
|
||||
}
|
||||
}
|
||||
|
||||
return response.statusCode == 201 || response.statusCode == 204;
|
||||
print('[WebDAV] PUT $url -> ${response.statusCode}');
|
||||
return response.statusCode == 200 || response.statusCode == 201 || response.statusCode == 204;
|
||||
} catch (e) {
|
||||
// print('WebDAV: Upload error: $e');
|
||||
print('[WebDAV] _uploadBytes error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载文件
|
||||
/// 下载文件到本地
|
||||
Future<bool> _downloadFile(
|
||||
http.Client client,
|
||||
String url,
|
||||
@@ -1019,8 +424,6 @@ class WebDAVService {
|
||||
File localFile,
|
||||
) async {
|
||||
try {
|
||||
// print('WebDAV: Downloading from $url to ${localFile.path}');
|
||||
|
||||
var request = http.Request('GET', Uri.parse(url));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
|
||||
@@ -1031,26 +434,24 @@ class WebDAVService {
|
||||
response.statusCode == 307 || response.statusCode == 308) {
|
||||
final location = response.headers['location'];
|
||||
if (location != null) {
|
||||
// print('WebDAV: Following redirect to $location');
|
||||
request = http.Request('GET', Uri.parse(location));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
response = await client.send(request);
|
||||
}
|
||||
}
|
||||
|
||||
// print('WebDAV: Download response status: ${response.statusCode}');
|
||||
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 to ${localFile.path}');
|
||||
print('[WebDAV] Downloaded ${bytes.length} bytes');
|
||||
return true;
|
||||
} else {
|
||||
// print('WebDAV: Download failed with status ${response.statusCode}');
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
// print('WebDAV: Download error: $e');
|
||||
// ignore
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: mooknote
|
||||
description: "app for tracking movies, books, and notes"
|
||||
publish_to: 'none'
|
||||
version: 0.1.7+2
|
||||
version: 0.1.8
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.0
|
||||
|
||||
Reference in New Issue
Block a user