优化项目结构

This commit is contained in:
DelLevin-Home
2026-07-05 13:58:25 +08:00
parent 82aca12402
commit de180eee47
41 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,236 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:shared_preferences/shared_preferences.dart';
import 'backup_service.dart';
/// 自动备份服务 - 定时自动备份到下载目录
class AutoBackupService {
static final AutoBackupService instance = AutoBackupService._init();
AutoBackupService._init();
Timer? _timer;
bool _isRunning = false;
static const String _prefsKey = 'auto_backup_enabled';
static const String _backupDirName = 'mooknote';
static const int _maxBackups = 5;
static const Duration _backupInterval = Duration(minutes: 5);
/// 是否正在运行
bool get isRunning => _isRunning;
/// 获取自动备份状态
Future<bool> getEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_prefsKey) ?? false;
}
/// 设置自动备份状态
Future<void> setEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKey, enabled);
if (enabled) {
await start();
} else {
await stop();
}
}
/// 启动自动备份
Future<void> start() async {
if (_isRunning) return;
_isRunning = true;
// 立即执行一次备份
await _performBackup();
// 启动定时器
_timer = Timer.periodic(_backupInterval, (_) async {
await _performBackup();
});
}
/// 停止自动备份
Future<void> stop() async {
_timer?.cancel();
_timer = null;
_isRunning = false;
}
/// 执行备份
Future<void> _performBackup() async {
try {
final backupDir = await _getBackupDirectory();
if (backupDir == null) {
debugPrint('AutoBackup: 无法获取备份目录');
return;
}
// 确保备份目录存在
if (!await backupDir.exists()) {
await backupDir.create(recursive: true);
}
// 导出数据
final result = await BackupService.instance.exportDataForAutoBackup();
if (!result.success) {
debugPrint('AutoBackup: 导出失败 - ${result.errorMessage}');
return;
}
// 生成备份文件名
final fileName = 'auto_backup_${_formatDateTime(DateTime.now())}.zip';
final backupFile = File(path.join(backupDir.path, fileName));
// 写入备份文件
await backupFile.writeAsBytes(result.zipBytes!);
debugPrint('AutoBackup: 备份成功 - ${backupFile.path}');
// 清理旧备份只保留最新的5个
await _cleanupOldBackups(backupDir);
} catch (e) {
debugPrint('AutoBackup: 备份失败 - $e');
}
}
/// 获取备份目录(下载目录/mooknote
Future<Directory?> _getBackupDirectory() async {
try {
if (Platform.isAndroid) {
// 优先级 1: 官方 API 获取下载目录
try {
final dirs = await getExternalStorageDirectories(
type: StorageDirectory.downloads,
);
if (dirs != null && dirs.isNotEmpty) {
return Directory('${dirs.first.path}/$_backupDirName');
}
} catch (_) {}
// 优先级 2: 标准路径直接拼
final standardPath = '/storage/emulated/0/Download/$_backupDirName';
final standardDir = Directory(standardPath);
if (await standardDir.parent.exists()) {
return standardDir;
}
// 优先级 3: 从外部存储路径推导(旧逻辑兜底)
final externalDir = await getExternalStorageDirectory();
if (externalDir != null) {
final segments = externalDir.uri.pathSegments;
if (segments.length >= 3) {
final pkg = segments[segments.length - 3];
final downloadPath = externalDir.path.replaceAll(
'/Android/data/$pkg/files',
'/Download',
);
return Directory('$downloadPath/$_backupDirName');
}
}
// 优先级 4: 降级到 app 内部目录
final appDir = await getApplicationDocumentsDirectory();
return Directory('${appDir.path}/$_backupDirName');
} else if (Platform.isIOS) {
final docDir = await getApplicationDocumentsDirectory();
return Directory('${docDir.path}/$_backupDirName');
} else {
// 桌面端
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
if (home != null) {
return Directory('$home/Downloads/$_backupDirName');
}
final docDir = await getApplicationDocumentsDirectory();
return Directory('${docDir.path}/$_backupDirName');
}
} catch (e) {
debugPrint('AutoBackup: 获取备份目录失败 - $e');
return null;
}
}
/// 清理旧备份只保留最新的5个
Future<void> _cleanupOldBackups(Directory backupDir) async {
try {
final files = await backupDir
.list()
.where((entity) => entity is File && entity.path.endsWith('.zip'))
.cast<File>()
.toList();
// 按修改时间排序(最新的在前)
files.sort((a, b) {
final aStat = a.statSync();
final bStat = b.statSync();
return bStat.modified.compareTo(aStat.modified);
});
// 删除超过10个的旧备份
if (files.length > _maxBackups) {
for (var i = _maxBackups; i < files.length; i++) {
try {
await files[i].delete();
debugPrint('AutoBackup: 删除旧备份 - ${files[i].path}');
} catch (e) {
debugPrint('AutoBackup: 删除旧备份失败 - $e');
}
}
}
} catch (e) {
debugPrint('AutoBackup: 清理旧备份失败 - $e');
}
}
/// 获取备份文件列表
Future<List<File>> getBackupFiles() async {
try {
final backupDir = await _getBackupDirectory();
if (backupDir == null || !await backupDir.exists()) {
return [];
}
final files = await backupDir
.list()
.where((entity) => entity is File && entity.path.endsWith('.zip'))
.cast<File>()
.toList();
// 按修改时间排序(最新的在前)
files.sort((a, b) {
final aStat = a.statSync();
final bStat = b.statSync();
return bStat.modified.compareTo(aStat.modified);
});
return files;
} catch (e) {
debugPrint('AutoBackup: 获取备份列表失败 - $e');
return [];
}
}
/// 获取备份目录路径
Future<String?> getBackupDirectoryPath() async {
final dir = await _getBackupDirectory();
return dir?.path;
}
/// 格式化日期时间用于文件名
String _formatDateTime(DateTime dateTime) {
return '${dateTime.year}${_pad(dateTime.month)}${_pad(dateTime.day)}_${_pad(dateTime.hour)}${_pad(dateTime.minute)}${_pad(dateTime.second)}';
}
String _pad(int number) {
return number.toString().padLeft(2, '0');
}
}

