优化界面2

This commit is contained in:
DelLevin-Home
2026-05-22 18:23:40 +08:00
parent 79f813c463
commit 6c3e55fead
18 changed files with 2004 additions and 2695 deletions

View File

@@ -1,372 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import '../models/data_models.dart';
import 'database_helper.dart';
import 'storage_helper.dart';
/// 数据迁移帮助类:将旧版文件系统数据迁移到 SQLite 数据库
class DataMigration {
final StorageHelper _storage = StorageHelper.instance;
final DatabaseHelper _db = DatabaseHelper.instance;
static bool _hasMigrated = false;
/// 执行数据迁移(幂等,只会执行一次)
Future<void> migrateIfNeeded() async {
if (_hasMigrated) return;
_hasMigrated = true;
try {
await _migrateMovies();
await _migrateBooks();
await _migrateNotes();
debugPrint('数据迁移完成');
} catch (e, stack) {
debugPrint('数据迁移失败: $e');
debugPrint('堆栈: $stack');
}
}
/// 迁移影视数据
Future<void> _migrateMovies() async {
final moviesDirPath = await _storage.moviesDir;
final movieDirs = await _listSubdirNames(moviesDirPath);
if (movieDirs.isEmpty) return;
debugPrint('发现 ${movieDirs.length} 个影视目录,开始迁移...');
final db = await _db.database;
for (final dirName in movieDirs) {
try {
final dirPath = p.join(moviesDirPath, dirName);
final dataPath = '$dirPath/data.json';
final data = await _readJsonFile(dataPath);
if (data == null) continue;
final movie = Movie.fromJson(data);
await db.insert(
'movies',
_movieToMap(movie),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
// 迁移影评
await _migrateMovieReviews(dirPath, movie.id);
// 迁移海报
await _migrateMoviePosters(dirPath, movie.id);
} catch (e) {
debugPrint('迁移影视 $dirName 失败: $e');
}
}
}
/// 迁移影评
Future<void> _migrateMovieReviews(String movieDirPath, String movieId) async {
final reviewsDir = p.join(movieDirPath, 'reviews');
if (!await Directory(reviewsDir).exists()) return;
final files = await _listJsonFiles(reviewsDir);
final db = await _db.database;
for (final data in files) {
try {
final review = MovieReview.fromJson(data);
await db.insert(
'movie_reviews',
_movieReviewToMap(review),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
} catch (e) {
debugPrint('迁移影评失败: $e');
}
}
}
/// 迁移海报
Future<void> _migrateMoviePosters(String movieDirPath, String movieId) async {
final postersDir = p.join(movieDirPath, 'posters');
if (!await Directory(postersDir).exists()) return;
final files = await _listJsonFiles(postersDir);
final db = await _db.database;
for (final data in files) {
try {
final poster = MoviePoster.fromJson(data);
await db.insert(
'movie_posters',
_moviePosterToMap(poster),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
} catch (e) {
debugPrint('迁移海报失败: $e');
}
}
}
/// 迁移书籍数据
Future<void> _migrateBooks() async {
final booksDirPath = await _storage.booksDir;
final bookDirs = await _listSubdirNames(booksDirPath);
if (bookDirs.isEmpty) return;
debugPrint('发现 ${bookDirs.length} 个书籍目录,开始迁移...');
final db = await _db.database;
for (final dirName in bookDirs) {
try {
final dirPath = p.join(booksDirPath, dirName);
final dataPath = '$dirPath/data.json';
final data = await _readJsonFile(dataPath);
if (data == null) continue;
final book = Book.fromJson(data);
await db.insert(
'books',
_bookToMap(book),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
// 迁移书评
await _migrateBookReviews(dirPath, book.id);
// 迁移摘抄
await _migrateBookExcerpts(dirPath, book.id);
} catch (e) {
debugPrint('迁移书籍 $dirName 失败: $e');
}
}
}
/// 迁移书评
Future<void> _migrateBookReviews(String bookDirPath, String bookId) async {
final reviewsDir = p.join(bookDirPath, 'reviews');
if (!await Directory(reviewsDir).exists()) return;
final files = await _listJsonFiles(reviewsDir);
final db = await _db.database;
for (final data in files) {
try {
final review = BookReview.fromJson(data);
await db.insert(
'book_reviews',
_bookReviewToMap(review),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
} catch (e) {
debugPrint('迁移书评失败: $e');
}
}
}
/// 迁移摘抄
Future<void> _migrateBookExcerpts(String bookDirPath, String bookId) async {
final excerptsDir = p.join(bookDirPath, 'excerpts');
if (!await Directory(excerptsDir).exists()) return;
final files = await _listJsonFiles(excerptsDir);
final db = await _db.database;
for (final data in files) {
try {
final excerpt = BookExcerpt.fromJson(data);
await db.insert(
'book_excerpts',
_bookExcerptToMap(excerpt),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
} catch (e) {
debugPrint('迁移摘抄失败: $e');
}
}
}
/// 迁移笔记数据
Future<void> _migrateNotes() async {
final notesDirPath = await _storage.notesDir;
final noteDirs = await _listSubdirNames(notesDirPath);
if (noteDirs.isEmpty) return;
debugPrint('发现 ${noteDirs.length} 个笔记目录,开始迁移...');
final db = await _db.database;
for (final dirName in noteDirs) {
try {
final dirPath = p.join(notesDirPath, dirName);
final dataPath = '$dirPath/data.json';
final data = await _readJsonFile(dataPath);
if (data == null) continue;
final note = Note.fromJson(data);
await db.insert(
'notes',
_noteToMap(note),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
} catch (e) {
debugPrint('迁移笔记 $dirName 失败: $e');
}
}
}
// ========== 转换方法 ==========
Map<String, dynamic> _movieToMap(Movie movie) {
return {
'id': movie.id,
'title': movie.title,
'poster_path': movie.posterPath,
'release_date': movie.releaseDate?.toIso8601String(),
'directors': jsonEncode(movie.directors),
'writers': jsonEncode(movie.writers),
'actors': jsonEncode(movie.actors),
'genres': jsonEncode(movie.genres),
'alternate_titles': jsonEncode(movie.alternateTitles),
'summary': movie.summary,
'rating': movie.rating,
'status': movie.status,
'watch_date': movie.watchDate?.toIso8601String(),
'created_at': movie.createdAt.toIso8601String(),
'updated_at': movie.updatedAt.toIso8601String(),
'is_deleted': movie.isDeleted ? 1 : 0,
};
}
Map<String, dynamic> _bookToMap(Book book) {
return {
'id': book.id,
'title': book.title,
'cover_path': book.coverPath,
'authors': jsonEncode(book.authors),
'alternate_titles': jsonEncode(book.alternateTitles),
'publisher': book.publisher,
'genres': jsonEncode(book.genres),
'summary': book.summary,
'rating': book.rating,
'status': book.status,
'isbn': book.isbn,
'publish_date': book.publishDate?.toIso8601String(),
'created_at': book.createdAt.toIso8601String(),
'updated_at': book.updatedAt.toIso8601String(),
'is_deleted': book.isDeleted ? 1 : 0,
};
}
Map<String, dynamic> _noteToMap(Note note) {
return {
'id': note.id,
'content': note.content,
'content_type': note.contentType,
'tags': jsonEncode(note.tags),
'images': jsonEncode(note.images),
'created_at': note.createdAt.toIso8601String(),
'updated_at': note.updatedAt.toIso8601String(),
'is_deleted': note.isDeleted ? 1 : 0,
};
}
Map<String, dynamic> _movieReviewToMap(MovieReview review) {
return {
'id': review.id,
'movie_id': review.movieId,
'content': review.content,
'reviewer': review.reviewer,
'source': review.source,
'review_type': review.reviewType,
'is_deleted': review.isDeleted ? 1 : 0,
'created_at': review.createdAt.toIso8601String(),
'updated_at': review.updatedAt.toIso8601String(),
};
}
Map<String, dynamic> _moviePosterToMap(MoviePoster poster) {
return {
'id': poster.id,
'movie_id': poster.movieId,
'poster_path': poster.posterPath,
'is_deleted': poster.isDeleted ? 1 : 0,
'created_at': poster.createdAt.toIso8601String(),
};
}
Map<String, dynamic> _bookReviewToMap(BookReview review) {
return {
'id': review.id,
'book_id': review.bookId,
'content': review.content,
'reviewer': review.reviewer,
'source': review.source,
'review_type': review.reviewType,
'is_deleted': review.isDeleted ? 1 : 0,
'created_at': review.createdAt.toIso8601String(),
'updated_at': review.updatedAt.toIso8601String(),
};
}
Map<String, dynamic> _bookExcerptToMap(BookExcerpt excerpt) {
return {
'id': excerpt.id,
'book_id': excerpt.bookId,
'chapter': excerpt.chapter,
'content': excerpt.content,
'comment': excerpt.comment,
'is_deleted': excerpt.isDeleted ? 1 : 0,
'created_at': excerpt.createdAt.toIso8601String(),
'updated_at': excerpt.updatedAt.toIso8601String(),
};
}
// ========== 辅助方法 ==========
/// 列出子目录名
Future<List<String>> _listSubdirNames(String dirPath) async {
try {
final dir = Directory(dirPath);
if (!await dir.exists()) return [];
final entities = await dir.list().toList();
return entities
.whereType<Directory>()
.map((e) => p.basename(e.path))
.toList();
} catch (e) {
return [];
}
}
/// 读取 JSON 文件
Future<Map<String, dynamic>?> _readJsonFile(String path) async {
try {
final file = File(path);
if (!await file.exists()) return null;
final content = await file.readAsString();
return jsonDecode(content) as Map<String, dynamic>;
} catch (e) {
return null;
}
}
/// 列出目录中的 JSON 文件并解析
Future<List<Map<String, dynamic>>> _listJsonFiles(String dirPath) async {
try {
final dir = Directory(dirPath);
if (!await dir.exists()) return [];
final files = await dir
.list()
.where((entity) => entity is File && entity.path.endsWith('.json'))
.toList();
final results = <Map<String, dynamic>>[];
for (final file in files) {
final data = await _readJsonFile(file.path);
if (data != null) results.add(data);
}
return results;
} catch (e) {
return [];
}
}
}

View File

@@ -1,5 +1,6 @@
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/data_models.dart';
/// 数据库帮助类 - 管理数据库的创建和版本控制
class DatabaseHelper {
@@ -31,7 +32,7 @@ class DatabaseHelper {
return await openDatabase(
path,
version: 12,
version: 13,
onCreate: _createDB,
onUpgrade: _onUpgrade,
);
@@ -86,6 +87,9 @@ class DatabaseHelper {
// 确保notes表有title列
await _upgradeNotesTableV12(db);
}
if (oldVersion < 13) {
await _upgradeToV13(db);
}
}
/// 升级books表到V11添加ISBN和出版时间字段
@@ -119,12 +123,72 @@ class DatabaseHelper {
// 检查是否存在 title 列
final columns = await db.rawQuery('PRAGMA table_info(notes)');
final hasTitle = columns.any((col) => col['name'] == 'title');
if (!hasTitle) {
await db.execute('ALTER TABLE notes ADD COLUMN title TEXT DEFAULT \'\'');
}
}
/// 升级到V13创建标签表并回填已有数据
Future<void> _upgradeToV13(Database db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(name, type)
)
''');
await _backfillTags(db);
}
Future<void> _backfillTags(Database db) async {
final now = DateTime.now().toIso8601String();
int counter = 0;
Future<void> insertTag(String name, String type) async {
try {
await db.insert('tags', {
'id': 'tag_${DateTime.now().millisecondsSinceEpoch}_${counter++}',
'name': name,
'type': type,
'created_at': now,
});
} catch (_) {
// 忽略 UNIQUE 约束冲突
}
}
// 回填影视类型
final movies = await db.query('movies',
where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']);
for (final row in movies) {
for (final genre in Movie.parseStringList(row['genres'])) {
await insertTag(genre, 'movie_genre');
}
}
// 回填书籍类型
final books = await db.query('books',
where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']);
for (final row in books) {
for (final genre in Movie.parseStringList(row['genres'])) {
await insertTag(genre, 'book_genre');
}
}
// 回填笔记标签
final notes = await db.query('notes',
where: 'tags IS NOT NULL AND tags != ? AND tags != ?',
whereArgs: ['[]', '']);
for (final row in notes) {
for (final tag in Movie.parseStringList(row['tags'])) {
await insertTag(tag, 'note_tag');
}
}
}
/// 升级notes表到V9添加图片字段
Future<void> _upgradeNotesTableV9(Database db) async {
// 检查是否存在 images 列
@@ -487,6 +551,17 @@ class DatabaseHelper {
FOREIGN KEY (book_id) REFERENCES books (id)
)
''');
// 标签表
await db.execute('''
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(name, type)
)
''');
}
// 关闭数据库

View File

@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:archive/archive_io.dart';
import '../database_helper.dart';
import '../user_prefs.dart';
@@ -380,7 +381,7 @@ class WebDAVService {
return result;
}
/// 双向同步图片(基于文件存在性和修改时间
/// 双向同步图片(下载远程 zip 合并 -> 打包上传本地
Future<_ImageSyncResult> _syncImagesBidirectional(
http.Client client,
String imagesUrl,
@@ -389,54 +390,34 @@ class WebDAVService {
) async {
int uploaded = 0;
int downloaded = 0;
try {
final appDir = await getApplicationDocumentsDirectory();
final localImagesDir = Directory('${appDir.path}/images');
if (!await localImagesDir.exists()) {
await localImagesDir.create(recursive: true);
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 localImages = <String, File>{};
await _collectLocalImages(localImagesDir, localImages, '');
// 获取远程所有图片
final remoteImages = await _listRemoteImagesRecursive(client, imagesUrl, username, password, '');
// 上传本地有但远程没有的
for (final entry in localImages.entries) {
final relativePath = entry.key;
if (!remoteImages.contains(relativePath)) {
final remoteUrl = '$imagesUrl/$relativePath';
final parentPath = p.dirname(relativePath);
if (parentPath != '.' && parentPath.isNotEmpty) {
await _ensureRemoteDir(client, '$imagesUrl/$parentPath', username, password);
}
final success = await _uploadFile(client, remoteUrl, username, password, entry.value);
if (success) uploaded++;
}
}
// 下载远程有但本地没有的
for (final relativePath in remoteImages) {
if (!localImages.containsKey(relativePath)) {
final remoteUrl = '$imagesUrl/$relativePath';
final localFile = File('${localImagesDir.path}/$relativePath');
await localFile.parent.create(recursive: true);
final success = await _downloadFile(client, remoteUrl, username, password, localFile);
if (success) downloaded++;
}
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,
@@ -446,51 +427,36 @@ class WebDAVService {
) async {
int uploaded = 0;
int downloaded = 0;
try {
final appDir = await getApplicationDocumentsDirectory();
final localAvatarsDir = Directory('${appDir.path}/avatars');
if (!await localAvatarsDir.exists()) {
if (direction == SyncDirection.download) {
await localAvatarsDir.create(recursive: true);
} else {
return _ImageSyncResult(uploaded: 0, downloaded: 0);
}
}
final localAvatars = <String, File>{};
await _collectLocalImages(localAvatarsDir, localAvatars, '');
final remoteAvatars = await _listRemoteImagesRecursive(client, avatarsUrl, username, password, '');
final zipUrl = '${avatarsUrl.substring(0, avatarsUrl.lastIndexOf('/'))}/avatars.zip';
if (direction == SyncDirection.upload) {
for (final entry in localAvatars.entries) {
final remoteUrl = '$avatarsUrl/${entry.key}';
final parentPath = p.dirname(entry.key);
if (parentPath != '.' && parentPath.isNotEmpty) {
await _ensureRemoteDir(client, '$avatarsUrl/$parentPath', username, password);
}
final success = await _uploadFile(client, remoteUrl, username, password, entry.value);
if (success) uploaded++;
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) {
for (final relativePath in remoteAvatars) {
final remoteUrl = '$avatarsUrl/$relativePath';
final localFile = File('${localAvatarsDir.path}/$relativePath');
await localFile.parent.create(recursive: true);
final success = await _downloadFile(client, remoteUrl, username, password, localFile);
if (success) downloaded++;
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,
@@ -499,47 +465,32 @@ class WebDAVService {
) async {
int uploaded = 0;
int downloaded = 0;
try {
final appDir = await getApplicationDocumentsDirectory();
final localAvatarsDir = Directory('${appDir.path}/avatars');
if (!await localAvatarsDir.exists()) {
await localAvatarsDir.create(recursive: true);
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 localAvatars = <String, File>{};
await _collectLocalImages(localAvatarsDir, localAvatars, '');
final remoteAvatars = await _listRemoteImagesRecursive(client, avatarsUrl, username, password, '');
for (final entry in localAvatars.entries) {
if (!remoteAvatars.contains(entry.key)) {
final remoteUrl = '$avatarsUrl/${entry.key}';
final parentPath = p.dirname(entry.key);
if (parentPath != '.' && parentPath.isNotEmpty) {
await _ensureRemoteDir(client, '$avatarsUrl/$parentPath', username, password);
}
final success = await _uploadFile(client, remoteUrl, username, password, entry.value);
if (success) uploaded++;
}
}
for (final relativePath in remoteAvatars) {
if (!localAvatars.containsKey(relativePath)) {
final remoteUrl = '$avatarsUrl/$relativePath';
final localFile = File('${localAvatarsDir.path}/$relativePath');
await localFile.parent.create(recursive: true);
final success = await _downloadFile(client, remoteUrl, username, password, localFile);
if (success) downloaded++;
}
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(
@@ -767,43 +718,29 @@ class WebDAVService {
});
}
/// 上传待处理的图片
/// 上传待处理的图片(重新打包 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 imagesUrl = '$baseUrl$path/images';
final appDir = await getApplicationDocumentsDirectory();
final imagesDir = Directory('${appDir.path}/images');
final zipUrl = '$baseUrl$path/images.zip';
_pendingImageUploads.clear();
final client = http.Client();
try {
final uploads = _pendingImageUploads.toList();
_pendingImageUploads.clear();
for (final localPath in uploads) {
final file = File(localPath);
if (await file.exists()) {
final relativePath = p.relative(localPath, from: imagesDir.path);
final remoteUrl = '$imagesUrl/$relativePath';
// 确保父目录存在
final parentPath = p.dirname(relativePath);
if (parentPath != '.' && parentPath.isNotEmpty) {
await _ensureRemoteDir(client, '$imagesUrl/$parentPath', username, password);
}
await _uploadFile(client, remoteUrl, username, password, file);
}
final zipFile = await _createImagesZip();
if (await zipFile.exists()) {
await _uploadFile(client, zipUrl, username, password, zipFile);
}
try { await zipFile.delete(); } catch (_) {}
} finally {
client.close();
}
@@ -900,8 +837,89 @@ class WebDAVService {
/// 同步图片(支持新的目录结构)
/// 同步 images/movies/{id}/、images/books/{id}/、images/notes/{id}/ 下的所有图片
/// 将本地 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,
@@ -911,248 +929,42 @@ class WebDAVService {
) async {
int uploaded = 0;
int downloaded = 0;
try {
// 获取本地图片目录
final appDir = await getApplicationDocumentsDirectory();
final localImagesDir = Directory('${appDir.path}/images');
if (!await localImagesDir.exists()) {
await localImagesDir.create(recursive: true);
}
// 递归获取本地所有图片文件(包含子目录)
final localImages = <String, File>{}; // 相对路径 -> 文件
await _collectLocalImages(localImagesDir, localImages, '');
// print('WebDAV: Local images: ${localImages.length}');
// 递归获取远程所有图片文件
final remoteImages = await _listRemoteImagesRecursive(client, imagesUrl, username, password, '');
// print('WebDAV: Remote images: ${remoteImages.length}');
final zipUrl = '${imagesUrl.substring(0, imagesUrl.lastIndexOf('/'))}/images.zip';
if (direction == SyncDirection.upload) {
// 仅上传:上传所有本地图片
// print('WebDAV: Starting upload of ${localImages.length} images...');
for (final entry in localImages.entries) {
final relativePath = entry.key;
final remoteUrl = '$imagesUrl/$relativePath';
// print('WebDAV: Uploading $relativePath...');
// 确保远程父目录存在
final parentPath = p.dirname(relativePath);
if (parentPath != '.' && parentPath.isNotEmpty) {
final parentUrl = '$imagesUrl/$parentPath';
await _ensureRemoteDir(client, parentUrl, username, password);
}
final success = await _uploadFile(client, remoteUrl, username, password, entry.value);
if (success) {
uploaded++;
// print('WebDAV: Uploaded $relativePath ($uploaded/${localImages.length})');
}
final zipFile = await _createImagesZip();
if (await zipFile.exists()) {
final success = await _uploadFile(client, zipUrl, username, password, zipFile);
if (success) uploaded = 1;
}
// print('WebDAV: Upload complete - $uploaded/${localImages.length} images uploaded');
try { await zipFile.delete(); } catch (_) {}
} else if (direction == SyncDirection.download) {
// 仅下载:下载所有远程图片
// print('WebDAV: Starting download of ${remoteImages.length} images...');
for (final relativePath in remoteImages) {
final remoteUrl = '$imagesUrl/$relativePath';
final localFile = File('${localImagesDir.path}/$relativePath');
// print('WebDAV: Downloading $relativePath...');
// 确保父目录存在
await localFile.parent.create(recursive: true);
final success = await _downloadFile(client, remoteUrl, username, password, localFile);
if (success) {
downloaded++;
// print('WebDAV: Downloaded $relativePath ($downloaded/${remoteImages.length})');
}
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;
}
// print('WebDAV: Download complete - $downloaded/${remoteImages.length} images downloaded');
try { await tempZip.delete(); } catch (_) {}
}
} catch (e) {
// print('WebDAV: Sync images error: $e');
// ignore
}
return _ImageSyncResult(uploaded: uploaded, downloaded: downloaded);
}
/// 递归收集本地图片文件
Future<void> _collectLocalImages(Directory dir, Map<String, File> result, String relativePath) async {
await for (final entity in dir.list()) {
if (entity is File) {
final fileName = p.basename(entity.path);
final path = relativePath.isEmpty ? fileName : '$relativePath/$fileName';
result[path] = entity;
} else if (entity is Directory) {
final dirName = p.basename(entity.path);
final newRelativePath = relativePath.isEmpty ? dirName : '$relativePath/$dirName';
await _collectLocalImages(entity, result, newRelativePath);
}
}
}
/// 获取远程图片列表(递归获取所有子目录中的图片)
Future<List<String>> _listRemoteImagesRecursive(
http.Client client,
String imagesUrl,
String username,
String password,
String relativePath,
) async {
final images = <String>[];
final currentUrl = relativePath.isEmpty ? imagesUrl : '$imagesUrl/$relativePath';
try {
// 创建图片目录(如果不存在)
final mkcolRequest = http.Request('MKCOL', Uri.parse(currentUrl));
mkcolRequest.headers['Authorization'] = _basicAuth(username, password);
await client.send(mkcolRequest);
// 列出目录内容
var request = http.Request('PROPFIND', Uri.parse(currentUrl));
request.headers['Authorization'] = _basicAuth(username, password);
request.headers['Depth'] = '1';
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) {
final newUrl = location;
request = http.Request('PROPFIND', Uri.parse(newUrl));
request.headers['Authorization'] = _basicAuth(username, password);
request.headers['Depth'] = '1';
response = await client.send(request);
}
}
if (response.statusCode == 207) {
final body = await response.stream.bytesToString();
// print('WebDAV: PROPFIND response for $relativePath: ${body.length} bytes');
// print('WebDAV: Response body: $body');
// 解析响应,提取文件和目录
final hrefMatches = RegExp(r'<d:href>([^<]+)</d:href>', caseSensitive: false)
.allMatches(body);
// print('WebDAV: Found ${hrefMatches.length} href entries');
for (final match in hrefMatches) {
final href = match.group(1)!;
final name = p.basename(href);
// 跳过当前目录自身WebDAV PROPFIND 结果中第一个或某个 entry 是当前目录)
if (name.isEmpty) continue;
final currentUrlPath = Uri.parse(currentUrl).path;
final currentDirName = p.basename(currentUrlPath);
if (name == currentDirName) continue;
// 检查是文件还是目录 - 查找这个 href 对应的 <D:response> 或 <d:response> 部分
// 使用正则匹配,因为标签可能有属性(如 <D:response xmlns:D="DAV:">
int responseStart = -1;
int responseEnd = -1;
// 查找包含当前 href 的 response 块(向前找最近的 response 开始标签)
final responseStartPattern = RegExp(r'<[Dd]:response\b', caseSensitive: false);
final responseEndPattern = RegExp(r'</[Dd]:response>', caseSensitive: false);
// 从 match.start 向前找最后一个 response 开始标签
final allStarts = responseStartPattern.allMatches(body.substring(0, match.start)).toList();
if (allStarts.isNotEmpty) {
responseStart = allStarts.last.start;
}
// 从 match.start 向后找第一个 response 结束标签
final endMatch = responseEndPattern.firstMatch(body.substring(match.start));
if (endMatch != null) {
responseEnd = match.start + endMatch.end;
}
bool isDirectory = false;
if (responseStart != -1 && responseEnd != -1 && responseStart < responseEnd) {
final responseSection = body.substring(responseStart, responseEnd);
// 检查是否包含 <D:collection/> 或 <d:collection/> 标签
isDirectory = responseSection.contains('<D:collection/>') ||
responseSection.contains('<d:collection/>') ||
responseSection.contains('<D:collection />') ||
responseSection.contains('<d:collection />');
}
// print('WebDAV: Found $name - isDirectory: $isDirectory');
if (isDirectory) {
// 递归获取子目录中的图片
final newRelativePath = relativePath.isEmpty ? name : '$relativePath/$name';
final subImages = await _listRemoteImagesRecursive(
client, imagesUrl, username, password, newRelativePath,
);
images.addAll(subImages);
} else {
// 是文件,添加到列表
final filePath = relativePath.isEmpty ? name : '$relativePath/$name';
// print('WebDAV: Adding file to list: $filePath');
images.add(filePath);
}
}
} else {
// print('WebDAV: PROPFIND failed with status ${response.statusCode} for $relativePath');
}
} catch (e) {
// print('WebDAV: List remote images error: $e');
}
return images;
}
/// 确保远程目录存在
Future<void> _ensureRemoteDir(
http.Client client,
String dirUrl,
String username,
String password,
) async {
try {
var request = http.Request('MKCOL', Uri.parse(dirUrl));
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('MKCOL', Uri.parse(location));
request.headers['Authorization'] = _basicAuth(username, password);
response = await client.send(request);
}
}
// 201 = 创建成功, 405 = 目录已存在, 409 = 父目录不存在需要先创建
if (response.statusCode == 409) {
// 需要创建父目录
final parentPath = p.dirname(dirUrl);
if (parentPath != dirUrl) {
await _ensureRemoteDir(client, parentPath, username, password);
// 再次尝试创建当前目录
request = http.Request('MKCOL', Uri.parse(dirUrl));
request.headers['Authorization'] = _basicAuth(username, password);
await client.send(request);
}
}
} catch (e) {
// print('WebDAV: 创建目录失败: $e');
}
}
/// 上传文件
/// 上传文件(数据库文件上传前自动 VACUUM 压缩)
Future<bool> _uploadFile(
http.Client client,
String url,
@@ -1161,6 +973,14 @@ class WebDAVService {
File file,
) async {
try {
// 数据库文件:上传前 VACUUM 压缩
if (p.basename(url).endsWith('.db')) {
try {
final db = await DatabaseHelper.instance.database;
await db.execute('VACUUM');
} catch (_) {}
}
final fileBytes = await file.readAsBytes();
var request = http.Request('PUT', Uri.parse(url));

226
lib/utils/tag/tag_dao.dart Normal file
View File

@@ -0,0 +1,226 @@
import 'dart:convert';
import '../database_helper.dart';
class TagDao {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
/// 获取指定类型的所有标签,按名称排序
Future<List<Map<String, dynamic>>> getTagsByType(String type) async {
final db = await _dbHelper.database;
return await db.query('tags',
where: 'type = ?',
whereArgs: [type],
orderBy: 'name ASC');
}
/// 根据ID获取标签
Future<Map<String, dynamic>?> getTagById(String id) async {
final db = await _dbHelper.database;
final results = await db.query('tags', where: 'id = ?', whereArgs: [id]);
return results.isNotEmpty ? results.first : null;
}
/// 添加标签返回新标签ID
Future<String> addTag(String name, String type) async {
final db = await _dbHelper.database;
final id = 'tag_${DateTime.now().millisecondsSinceEpoch}';
await db.insert('tags', {
'id': id,
'name': name,
'type': type,
'created_at': DateTime.now().toIso8601String(),
});
return id;
}
/// 重命名标签,同时级联更新所有关联条目
Future<bool> renameTag(String tagId, String newName) async {
final db = await _dbHelper.database;
final tag = await getTagById(tagId);
if (tag == null) return false;
final oldName = tag['name'] as String;
final type = tag['type'] as String;
if (oldName == newName) return true;
// 检查新名称是否已存在同类型标签
final existing = await db.query('tags',
where: 'name = ? AND type = ? AND id != ?',
whereArgs: [newName, type, tagId]);
if (existing.isNotEmpty) return false;
await db.update('tags', {'name': newName},
where: 'id = ?', whereArgs: [tagId]);
// 级联更新
switch (type) {
case 'movie_genre':
await _cascadeRenameInMovies(oldName, newName);
case 'book_genre':
await _cascadeRenameInBooks(oldName, newName);
case 'note_tag':
await _cascadeRenameInNotes(oldName, newName);
}
return true;
}
/// 删除标签
/// [replacementName] 不为 null 时,先将所有条目中的旧标签替换为新标签,再删除
/// [replacementName] 为 null 时,从所有条目中移除该标签
Future<void> deleteTag(String tagId, {String? replacementName}) async {
final db = await _dbHelper.database;
final tag = await getTagById(tagId);
if (tag == null) return;
final name = tag['name'] as String;
final type = tag['type'] as String;
if (replacementName != null && replacementName != name) {
await _ensureTagExists(replacementName, type);
switch (type) {
case 'movie_genre':
await _cascadeRenameInMovies(name, replacementName);
case 'book_genre':
await _cascadeRenameInBooks(name, replacementName);
case 'note_tag':
await _cascadeRenameInNotes(name, replacementName);
}
} else if (replacementName == null) {
switch (type) {
case 'movie_genre':
await _cascadeDeleteFromMovies(name);
case 'book_genre':
await _cascadeDeleteFromBooks(name);
case 'note_tag':
await _cascadeDeleteFromNotes(name);
}
}
await db.delete('tags', where: 'id = ?', whereArgs: [tagId]);
}
/// 确保标签存在(用于替换操作)
Future<void> _ensureTagExists(String name, String type) async {
final db = await _dbHelper.database;
final existing = await db.query('tags',
where: 'name = ? AND type = ?', whereArgs: [name, type]);
if (existing.isEmpty) {
await addTag(name, type);
}
}
// ====== 级联重命名 ======
Future<void> _cascadeRenameInMovies(String oldName, String newName) async {
final db = await _dbHelper.database;
final movies = await db.query('movies');
for (final row in movies) {
final genres = _parseList(row['genres']);
if (genres.contains(oldName) && !genres.contains(newName)) {
final updated = genres.map((g) => g == oldName ? newName : g).toList();
await db.update('movies', {
'genres': jsonEncode(updated),
'updated_at': DateTime.now().toIso8601String(),
}, where: 'id = ?', whereArgs: [row['id']]);
}
}
}
Future<void> _cascadeRenameInBooks(String oldName, String newName) async {
final db = await _dbHelper.database;
final books = await db.query('books');
for (final row in books) {
final genres = _parseList(row['genres']);
if (genres.contains(oldName) && !genres.contains(newName)) {
final updated = genres.map((g) => g == oldName ? newName : g).toList();
await db.update('books', {
'genres': jsonEncode(updated),
'updated_at': DateTime.now().toIso8601String(),
}, where: 'id = ?', whereArgs: [row['id']]);
}
}
}
Future<void> _cascadeRenameInNotes(String oldName, String newName) async {
final db = await _dbHelper.database;
final notes = await db.query('notes');
for (final row in notes) {
final tags = _parseList(row['tags']);
if (tags.contains(oldName) && !tags.contains(newName)) {
final updated = tags.map((t) => t == oldName ? newName : t).toList();
await db.update('notes', {
'tags': jsonEncode(updated),
'updated_at': DateTime.now().toIso8601String(),
}, where: 'id = ?', whereArgs: [row['id']]);
}
}
}
// ====== 级联删除 ======
Future<void> _cascadeDeleteFromMovies(String tagName) async {
final db = await _dbHelper.database;
final movies = await db.query('movies');
for (final row in movies) {
final genres = _parseList(row['genres']);
if (genres.contains(tagName)) {
genres.removeWhere((g) => g == tagName);
await db.update('movies', {
'genres': jsonEncode(genres),
'updated_at': DateTime.now().toIso8601String(),
}, where: 'id = ?', whereArgs: [row['id']]);
}
}
}
Future<void> _cascadeDeleteFromBooks(String tagName) async {
final db = await _dbHelper.database;
final books = await db.query('books');
for (final row in books) {
final genres = _parseList(row['genres']);
if (genres.contains(tagName)) {
genres.removeWhere((g) => g == tagName);
await db.update('books', {
'genres': jsonEncode(genres),
'updated_at': DateTime.now().toIso8601String(),
}, where: 'id = ?', whereArgs: [row['id']]);
}
}
}
Future<void> _cascadeDeleteFromNotes(String tagName) async {
final db = await _dbHelper.database;
final notes = await db.query('notes');
for (final row in notes) {
final tags = _parseList(row['tags']);
if (tags.contains(tagName)) {
tags.removeWhere((t) => t == tagName);
await db.update('notes', {
'tags': jsonEncode(tags),
'updated_at': DateTime.now().toIso8601String(),
}, where: 'id = ?', whereArgs: [row['id']]);
}
}
}
/// 解析 JSON 字符串列表
List<String> _parseList(dynamic data) {
if (data == null) return [];
if (data is List) return data.map((e) => e.toString()).toList();
if (data is String) {
if (data.isEmpty || data == '[]') return [];
try {
final decoded = jsonDecode(data);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (_) {
return data.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
}
}
return [];
}
}