Files

189 lines
9.4 KiB
Swift

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")
}
}