View File

@@ -0,0 +1,855 @@
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:share_plus/share_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart';
import '../database_helper.dart';
import '../user_prefs.dart';
/// 数据备份服务 - 支持导出和导入数据(包含图片)
class BackupService {
static final BackupService instance = BackupService._init();
BackupService._init();
// ─── 共享导出逻辑 ─────────────────────────────────────
/// 收集所有表数据和图片,构建 ZIP 字节
Future<_ExportData> _buildExportData() async {
final db = await DatabaseHelper.instance.database;
final movies = await db.query('movies');
final books = await db.query('books');
final notes = await db.query('notes');
final movieReviews = await db.query('movie_reviews');
final moviePosters = await db.query('movie_posters');
final bookReviews = await db.query('book_reviews');
final bookExcerpts = await db.query('book_excerpts');
final tags = await db.query('tags');
final readerBooks = await db.query('reader_books');
final bookAnnotations = await db.query('book_annotations');
// 收集图片路径
final imagePaths = <String>{};
for (final m in movies) {
final p = m['poster_path'] as String?;
if (p != null && p.isNotEmpty) imagePaths.add(p);
}
for (final b in books) {
final p = b['cover_path'] as String?;
if (p != null && p.isNotEmpty) imagePaths.add(p);
}
for (final p in moviePosters) {
final pp = p['poster_path'] as String?;
if (pp != null && pp.isNotEmpty) imagePaths.add(pp);
}
for (final n in notes) {
final imagesJson = n['images'] as String?;
if (imagesJson != null && imagesJson.isNotEmpty) {
try {
for (final ip in jsonDecode(imagesJson) as List<dynamic>) {
if (ip is String && ip.isNotEmpty) imagePaths.add(ip);
}
} catch (e) {
debugPrint('[BackupService] 笔记图片解析失败 (noteId=${n['id']}): $e');
}
}
}
// reader_books 的封面在 epub_books/ 目录下,由 epub_books 归档处理
// 不加入 imagePaths避免 basename 碰撞导致所有封面变成同一个路径
final userPrefs = UserPrefs();
final userInfo = {
'nickname': userPrefs.nickname,
'motto': userPrefs.motto,
'avatarPath': userPrefs.avatarPath,
};
final avatarPath = userPrefs.avatarPath;
if (avatarPath != null && avatarPath.isNotEmpty) imagePaths.add(avatarPath);
// 构建备份数据
final backupData = {
'version': 2,
'exportTime': DateTime.now().toIso8601String(),
'appName': 'MookNote',
'hasImages': true,
'userInfo': userInfo,
'sharedPrefs': await _exportSharedPrefs(),
'data': {
'movies': movies,
'books': books,
'notes': notes,
'movie_reviews': movieReviews,
'movie_posters': moviePosters,
'book_reviews': bookReviews,
'book_excerpts': bookExcerpts,
'tags': tags,
'reader_books': readerBooks,
'book_annotations': bookAnnotations,
},
};
// 创建 ZIP逐文件写入磁盘避免全部加载到内存
final tempDir = await getTemporaryDirectory();
final tempZipPath = path.join(tempDir.path, 'mooknote_backup_temp.zip');
final encoder = ZipFileEncoder();
encoder.create(tempZipPath);
try {
// data.json
final jsonString = const JsonEncoder.withIndent(' ').convert(backupData);
final jsonBytes = Uint8List.fromList(utf8.encode(jsonString));
final dataFile = File(path.join(tempDir.path, 'mooknote_data.json'));
await dataFile.writeAsBytes(jsonBytes);
encoder.addFile(dataFile, 'data.json');
await dataFile.delete();
int imageCount = 0;
final appDir = await getApplicationDocumentsDirectory();
final imagesRoot = path.join(appDir.path, 'images');
for (final imagePath in imagePaths) {
final file = File(imagePath);
if (await file.exists()) {
String relativePath;
if (imagePath.startsWith(imagesRoot)) {
relativePath = imagePath.substring(imagesRoot.length + 1);
} else {
relativePath = path.basename(imagePath);
}
encoder.addFile(file, 'images/$relativePath');
imageCount++;
}
}
// 收集 epub_books 目录下的 epub 文件
int epubCount = 0;
final epubRoot = path.join(appDir.path, 'epub_books');
final epubDir = Directory(epubRoot);
if (await epubDir.exists()) {
await for (final entity in epubDir.list(recursive: true)) {
if (entity is File) {
final relativePath = entity.path.substring(epubRoot.length + 1);
encoder.addFile(entity, 'epub_books/$relativePath');
epubCount++;
}
}
}
encoder.close();
// 读取最终 zip 文件
final zipFile = File(tempZipPath);
final zipBytes = await zipFile.readAsBytes();
await zipFile.delete();
return _ExportData(
zipBytes: Uint8List.fromList(zipBytes),
movieCount: movies.length,
bookCount: books.length,
noteCount: notes.length,
imageCount: imageCount,
epubCount: epubCount,
);
} catch (e) {
encoder.close();
try { await File(tempZipPath).delete(); } catch (_) {}
rethrow;
}
}
// ─── 手动导出 ─────────────────────────────────────────
/// 导出所有数据和图片为 ZIP 文件,并选择保存路径
Future<ExportResult> exportDataWithImages() async {
try {
final data = await _buildExportData();
final tempDir = await getTemporaryDirectory();
final fileName = 'mooknote_backup_${_formatDateTime(DateTime.now())}.zip';
final tempFilePath = path.join(tempDir.path, fileName);
await File(tempFilePath).writeAsBytes(data.zipBytes);
String? finalPath;
try {
final outputPath = await FilePicker.platform.saveFile(
dialogTitle: '保存备份文件',
fileName: fileName,
type: FileType.custom,
allowedExtensions: ['zip'],
bytes: data.zipBytes,
);
if (outputPath == null) {
return ExportResult.cancelled();
}
finalPath = outputPath;
if (finalPath != tempFilePath) {
await File(finalPath).writeAsBytes(data.zipBytes);
}
} catch (e) {
finalPath = tempFilePath;
}
return ExportResult.success(
filePath: finalPath,
movieCount: data.movieCount,
bookCount: data.bookCount,
noteCount: data.noteCount,
imageCount: data.imageCount,
);
} catch (e) {
return ExportResult.error('导出失败: $e');
}
}
/// 分享备份文件
Future<void> shareBackup(String filePath) async {
final file = XFile(filePath);
await Share.shareXFiles([file], subject: 'MookNote 数据备份', text: '这是我的 MookNote 数据备份文件');
}
// ─── 自动备份导出 ─────────────────────────────────────
/// 导出数据用于自动备份(返回字节数据)
Future<AutoBackupExportResult> exportDataForAutoBackup() async {
try {
final data = await _buildExportData();
return AutoBackupExportResult.success(
zipBytes: data.zipBytes,
movieCount: data.movieCount,
bookCount: data.bookCount,
noteCount: data.noteCount,
imageCount: data.imageCount,
epubCount: data.epubCount,
);
} catch (e) {
return AutoBackupExportResult.error('导出失败: $e');
}
}
// ─── 导入 ─────────────────────────────────────────────
/// 选择并导入备份文件(支持 ZIP 和旧版 JSON
Future<ImportResult> importData() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['zip', 'json'],
allowMultiple: false,
);
if (result == null || result.files.isEmpty) return ImportResult.cancelled();
final filePath = result.files.first.path;
if (filePath == null) return ImportResult.error('无法读取文件路径');
final file = File(filePath);
final extension = path.extension(filePath).toLowerCase();
Map<String, dynamic> backupData;
int imageCount = 0;
// 完整相对路径 → 新绝对路径 的映射(避免同名文件碰撞)
final imagePathMap = <String, String>{};
// epub_books/ 内相对路径 → 新绝对路径 的映射
final epubFileMap = <String, String>{};
if (extension == '.zip') {
final bytes = await file.readAsBytes();
final archive = ZipDecoder().decodeBytes(bytes);
final dataFile = archive.findFile('data.json');
if (dataFile == null) return ImportResult.error('备份文件中没有找到数据文件');
backupData = jsonDecode(utf8.decode(dataFile.content as List<int>)) as Map<String, dynamic>;
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);
final outputFile = File(path.join(imagesDir.path, relativePath));
if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true);
await outputFile.writeAsBytes(archiveFile.content as List<int>);
// 用完整相对路径做 key避免不同目录下同名文件碰撞
imagePathMap[relativePath] = outputFile.path;
imageCount++;
} else if (archiveFile.name.startsWith('epub_books/')) {
final relativePath = archiveFile.name.substring(12);
final epubDir = Directory(path.join(appDir.path, 'epub_books'));
if (!await epubDir.exists()) await epubDir.create(recursive: true);
final outputFile = File(path.join(epubDir.path, relativePath));
if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true);
await outputFile.writeAsBytes(archiveFile.content as List<int>);
epubFileMap[relativePath] = outputFile.path;
}
}
} else {
// 旧版 JSON
backupData = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
}
if (!backupData.containsKey('data')) return ImportResult.error('无效的备份文件格式');
// 验证版本
final version = backupData['version'] as int? ?? 1;
if (version > 2) {
debugPrint('[BackupService] 警告: 备份版本 $version 高于当前支持的版本 2部分数据可能丢失');
}
final data = backupData['data'] as Map<String, dynamic>;
final db = await DatabaseHelper.instance.database;
final moviesCols = await _getTableColumns(db, 'movies');
final booksCols = await _getTableColumns(db, 'books');
final notesCols = await _getTableColumns(db, 'notes');
final movieReviewsCols = await _getTableColumns(db, 'movie_reviews');
final moviePostersCols = await _getTableColumns(db, 'movie_posters');
final bookReviewsCols = await _getTableColumns(db, 'book_reviews');
final bookExcerptsCols = await _getTableColumns(db, 'book_excerpts');
final tagsCols = await _getTableColumns(db, 'tags');
final readerBooksCols = await _getTableColumns(db, 'reader_books');
final bookAnnotationsCols = await _getTableColumns(db, 'book_annotations');
await db.transaction((txn) async {
await txn.delete('movie_reviews');
await txn.delete('movie_posters');
await txn.delete('book_reviews');
await txn.delete('book_excerpts');
await txn.delete('book_annotations');
await txn.delete('movies');
await txn.delete('books');
await txn.delete('notes');
await txn.delete('reader_books');
await txn.delete('tags');
if (data.containsKey('movies')) {
for (final m in data['movies'] as List) {
await txn.insert('movies', _updateImagePath(_convertToDbMapSafe(m, moviesCols), 'poster_path', imagePathMap));
}
}
if (data.containsKey('books')) {
for (final b in data['books'] as List) {
await txn.insert('books', _updateImagePath(_convertToDbMapSafe(b, booksCols), 'cover_path', imagePathMap));
}
}
if (data.containsKey('notes')) {
for (final n in data['notes'] as List) {
await txn.insert('notes', _updateNoteImagesPath(_convertToDbMapSafe(n, notesCols), imagePathMap));
}
}
if (data.containsKey('movie_reviews')) {
for (final r in data['movie_reviews'] as List) {
await txn.insert('movie_reviews', _convertToDbMapSafe(r, movieReviewsCols));
}
}
if (data.containsKey('movie_posters')) {
for (final p in data['movie_posters'] as List) {
await txn.insert('movie_posters', _updateImagePath(_convertToDbMapSafe(p, moviePostersCols), 'poster_path', imagePathMap));
}
}
if (data.containsKey('book_reviews')) {
for (final r in data['book_reviews'] as List) {
await txn.insert('book_reviews', _convertToDbMapSafe(r, bookReviewsCols));
}
}
if (data.containsKey('book_excerpts')) {
for (final e in data['book_excerpts'] as List) {
await txn.insert('book_excerpts', _convertToDbMapSafe(e, bookExcerptsCols));
}
}
if (data.containsKey('reader_books')) {
for (final rb in data['reader_books'] as List) {
var row = _updateImagePath(_convertToDbMapSafe(rb, readerBooksCols), 'cover_path', imagePathMap);
row = _updateEpubPaths(row, epubFileMap);
await txn.insert('reader_books', row);
}
}
if (data.containsKey('book_annotations')) {
for (final a in data['book_annotations'] as List) {
await txn.insert('book_annotations', _convertToDbMapSafe(a, bookAnnotationsCols));
}
}
if (data.containsKey('tags')) {
for (final t in data['tags'] as List) {
final map = _convertToDbMapSafe(t, tagsCols);
await txn.rawInsert(
'INSERT OR IGNORE INTO tags (id, name, type, created_at) VALUES (?, ?, ?, ?)',
[map['id'], map['name'], map['type'], map['created_at']],
);
}
}
});
// 恢复用户信息
await _restoreUserInfo(backupData, imagePathMap);
return ImportResult.success(_buildStats(data, imageCount));
} catch (e) {
return ImportResult.error('导入失败: $e');
}
}
/// 从 ZIP 字节数据恢复(供 WebDAV 同步等场景使用)
Future<ImportResult> restoreFromZipBytes(Uint8List zipBytes) async {
try {
final archive = ZipDecoder().decodeBytes(zipBytes);
final dataFile = archive.findFile('data.json');
if (dataFile == null) return ImportResult.error('备份文件中没有找到数据文件');
final backupData = jsonDecode(utf8.decode(dataFile.content as List<int>)) as Map<String, dynamic>;
final imagePathMap = <String, String>{};
final epubFileMap = <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);
final outputFile = File(path.join(imagesDir.path, relativePath));
if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true);
await outputFile.writeAsBytes(archiveFile.content as List<int>);
imagePathMap[relativePath] = outputFile.path;
imageCount++;
} else if (archiveFile.name.startsWith('epub_books/')) {
final relativePath = archiveFile.name.substring(12);
final epubDir = Directory(path.join(appDir.path, 'epub_books'));
if (!await epubDir.exists()) await epubDir.create(recursive: true);
final outputFile = File(path.join(epubDir.path, relativePath));
if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true);
await outputFile.writeAsBytes(archiveFile.content as List<int>);
epubFileMap[relativePath] = outputFile.path;
}
}
if (!backupData.containsKey('data')) return ImportResult.error('无效的备份文件格式');
final version = backupData['version'] as int? ?? 1;
if (version > 2) {
debugPrint('[BackupService] 警告: 备份版本 $version 高于当前支持的版本 2部分数据可能丢失');
}
final data = backupData['data'] as Map<String, dynamic>;
final db = await DatabaseHelper.instance.database;
final moviesCols = await _getTableColumns(db, 'movies');
final booksCols = await _getTableColumns(db, 'books');
final notesCols = await _getTableColumns(db, 'notes');
final movieReviewsCols = await _getTableColumns(db, 'movie_reviews');
final moviePostersCols = await _getTableColumns(db, 'movie_posters');
final bookReviewsCols = await _getTableColumns(db, 'book_reviews');
final bookExcerptsCols = await _getTableColumns(db, 'book_excerpts');
final tagsCols = await _getTableColumns(db, 'tags');
final readerBooksCols = await _getTableColumns(db, 'reader_books');
final bookAnnotationsCols = await _getTableColumns(db, 'book_annotations');
await db.transaction((txn) async {
await txn.delete('movie_reviews');
await txn.delete('movie_posters');
await txn.delete('book_reviews');
await txn.delete('book_excerpts');
await txn.delete('book_annotations');
await txn.delete('movies');
await txn.delete('books');
await txn.delete('notes');
await txn.delete('reader_books');
await txn.delete('tags'); // 修复: 之前漏删 tags 表
if (data.containsKey('movies')) {
for (final m in data['movies'] as List) {
await txn.insert('movies', _updateImagePath(_convertToDbMapSafe(m, moviesCols), 'poster_path', imagePathMap));
}
}
if (data.containsKey('books')) {
for (final b in data['books'] as List) {
await txn.insert('books', _updateImagePath(_convertToDbMapSafe(b, booksCols), 'cover_path', imagePathMap));
}
}
if (data.containsKey('notes')) {
for (final n in data['notes'] as List) {
await txn.insert('notes', _updateNoteImagesPath(_convertToDbMapSafe(n, notesCols), imagePathMap));
}
}
if (data.containsKey('movie_reviews')) {
for (final r in data['movie_reviews'] as List) {
await txn.insert('movie_reviews', _convertToDbMapSafe(r, movieReviewsCols));
}
}
if (data.containsKey('movie_posters')) {
for (final p in data['movie_posters'] as List) {
await txn.insert('movie_posters', _updateImagePath(_convertToDbMapSafe(p, moviePostersCols), 'poster_path', imagePathMap));
}
}
if (data.containsKey('book_reviews')) {
for (final r in data['book_reviews'] as List) {
await txn.insert('book_reviews', _convertToDbMapSafe(r, bookReviewsCols));
}
}
if (data.containsKey('book_excerpts')) {
for (final e in data['book_excerpts'] as List) {
await txn.insert('book_excerpts', _convertToDbMapSafe(e, bookExcerptsCols));
}
}
if (data.containsKey('reader_books')) {
for (final rb in data['reader_books'] as List) {
var row = _updateImagePath(_convertToDbMapSafe(rb, readerBooksCols), 'cover_path', imagePathMap);
row = _updateEpubPaths(row, epubFileMap);
await txn.insert('reader_books', row);
}
}
if (data.containsKey('book_annotations')) {
for (final a in data['book_annotations'] as List) {
await txn.insert('book_annotations', _convertToDbMapSafe(a, bookAnnotationsCols));
}
}
if (data.containsKey('tags')) {
for (final t in data['tags'] as List) {
final map = _convertToDbMapSafe(t, tagsCols);
await txn.rawInsert(
'INSERT OR IGNORE INTO tags (id, name, type, created_at) VALUES (?, ?, ?, ?)',
[map['id'], map['name'], map['type'], map['created_at']],
);
}
}
});
await _restoreUserInfo(backupData, imagePathMap);
return ImportResult.success(_buildStats(data, imageCount));
} catch (e) {
return ImportResult.error('恢复失败: $e');
}
}
// ─── 内部辅助方法 ─────────────────────────────────────
Future<void> _restoreUserInfo(Map<String, dynamic> backupData, Map<String, String> imagePathMap) async {
if (!backupData.containsKey('userInfo')) return;
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 relPath = _toRelativePath(avatarPath);
if (imagePathMap.containsKey(relPath)) {
await userPrefs.setAvatarPath(imagePathMap[relPath]!);
}
}
}
// 恢复完整 SharedPreferences
if (backupData.containsKey('sharedPrefs')) {
await _restoreSharedPrefs(backupData['sharedPrefs'] as Map<String, dynamic>);
}
}
/// 导出完整 SharedPreferences
Future<Map<String, dynamic>> _exportSharedPrefs() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys();
final map = <String, dynamic>{};
for (final key in keys) {
map[key] = prefs.get(key);
}
return map;
}
/// 恢复 SharedPreferences保留当前设备的同步和路径配置
Future<void> _restoreSharedPrefs(Map<String, dynamic> data) async {
final prefs = await SharedPreferences.getInstance();
// 这些键是设备特定的,不应从备份恢复
const skipKeys = {
'avatarPath',
'webdav_config',
'webdav_last_sync',
'webdav_auto_sync',
'webdav_auto_sync_interval',
};
for (final entry in data.entries) {
final key = entry.key;
final value = entry.value;
if (skipKeys.contains(key)) continue;
if (value is String) {
await prefs.setString(key, value);
} else if (value is int) {
await prefs.setInt(key, value);
} else if (value is double) {
await prefs.setDouble(key, value);
} else if (value is bool) {
await prefs.setBool(key, value);
} else if (value is List) {
await prefs.setStringList(key, value.cast<String>());
}
}
}
Map<String, int> _buildStats(Map<String, dynamic> data, int imageCount) {
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 (data.containsKey('book_reviews')) stats['书评'] = (data['book_reviews'] as List).length;
if (data.containsKey('book_excerpts')) stats['书摘'] = (data['book_excerpts'] as List).length;
if (data.containsKey('tags')) stats['标签'] = (data['tags'] as List).length;
if (data.containsKey('reader_books')) stats['阅读'] = (data['reader_books'] as List).length;
if (data.containsKey('book_annotations')) stats['批注'] = (data['book_annotations'] as List).length;
if (imageCount > 0) stats['图片'] = imageCount;
return stats;
}
/// 将绝对路径转为 images/ 下的相对路径(用于 imagePathMap key
String _toRelativePath(String absolutePath) {
// 尝试提取 images/ 后面的部分
final idx = absolutePath.indexOf('/images/');
if (idx >= 0) return absolutePath.substring(idx + 8); // skip '/images/'
// Windows 路径
final winIdx = absolutePath.indexOf('\\images\\');
if (winIdx >= 0) return absolutePath.substring(winIdx + 8);
return path.basename(absolutePath);
}
Map<String, dynamic> _convertToDbMapSafe(dynamic item, Set<String> validColumns) {
final raw = _convertToDbMap(item);
if (raw.isEmpty) return raw;
return Map.fromEntries(raw.entries.where((e) => validColumns.contains(e.key)));
}
Future<Set<String>> _getTableColumns(Database db, String table) async {
final columns = await db.rawQuery('PRAGMA table_info($table)');
return columns.map((c) => c['name'] as String).toSet();
}
Map<String, dynamic> _convertToDbMap(dynamic item) {
if (item is Map<String, dynamic>) {
return item.map((key, value) {
if (value is bool) return MapEntry(key, value ? 1 : 0);
return MapEntry(key, value);
});
}
return {};
}
/// 更新 epub 阅读器的 file_path 和 cover_path
Map<String, dynamic> _updateEpubPaths(Map<String, dynamic> item, Map<String, String> epubFileMap) {
if (epubFileMap.isEmpty) return item;
final newItem = Map<String, dynamic>.from(item);
final oldFilePath = item['file_path'] as String?;
if (oldFilePath != null && oldFilePath.isNotEmpty) {
final oldRel = _toEpubRelativePath(oldFilePath);
if (oldRel != null && epubFileMap.containsKey(oldRel)) {
newItem['file_path'] = epubFileMap[oldRel];
}
}
final oldCoverPath = item['cover_path'] as String?;
if (oldCoverPath != null && oldCoverPath.isNotEmpty) {
final oldRel = _toEpubRelativePath(oldCoverPath);
if (oldRel != null && epubFileMap.containsKey(oldRel)) {
newItem['cover_path'] = epubFileMap[oldRel];
}
}
return newItem;
}
/// 从绝对路径中提取 epub_books/ 下的相对路径
String? _toEpubRelativePath(String absolutePath) {
final idx = absolutePath.indexOf('/epub_books/');
if (idx >= 0) return absolutePath.substring(idx + 13); // skip '/epub_books/'
final winIdx = absolutePath.indexOf('\\epub_books\\');
if (winIdx >= 0) return absolutePath.substring(winIdx + 13);
return null;
}
/// 更新单值图片路径poster_path / cover_path
Map<String, dynamic> _updateImagePath(Map<String, dynamic> item, String pathField, Map<String, String> imagePathMap) {
final newItem = Map<String, dynamic>.from(item);
final oldPath = item[pathField] as String?;
if (oldPath != null && oldPath.isNotEmpty) {
final relPath = _toRelativePath(oldPath);
if (imagePathMap.containsKey(relPath)) {
newItem[pathField] = imagePathMap[relPath];
}
}
return newItem;
}
/// 更新笔记多图路径images JSON 列表)
Map<String, dynamic> _updateNoteImagesPath(Map<String, dynamic> item, Map<String, String> imagePathMap) {
final newItem = Map<String, dynamic>.from(item);
final imagesJson = item['images'] as String?;
if (imagesJson == null || imagesJson.isEmpty) return newItem;
try {
final images = jsonDecode(imagesJson) as List<dynamic>;
final updatedImages = <String>[];
for (final imagePath in images) {
if (imagePath is String && imagePath.isNotEmpty) {
final relPath = _toRelativePath(imagePath);
updatedImages.add(imagePathMap[relPath] ?? imagePath);
}
}
newItem['images'] = jsonEncode(updatedImages);
} catch (e) {
debugPrint('[BackupService] 笔记图片路径更新失败: $e');
}
return newItem;
}
String _formatDateTime(DateTime dateTime) {
return '${dateTime.year}${_pad(dateTime.month)}${_pad(dateTime.day)}_${_pad(dateTime.hour)}${_pad(dateTime.minute)}${_pad(dateTime.second)}';
}
String _pad(int number) => number.toString().padLeft(2, '0');
}
/// 导出中间数据
class _ExportData {
final Uint8List zipBytes;
final int movieCount;
final int bookCount;
final int noteCount;
final int imageCount;
final int epubCount;
_ExportData({
required this.zipBytes,
required this.movieCount,
required this.bookCount,
required this.noteCount,
required this.imageCount,
this.epubCount = 0,
});
}
// ─── 结果类型 ──────────────────────────────────────────
class AutoBackupExportResult {
final bool success;
final String? errorMessage;
final Uint8List? zipBytes;
final int movieCount;
final int bookCount;
final int noteCount;
final int imageCount;
final int epubCount;
AutoBackupExportResult._({
required this.success,
this.errorMessage,
this.zipBytes,
this.movieCount = 0,
this.bookCount = 0,
this.noteCount = 0,
this.imageCount = 0,
this.epubCount = 0,
});
factory AutoBackupExportResult.success({
required Uint8List zipBytes,
required int movieCount,
required int bookCount,
required int noteCount,
required int imageCount,
int epubCount = 0,
}) {
return AutoBackupExportResult._(
success: true, zipBytes: zipBytes,
movieCount: movieCount, bookCount: bookCount,
noteCount: noteCount, imageCount: imageCount,
epubCount: epubCount,
);
}
factory AutoBackupExportResult.error(String message) {
return AutoBackupExportResult._(success: false, errorMessage: message);
}
}
class ExportResult {
final bool success;
final bool cancelled;
final String? errorMessage;
final String? filePath;
final int movieCount;
final int bookCount;
final int noteCount;
final int imageCount;
ExportResult._({
required this.success,
this.cancelled = false,
this.errorMessage,
this.filePath,
this.movieCount = 0,
this.bookCount = 0,
this.noteCount = 0,
this.imageCount = 0,
});
factory ExportResult.success({
required String filePath,
required int movieCount,
required int bookCount,
required int noteCount,
required int imageCount,
}) {
return ExportResult._(
success: true, filePath: filePath,
movieCount: movieCount, bookCount: bookCount,
noteCount: noteCount, imageCount: imageCount,
);
}
factory ExportResult.cancelled() {
return ExportResult._(success: false, cancelled: true);
}
factory ExportResult.error(String message) {
return ExportResult._(success: false, errorMessage: message);
}
}
class ImportResult {
final bool success;
final bool cancelled;
final String? errorMessage;
final Map<String, int>? stats;
ImportResult._({
required this.success,
this.cancelled = false,
this.errorMessage,
this.stats,
});
factory ImportResult.success(Map<String, int> stats) {
return ImportResult._(success: true, stats: stats);
}
factory ImportResult.cancelled() {
return ImportResult._(success: false, cancelled: true);
}
factory ImportResult.error(String message) {
return ImportResult._(success: false, errorMessage: message);
}
String get statsText {
if (stats == null || stats!.isEmpty) return '没有导入任何数据';
return stats!.entries.map((e) => '${e.key}: ${e.value}').join('');
}
}

