mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
279 lines
11 KiB
Swift
279 lines
11 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
|
|
public struct CachedChapterContent: Codable, Equatable, Sendable {
|
|
public var bookURL: String
|
|
public var chapterURL: String
|
|
public var title: String?
|
|
public var content: String
|
|
public var supplementalContent: String?
|
|
public var savedAt: Int64
|
|
public var sourceID: String?
|
|
|
|
public init(
|
|
bookURL: String,
|
|
chapterURL: String,
|
|
title: String? = nil,
|
|
content: String,
|
|
supplementalContent: String? = nil,
|
|
savedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1_000),
|
|
sourceID: String? = nil
|
|
) {
|
|
self.bookURL = bookURL
|
|
self.chapterURL = chapterURL
|
|
self.title = title
|
|
self.content = content
|
|
self.supplementalContent = supplementalContent
|
|
self.savedAt = savedAt
|
|
self.sourceID = sourceID
|
|
}
|
|
}
|
|
|
|
public enum ChapterContentCacheError: Error, LocalizedError, Equatable {
|
|
case entryTooLarge(Int)
|
|
case invalidEntry
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .entryTooLarge(let maximum): "章节正文超过缓存上限 \(maximum / 1_024 / 1_024) MB"
|
|
case .invalidEntry: "章节缓存内容无效"
|
|
}
|
|
}
|
|
}
|
|
|
|
public actor ChapterContentCache {
|
|
public static let maximumEntryBytes = 8 * 1_024 * 1_024
|
|
public static let defaultMaximumTotalBytes = 256 * 1_024 * 1_024
|
|
|
|
private let directory: URL
|
|
private let maximumTotalBytes: Int?
|
|
|
|
public init(directory: URL, maximumTotalBytes: Int? = nil) {
|
|
self.directory = directory
|
|
self.maximumTotalBytes = maximumTotalBytes.map { max($0, Self.maximumEntryBytes) }
|
|
}
|
|
|
|
public static func live() -> ChapterContentCache {
|
|
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent("Legado/Chapters", isDirectory: true)
|
|
return ChapterContentCache(directory: base)
|
|
}
|
|
|
|
public func load(bookURL: String, chapterURL: String) throws -> CachedChapterContent? {
|
|
let url = fileURL(bookURL: bookURL, chapterURL: chapterURL)
|
|
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
|
do {
|
|
let value = try JSONDecoder().decode(CachedChapterContent.self, from: Data(contentsOf: url, options: .mappedIfSafe))
|
|
guard value.bookURL == bookURL, value.chapterURL == chapterURL, !value.content.isEmpty else {
|
|
try? FileManager.default.removeItem(at: url)
|
|
throw ChapterContentCacheError.invalidEntry
|
|
}
|
|
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
|
|
return value
|
|
} catch let error as ChapterContentCacheError {
|
|
throw error
|
|
} catch {
|
|
try? FileManager.default.removeItem(at: url)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public func save(_ entry: CachedChapterContent) throws {
|
|
guard !entry.bookURL.isEmpty, !entry.chapterURL.isEmpty, !entry.content.isEmpty else {
|
|
throw ChapterContentCacheError.invalidEntry
|
|
}
|
|
let encoder = JSONEncoder()
|
|
let data = try encoder.encode(entry)
|
|
guard data.count <= Self.maximumEntryBytes else {
|
|
throw ChapterContentCacheError.entryTooLarge(Self.maximumEntryBytes)
|
|
}
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
try data.write(to: fileURL(bookURL: entry.bookURL, chapterURL: entry.chapterURL), options: .atomic)
|
|
try pruneIfNeeded()
|
|
}
|
|
|
|
public func remove(bookURL: String) throws {
|
|
guard FileManager.default.fileExists(atPath: directory.path) else { return }
|
|
let prefix = digest(bookURL) + "-"
|
|
for url in try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
|
|
where url.lastPathComponent.hasPrefix(prefix) {
|
|
try FileManager.default.removeItem(at: url)
|
|
}
|
|
}
|
|
|
|
public func remove(bookURL: String, chapterURL: String) throws {
|
|
let url = fileURL(bookURL: bookURL, chapterURL: chapterURL)
|
|
guard FileManager.default.fileExists(atPath: url.path) else { return }
|
|
try FileManager.default.removeItem(at: url)
|
|
}
|
|
|
|
public func removeAll() throws {
|
|
guard FileManager.default.fileExists(atPath: directory.path) else { return }
|
|
try FileManager.default.removeItem(at: directory)
|
|
}
|
|
|
|
public func statistics() throws -> (entries: Int, bytes: Int) {
|
|
let files = try cacheFiles()
|
|
return (files.count, files.reduce(0) { $0 + $1.size })
|
|
}
|
|
|
|
public func pruneIfNeeded() throws {
|
|
let maximumTotalBytes = maximumTotalBytes ?? ApplicationSettingsStore.shared.settings.chapterCacheMB * 1024 * 1024
|
|
var files = try cacheFiles().sorted { $0.modified < $1.modified }
|
|
var total = files.reduce(0) { $0 + $1.size }
|
|
while total > maximumTotalBytes, let oldest = files.first {
|
|
try FileManager.default.removeItem(at: oldest.url)
|
|
total -= oldest.size
|
|
files.removeFirst()
|
|
}
|
|
}
|
|
|
|
private func cacheFiles() throws -> [(url: URL, size: Int, modified: Date)] {
|
|
guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
|
|
return try FileManager.default.contentsOfDirectory(
|
|
at: directory,
|
|
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
|
|
options: [.skipsHiddenFiles]
|
|
).compactMap { url in
|
|
guard url.pathExtension == "json" else { return nil }
|
|
let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
|
|
return (url, values.fileSize ?? 0, values.contentModificationDate ?? .distantPast)
|
|
}
|
|
}
|
|
|
|
private func fileURL(bookURL: String, chapterURL: String) -> URL {
|
|
directory.appendingPathComponent("\(digest(bookURL))-\(digest(chapterURL)).json")
|
|
}
|
|
|
|
private func digest(_ value: String) -> String {
|
|
SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
}
|
|
|
|
public protocol ChapterContentLoading: Sendable {
|
|
func loadContent(
|
|
book: Book,
|
|
chapter: BookChapter,
|
|
source: BookSource,
|
|
nextChapterURL: String?,
|
|
maxPages: Int
|
|
) async -> ChapterContentReport
|
|
}
|
|
|
|
extension NetworkBookService: ChapterContentLoading {}
|
|
|
|
public struct ChapterCacheJob: Sendable, Equatable {
|
|
public var chapter: BookChapter
|
|
public var nextChapterURL: String?
|
|
|
|
public init(chapter: BookChapter, nextChapterURL: String?) {
|
|
self.chapter = chapter
|
|
self.nextChapterURL = nextChapterURL
|
|
}
|
|
}
|
|
|
|
public enum ChapterCachePlanner {
|
|
public static func plan(chapters: [BookChapter], startIndex: Int, limit: Int? = nil) -> [ChapterCacheJob] {
|
|
guard !chapters.isEmpty, limit.map({ $0 > 0 }) ?? true else { return [] }
|
|
let start = min(max(startIndex, 0), chapters.count - 1)
|
|
var jobs: [ChapterCacheJob] = []
|
|
for index in chapters.indices where index >= start && !chapters[index].isVolume {
|
|
let nextURL = chapters[(index + 1)...].first(where: { !$0.isVolume })?.url
|
|
jobs.append(.init(chapter: chapters[index], nextChapterURL: nextURL))
|
|
if let limit, jobs.count >= limit { break }
|
|
}
|
|
return jobs
|
|
}
|
|
}
|
|
|
|
public struct CoordinatedChapterContent: Sendable {
|
|
public var report: ChapterContentReport
|
|
public var loadedFromCache: Bool
|
|
|
|
public init(report: ChapterContentReport, loadedFromCache: Bool) {
|
|
self.report = report
|
|
self.loadedFromCache = loadedFromCache
|
|
}
|
|
}
|
|
|
|
public actor ChapterContentCoordinator {
|
|
private let loader: any ChapterContentLoading
|
|
private let cache: ChapterContentCache
|
|
|
|
public init(loader: any ChapterContentLoading = NetworkBookService(), cache: ChapterContentCache = .live()) {
|
|
self.loader = loader
|
|
self.cache = cache
|
|
}
|
|
|
|
public func load(
|
|
book: Book,
|
|
chapter: BookChapter,
|
|
source: BookSource,
|
|
nextChapterURL: String? = nil,
|
|
maxPages: Int = 20,
|
|
forceRefresh: Bool = false
|
|
) async -> CoordinatedChapterContent {
|
|
if forceRefresh {
|
|
try? await cache.remove(bookURL: book.bookUrl, chapterURL: chapter.url)
|
|
} else if let cached = try? await cache.load(bookURL: book.bookUrl, chapterURL: chapter.url),
|
|
cached.sourceID == nil || cached.sourceID == source.id {
|
|
return .init(report: .init(
|
|
title: cached.title,
|
|
content: cached.content,
|
|
supplementalContent: cached.supplementalContent
|
|
), loadedFromCache: true)
|
|
}
|
|
let report = await loader.loadContent(
|
|
book: book,
|
|
chapter: chapter,
|
|
source: source,
|
|
nextChapterURL: nextChapterURL,
|
|
maxPages: maxPages
|
|
)
|
|
await saveContent(report, book: book, chapter: chapter, source: source)
|
|
return .init(report: report, loadedFromCache: false)
|
|
}
|
|
|
|
public func saveContent(_ report: ChapterContentReport, book: Book, chapter: BookChapter, source: BookSource) async {
|
|
if !report.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
try? await cache.save(.init(
|
|
bookURL: book.bookUrl,
|
|
chapterURL: chapter.url,
|
|
title: report.title,
|
|
content: report.content,
|
|
supplementalContent: report.supplementalContent,
|
|
sourceID: source.id
|
|
))
|
|
}
|
|
}
|
|
|
|
public func saveEditedContent(book: Book, chapter: BookChapter, title: String?, content: String) async throws {
|
|
let value = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !value.isEmpty else { throw ChapterContentCacheError.invalidEntry }
|
|
try await cache.save(.init(
|
|
bookURL: book.bookUrl,
|
|
chapterURL: chapter.url,
|
|
title: title,
|
|
content: content,
|
|
sourceID: book.origin
|
|
))
|
|
}
|
|
|
|
@discardableResult
|
|
public func prefetch(
|
|
book: Book,
|
|
chapter: BookChapter,
|
|
source: BookSource,
|
|
nextChapterURL: String? = nil
|
|
) async -> Bool {
|
|
if let cached = try? await cache.load(bookURL: book.bookUrl, chapterURL: chapter.url),
|
|
cached.sourceID == nil || cached.sourceID == source.id { return false }
|
|
let result = await load(book: book, chapter: chapter, source: source, nextChapterURL: nextChapterURL)
|
|
return !result.report.content.isEmpty
|
|
}
|
|
|
|
public func invalidate(bookURL: String) async {
|
|
try? await cache.remove(bookURL: bookURL)
|
|
}
|
|
}
|