Files
MookNote/lib/services/sync/cache_cleaner.dart
2026-07-13 19:39:01 +08:00

306 lines
9.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import '../../data/database_helper.dart';
import '../../utils/image_path_helper.dart';
import '../../utils/user_prefs.dart';
/// 缓存清理服务
class CacheCleaner {
CacheCleaner._();
static final CacheCleaner instance = CacheCleaner._();
/// 执行完整缓存清理,返回各分类删除数量
Future<CacheCleanResult> clean() async {
final dbImagePaths = await _getAllDbImagePaths();
final deletedImages = await _cleanImageDirectory(dbImagePaths);
final deletedEpubs = await _cleanOrphanedEpubBooks();
final deletedTemp = await _cleanTempDirectory();
final deletedEmptyDirs = await _cleanEmptyDirectories();
return CacheCleanResult(
images: deletedImages,
epubs: deletedEpubs,
temp: deletedTemp,
emptyDirs: deletedEmptyDirs,
);
}
/// 直接查 DB 收集所有图片路径(含软删除记录,与 BackupService 保持一致)
Future<Set<String>> _getAllDbImagePaths() async {
final db = await DatabaseHelper.instance.database;
final paths = <String>{};
// 影视海报
final movies = await db.query('movies', columns: ['poster_path']);
for (final m in movies) {
final p = m['poster_path'] as String?;
if (p != null && p.isNotEmpty) paths.add(p);
}
// 书籍封面
final books = await db.query('books', columns: ['cover_path']);
for (final b in books) {
final p = b['cover_path'] as String?;
if (p != null && p.isNotEmpty) paths.add(p);
}
// 笔记图片
final notes = await db.query('notes', columns: ['images']);
for (final n in notes) {
final imagesJson = n['images'] as String?;
if (imagesJson != null && imagesJson.isNotEmpty) {
try {
for (final ip in jsonDecode(imagesJson) as List<dynamic>) {
if (ip is String && ip.isNotEmpty) paths.add(ip);
}
} catch (_) {}
}
}
// 影视海报墙图片
final moviePosters = await db.query('movie_posters', columns: ['poster_path']);
for (final p in moviePosters) {
final pp = p['poster_path'] as String?;
if (pp != null && pp.isNotEmpty) paths.add(pp);
}
// 游戏封面
final games = await db.query('games', columns: ['cover_path']);
for (final g in games) {
final p = g['cover_path'] as String?;
if (p != null && p.isNotEmpty) paths.add(p);
}
// 游戏截图
final gameScreenshots = await db.query('game_screenshots', columns: ['screenshot_path']);
for (final s in gameScreenshots) {
final p = s['screenshot_path'] as String?;
if (p != null && p.isNotEmpty) paths.add(p);
}
// 用户头像
final userPrefs = UserPrefs();
final avatarPath = userPrefs.avatarPath;
if (avatarPath != null && avatarPath.isNotEmpty) paths.add(avatarPath);
return paths;
}
/// 规范化路径用于跨平台比较(统一分隔符、去掉末尾分隔符)
/// Windows 上 DB 存的路径和文件系统遍历得到的路径分隔符可能不一致,
/// 直接字符串比较会漏匹配导致图片被误删。
String _normalize(String p) {
// 统一为正斜杠后再用 path.normalize 处理 .. 和 . 等
final unified = p.replaceAll('\\', '/');
return path.normalize(unified);
}
Future<int> _cleanImageDirectory(Set<String> dbImagePaths) async {
int deletedCount = 0;
try {
final appDirPath = await ImagePathHelper.getAppDir();
final imagesDir = Directory(path.join(appDirPath, 'images'));
if (!await imagesDir.exists()) return 0;
// 预先规范化 DB 路径,避免每个文件都做转换
final normalizedDbPaths = dbImagePaths.map(_normalize).toSet();
await for (final entity in imagesDir.list(recursive: true, followLinks: false)) {
if (entity is File &&
!normalizedDbPaths.contains(_normalize(entity.path)) &&
!path.basename(entity.path).startsWith('avatar')) {
try {
await entity.delete();
deletedCount++;
} catch (_) {}
}
}
} catch (e) {
debugPrint('清理图片目录失败: $e');
}
return deletedCount;
}
Future<int> _cleanOrphanedEpubBooks() async {
int deletedCount = 0;
try {
final db = await DatabaseHelper.instance.database;
final rows = await db.query('reader_books', columns: ['id', 'file_path', 'cover_path', 'is_deleted']);
final usedDirs = <String>{};
for (final r in rows) {
final isDeleted = r['is_deleted'] == 1 || r['is_deleted'] == true;
if (isDeleted) continue;
final id = r['id'] as String?;
if (id != null && id.isNotEmpty) usedDirs.add(id);
_collectEpubDirName(r['file_path'] as String?, usedDirs);
_collectEpubDirName(r['cover_path'] as String?, usedDirs);
}
final appDirPath = await ImagePathHelper.getAppDir();
final possiblePaths = [
path.join(appDirPath, 'epub_books'),
// Android 旧版绝对路径path.join 在 Windows 上不会破坏它)
'/data/user/0/top.iletter.mooknote/app_flutter/epub_books',
];
for (final epubPath in possiblePaths) {
final epubDir = Directory(epubPath);
if (!await epubDir.exists()) continue;
await for (final entity in epubDir.list(followLinks: false)) {
if (entity is Directory) {
final dirName = path.basename(entity.path);
if (!usedDirs.contains(dirName)) {
try {
await entity.delete(recursive: true);
deletedCount++;
} catch (_) {}
}
}
}
}
} catch (e) {
debugPrint('清理 epub_books 目录失败: $e');
}
return deletedCount;
}
/// 从路径中提取 epub_books/{bookId} 的 bookId 部分
/// 兼容 Windows(\) 和 Unix(/) 分隔符
void _collectEpubDirName(String? pathStr, Set<String> dirs) {
if (pathStr == null || pathStr.isEmpty) return;
// 统一为正斜杠便于查找 marker
final unified = pathStr.replaceAll('\\', '/');
final marker = '/epub_books/';
final idx = unified.indexOf(marker);
if (idx < 0) return;
final rest = unified.substring(idx + marker.length);
final slashIdx = rest.indexOf('/');
dirs.add(slashIdx >= 0 ? rest.substring(0, slashIdx) : rest);
}
/// mooknote 自己产生的临时文件名前缀
static const _tempPrefixes = [
'book_poster_',
'movie_poster_',
'note_share_',
'mooknote_download',
'mooknote_bidir',
];
bool _isMooknoteTempFile(String name) {
for (final prefix in _tempPrefixes) {
if (name.startsWith(prefix)) return true;
}
return false;
}
Future<int> _cleanTempDirectory() async {
int deletedCount = 0;
final now = DateTime.now();
try {
final tempDir = await getTemporaryDirectory();
if (await tempDir.exists()) {
await for (final entity in tempDir.list(followLinks: false)) {
if (entity is File) {
final name = path.basename(entity.path);
if (_isMooknoteTempFile(name)) {
try {
final stat = await entity.stat();
if (now.difference(stat.modified).inHours >= 1) {
await entity.delete();
deletedCount++;
}
} catch (_) {}
}
}
}
}
} catch (e) {
debugPrint('清理临时目录失败: $e');
}
// cacheDir 只删 mooknote 自己产生的临时文件,不再无差别全清
// Windows/Flutter 引擎也在该目录放缓存文件,全清可能误伤)
try {
final cacheDir = await getApplicationCacheDirectory();
if (await cacheDir.exists()) {
await for (final entity in cacheDir.list(recursive: true, followLinks: false)) {
if (entity is File) {
final name = path.basename(entity.path);
if (_isMooknoteTempFile(name)) {
try {
await entity.delete();
deletedCount++;
} catch (_) {}
}
}
}
}
} catch (e) {
debugPrint('清理缓存目录失败: $e');
}
return deletedCount;
}
Future<int> _cleanEmptyDirectories() async {
int deletedCount = 0;
try {
final appDirPath = await ImagePathHelper.getAppDir();
final cacheDir = await getApplicationCacheDirectory();
final dirs = [
Directory(path.join(appDirPath, 'images')),
Directory(path.join(appDirPath, 'epub_books')),
cacheDir,
];
for (final dir in dirs) {
if (!await dir.exists()) continue;
deletedCount += await _removeEmptyDirsRecursive(dir);
}
} catch (e) {
debugPrint('清理空文件夹失败: $e');
}
return deletedCount;
}
Future<int> _removeEmptyDirsRecursive(Directory dir) async {
int count = 0;
try {
final children = await dir.list(followLinks: false).toList();
for (final child in children) {
if (child is Directory) {
count += await _removeEmptyDirsRecursive(child);
final remaining = await child.list(followLinks: false).toList();
if (remaining.isEmpty) {
try {
await child.delete();
count++;
} catch (_) {}
}
}
}
} catch (_) {}
return count;
}
}
class CacheCleanResult {
final int images;
final int epubs;
final int temp;
final int emptyDirs;
const CacheCleanResult({
required this.images,
required this.epubs,
required this.temp,
required this.emptyDirs,
});
int get total => images + epubs + temp + emptyDirs;
String get description =>
'已清理 $images 个孤立图片,$epubs 个孤立电子书,$temp 个临时文件,$emptyDirs 个空文件夹';
}