WIP: markdown reader, data migration, 编辑器增强等

This commit is contained in:
DelLevin-Home
2026-05-22 03:24:17 +08:00
parent edf35c5844
commit 8d5e1642d6
19 changed files with 2371 additions and 1177 deletions

View File

@@ -0,0 +1,372 @@
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

@@ -31,7 +31,7 @@ class DatabaseHelper {
return await openDatabase(
path,
version: 11,
version: 12,
onCreate: _createDB,
onUpgrade: _onUpgrade,
);
@@ -82,6 +82,10 @@ class DatabaseHelper {
// 为书籍表添加ISBN和出版时间字段
await _upgradeBooksTableV11(db);
}
if (oldVersion < 12) {
// 确保notes表有title列
await _upgradeNotesTableV12(db);
}
}
/// 升级books表到V11添加ISBN和出版时间字段
@@ -110,6 +114,17 @@ class DatabaseHelper {
}
}
/// 升级notes表到V12确保title列存在
Future<void> _upgradeNotesTableV12(Database db) async {
// 检查是否存在 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 \'\'');
}
}
/// 升级notes表到V9添加图片字段
Future<void> _upgradeNotesTableV9(Database db) async {
// 检查是否存在 images 列
@@ -211,6 +226,7 @@ class DatabaseHelper {
await db.execute('''
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT DEFAULT '',
content TEXT NOT NULL,
content_type TEXT DEFAULT 'markdown',
tags TEXT,
@@ -219,19 +235,17 @@ class DatabaseHelper {
)
''');
// 迁移旧数据将title合并到content中
// 迁移旧数据将title字段恢复
for (final row in oldData) {
try {
final now = DateTime.now().toIso8601String();
final title = row['title']?.toString() ?? '';
final content = row['content']?.toString() ?? '';
final combinedContent = title.isNotEmpty
? '# $title\n\n$content'
: content;
await db.insert('notes', {
'id': row['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(),
'content': combinedContent,
'title': title,
'content': content,
'content_type': 'markdown',
'tags': row['tags'] ?? '',
'created_at': row['created_at']?.toString() ?? now,
@@ -404,6 +418,7 @@ class DatabaseHelper {
await db.execute('''
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT DEFAULT '',
content TEXT NOT NULL,
content_type TEXT DEFAULT 'markdown',
tags TEXT,

View File

@@ -101,8 +101,8 @@ class NoteDao {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'notes',
where: '(content LIKE ? OR tags LIKE ?) AND is_deleted = ?',
whereArgs: ['%$query%', '%$query%', 0],
where: '(title LIKE ? OR content LIKE ? OR tags LIKE ?) AND is_deleted = ?',
whereArgs: ['%$query%', '%$query%', '%$query%', 0],
orderBy: 'created_at DESC',
);

View File

@@ -48,6 +48,10 @@ class UserPrefs {
// ========== 主界面显示设置 ==========
/// 是否启用底部导航栏滚动隐藏(默认开启)
bool get hideBottomNavOnScroll => prefs.getBool('hideBottomNavOnScroll') ?? true;
Future<bool> setHideBottomNavOnScroll(bool value) => prefs.setBool('hideBottomNavOnScroll', value);
/// 是否显示观影标签
bool get showMovieTab => prefs.getBool('showMovieTab') ?? true;
Future<bool> setShowMovieTab(bool value) => prefs.setBool('showMovieTab', value);