import Foundation public struct LocalFileEntry: Identifiable, Sendable, Equatable { public let url: URL public let bytes: Int64 public let modified: Date? public var id: String { url.absoluteString } } public enum LocalFileError: Error, LocalizedError, Equatable { case outsideLibrary, invalidName, alreadyExists, missingFile, emptyCache, missingDirectory case saveFailed(String) public var errorDescription: String? { switch self { case .outsideLibrary: "只能管理应用内的本地书文件。" case .invalidName: "文件名不能为空,不能包含路径分隔符或控制字符。" case .alreadyExists: "同名文件已存在,请换一个名称。" case .missingFile: "文件已不存在,请重新导入。" case .emptyCache: "这本书还没有可导出的缓存正文,请先缓存章节。" case .missingDirectory: "还没有这本书的目录,请先打开详情加载目录。" case .saveFailed(let message): "书架保存失败:\(message)" } } } public struct StagedLocalFile: Sendable { public let original: URL public let staged: URL } public actor LocalFileService { public let directory: URL public init(directory: URL) { self.directory = directory.standardizedFileURL } public func list() throws -> [LocalFileEntry] { guard FileManager.default.fileExists(atPath: directory.path) else { return [] } return try FileManager.default.contentsOfDirectory( at: directory, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey], options: [.skipsHiddenFiles] ).compactMap { url in let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey]) guard values.isRegularFile == true, values.isSymbolicLink != true, url.pathExtension.lowercased() == "txt" else { return nil } return .init(url: url, bytes: Int64(values.fileSize ?? 0), modified: values.contentModificationDate) }.sorted { $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending } } public func importFile(from source: URL) throws -> URL { guard source.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat } let values = try source.resourceValues(forKeys: [.isRegularFileKey]) guard values.isRegularFile == true else { throw LocalFileError.missingFile } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let stem = source.deletingPathExtension().lastPathComponent var target = directory.appendingPathComponent(source.lastPathComponent) var suffix = 2 while FileManager.default.fileExists(atPath: target.path) { target = directory.appendingPathComponent("\(stem) (\(suffix)).txt") suffix += 1 } try FileManager.default.copyItem(at: source, to: target) return target } public func rename(_ source: URL, to name: String) throws -> URL { try validate(source) let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed != ".", trimmed != "..", !trimmed.hasPrefix("."), !trimmed.contains(where: { $0 == "/" || $0 == "\\" || $0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }) else { throw LocalFileError.invalidName } let filename = trimmed.lowercased().hasSuffix(".txt") ? trimmed : trimmed + ".txt" guard filename.utf8.count <= 255 else { throw LocalFileError.invalidName } let target = directory.appendingPathComponent(filename) if source.standardizedFileURL == target.standardizedFileURL { return source } guard !FileManager.default.fileExists(atPath: target.path) else { throw LocalFileError.alreadyExists } try FileManager.default.moveItem(at: source, to: target) return target } public func stageRemoval(_ source: URL) throws -> StagedLocalFile? { try validate(source, allowMissing: true) guard FileManager.default.fileExists(atPath: source.path) else { return nil } let trash = directory.appendingPathComponent(".Trash", isDirectory: true) try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true) let staged = trash.appendingPathComponent(UUID().uuidString + ".txt") try FileManager.default.moveItem(at: source, to: staged) return .init(original: source, staged: staged) } public func restore(_ file: StagedLocalFile) throws { try validate(file.original, allowMissing: true) guard file.staged.deletingLastPathComponent().standardizedFileURL == directory.appendingPathComponent(".Trash").standardizedFileURL else { throw LocalFileError.outsideLibrary } try FileManager.default.moveItem(at: file.staged, to: file.original) } public func finishRemoval(_ file: StagedLocalFile) throws { guard file.staged.deletingLastPathComponent().standardizedFileURL == directory.appendingPathComponent(".Trash").standardizedFileURL else { throw LocalFileError.outsideLibrary } try FileManager.default.removeItem(at: file.staged) } private func validate(_ url: URL, allowMissing: Bool = false) throws { guard url.isFileURL, url.pathExtension.lowercased() == "txt", url.standardizedFileURL.deletingLastPathComponent() == directory, url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else { throw LocalFileError.outsideLibrary } if !allowMissing, !FileManager.default.fileExists(atPath: url.path) { throw LocalFileError.missingFile } } } public struct BookFileExport: Identifiable, Sendable { public var id: String { url.absoluteString } public let url: URL public let cachedChapters: Int public let totalChapters: Int public var isPartial: Bool { cachedChapters < totalChapters } } public actor BookFileExporter { private let directory: URL private let cache: ChapterContentCache public init(directory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("LegadoExports"), cache: ChapterContentCache = .live()) { self.directory = directory self.cache = cache } public func originalFile(_ source: URL, filename: String) throws -> BookFileExport { let target = try destination(filename: filename) do { try FileManager.default.copyItem(at: source, to: target) return .init(url: target, cachedChapters: 0, totalChapters: 0) } catch { try? FileManager.default.removeItem(at: target.deletingLastPathComponent()) throw error } } public func cachedBook(_ book: Book, chapters: [BookChapter]) async throws -> BookFileExport { guard chapters.contains(where: { !$0.isVolume }) else { throw LocalFileError.missingDirectory } let target = try destination(filename: book.name + ".txt") do { _ = FileManager.default.createFile(atPath: target.path, contents: nil) let output = try FileHandle(forWritingTo: target) defer { try? output.close() } try output.write(contentsOf: Data((book.name + (book.author.isEmpty ? "" : "\n作者:" + book.author) + "\n\n").utf8)) var cached = 0 var total = 0 for chapter in chapters.sorted(by: { $0.index < $1.index }) { try Task.checkCancellation() try output.write(contentsOf: Data((chapter.title + "\n\n").utf8)) guard !chapter.isVolume else { continue } total += 1 if let entry = try? await cache.load(bookURL: book.bookUrl, chapterURL: chapter.url) { cached += 1 try output.write(contentsOf: Data((entry.content + "\n" + (entry.supplementalContent ?? "") + "\n\n").utf8)) } else { try output.write(contentsOf: Data("【本章尚未缓存】\n\n".utf8)) } } guard cached > 0 else { throw LocalFileError.emptyCache } return .init(url: target, cachedChapters: cached, totalChapters: total) } catch { try? FileManager.default.removeItem(at: target.deletingLastPathComponent()) throw error } } public func discard(_ export: BookFileExport) throws { guard export.url.deletingLastPathComponent().deletingLastPathComponent().standardizedFileURL == directory.standardizedFileURL else { throw LocalFileError.outsideLibrary } try FileManager.default.removeItem(at: export.url.deletingLastPathComponent()) } private func destination(filename: String) throws -> URL { let folder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let safe = filename.components(separatedBy: CharacterSet(charactersIn: "/\\:").union(.controlCharacters)).joined(separator: "_") let stem = String(safe.replacingOccurrences(of: ".txt", with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70)) return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + ".txt") } }