View File

@@ -0,0 +1,585 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
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;
bool _isSyncing = false;
/// 获取配置
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 {
// 防止并发同步
if (_isSyncing) {
return SyncResult(success: false, message: '同步正在进行中,请稍后再试');
}
_isSyncing = true;
final config = await getConfig();
if (config == null) {
_isSyncing = false;
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;
debugPrint('[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;
debugPrint('[WebDAV] 备份恢复成功: ${importResult.statsText}');
} else {
return SyncResult(success: false, message: importResult.errorMessage ?? '恢复备份失败');
}
} else {
return SyncResult(success: false, message: '服务器上没有备份文件,请先从其他设备上传');
}
} else if (direction == SyncDirection.bidirectional) {
// 获取远程备份的修改时间
final remoteModTime = await _getRemoteFileModifiedTime(client, zipUrl, username, password);
// 获取上次同步时间
final syncPrefs = await SharedPreferences.getInstance();
final lastSyncStr = syncPrefs.getString(_lastSyncKey);
final lastSyncTime = lastSyncStr != null ? DateTime.tryParse(lastSyncStr) : null;
final bool remoteIsNewer = remoteModTime != null &&
(lastSyncTime == null || remoteModTime.isAfter(lastSyncTime));
if (remoteIsNewer) {
// 远程更新,下载并恢复
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;
debugPrint('[WebDAV] 远程备份较新,已恢复: ${importResult.statsText}');
}
} else {
try { await tempZip.delete(); } catch (_) {}
}
} else {
debugPrint('[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;
debugPrint('[WebDAV] 本地备份已上传 (影视${exportResult.movieCount} 书籍${exportResult.bookCount} 笔记${exportResult.noteCount} 图片${exportResult.imageCount})');
}
}
}
final prefs = await SharedPreferences.getInstance();
// 仅在上传成功或下载成功时记录同步时间
final bool anySuccess = uploadedFiles > 0 || downloadedFiles > 0;
if (anySuccess) {
await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String());
}
return SyncResult(
success: anySuccess,
message: anySuccess ? '同步完成' : '同步未完成,未传输任何数据',
lastSyncTime: DateTime.now(),
uploadedFiles: uploadedFiles,
downloadedFiles: downloadedFiles,
uploadedImages: uploadedImages,
downloadedImages: downloadedImages,
needReload: needReload,
);
} finally {
client.close();
}
} catch (e) {
return SyncResult(success: false, message: '同步失败: $e');
} finally {
_isSyncing = false;
}
}
/// 获取自动同步间隔(分钟)
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);
// 立即执行一次同步
debugPrint('[WebDAV] 自动同步已启动,间隔 $_autoSyncIntervalMinutes 分钟');
await syncData(direction: SyncDirection.bidirectional);
// 设置定时器
_autoSyncTimer = Timer.periodic(
Duration(minutes: _autoSyncIntervalMinutes),
(timer) async {
if (_isAutoSyncEnabled) {
try {
await syncData(direction: SyncDirection.bidirectional);
} catch (e) {
debugPrint('[WebDAV] 自动同步异常: $e');
}
}
},
);
}
/// 停止自动同步
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);
}
}
debugPrint('[WebDAV] PUT $url -> ${response.statusCode}');
return response.statusCode == 200 || response.statusCode == 201 || response.statusCode == 204;
} catch (e) {
debugPrint('[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);
}
}
debugPrint('[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);
debugPrint('[WebDAV] Downloaded ${bytes.length} bytes');
return true;
}
return false;
} catch (e) {
// ignore
return false;
}
}
/// 获取远程备份文件信息(修改时间和大小)
Future<Map<String, dynamic>?> getRemoteBackupInfo() async {
final config = await getConfig();
if (config == null) return null;
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();
try {
var request = http.Request('HEAD', Uri.parse(zipUrl));
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('HEAD', Uri.parse(location));
request.headers['Authorization'] = _basicAuth(username, password);
response = await client.send(request);
}
}
if (response.statusCode == 200) {
final lastModified = response.headers['last-modified'];
final contentLength = response.headers['content-length'];
DateTime? modifiedTime;
if (lastModified != null) {
modifiedTime = HttpDate.parse(lastModified).toLocal();
}
return {
'modifiedTime': modifiedTime,
'size': contentLength != null ? int.tryParse(contentLength) : null,
};
}
return null;
} catch (e) {
debugPrint('[WebDAV] 获取远程备份信息失败: $e');
return null;
} finally {
client.close();
}
}
Future<DateTime?> _getRemoteFileModifiedTime(
http.Client client,
String url,
String username,
String password,
) async {
try {
var request = http.Request('HEAD', 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('HEAD', Uri.parse(location));
request.headers['Authorization'] = _basicAuth(username, password);
response = await client.send(request);
}
}
if (response.statusCode == 200) {
final lastModified = response.headers['last-modified'];
if (lastModified != null) {
return HttpDate.parse(lastModified);
}
}
return null;
} catch (e) {
debugPrint('[WebDAV] 获取远程文件时间失败: $e');
return null;
}
}
/// Basic Auth 编码
String _basicAuth(String username, String password) {
final credentials = base64Encode(utf8.encode('$username:$password'));
return 'Basic $credentials';
}
}