generated from dellevin/template
优化界面2
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
// 关闭数据库
|
||||
|
||||
@@ -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
226
lib/utils/tag/tag_dao.dart
Normal 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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user