generated from dellevin/template
优化webdav恢复
This commit is contained in:
@@ -3,7 +3,8 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(flutter analyze *)",
|
"Bash(flutter analyze *)",
|
||||||
"Bash(python _fix_script.py)",
|
"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);
|
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) {
|
if (context.mounted) {
|
||||||
final isSuccess = result.success;
|
final isSuccess = result.success;
|
||||||
|
|||||||
@@ -233,38 +233,12 @@ class _ProfilePageState extends State<ProfilePage> {
|
|||||||
final notes = provider.notes;
|
final notes = provider.notes;
|
||||||
|
|
||||||
final movieCount = movies.where((m) => !m.isDeleted).length;
|
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 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 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(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
|
||||||
child: Column(
|
child: Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// 主统计卡片
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8F8F8),
|
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() {
|
Widget _buildMenuSection() {
|
||||||
return Container(
|
return Container(
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ class _StatisticsPageState extends State<StatisticsPage> {
|
|||||||
final readingBooks = books.where((b) => b.status == 'reading').length;
|
final readingBooks = books.where((b) => b.status == 'reading').length;
|
||||||
final wantToReadBooks = books.where((b) => b.status == 'want_to_read').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>[];
|
final children = <Widget>[];
|
||||||
|
|
||||||
// 数据总览
|
// 数据总览
|
||||||
@@ -106,25 +116,25 @@ class _StatisticsPageState extends State<StatisticsPage> {
|
|||||||
|
|
||||||
// 影视状态分布
|
// 影视状态分布
|
||||||
if (_showMovies) {
|
if (_showMovies) {
|
||||||
children.add(_buildSectionTitle('影视状态分布'));
|
children.add(_buildSectionTitle('影视'));
|
||||||
children.add(const SizedBox(height: 16));
|
children.add(const SizedBox(height: 16));
|
||||||
children.add(_buildStatusDistribution([
|
children.add(_buildStatusDistribution([
|
||||||
_StatusData('已看', watchedMovies, const Color(0xFF1A1A1A)),
|
_StatusData('已看', watchedMovies, const Color(0xFF1A1A1A)),
|
||||||
_StatusData('在看', watchingMovies, const Color(0xFF666666)),
|
_StatusData('在看', watchingMovies, const Color(0xFF666666)),
|
||||||
_StatusData('想看', wantToWatchMovies, const Color(0xFF999999)),
|
_StatusData('想看', wantToWatchMovies, const Color(0xFF999999)),
|
||||||
]));
|
], avgRating: avgMovieRating));
|
||||||
children.add(const SizedBox(height: 32));
|
children.add(const SizedBox(height: 32));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 书籍状态分布
|
// 书籍状态分布
|
||||||
if (_showBooks) {
|
if (_showBooks) {
|
||||||
children.add(_buildSectionTitle('书籍状态分布'));
|
children.add(_buildSectionTitle('书籍'));
|
||||||
children.add(const SizedBox(height: 16));
|
children.add(const SizedBox(height: 16));
|
||||||
children.add(_buildStatusDistribution([
|
children.add(_buildStatusDistribution([
|
||||||
_StatusData('已读', readBooks, const Color(0xFF1A1A1A)),
|
_StatusData('已读', readBooks, const Color(0xFF1A1A1A)),
|
||||||
_StatusData('在读', readingBooks, const Color(0xFF666666)),
|
_StatusData('在读', readingBooks, const Color(0xFF666666)),
|
||||||
_StatusData('想读', wantToReadBooks, const Color(0xFF999999)),
|
_StatusData('想读', wantToReadBooks, const Color(0xFF999999)),
|
||||||
]));
|
], avgRating: avgBookRating));
|
||||||
children.add(const SizedBox(height: 32));
|
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);
|
final total = data.fold(0, (sum, item) => sum + item.count);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -305,7 +315,8 @@ class _StatisticsPageState extends State<StatisticsPage> {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: data.map((item) {
|
children: [
|
||||||
|
...data.map((item) {
|
||||||
final percentage = total > 0 ? (item.count / total * 100).toStringAsFixed(1) : '0';
|
final percentage = total > 0 ? (item.count / total * 100).toStringAsFixed(1) : '0';
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
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
|
||||||
Map<String, dynamic> _convertToDbMap(dynamic item) {
|
Map<String, dynamic> _convertToDbMap(dynamic item) {
|
||||||
if (item is Map<String, dynamic>) {
|
if (item is Map<String, dynamic>) {
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:archive/archive_io.dart';
|
import 'backup_service.dart';
|
||||||
import '../database_helper.dart';
|
|
||||||
import '../user_prefs.dart';
|
|
||||||
|
|
||||||
/// WebDAV 同步结果
|
/// WebDAV 同步结果
|
||||||
class SyncResult {
|
class SyncResult {
|
||||||
@@ -40,14 +38,7 @@ enum SyncDirection {
|
|||||||
bidirectional, // 双向同步
|
bidirectional, // 双向同步
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 图片同步结果
|
/// WebDAV 服务类 - 完整备份 zip 同步
|
||||||
class _ImageSyncResult {
|
|
||||||
final int uploaded;
|
|
||||||
final int downloaded;
|
|
||||||
_ImageSyncResult({required this.uploaded, required this.downloaded});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// WebDAV 服务类 - 支持实时同步(数据+图片分离存储)
|
|
||||||
class WebDAVService {
|
class WebDAVService {
|
||||||
static final WebDAVService _instance = WebDAVService._internal();
|
static final WebDAVService _instance = WebDAVService._internal();
|
||||||
static WebDAVService get instance => _instance;
|
static WebDAVService get instance => _instance;
|
||||||
@@ -58,7 +49,6 @@ class WebDAVService {
|
|||||||
static const String _lastSyncKey = 'webdav_last_sync';
|
static const String _lastSyncKey = 'webdav_last_sync';
|
||||||
static const String _autoSyncKey = 'webdav_auto_sync';
|
static const String _autoSyncKey = 'webdav_auto_sync';
|
||||||
static const String _autoSyncIntervalKey = 'webdav_auto_sync_interval';
|
static const String _autoSyncIntervalKey = 'webdav_auto_sync_interval';
|
||||||
static const String _lastDbModifiedKey = 'webdav_last_db_modified';
|
|
||||||
|
|
||||||
// 默认自动同步间隔(分钟)
|
// 默认自动同步间隔(分钟)
|
||||||
static const int _defaultAutoSyncInterval = 5;
|
static const int _defaultAutoSyncInterval = 5;
|
||||||
@@ -68,12 +58,6 @@ class WebDAVService {
|
|||||||
bool _isAutoSyncEnabled = false;
|
bool _isAutoSyncEnabled = false;
|
||||||
int _autoSyncIntervalMinutes = _defaultAutoSyncInterval;
|
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 {
|
Future<Map<String, String>?> getConfig() async {
|
||||||
if (_cachedConfig != null) {
|
if (_cachedConfig != null) {
|
||||||
@@ -120,7 +104,6 @@ class WebDAVService {
|
|||||||
await prefs.remove(_lastSyncKey);
|
await prefs.remove(_lastSyncKey);
|
||||||
await prefs.remove(_autoSyncKey);
|
await prefs.remove(_autoSyncKey);
|
||||||
await prefs.remove(_autoSyncIntervalKey);
|
await prefs.remove(_autoSyncIntervalKey);
|
||||||
await prefs.remove(_lastDbModifiedKey);
|
|
||||||
_cachedConfig = null;
|
_cachedConfig = null;
|
||||||
stopAutoSync();
|
stopAutoSync();
|
||||||
}
|
}
|
||||||
@@ -136,8 +119,6 @@ class WebDAVService {
|
|||||||
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||||
var davUrl = '$baseUrl$path';
|
var davUrl = '$baseUrl$path';
|
||||||
|
|
||||||
// print('WebDAV: Testing connection to $davUrl');
|
|
||||||
|
|
||||||
final client = http.Client();
|
final client = http.Client();
|
||||||
try {
|
try {
|
||||||
var propfindRequest = http.Request('PROPFIND', Uri.parse(davUrl));
|
var propfindRequest = http.Request('PROPFIND', Uri.parse(davUrl));
|
||||||
@@ -151,7 +132,6 @@ class WebDAVService {
|
|||||||
</D:propfind>''';
|
</D:propfind>''';
|
||||||
|
|
||||||
var propfindResponse = await client.send(propfindRequest);
|
var propfindResponse = await client.send(propfindRequest);
|
||||||
// print('WebDAV: PROPFIND status ${propfindResponse.statusCode}');
|
|
||||||
|
|
||||||
if (propfindResponse.statusCode == 301 ||
|
if (propfindResponse.statusCode == 301 ||
|
||||||
propfindResponse.statusCode == 302 ||
|
propfindResponse.statusCode == 302 ||
|
||||||
@@ -178,12 +158,12 @@ class WebDAVService {
|
|||||||
} else if (propfindResponse.statusCode == 401) {
|
} else if (propfindResponse.statusCode == 401) {
|
||||||
return {'success': false, 'message': '认证失败,请检查用户名和密码'};
|
return {'success': false, 'message': '认证失败,请检查用户名和密码'};
|
||||||
} else if (propfindResponse.statusCode == 404) {
|
} else if (propfindResponse.statusCode == 404) {
|
||||||
// print('WebDAV: Directory not found, trying to create...');
|
// 目录不存在,尝试创建
|
||||||
} else {
|
} else {
|
||||||
return {'success': false, 'message': '服务器返回错误: ${propfindResponse.statusCode}'};
|
return {'success': false, 'message': '服务器返回错误: ${propfindResponse.statusCode}'};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// print('WebDAV: PROPFIND error: $e');
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -191,7 +171,6 @@ class WebDAVService {
|
|||||||
mkcolRequest.headers['Authorization'] = _basicAuth(username, password);
|
mkcolRequest.headers['Authorization'] = _basicAuth(username, password);
|
||||||
|
|
||||||
final mkcolResponse = await client.send(mkcolRequest);
|
final mkcolResponse = await client.send(mkcolRequest);
|
||||||
// print('WebDAV: MKCOL status ${mkcolResponse.statusCode}');
|
|
||||||
|
|
||||||
if (mkcolResponse.statusCode == 201) {
|
if (mkcolResponse.statusCode == 201) {
|
||||||
return {'success': true, 'message': '连接成功,已创建目录'};
|
return {'success': true, 'message': '连接成功,已创建目录'};
|
||||||
@@ -205,18 +184,16 @@ class WebDAVService {
|
|||||||
return {'success': false, 'message': '创建目录失败: ${mkcolResponse.statusCode}'};
|
return {'success': false, 'message': '创建目录失败: ${mkcolResponse.statusCode}'};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// print('WebDAV: MKCOL error: $e');
|
|
||||||
return {'success': false, 'message': '连接失败: $e'};
|
return {'success': false, 'message': '连接失败: $e'};
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// print('WebDAV test connection error: $e');
|
|
||||||
return {'success': false, 'message': '连接失败: $e'};
|
return {'success': false, 'message': '连接失败: $e'};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步数据(数据+图片分离存储,非压缩包方式)
|
/// 同步数据 — 完整备份 zip 格式,与本地备份完全一致
|
||||||
Future<SyncResult> syncData({SyncDirection direction = SyncDirection.bidirectional}) async {
|
Future<SyncResult> syncData({SyncDirection direction = SyncDirection.bidirectional}) async {
|
||||||
final config = await getConfig();
|
final config = await getConfig();
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
@@ -229,18 +206,8 @@ class WebDAVService {
|
|||||||
final password = config['password']!;
|
final password = config['password']!;
|
||||||
final path = config['path']!;
|
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 baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||||
final dbUrl = '$baseUrl$path/mooknote.db';
|
final zipUrl = '$baseUrl$path/mooknote_backup.zip';
|
||||||
final imagesUrl = '$baseUrl$path/images';
|
|
||||||
final avatarsUrl = '$baseUrl$path/avatars';
|
|
||||||
final userConfigUrl = '$baseUrl$path/user_config.json';
|
|
||||||
|
|
||||||
final client = http.Client();
|
final client = http.Client();
|
||||||
int uploadedFiles = 0;
|
int uploadedFiles = 0;
|
||||||
@@ -251,70 +218,73 @@ class WebDAVService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (direction == SyncDirection.upload) {
|
if (direction == SyncDirection.upload) {
|
||||||
// 上传数据库文件
|
final exportResult = await BackupService.instance.exportDataForAutoBackup();
|
||||||
final dbSuccess = await _uploadFile(client, dbUrl, username, password, dbFile);
|
if (!exportResult.success || exportResult.zipBytes == null) {
|
||||||
if (dbSuccess) {
|
return SyncResult(success: false, message: exportResult.errorMessage ?? '创建备份失败');
|
||||||
uploadedFiles = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传所有图片
|
final success = await _uploadBytes(client, zipUrl, username, password, exportResult.zipBytes!);
|
||||||
final imageResult = await _syncImages(client, imagesUrl, username, password, SyncDirection.upload);
|
if (success) {
|
||||||
uploadedImages = imageResult.uploaded;
|
uploadedFiles = 1;
|
||||||
|
uploadedImages = exportResult.imageCount;
|
||||||
// 上传头像
|
print('[WebDAV] 备份上传成功 (影视${exportResult.movieCount} 书籍${exportResult.bookCount} 笔记${exportResult.noteCount} 图片${exportResult.imageCount})');
|
||||||
final avatarResult = await _syncAvatars(client, avatarsUrl, username, password, SyncDirection.upload);
|
} else {
|
||||||
uploadedImages += avatarResult.uploaded;
|
return SyncResult(success: false, message: '上传备份文件失败');
|
||||||
|
}
|
||||||
// 上传用户配置
|
|
||||||
await _uploadUserConfig(client, userConfigUrl, username, password);
|
|
||||||
|
|
||||||
} else if (direction == SyncDirection.download) {
|
} else if (direction == SyncDirection.download) {
|
||||||
// 下载数据库文件
|
final tempDir = await getTemporaryDirectory();
|
||||||
final tempDbFile = File('${dbFile.parent.path}/mooknote_download.db');
|
final tempZip = File(p.join(tempDir.path, 'mooknote_download.zip'));
|
||||||
final dbSuccess = await _downloadFile(client, dbUrl, username, password, tempDbFile);
|
final success = await _downloadFile(client, zipUrl, username, password, tempZip);
|
||||||
|
|
||||||
if (dbSuccess && await tempDbFile.exists()) {
|
if (success && await tempZip.exists()) {
|
||||||
// 替换本地数据库
|
final bytes = await tempZip.readAsBytes();
|
||||||
await tempDbFile.copy(dbFile.path);
|
final importResult = await BackupService.instance.restoreFromZipBytes(bytes);
|
||||||
await tempDbFile.delete();
|
await tempZip.delete();
|
||||||
await DatabaseHelper.instance.reopenDatabase();
|
|
||||||
|
if (importResult.success) {
|
||||||
downloadedFiles = 1;
|
downloadedFiles = 1;
|
||||||
|
downloadedImages = importResult.stats?['图片'] ?? 0;
|
||||||
needReload = true;
|
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) {
|
} else if (direction == SyncDirection.bidirectional) {
|
||||||
// 双向同步:分别同步数据库和图片
|
final tempDir = await getTemporaryDirectory();
|
||||||
final dbResult = await _syncDatabaseFile(client, dbUrl, username, password, dbFile);
|
final tempZip = File(p.join(tempDir.path, 'mooknote_bidir.zip'));
|
||||||
if (dbResult['uploaded'] == true) uploadedFiles = 1;
|
|
||||||
if (dbResult['downloaded'] == true) {
|
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;
|
downloadedFiles = 1;
|
||||||
|
downloadedImages = importResult.stats?['图片'] ?? 0;
|
||||||
needReload = true;
|
needReload = true;
|
||||||
|
print('[WebDAV] 远程备份已恢复: ${importResult.statsText}');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try { await tempZip.delete(); } catch (_) {}
|
||||||
|
print('[WebDAV] 服务器无备份,仅上传本地数据');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 双向同步图片
|
// 始终上传本地,确保远程是最新的
|
||||||
final imageResult = await _syncImagesBidirectional(client, imagesUrl, username, password);
|
final exportResult = await BackupService.instance.exportDataForAutoBackup();
|
||||||
uploadedImages = imageResult.uploaded;
|
if (exportResult.success && exportResult.zipBytes != null) {
|
||||||
downloadedImages = imageResult.downloaded;
|
final uploadSuccess = await _uploadBytes(client, zipUrl, username, password, exportResult.zipBytes!);
|
||||||
|
if (uploadSuccess) {
|
||||||
// 双向同步头像
|
uploadedFiles = 1;
|
||||||
final avatarResult = await _syncAvatarsBidirectional(client, avatarsUrl, username, password);
|
uploadedImages = exportResult.imageCount;
|
||||||
uploadedImages += avatarResult.uploaded;
|
print('[WebDAV] 本地备份已上传 (影视${exportResult.movieCount} 书籍${exportResult.bookCount} 笔记${exportResult.noteCount} 图片${exportResult.imageCount})');
|
||||||
downloadedImages += avatarResult.downloaded;
|
}
|
||||||
|
}
|
||||||
// 双向同步用户配置:先下载远程,再上传本地
|
|
||||||
await _downloadUserConfig(client, userConfigUrl, username, password);
|
|
||||||
await _uploadUserConfig(client, userConfigUrl, username, password);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
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 {
|
Future<int> getAutoSyncInterval() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -618,15 +323,13 @@ class WebDAVService {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setInt(_autoSyncIntervalKey, minutes);
|
await prefs.setInt(_autoSyncIntervalKey, minutes);
|
||||||
|
|
||||||
// 如果正在自动同步,重启以应用新间隔
|
|
||||||
if (_isAutoSyncEnabled) {
|
if (_isAutoSyncEnabled) {
|
||||||
await startAutoSync();
|
await startAutoSync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动自动同步(带文件监听)
|
/// 启动自动同步
|
||||||
Future<void> startAutoSync() async {
|
Future<void> startAutoSync() async {
|
||||||
// 停止现有的定时器和监听
|
|
||||||
await stopAutoSync();
|
await stopAutoSync();
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -635,20 +338,18 @@ class WebDAVService {
|
|||||||
await prefs.setBool(_autoSyncKey, true);
|
await prefs.setBool(_autoSyncKey, true);
|
||||||
|
|
||||||
// 立即执行一次同步
|
// 立即执行一次同步
|
||||||
await _performIncrementalSync();
|
print('[WebDAV] 自动同步已启动,间隔 $_autoSyncIntervalMinutes 分钟');
|
||||||
|
await syncData(direction: SyncDirection.bidirectional);
|
||||||
|
|
||||||
// 设置定时器进行定期同步
|
// 设置定时器
|
||||||
_autoSyncTimer = Timer.periodic(
|
_autoSyncTimer = Timer.periodic(
|
||||||
Duration(minutes: _autoSyncIntervalMinutes),
|
Duration(minutes: _autoSyncIntervalMinutes),
|
||||||
(timer) async {
|
(timer) async {
|
||||||
if (_isAutoSyncEnabled) {
|
if (_isAutoSyncEnabled) {
|
||||||
await _performIncrementalSync();
|
await syncData(direction: SyncDirection.bidirectional);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 启动文件系统监听
|
|
||||||
await _startFileWatcher();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停止自动同步
|
/// 停止自动同步
|
||||||
@@ -657,12 +358,6 @@ class WebDAVService {
|
|||||||
_autoSyncTimer = null;
|
_autoSyncTimer = null;
|
||||||
_isAutoSyncEnabled = false;
|
_isAutoSyncEnabled = false;
|
||||||
|
|
||||||
// 停止文件监听
|
|
||||||
await _imagesDirWatcher?.cancel();
|
|
||||||
_imagesDirWatcher = null;
|
|
||||||
_debounceTimer?.cancel();
|
|
||||||
_pendingImageUploads.clear();
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_autoSyncKey, false);
|
await prefs.setBool(_autoSyncKey, false);
|
||||||
}
|
}
|
||||||
@@ -677,316 +372,25 @@ class WebDAVService {
|
|||||||
return prefs.getBool(_autoSyncKey) ?? false;
|
return prefs.getBool(_autoSyncKey) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动文件系统监听
|
/// 获取上次同步时间
|
||||||
Future<void> _startFileWatcher() async {
|
Future<String?> getLastSyncTime() 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 {
|
|
||||||
// 检查数据库是否需要同步
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final lastDbModifiedStr = prefs.getString(_lastDbModifiedKey);
|
return prefs.getString(_lastSyncKey);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needDbSync) {
|
/// 上传字节数据到 WebDAV
|
||||||
// 上传数据库
|
Future<bool> _uploadBytes(
|
||||||
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(
|
|
||||||
http.Client client,
|
http.Client client,
|
||||||
String url,
|
String url,
|
||||||
String username,
|
String username,
|
||||||
String password,
|
String password,
|
||||||
File file,
|
Uint8List bytes,
|
||||||
) async {
|
) async {
|
||||||
try {
|
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));
|
var request = http.Request('PUT', Uri.parse(url));
|
||||||
request.headers['Authorization'] = _basicAuth(username, password);
|
request.headers['Authorization'] = _basicAuth(username, password);
|
||||||
request.headers['Content-Type'] = 'application/octet-stream';
|
request.headers['Content-Type'] = 'application/zip';
|
||||||
request.bodyBytes = fileBytes;
|
request.bodyBytes = bytes;
|
||||||
|
|
||||||
var response = await client.send(request);
|
var response = await client.send(request);
|
||||||
|
|
||||||
@@ -997,20 +401,21 @@ class WebDAVService {
|
|||||||
if (location != null) {
|
if (location != null) {
|
||||||
request = http.Request('PUT', Uri.parse(location));
|
request = http.Request('PUT', Uri.parse(location));
|
||||||
request.headers['Authorization'] = _basicAuth(username, password);
|
request.headers['Authorization'] = _basicAuth(username, password);
|
||||||
request.headers['Content-Type'] = 'application/octet-stream';
|
request.headers['Content-Type'] = 'application/zip';
|
||||||
request.bodyBytes = fileBytes;
|
request.bodyBytes = bytes;
|
||||||
response = await client.send(request);
|
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) {
|
} catch (e) {
|
||||||
// print('WebDAV: Upload error: $e');
|
print('[WebDAV] _uploadBytes error: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 下载文件
|
/// 下载文件到本地
|
||||||
Future<bool> _downloadFile(
|
Future<bool> _downloadFile(
|
||||||
http.Client client,
|
http.Client client,
|
||||||
String url,
|
String url,
|
||||||
@@ -1019,8 +424,6 @@ class WebDAVService {
|
|||||||
File localFile,
|
File localFile,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
// print('WebDAV: Downloading from $url to ${localFile.path}');
|
|
||||||
|
|
||||||
var request = http.Request('GET', Uri.parse(url));
|
var request = http.Request('GET', Uri.parse(url));
|
||||||
request.headers['Authorization'] = _basicAuth(username, password);
|
request.headers['Authorization'] = _basicAuth(username, password);
|
||||||
|
|
||||||
@@ -1031,26 +434,24 @@ class WebDAVService {
|
|||||||
response.statusCode == 307 || response.statusCode == 308) {
|
response.statusCode == 307 || response.statusCode == 308) {
|
||||||
final location = response.headers['location'];
|
final location = response.headers['location'];
|
||||||
if (location != null) {
|
if (location != null) {
|
||||||
// print('WebDAV: Following redirect to $location');
|
|
||||||
request = http.Request('GET', Uri.parse(location));
|
request = http.Request('GET', Uri.parse(location));
|
||||||
request.headers['Authorization'] = _basicAuth(username, password);
|
request.headers['Authorization'] = _basicAuth(username, password);
|
||||||
response = await client.send(request);
|
response = await client.send(request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// print('WebDAV: Download response status: ${response.statusCode}');
|
print('[WebDAV] GET $url -> ${response.statusCode}');
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
|
await localFile.parent.create(recursive: true);
|
||||||
final bytes = await response.stream.toBytes();
|
final bytes = await response.stream.toBytes();
|
||||||
await localFile.writeAsBytes(bytes);
|
await localFile.writeAsBytes(bytes);
|
||||||
// print('WebDAV: Downloaded ${bytes.length} bytes to ${localFile.path}');
|
print('[WebDAV] Downloaded ${bytes.length} bytes');
|
||||||
return true;
|
return true;
|
||||||
} else {
|
|
||||||
// print('WebDAV: Download failed with status ${response.statusCode}');
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// print('WebDAV: Download error: $e');
|
// ignore
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: mooknote
|
name: mooknote
|
||||||
description: "app for tracking movies, books, and notes"
|
description: "app for tracking movies, books, and notes"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.1.7+2
|
version: 0.1.8
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.5.0
|
sdk: ^3.5.0
|
||||||
|
|||||||
Reference in New Issue
Block a user