Files
MaliangAINovalWriter/AINoval/lib/services/novel_file_service.dart
2025-09-10 00:07:52 +08:00

522 lines
16 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/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:intl/intl.dart';
import 'package:share_plus/share_plus.dart';
import 'package:ainoval/models/novel_structure.dart';
import 'package:ainoval/services/api_service/repositories/novel_repository.dart';
import 'package:ainoval/services/api_service/repositories/editor_repository.dart';
import 'package:ainoval/utils/logger.dart';
/// 小说文件导出格式
enum NovelExportFormat {
txt, // 纯文本
json, // JSON格式(包含结构信息)
markdown, // Markdown格式
}
/// 导出结果
class NovelExportResult {
final String filePath;
final String fileName;
final int fileSizeBytes;
final NovelExportFormat format;
final DateTime exportedAt;
const NovelExportResult({
required this.filePath,
required this.fileName,
required this.fileSizeBytes,
required this.format,
required this.exportedAt,
});
Map<String, dynamic> toJson() => {
'filePath': filePath,
'fileName': fileName,
'fileSizeBytes': fileSizeBytes,
'format': format.name,
'exportedAt': exportedAt.toIso8601String(),
};
factory NovelExportResult.fromJson(Map<String, dynamic> json) => NovelExportResult(
filePath: json['filePath'],
fileName: json['fileName'],
fileSizeBytes: json['fileSizeBytes'],
format: NovelExportFormat.values.firstWhere(
(e) => e.name == json['format'],
orElse: () => NovelExportFormat.txt,
),
exportedAt: DateTime.parse(json['exportedAt']),
);
}
/// 小说文件服务 - 处理小说内容的本地保存
class NovelFileService {
final NovelRepository _novelRepository;
final EditorRepository? _editorRepository;
NovelFileService({
required NovelRepository novelRepository,
EditorRepository? editorRepository,
}) : _novelRepository = novelRepository,
_editorRepository = editorRepository;
/// 获取小说存储目录
Future<Directory> _getNovelStorageDirectory() async {
Directory? directory;
if (Platform.isAndroid) {
// Android: 使用外部存储的Documents目录
directory = await getExternalStorageDirectory();
if (directory != null) {
directory = Directory('${directory.path}/Documents/AINoval/Novels');
} else {
// 如果外部存储不可用,使用应用文档目录
directory = await getApplicationDocumentsDirectory();
directory = Directory('${directory.path}/Novels');
}
} else if (Platform.isIOS) {
// iOS: 使用应用文档目录
directory = await getApplicationDocumentsDirectory();
directory = Directory('${directory.path}/Novels');
} else {
// 其他平台使用应用文档目录
directory = await getApplicationDocumentsDirectory();
directory = Directory('${directory.path}/Novels');
}
// 确保目录存在
if (!await directory.exists()) {
await directory.create(recursive: true);
}
return directory;
}
/// 从后端获取完整小说内容
Future<Novel> _fetchCompleteNovel(String novelId) async {
try {
AppLogger.i('NovelFileService', '开始获取完整小说内容: $novelId');
// 优先尝试使用EditorRepository获取全部场景
if (_editorRepository != null) {
final novelWithAllScenes = await _editorRepository!.getNovelWithAllScenes(novelId);
if (novelWithAllScenes != null) {
AppLogger.i('NovelFileService', '通过EditorRepository获取完整小说成功');
return novelWithAllScenes;
}
}
// 回退到NovelRepository
AppLogger.i('NovelFileService', '回退到NovelRepository获取小说基本信息');
final novel = await _novelRepository.fetchNovel(novelId);
// 逐个获取场景内容
for (final act in novel.acts) {
for (final chapter in act.chapters) {
final List<Scene> scenesWithContent = [];
for (final scene in chapter.scenes) {
try {
final sceneWithContent = await _novelRepository.fetchSceneContent(
novelId,
act.id,
chapter.id,
scene.id
);
scenesWithContent.add(sceneWithContent);
} catch (e) {
AppLogger.w('NovelFileService',
'获取场景内容失败,使用默认内容: novelId=$novelId, sceneId=${scene.id}', e);
scenesWithContent.add(scene);
}
}
// 更新章节的场景列表
chapter.scenes.clear();
chapter.scenes.addAll(scenesWithContent);
}
}
AppLogger.i('NovelFileService', '获取完整小说内容成功: ${novel.title}');
return novel;
} catch (e) {
AppLogger.e('NovelFileService', '获取完整小说内容失败: $novelId', e);
rethrow;
}
}
/// 将小说导出为TXT格式
String _exportToTxt(Novel novel) {
final buffer = StringBuffer();
// 标题和基本信息
buffer.writeln('${novel.title}');
buffer.writeln('${'=' * novel.title.length}');
buffer.writeln();
if (novel.author != null) {
buffer.writeln('作者:${novel.author!.username}');
}
buffer.writeln('创建时间:${DateFormat('yyyy-MM-dd HH:mm').format(novel.createdAt)}');
buffer.writeln('最后更新:${DateFormat('yyyy-MM-dd HH:mm').format(novel.updatedAt)}');
buffer.writeln();
buffer.writeln('-' * 50);
buffer.writeln();
// 内容
for (final act in novel.acts) {
// 幕标题
buffer.writeln('${act.title}');
buffer.writeln('${'*' * act.title.length}');
buffer.writeln();
for (final chapter in act.chapters) {
// 章节标题
buffer.writeln('${chapter.title}');
buffer.writeln('${'-' * chapter.title.length}');
buffer.writeln();
for (final scene in chapter.scenes) {
// 场景内容
if (scene.content.isNotEmpty) {
buffer.writeln(scene.content);
buffer.writeln();
}
// 如果场景有摘要,也添加进去
if (scene.summary != null && scene.summary!.content.isNotEmpty) {
buffer.writeln('【场景摘要:${scene.summary!.content}');
buffer.writeln();
}
}
buffer.writeln(); // 章节间空行
}
buffer.writeln(); // 幕间空行
}
return buffer.toString();
}
/// 将小说导出为Markdown格式
String _exportToMarkdown(Novel novel) {
final buffer = StringBuffer();
// 标题和基本信息
buffer.writeln('# ${novel.title}');
buffer.writeln();
if (novel.author != null) {
buffer.writeln('**作者:** ${novel.author!.username}');
}
buffer.writeln('**创建时间:** ${DateFormat('yyyy-MM-dd HH:mm').format(novel.createdAt)}');
buffer.writeln('**最后更新:** ${DateFormat('yyyy-MM-dd HH:mm').format(novel.updatedAt)}');
buffer.writeln();
buffer.writeln('---');
buffer.writeln();
// 内容
for (final act in novel.acts) {
// 幕标题 (二级标题)
buffer.writeln('## ${act.title}');
buffer.writeln();
for (final chapter in act.chapters) {
// 章节标题 (三级标题)
buffer.writeln('### ${chapter.title}');
buffer.writeln();
for (final scene in chapter.scenes) {
// 场景内容
if (scene.content.isNotEmpty) {
buffer.writeln(scene.content);
buffer.writeln();
}
// 如果场景有摘要,作为引用添加
if (scene.summary != null && scene.summary!.content.isNotEmpty) {
buffer.writeln('> **场景摘要:** ${scene.summary!.content}');
buffer.writeln();
}
}
}
}
return buffer.toString();
}
/// 将小说导出为JSON格式
String _exportToJson(Novel novel) {
final jsonData = {
'exportInfo': {
'exportedAt': DateTime.now().toIso8601String(),
'exportVersion': '1.0.0',
'appVersion': '0.1.0+1',
},
'novel': novel.toJson(),
};
return const JsonEncoder.withIndent(' ').convert(jsonData);
}
/// 生成文件名
String _generateFileName(Novel novel, NovelExportFormat format) {
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
final safeTitle = novel.title.replaceAll(RegExp(r'[^\w\s-]'), '').replaceAll(' ', '_');
return '${safeTitle}_$timestamp.${format.name}';
}
/// 导出小说到本地文件
Future<NovelExportResult> exportNovelToFile(
String novelId, {
NovelExportFormat format = NovelExportFormat.txt,
String? customFileName,
}) async {
try {
AppLogger.i('NovelFileService', '开始导出小说: $novelId, 格式: ${format.name}');
// 1. 获取完整小说内容
final novel = await _fetchCompleteNovel(novelId);
// 2. 根据格式生成内容
String content;
switch (format) {
case NovelExportFormat.txt:
content = _exportToTxt(novel);
break;
case NovelExportFormat.markdown:
content = _exportToMarkdown(novel);
break;
case NovelExportFormat.json:
content = _exportToJson(novel);
break;
}
// 3. 生成文件名
final fileName = customFileName ?? _generateFileName(novel, format);
// 4. 获取存储目录
final directory = await _getNovelStorageDirectory();
// 5. 写入文件
final file = File('${directory.path}/$fileName');
await file.writeAsString(content, encoding: utf8);
// 6. 获取文件大小
final fileStat = await file.stat();
final result = NovelExportResult(
filePath: file.path,
fileName: fileName,
fileSizeBytes: fileStat.size,
format: format,
exportedAt: DateTime.now(),
);
AppLogger.i('NovelFileService', '小说导出成功: ${result.fileName}, 大小: ${result.fileSizeBytes} bytes');
return result;
} catch (e) {
AppLogger.e('NovelFileService', '导出小说失败: $novelId', e);
rethrow;
}
}
/// 批量导出小说(多种格式)
Future<List<NovelExportResult>> exportNovelMultipleFormats(
String novelId, {
List<NovelExportFormat> formats = const [
NovelExportFormat.txt,
NovelExportFormat.markdown,
NovelExportFormat.json,
],
}) async {
final results = <NovelExportResult>[];
for (final format in formats) {
try {
final result = await exportNovelToFile(novelId, format: format);
results.add(result);
} catch (e) {
AppLogger.e('NovelFileService', '导出格式 ${format.name} 失败', e);
// 继续导出其他格式
}
}
return results;
}
/// 分享导出的文件
Future<void> shareExportedFile(NovelExportResult exportResult) async {
try {
final file = File(exportResult.filePath);
if (await file.exists()) {
await Share.shareXFiles(
[XFile(exportResult.filePath)],
text: '分享小说文件:${exportResult.fileName}',
);
AppLogger.i('NovelFileService', '分享文件成功: ${exportResult.fileName}');
} else {
throw Exception('文件不存在:${exportResult.filePath}');
}
} catch (e) {
AppLogger.e('NovelFileService', '分享文件失败', e);
rethrow;
}
}
/// 获取已导出文件列表
Future<List<NovelExportResult>> getExportedFiles() async {
try {
final directory = await _getNovelStorageDirectory();
if (!await directory.exists()) {
return [];
}
final files = await directory.list().where((entity) => entity is File).cast<File>().toList();
final results = <NovelExportResult>[];
for (final file in files) {
try {
final fileName = file.path.split('/').last;
final fileStat = await file.stat();
// 尝试从文件名推断格式
NovelExportFormat format = NovelExportFormat.txt;
if (fileName.endsWith('.md')) {
format = NovelExportFormat.markdown;
} else if (fileName.endsWith('.json')) {
format = NovelExportFormat.json;
}
results.add(NovelExportResult(
filePath: file.path,
fileName: fileName,
fileSizeBytes: fileStat.size,
format: format,
exportedAt: fileStat.modified,
));
} catch (e) {
AppLogger.w('NovelFileService', '无法获取文件信息: ${file.path}', e);
}
}
// 按修改时间倒序排列
results.sort((a, b) => b.exportedAt.compareTo(a.exportedAt));
return results;
} catch (e) {
AppLogger.e('NovelFileService', '获取导出文件列表失败', e);
return [];
}
}
/// 删除导出的文件
Future<bool> deleteExportedFile(String filePath) async {
try {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
AppLogger.i('NovelFileService', '删除文件成功: $filePath');
return true;
}
return false;
} catch (e) {
AppLogger.e('NovelFileService', '删除文件失败: $filePath', e);
return false;
}
}
/// 清理过期的导出文件超过30天
Future<int> cleanupOldExports({Duration maxAge = const Duration(days: 30)}) async {
try {
final directory = await _getNovelStorageDirectory();
if (!await directory.exists()) {
return 0;
}
final files = await directory.list().where((entity) => entity is File).cast<File>().toList();
final now = DateTime.now();
int deletedCount = 0;
for (final file in files) {
try {
final fileStat = await file.stat();
if (now.difference(fileStat.modified) > maxAge) {
await file.delete();
deletedCount++;
AppLogger.i('NovelFileService', '清理过期文件: ${file.path}');
}
} catch (e) {
AppLogger.w('NovelFileService', '清理文件时出错: ${file.path}', e);
}
}
AppLogger.i('NovelFileService', '清理完成,删除了 $deletedCount 个过期文件');
return deletedCount;
} catch (e) {
AppLogger.e('NovelFileService', '清理过期文件失败', e);
return 0;
}
}
/// 获取存储目录路径(用于用户查看)
Future<String> getStorageDirectoryPath() async {
final directory = await _getNovelStorageDirectory();
return directory.path;
}
/// 检查存储空间使用情况
Future<Map<String, dynamic>> getStorageInfo() async {
try {
final directory = await _getNovelStorageDirectory();
if (!await directory.exists()) {
return {
'directoryPath': directory.path,
'fileCount': 0,
'totalSizeBytes': 0,
'totalSizeMB': 0.0,
};
}
final files = await directory.list().where((entity) => entity is File).cast<File>().toList();
int totalSize = 0;
for (final file in files) {
try {
final fileStat = await file.stat();
totalSize += fileStat.size;
} catch (e) {
AppLogger.w('NovelFileService', '无法获取文件大小: ${file.path}', e);
}
}
return {
'directoryPath': directory.path,
'fileCount': files.length,
'totalSizeBytes': totalSize,
'totalSizeMB': totalSize / (1024 * 1024),
};
} catch (e) {
AppLogger.e('NovelFileService', '获取存储信息失败', e);
return {
'directoryPath': 'unknown',
'fileCount': 0,
'totalSizeBytes': 0,
'totalSizeMB': 0.0,
};
}
}
}