mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 12:51:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public protocol BookChapterRepository: Sendable {
|
||||
func load(bookURL: String) async throws -> [BookChapter]
|
||||
func save(_ chapters: [BookChapter], bookURL: String) async throws
|
||||
func remove(bookURL: String) async throws
|
||||
}
|
||||
|
||||
public actor FileBookChapterRepository: BookChapterRepository {
|
||||
public static let maximumDocumentBytes = 24 * 1_024 * 1_024
|
||||
private let directory: URL
|
||||
|
||||
public init(directory: URL) { self.directory = directory }
|
||||
|
||||
public static func live() -> FileBookChapterRepository {
|
||||
let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado/Chapters", isDirectory: true)
|
||||
return FileBookChapterRepository(directory: directory)
|
||||
}
|
||||
|
||||
public func load(bookURL: String) throws -> [BookChapter] {
|
||||
let url = fileURL(bookURL)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
|
||||
let data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||
guard data.count <= Self.maximumDocumentBytes else {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return []
|
||||
}
|
||||
return try JSONDecoder().decode([BookChapter].self, from: data).sorted { $0.index < $1.index }
|
||||
}
|
||||
|
||||
public func save(_ chapters: [BookChapter], bookURL: String) throws {
|
||||
var normalized = chapters
|
||||
for index in normalized.indices {
|
||||
normalized[index].bookUrl = bookURL
|
||||
normalized[index].index = index
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(normalized)
|
||||
guard data.count <= Self.maximumDocumentBytes else {
|
||||
throw CocoaError(.fileWriteOutOfSpace)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: fileURL(bookURL), options: .atomic)
|
||||
}
|
||||
|
||||
public func remove(bookURL: String) throws {
|
||||
let url = fileURL(bookURL)
|
||||
if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) }
|
||||
}
|
||||
|
||||
private func fileURL(_ bookURL: String) -> URL {
|
||||
let name = SHA256.hash(data: Data(bookURL.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
return directory.appendingPathComponent("\(name).json")
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryBookChapterRepository: BookChapterRepository {
|
||||
private var values: [String: [BookChapter]]
|
||||
public init(values: [String: [BookChapter]] = [:]) { self.values = values }
|
||||
public func load(bookURL: String) -> [BookChapter] { values[bookURL] ?? [] }
|
||||
public func save(_ chapters: [BookChapter], bookURL: String) { values[bookURL] = chapters }
|
||||
public func remove(bookURL: String) { values[bookURL] = nil }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
public protocol BookRepository: Sendable {
|
||||
func load() async throws -> [Book]
|
||||
func save(_ books: [Book]) async throws
|
||||
}
|
||||
|
||||
public actor FileBookRepository: BookRepository {
|
||||
private let fileURL: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
public init(fileURL: URL) {
|
||||
self.fileURL = fileURL
|
||||
encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
public func load() throws -> [Book] {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
|
||||
return try decoder.decode([Book].self, from: Data(contentsOf: fileURL))
|
||||
}
|
||||
|
||||
public func save(_ books: [Book]) throws {
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try encoder.encode(books).write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryBookRepository: BookRepository {
|
||||
private var books: [Book]
|
||||
|
||||
public init(books: [Book] = []) {
|
||||
self.books = books
|
||||
}
|
||||
|
||||
public func load() -> [Book] { books }
|
||||
|
||||
public func save(_ books: [Book]) {
|
||||
self.books = books
|
||||
}
|
||||
}
|
||||
|
||||
public protocol BookGroupRepository: Sendable {
|
||||
func load() async throws -> [BookGroup]
|
||||
func save(_ groups: [BookGroup]) async throws
|
||||
}
|
||||
|
||||
public actor FileBookGroupRepository: BookGroupRepository {
|
||||
private let fileURL: URL
|
||||
public init(fileURL: URL) { self.fileURL = fileURL }
|
||||
public func load() throws -> [BookGroup] {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
|
||||
return try JSONDecoder().decode([BookGroup].self, from: Data(contentsOf: fileURL))
|
||||
}
|
||||
public func save(_ groups: [BookGroup]) throws {
|
||||
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try encoder.encode(groups).write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryBookGroupRepository: BookGroupRepository {
|
||||
private var groups: [BookGroup]
|
||||
public init(groups: [BookGroup] = []) { self.groups = groups }
|
||||
public func load() -> [BookGroup] { groups }
|
||||
public func save(_ groups: [BookGroup]) { self.groups = groups }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
public protocol BookSourceRepository: Sendable {
|
||||
func load() async throws -> [BookSource]
|
||||
func save(_ sources: [BookSource]) async throws
|
||||
}
|
||||
|
||||
public actor FileBookSourceRepository: BookSourceRepository {
|
||||
private let fileURL: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
public init(fileURL: URL) {
|
||||
self.fileURL = fileURL
|
||||
encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
public func load() throws -> [BookSource] {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
|
||||
return try decoder.decode([BookSource].self, from: Data(contentsOf: fileURL))
|
||||
}
|
||||
|
||||
public func save(_ sources: [BookSource]) throws {
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let data = try encoder.encode(sources)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryBookSourceRepository: BookSourceRepository {
|
||||
private var sources: [BookSource]
|
||||
|
||||
public init(sources: [BookSource] = []) {
|
||||
self.sources = sources
|
||||
}
|
||||
|
||||
public func load() -> [BookSource] { sources }
|
||||
|
||||
public func save(_ sources: [BookSource]) {
|
||||
self.sources = sources
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
public struct SearchKeyword: Codable, Hashable, Identifiable, Sendable {
|
||||
public var word: String
|
||||
public var usage: Int
|
||||
public var lastUseTime: Int64
|
||||
public var id: String { word }
|
||||
|
||||
public init(word: String, usage: Int = 1, lastUseTime: Int64? = nil) {
|
||||
self.word = word
|
||||
self.usage = usage
|
||||
self.lastUseTime = lastUseTime ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
}
|
||||
}
|
||||
|
||||
public struct LegadoCache: Codable, Hashable, Identifiable, Sendable {
|
||||
public var key: String
|
||||
public var value: String?
|
||||
public var deadline: Int64
|
||||
public var id: String { key }
|
||||
|
||||
public init(key: String, value: String? = nil, deadline: Int64 = 0) {
|
||||
self.key = key
|
||||
self.value = value
|
||||
self.deadline = deadline
|
||||
}
|
||||
}
|
||||
|
||||
public struct LegadoCookie: Codable, Hashable, Identifiable, Sendable {
|
||||
public var url: String
|
||||
public var cookie: String
|
||||
public var id: String { url }
|
||||
|
||||
public init(url: String, cookie: String = "") {
|
||||
self.url = url
|
||||
self.cookie = cookie
|
||||
}
|
||||
}
|
||||
|
||||
public struct TxtTocRule: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: Int64
|
||||
public var name: String
|
||||
public var rule: String
|
||||
public var replacement: String
|
||||
public var example: String?
|
||||
public var serialNumber: Int
|
||||
public var enable: Bool
|
||||
public init(
|
||||
id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000),
|
||||
name: String = "",
|
||||
rule: String = "",
|
||||
replacement: String = "",
|
||||
example: String? = nil,
|
||||
serialNumber: Int = -1,
|
||||
enable: Bool = true
|
||||
) {
|
||||
self.id = id; self.name = name; self.rule = rule; self.replacement = replacement
|
||||
self.example = example; self.serialNumber = serialNumber; self.enable = enable
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, rule, replacement, example, serialNumber, enable
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try value.decodeIfPresent(Int64.self, forKey: .id) ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
rule = try value.decodeIfPresent(String.self, forKey: .rule) ?? ""
|
||||
replacement = try value.decodeIfPresent(String.self, forKey: .replacement) ?? ""
|
||||
example = try value.decodeIfPresent(String.self, forKey: .example)
|
||||
serialNumber = try value.decodeIfPresent(Int.self, forKey: .serialNumber) ?? -1
|
||||
enable = try value.decodeIfPresent(Bool.self, forKey: .enable) ?? true
|
||||
}
|
||||
}
|
||||
|
||||
public struct HttpTTS: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: Int64
|
||||
public var name: String
|
||||
public var url: String
|
||||
public var contentType: String?
|
||||
public var concurrentRate: String?
|
||||
public var loginUrl: String?
|
||||
public var loginUi: String?
|
||||
public var header: String?
|
||||
public var jsLib: String?
|
||||
public var enabledCookieJar: Bool?
|
||||
public var loginCheckJs: String?
|
||||
public var lastUpdateTime: Int64
|
||||
public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", url: String = "") {
|
||||
self.id = id; self.name = name; self.url = url; contentType = nil; concurrentRate = "0"
|
||||
loginUrl = nil; loginUi = nil; header = nil; jsLib = nil; enabledCookieJar = false
|
||||
loginCheckJs = nil; lastUpdateTime = id
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
id = (try? values.decode(Int64.self, forKey: .id))
|
||||
?? Int64((try? values.decode(String.self, forKey: .id)) ?? "")
|
||||
?? now
|
||||
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
url = try values.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||
contentType = try values.decodeIfPresent(String.self, forKey: .contentType)
|
||||
concurrentRate = try values.decodeIfPresent(String.self, forKey: .concurrentRate)
|
||||
loginUrl = try values.decodeIfPresent(String.self, forKey: .loginUrl)
|
||||
if let text = try? values.decodeIfPresent(String.self, forKey: .loginUi) {
|
||||
loginUi = text
|
||||
} else if let json = try? values.decodeIfPresent(JSONValue.self, forKey: .loginUi) {
|
||||
loginUi = (try? JSONEncoder().encode(json)).flatMap { String(data: $0, encoding: .utf8) }
|
||||
} else {
|
||||
loginUi = nil
|
||||
}
|
||||
header = try values.decodeIfPresent(String.self, forKey: .header)
|
||||
jsLib = try values.decodeIfPresent(String.self, forKey: .jsLib)
|
||||
enabledCookieJar = try values.decodeIfPresent(Bool.self, forKey: .enabledCookieJar) ?? false
|
||||
loginCheckJs = try values.decodeIfPresent(String.self, forKey: .loginCheckJs)
|
||||
lastUpdateTime = (try? values.decode(Int64.self, forKey: .lastUpdateTime))
|
||||
?? Int64((try? values.decode(String.self, forKey: .lastUpdateTime)) ?? "")
|
||||
?? now
|
||||
}
|
||||
}
|
||||
|
||||
public struct RuleSub: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: Int64
|
||||
public var name: String
|
||||
public var url: String
|
||||
public var type: Int
|
||||
public var customOrder: Int
|
||||
public var autoUpdate: Bool
|
||||
public var update: Int64
|
||||
public var updateInterval: Int
|
||||
public var silentUpdate: Bool
|
||||
public var js: String?
|
||||
public var showRule: String?
|
||||
public var sourceUrl: String?
|
||||
public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", url: String = "") {
|
||||
self.id = id; self.name = name; self.url = url; type = 0; customOrder = 0
|
||||
autoUpdate = false; update = id; updateInterval = 0; silentUpdate = false
|
||||
js = nil; showRule = nil; sourceUrl = nil
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeyboardAssist: Codable, Hashable, Identifiable, Sendable {
|
||||
public var type: Int
|
||||
public var key: String
|
||||
public var value: String
|
||||
public var serialNo: Int
|
||||
public var id: String { "\(type)\u{0}\(key)" }
|
||||
public init(type: Int = 0, key: String, value: String, serialNo: Int = 0) {
|
||||
self.type = type; self.key = key; self.value = value; self.serialNo = serialNo
|
||||
}
|
||||
}
|
||||
|
||||
public struct Server: Codable, Hashable, Identifiable, Sendable {
|
||||
public enum ServerType: String, Codable, Sendable { case webDAV = "WEBDAV" }
|
||||
public struct WebDAVConfig: Codable, Hashable, Sendable {
|
||||
public var url: String
|
||||
public var username: String
|
||||
public var password: String
|
||||
public init(url: String, username: String, password: String) {
|
||||
self.url = url; self.username = username; self.password = password
|
||||
}
|
||||
}
|
||||
public var id: Int64
|
||||
public var name: String
|
||||
public var type: ServerType
|
||||
public var config: String?
|
||||
public var sortNumber: Int
|
||||
public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", type: ServerType = .webDAV) {
|
||||
self.id = id; self.name = name; self.type = type; config = nil; sortNumber = 0
|
||||
}
|
||||
public var webDAVConfig: WebDAVConfig? {
|
||||
guard type == .webDAV, let config else { return nil }
|
||||
return try? JSONDecoder().decode(WebDAVConfig.self, from: Data(config.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
public enum SearchKeywordOrder: Sendable { case usage, lastUseTime }
|
||||
|
||||
public protocol SearchKeywordRepository: Sendable {
|
||||
func load(order: SearchKeywordOrder) async throws -> [SearchKeyword]
|
||||
@discardableResult func record(_ word: String, at time: Int64) async throws -> SearchKeyword
|
||||
func delete(_ word: String) async throws
|
||||
func deleteAll() async throws
|
||||
}
|
||||
|
||||
public actor SQLiteSearchKeywordRepository: SearchKeywordRepository {
|
||||
private let database: LegadoDatabase
|
||||
private let legacyWords: [String]
|
||||
private var migratedLegacy = false
|
||||
|
||||
public init(database: LegadoDatabase, legacyWords: [String] = []) {
|
||||
self.database = database
|
||||
self.legacyWords = legacyWords
|
||||
}
|
||||
|
||||
public func load(order: SearchKeywordOrder) async throws -> [SearchKeyword] {
|
||||
try await migrateLegacyIfNeeded()
|
||||
return try await database.loadSearchKeywords(order: order)
|
||||
}
|
||||
|
||||
public func record(_ word: String, at time: Int64) async throws -> SearchKeyword {
|
||||
try await migrateLegacyIfNeeded()
|
||||
return try await database.recordSearchKeyword(word, at: time)
|
||||
}
|
||||
|
||||
public func delete(_ word: String) async throws { try await database.deleteSearchKeyword(word) }
|
||||
public func deleteAll() async throws { try await database.deleteAllSearchKeywords() }
|
||||
|
||||
private func migrateLegacyIfNeeded() async throws {
|
||||
guard !migratedLegacy else { return }
|
||||
migratedLegacy = true
|
||||
try await database.importLegacySearchKeywords(legacyWords)
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemorySearchKeywordRepository: SearchKeywordRepository {
|
||||
private var values: [String: SearchKeyword]
|
||||
public init(values: [SearchKeyword] = []) {
|
||||
self.values = Dictionary(uniqueKeysWithValues: values.map { ($0.word, $0) })
|
||||
}
|
||||
public func load(order: SearchKeywordOrder) -> [SearchKeyword] {
|
||||
values.values.sorted {
|
||||
switch order {
|
||||
case .usage:
|
||||
$0.usage == $1.usage ? $0.lastUseTime > $1.lastUseTime : $0.usage > $1.usage
|
||||
case .lastUseTime:
|
||||
$0.lastUseTime == $1.lastUseTime ? $0.usage > $1.usage : $0.lastUseTime > $1.lastUseTime
|
||||
}
|
||||
}
|
||||
}
|
||||
public func record(_ word: String, at time: Int64) -> SearchKeyword {
|
||||
var value = values[word] ?? .init(word: word, usage: 0, lastUseTime: time)
|
||||
value.usage += 1
|
||||
value.lastUseTime = time
|
||||
values[word] = value
|
||||
return value
|
||||
}
|
||||
public func delete(_ word: String) { values[word] = nil }
|
||||
public func deleteAll() { values.removeAll() }
|
||||
}
|
||||
|
||||
public protocol LegadoCacheRepository: Sendable {
|
||||
func value(for key: String, now: Int64) async throws -> String?
|
||||
func put(_ cache: LegadoCache) async throws
|
||||
func delete(_ key: String) async throws
|
||||
func deleteSourceVariables(_ sourceKey: String) async throws
|
||||
func clearExpired(now: Int64) async throws
|
||||
}
|
||||
|
||||
public actor SQLiteLegadoCacheRepository: LegadoCacheRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
public func value(for key: String, now: Int64) async throws -> String? {
|
||||
try await database.cacheValue(for: key, now: now)
|
||||
}
|
||||
public func put(_ cache: LegadoCache) async throws { try await database.putCache(cache) }
|
||||
public func delete(_ key: String) async throws { try await database.deleteCache(key) }
|
||||
public func deleteSourceVariables(_ sourceKey: String) async throws {
|
||||
try await database.deleteSourceVariables(sourceKey)
|
||||
}
|
||||
public func clearExpired(now: Int64) async throws { try await database.clearExpiredCaches(now: now) }
|
||||
}
|
||||
|
||||
public protocol LegadoCookieRepository: Sendable {
|
||||
func get(_ url: String) async throws -> LegadoCookie?
|
||||
func getOkHTTP() async throws -> [LegadoCookie]
|
||||
func put(_ cookie: LegadoCookie) async throws
|
||||
func delete(_ url: String) async throws
|
||||
func deleteOkHTTP() async throws
|
||||
}
|
||||
|
||||
public protocol SearchBookRepository: Sendable {
|
||||
func cachedMatches(keyword: String) async throws -> [SearchBook]
|
||||
func get(bookURL: String) async throws -> SearchBook?
|
||||
func first(name: String, author: String) async throws -> SearchBook?
|
||||
func changeSources(name: String, author: String, sourceGroup: String, key: String?) async throws -> [SearchBook]
|
||||
func enabledWithCover(name: String, author: String) async throws -> [SearchBook]
|
||||
func put(_ books: [SearchBook]) async throws
|
||||
func clear(name: String, author: String) async throws
|
||||
func clearExpired(before time: Int64) async throws
|
||||
}
|
||||
|
||||
extension SearchBookRepository {
|
||||
public func cachedMatches(keyword: String) async throws -> [SearchBook] {
|
||||
try await changeSources(name: keyword, author: "", sourceGroup: "", key: nil)
|
||||
}
|
||||
}
|
||||
|
||||
public protocol ConfigurationRepository: Sendable {
|
||||
func loadTxtTocRules() async throws -> [TxtTocRule]
|
||||
func saveTxtTocRules(_ values: [TxtTocRule]) async throws
|
||||
func loadHttpTTS() async throws -> [HttpTTS]
|
||||
func saveHttpTTS(_ values: [HttpTTS]) async throws
|
||||
func loadRuleSubs() async throws -> [RuleSub]
|
||||
func saveRuleSubs(_ values: [RuleSub]) async throws
|
||||
func loadKeyboardAssists() async throws -> [KeyboardAssist]
|
||||
func saveKeyboardAssists(_ values: [KeyboardAssist]) async throws
|
||||
func loadServers() async throws -> [Server]
|
||||
func saveServers(_ values: [Server]) async throws
|
||||
}
|
||||
|
||||
public actor SQLiteConfigurationRepository: ConfigurationRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
public func loadTxtTocRules() async throws -> [TxtTocRule] { try await database.loadTxtTocRules() }
|
||||
public func saveTxtTocRules(_ values: [TxtTocRule]) async throws { try await database.replaceTxtTocRules(values) }
|
||||
public func loadHttpTTS() async throws -> [HttpTTS] { try await database.loadHttpTTS() }
|
||||
public func saveHttpTTS(_ values: [HttpTTS]) async throws { try await database.replaceHttpTTS(values) }
|
||||
public func loadRuleSubs() async throws -> [RuleSub] { try await database.loadRuleSubs() }
|
||||
public func saveRuleSubs(_ values: [RuleSub]) async throws { try await database.replaceRuleSubs(values) }
|
||||
public func loadKeyboardAssists() async throws -> [KeyboardAssist] { try await database.loadKeyboardAssists() }
|
||||
public func saveKeyboardAssists(_ values: [KeyboardAssist]) async throws { try await database.replaceKeyboardAssists(values) }
|
||||
public func loadServers() async throws -> [Server] { try await database.loadServers() }
|
||||
public func saveServers(_ values: [Server]) async throws { try await database.replaceServers(values) }
|
||||
}
|
||||
|
||||
public actor SQLiteSearchBookRepository: SearchBookRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
|
||||
public func cachedMatches(keyword: String) async throws -> [SearchBook] {
|
||||
try await database.cachedSearchMatches(keyword: keyword)
|
||||
}
|
||||
|
||||
public func get(bookURL: String) async throws -> SearchBook? {
|
||||
let books = try await database.loadSearchBooks()
|
||||
return books.first { $0.bookURL == bookURL }
|
||||
}
|
||||
|
||||
public func first(name: String, author: String) async throws -> SearchBook? {
|
||||
let books = try await database.loadSearchBooks()
|
||||
let sources = try await database.loadBookSources()
|
||||
let sourceIDs = Set(sources.map(\.bookSourceUrl))
|
||||
return books
|
||||
.filter { $0.name == name && $0.author == author }
|
||||
.filter { sourceIDs.contains($0.origin) }
|
||||
.sorted { $0.originOrder < $1.originOrder }
|
||||
.first
|
||||
}
|
||||
|
||||
public func changeSources(
|
||||
name: String,
|
||||
author: String,
|
||||
sourceGroup: String,
|
||||
key: String?
|
||||
) async throws -> [SearchBook] {
|
||||
let sources = try await database.loadBookSources()
|
||||
let sourceMap = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) })
|
||||
let books = try await database.loadSearchBooks()
|
||||
return books.compactMap { book in
|
||||
guard book.name == name,
|
||||
book.author.localizedCaseInsensitiveContains(author),
|
||||
let source = sourceMap[book.origin], source.enabled,
|
||||
sourceGroup.isEmpty || (source.bookSourceGroup ?? "").contains(sourceGroup) else { return nil }
|
||||
if let key, !key.isEmpty,
|
||||
!book.originName.localizedCaseInsensitiveContains(key),
|
||||
!(book.latestChapterTitle ?? "").localizedCaseInsensitiveContains(key) { return nil }
|
||||
var value = book
|
||||
value.originOrder = source.customOrder
|
||||
return value
|
||||
}.sorted { $0.originOrder < $1.originOrder }
|
||||
}
|
||||
|
||||
public func enabledWithCover(name: String, author: String) async throws -> [SearchBook] {
|
||||
let sources = try await database.loadBookSources()
|
||||
let sourceMap = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) })
|
||||
let books = try await database.loadSearchBooks()
|
||||
return books.compactMap { book in
|
||||
guard book.name == name, book.author == author,
|
||||
!(book.coverURL ?? "").isEmpty,
|
||||
let source = sourceMap[book.origin], source.enabled else { return nil }
|
||||
var value = book
|
||||
value.originOrder = source.customOrder
|
||||
return value
|
||||
}.sorted { $0.originOrder < $1.originOrder }
|
||||
}
|
||||
|
||||
public func put(_ books: [SearchBook]) async throws { try await database.putSearchBooks(books) }
|
||||
public func clear(name: String, author: String) async throws {
|
||||
try await database.deleteSearchBooks(name: name, author: author)
|
||||
}
|
||||
public func clearExpired(before time: Int64) async throws {
|
||||
try await database.clearExpiredSearchBooks(before: time)
|
||||
}
|
||||
}
|
||||
|
||||
public actor SQLiteLegadoCookieRepository: LegadoCookieRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
public func get(_ url: String) async throws -> LegadoCookie? { try await database.cookie(for: url) }
|
||||
public func getOkHTTP() async throws -> [LegadoCookie] { try await database.loadOkHTTPCookies() }
|
||||
public func put(_ cookie: LegadoCookie) async throws { try await database.putCookie(cookie) }
|
||||
public func delete(_ url: String) async throws { try await database.deleteCookie(url) }
|
||||
public func deleteOkHTTP() async throws { try await database.deleteOkHTTPCookies() }
|
||||
}
|
||||
|
||||
public enum SearchKeywordRepositoryFactory {
|
||||
public static func live(defaults: UserDefaults = .standard) -> any SearchKeywordRepository {
|
||||
guard let database = try? LegadoDatabase(url: LegadoDatabase.defaultURL()) else {
|
||||
return InMemorySearchKeywordRepository()
|
||||
}
|
||||
return SQLiteSearchKeywordRepository(
|
||||
database: database,
|
||||
legacyWords: defaults.stringArray(forKey: "bookSearchHistory") ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum SearchBookRepositoryFactory {
|
||||
public static func live() -> any SearchBookRepository {
|
||||
guard let database = try? LegadoDatabase(url: LegadoDatabase.defaultURL()) else {
|
||||
return InMemorySearchBookRepository()
|
||||
}
|
||||
return SQLiteSearchBookRepository(database: database)
|
||||
}
|
||||
}
|
||||
|
||||
public final class PersistentRuntimeCache: @unchecked Sendable {
|
||||
public static let shared = PersistentRuntimeCache()
|
||||
private let lock = NSLock()
|
||||
private var handle: OpaquePointer?
|
||||
|
||||
public init(url: URL = LegadoDatabase.defaultURL()) {
|
||||
let migrator = try? LegadoDatabase(url: url)
|
||||
_ = migrator
|
||||
sqlite3_open_v2(
|
||||
url.path,
|
||||
&handle,
|
||||
SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX,
|
||||
nil
|
||||
)
|
||||
if let handle { sqlite3_busy_timeout(handle, 5_000) }
|
||||
}
|
||||
|
||||
deinit { if let handle { sqlite3_close(handle) } }
|
||||
|
||||
public func get(_ key: String, now: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let handle else { return nil }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(
|
||||
handle,
|
||||
"SELECT value FROM caches WHERE `key` = ? AND (deadline = 0 OR deadline > ?)",
|
||||
-1,
|
||||
&statement,
|
||||
nil
|
||||
) == SQLITE_OK, let statement else { return nil }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
||||
sqlite3_bind_int64(statement, 2, now)
|
||||
guard sqlite3_step(statement) == SQLITE_ROW,
|
||||
let value = sqlite3_column_text(statement, 0) else { return nil }
|
||||
return String(cString: value)
|
||||
}
|
||||
|
||||
public func put(_ key: String, value: String, deadline: Int64 = 0) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let handle else { return }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(
|
||||
handle,
|
||||
"""
|
||||
INSERT INTO caches(`key`, value, deadline) VALUES(?, ?, ?)
|
||||
ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, deadline = excluded.deadline
|
||||
""",
|
||||
-1,
|
||||
&statement,
|
||||
nil
|
||||
) == SQLITE_OK, let statement else { return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
||||
sqlite3_bind_text(statement, 2, value, -1, Self.transient)
|
||||
sqlite3_bind_int64(statement, 3, deadline)
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
public func cookie(for key: String) -> String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let handle else { return nil }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(handle, "SELECT cookie FROM cookies WHERE url = ?", -1, &statement, nil) == SQLITE_OK,
|
||||
let statement else { return nil }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
||||
guard sqlite3_step(statement) == SQLITE_ROW, let value = sqlite3_column_text(statement, 0) else { return nil }
|
||||
return String(cString: value)
|
||||
}
|
||||
|
||||
public func putCookie(key: String, value: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let handle else { return }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(
|
||||
handle,
|
||||
"INSERT INTO cookies(url, cookie) VALUES(?, ?) ON CONFLICT(url) DO UPDATE SET cookie = excluded.cookie",
|
||||
-1,
|
||||
&statement,
|
||||
nil
|
||||
) == SQLITE_OK, let statement else { return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
||||
sqlite3_bind_text(statement, 2, value, -1, Self.transient)
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
public func deleteCookie(key: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let handle else { return }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(handle, "DELETE FROM cookies WHERE url = ?", -1, &statement, nil) == SQLITE_OK,
|
||||
let statement else { return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||
}
|
||||
|
||||
public actor InMemorySearchBookRepository: SearchBookRepository {
|
||||
private var values: [String: SearchBook] = [:]
|
||||
public init(values: [SearchBook] = []) {
|
||||
self.values = Dictionary(uniqueKeysWithValues: values.map { ($0.bookURL, $0) })
|
||||
}
|
||||
public func get(bookURL: String) -> SearchBook? { values[bookURL] }
|
||||
public func cachedMatches(keyword: String) async throws -> [SearchBook] {
|
||||
guard !keyword.isEmpty else { return [] }
|
||||
return Array(values.values.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(keyword) || $0.author.localizedCaseInsensitiveContains(keyword)
|
||||
}.sorted {
|
||||
let lhsExact = $0.name == keyword || $0.author == keyword
|
||||
let rhsExact = $1.name == keyword || $1.author == keyword
|
||||
if lhsExact != rhsExact { return lhsExact }
|
||||
return $0.time > $1.time
|
||||
}.prefix(200))
|
||||
}
|
||||
public func first(name: String, author: String) -> SearchBook? {
|
||||
values.values.filter { $0.name == name && $0.author == author }
|
||||
.sorted { $0.originOrder < $1.originOrder }.first
|
||||
}
|
||||
public func changeSources(name: String, author: String, sourceGroup: String, key: String?) -> [SearchBook] {
|
||||
values.values.filter {
|
||||
$0.name == name && $0.author.localizedCaseInsensitiveContains(author)
|
||||
&& (key?.isEmpty != false
|
||||
|| $0.originName.localizedCaseInsensitiveContains(key ?? "")
|
||||
|| ($0.latestChapterTitle ?? "").localizedCaseInsensitiveContains(key ?? ""))
|
||||
}.sorted { $0.originOrder < $1.originOrder }
|
||||
}
|
||||
public func enabledWithCover(name: String, author: String) -> [SearchBook] {
|
||||
values.values.filter { $0.name == name && $0.author == author && !($0.coverURL ?? "").isEmpty }
|
||||
.sorted { $0.originOrder < $1.originOrder }
|
||||
}
|
||||
public func put(_ books: [SearchBook]) { for book in books { values[book.bookURL] = book } }
|
||||
public func clear(name: String, author: String) {
|
||||
values = values.filter { $0.value.name != name || $0.value.author != author }
|
||||
}
|
||||
public func clearExpired(before time: Int64) { values = values.filter { $0.value.time >= time } }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
|
||||
public protocol RssSourceRepository: Sendable {
|
||||
func all() async throws -> [RssSource]
|
||||
func enabled() async throws -> [RssSource]
|
||||
func disabled() async throws -> [RssSource]
|
||||
func groups() async throws -> [String]
|
||||
func inGroup(_ group: String) async throws -> [RssSource]
|
||||
func search(_ key: String) async throws -> [RssSource]
|
||||
func save(_ values: [RssSource]) async throws
|
||||
func setEnabled(_ value: Bool, sourceURL: String) async throws
|
||||
func remove(sourceURL: String) async throws
|
||||
}
|
||||
|
||||
public actor SQLiteRssSourceRepository: RssSourceRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
|
||||
public func all() async throws -> [RssSource] { try await database.loadRssSources() }
|
||||
public func enabled() async throws -> [RssSource] { try await all().filter(\.enabled) }
|
||||
public func disabled() async throws -> [RssSource] { try await all().filter { !$0.enabled } }
|
||||
public func groups() async throws -> [String] {
|
||||
var seen = Set<String>()
|
||||
return try await all().flatMap(\.groups).filter { seen.insert($0).inserted }
|
||||
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
public func inGroup(_ group: String) async throws -> [RssSource] {
|
||||
try await all().filter { $0.groups.contains(group) }
|
||||
}
|
||||
public func search(_ key: String) async throws -> [RssSource] {
|
||||
guard !key.isEmpty else { return try await all() }
|
||||
return try await all().filter {
|
||||
[$0.sourceName, $0.sourceUrl, $0.sourceGroup ?? "", $0.sourceComment ?? ""]
|
||||
.contains { $0.localizedCaseInsensitiveContains(key) }
|
||||
}
|
||||
}
|
||||
public func enabled(search key: String) async throws -> [RssSource] {
|
||||
try await search(key).filter(\.enabled)
|
||||
}
|
||||
public func enabled(inGroup group: String) async throws -> [RssSource] {
|
||||
try await inGroup(group).filter(\.enabled)
|
||||
}
|
||||
public func loginSources() async throws -> [RssSource] {
|
||||
try await all().filter { !($0.loginUrl ?? "").isEmpty }
|
||||
}
|
||||
public func noGroup() async throws -> [RssSource] {
|
||||
try await all().filter { $0.groups.isEmpty || $0.groups.contains("未分组") }
|
||||
}
|
||||
public func contains(sourceURL: String) async throws -> Bool {
|
||||
try await all().contains { $0.sourceUrl == sourceURL }
|
||||
}
|
||||
public func source(for sourceURL: String) async throws -> RssSource? {
|
||||
try await all().first { $0.sourceUrl == sourceURL }
|
||||
}
|
||||
public func save(_ values: [RssSource]) async throws { try await database.replaceRssSources(values) }
|
||||
public func setEnabled(_ value: Bool, sourceURL: String) async throws {
|
||||
var values = try await all()
|
||||
guard let index = values.firstIndex(where: { $0.sourceUrl == sourceURL }) else { return }
|
||||
values[index].enabled = value
|
||||
try await save(values)
|
||||
}
|
||||
public func remove(sourceURL: String) async throws {
|
||||
let values = try await all().filter { $0.sourceUrl != sourceURL }
|
||||
try await save(values)
|
||||
try await database.removeRssArticles(origin: sourceURL)
|
||||
try await database.deleteSourceVariables(sourceURL)
|
||||
}
|
||||
}
|
||||
|
||||
public protocol RssArticleRepository: Sendable {
|
||||
func articles(origin: String, sort: String) async throws -> [RssArticle]
|
||||
func replace(_ values: [RssArticle], origin: String, sort: String) async throws
|
||||
func append(_ values: [RssArticle]) async throws
|
||||
func put(_ values: [RssArticle]) async throws
|
||||
func clearOld(origin: String, sort: String, before order: Int64) async throws
|
||||
func remove(origin: String) async throws
|
||||
func article(origin: String, link: String, sort: String) async throws -> RssArticle?
|
||||
func article(origin: String, link: String) async throws -> RssArticle?
|
||||
func update(_ values: [RssArticle]) async throws
|
||||
func updateOrigin(from oldOrigin: String, to origin: String) async throws
|
||||
}
|
||||
|
||||
public actor SQLiteRssArticleRepository: RssArticleRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
public func articles(origin: String, sort: String) async throws -> [RssArticle] {
|
||||
try await database.loadRssArticles(origin: origin, sort: sort)
|
||||
}
|
||||
public func replace(_ values: [RssArticle], origin: String, sort: String) async throws {
|
||||
try await database.replaceRssArticles(values, origin: origin, sort: sort)
|
||||
}
|
||||
public func append(_ values: [RssArticle]) async throws { try await database.appendRssArticles(values) }
|
||||
public func put(_ values: [RssArticle]) async throws { try await database.putRssArticles(values) }
|
||||
public func clearOld(origin: String, sort: String, before order: Int64) async throws {
|
||||
try await database.clearOldRssArticles(origin: origin, sort: sort, before: order)
|
||||
}
|
||||
public func remove(origin: String) async throws { try await database.removeRssArticles(origin: origin) }
|
||||
public func article(origin: String, link: String, sort: String) async throws -> RssArticle? {
|
||||
try await database.findRssArticle(origin: origin, link: link, sort: sort)
|
||||
}
|
||||
public func article(origin: String, link: String) async throws -> RssArticle? {
|
||||
try await database.findRssArticle(origin: origin, link: link)
|
||||
}
|
||||
public func update(_ values: [RssArticle]) async throws { try await database.updateRssArticles(values) }
|
||||
public func updateOrigin(from oldOrigin: String, to origin: String) async throws {
|
||||
try await database.updateRssArticleOrigin(from: oldOrigin, to: origin)
|
||||
}
|
||||
}
|
||||
|
||||
public actor SQLiteRssReadRecordRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
public func all() async throws -> [RssReadRecord] { try await database.loadRssReadRecords() }
|
||||
public func forOrigin(_ origin: String) async throws -> [RssReadRecord] {
|
||||
try await all().filter { $0.origin == origin }
|
||||
}
|
||||
public func insertIgnoringExisting(_ value: RssReadRecord) async throws {
|
||||
try await database.appendRssReadRecords([value])
|
||||
}
|
||||
public func record(_ record: String, origin: String) async throws -> RssReadRecord? {
|
||||
try await database.findRssReadRecord(record: record, origin: origin)
|
||||
}
|
||||
public func update(_ values: [RssReadRecord]) async throws { try await database.updateRssReadRecords(values) }
|
||||
public func clear() async throws { try await database.clearRssReadRecords() }
|
||||
public func remove(origin: String) async throws { try await database.removeRssReadRecords(origin: origin) }
|
||||
}
|
||||
|
||||
public actor SQLiteRssStarRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
public func all() async throws -> [RssStar] { try await database.loadRssStars() }
|
||||
public func groups() async throws -> [String] {
|
||||
var seen = Set<String>()
|
||||
return try await all().map(\.group).filter { seen.insert($0).inserted }.sorted()
|
||||
}
|
||||
public func inGroup(_ group: String) async throws -> [RssStar] {
|
||||
try await all().filter { $0.group == group }
|
||||
}
|
||||
public func save(_ value: RssStar) async throws {
|
||||
try await database.updateRssStars([value])
|
||||
}
|
||||
public func remove(origin: String, link: String) async throws {
|
||||
try await database.removeRssStars(origin: origin, link: link)
|
||||
}
|
||||
public func star(origin: String, link: String) async throws -> RssStar? { try await database.findRssStar(origin: origin, link: link) }
|
||||
public func remove(origin: String) async throws { try await database.removeRssStars(origin: origin) }
|
||||
public func remove(group: String) async throws { try await database.removeRssStars(group: group) }
|
||||
public func clear() async throws { try await database.clearRssStars() }
|
||||
public func updateOrigin(from oldOrigin: String, to origin: String) async throws {
|
||||
try await database.updateRssStarOrigin(from: oldOrigin, to: origin)
|
||||
}
|
||||
}
|
||||
|
||||
public enum RSSRepositoryFactory {
|
||||
public static func database(url: URL = LegadoDatabase.defaultURL()) throws -> LegadoDatabase {
|
||||
try LegadoDatabase(url: url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import Foundation
|
||||
|
||||
public protocol ReadingActivityRepository: Sendable {
|
||||
func loadBookmarks() async throws -> [Bookmark]
|
||||
func saveBookmarks(_ bookmarks: [Bookmark]) async throws
|
||||
func loadReadRecords() async throws -> [ReadRecord]
|
||||
func saveReadRecords(_ records: [ReadRecord]) async throws
|
||||
}
|
||||
|
||||
public actor FileReadingActivityRepository: ReadingActivityRepository {
|
||||
private let bookmarksURL: URL
|
||||
private let recordsURL: URL
|
||||
|
||||
public init(bookmarksURL: URL, recordsURL: URL) {
|
||||
self.bookmarksURL = bookmarksURL
|
||||
self.recordsURL = recordsURL
|
||||
}
|
||||
|
||||
public func loadBookmarks() throws -> [Bookmark] { try load([Bookmark].self, from: bookmarksURL) }
|
||||
public func saveBookmarks(_ bookmarks: [Bookmark]) throws { try save(bookmarks, to: bookmarksURL) }
|
||||
public func loadReadRecords() throws -> [ReadRecord] { try load([ReadRecord].self, from: recordsURL) }
|
||||
public func saveReadRecords(_ records: [ReadRecord]) throws { try save(records, to: recordsURL) }
|
||||
|
||||
private func load<T: Decodable>(_ type: T.Type, from url: URL) throws -> T where T: ExpressibleByArrayLiteral {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
|
||||
return try JSONDecoder().decode(type, from: Data(contentsOf: url))
|
||||
}
|
||||
|
||||
private func save<T: Encodable>(_ value: T, to url: URL) throws {
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try encoder.encode(value).write(to: url, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryReadingActivityRepository: ReadingActivityRepository {
|
||||
private var bookmarks: [Bookmark]
|
||||
private var records: [ReadRecord]
|
||||
|
||||
public init(bookmarks: [Bookmark] = [], records: [ReadRecord] = []) {
|
||||
self.bookmarks = bookmarks
|
||||
self.records = records
|
||||
}
|
||||
|
||||
public func loadBookmarks() -> [Bookmark] { bookmarks }
|
||||
public func saveBookmarks(_ bookmarks: [Bookmark]) { self.bookmarks = bookmarks }
|
||||
public func loadReadRecords() -> [ReadRecord] { records }
|
||||
public func saveReadRecords(_ records: [ReadRecord]) { self.records = records }
|
||||
}
|
||||
|
||||
public struct ReadingActivityService: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func sortedBookmarks(_ bookmarks: [Bookmark]) -> [Bookmark] {
|
||||
bookmarks.sorted {
|
||||
let name = $0.bookName.localizedStandardCompare($1.bookName)
|
||||
if name != .orderedSame { return name == .orderedAscending }
|
||||
let author = $0.bookAuthor.localizedStandardCompare($1.bookAuthor)
|
||||
if author != .orderedSame { return author == .orderedAscending }
|
||||
if $0.chapterIndex != $1.chapterIndex { return $0.chapterIndex < $1.chapterIndex }
|
||||
return $0.chapterPosition < $1.chapterPosition
|
||||
}
|
||||
}
|
||||
|
||||
public func summaries(_ records: [ReadRecord]) -> [ReadRecordSummary] {
|
||||
Dictionary(grouping: records, by: \.bookName)
|
||||
.map { name, values in
|
||||
ReadRecordSummary(
|
||||
bookName: name,
|
||||
readTime: values.reduce(0) { $0 + max($1.readTime, 0) },
|
||||
lastRead: values.map(\.lastRead).max() ?? 0
|
||||
)
|
||||
}
|
||||
.sorted { $0.lastRead > $1.lastRead }
|
||||
}
|
||||
|
||||
public func displayedSummaries(
|
||||
_ records: [ReadRecord],
|
||||
searchKey: String,
|
||||
sortMode: Int
|
||||
) -> [ReadRecordSummary] {
|
||||
let values = summaries(records).filter {
|
||||
searchKey.isEmpty || $0.bookName.localizedCaseInsensitiveContains(searchKey)
|
||||
}
|
||||
return values.sorted {
|
||||
switch sortMode {
|
||||
case 1:
|
||||
if $0.readTime != $1.readTime { return $0.readTime > $1.readTime }
|
||||
case 2:
|
||||
if $0.lastRead != $1.lastRead { return $0.lastRead > $1.lastRead }
|
||||
default:
|
||||
let order = $0.bookName.localizedStandardCompare($1.bookName)
|
||||
if order != .orderedSame { return order == .orderedAscending }
|
||||
}
|
||||
return $0.bookName.localizedStandardCompare($1.bookName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
public func addingReadTime(
|
||||
_ elapsedMilliseconds: Int64,
|
||||
deviceID: String,
|
||||
bookName: String,
|
||||
now: Int64,
|
||||
to records: [ReadRecord],
|
||||
calendar: Calendar = .current
|
||||
) -> [ReadRecord] {
|
||||
guard elapsedMilliseconds > 0, !bookName.isEmpty else { return records }
|
||||
var updated = records
|
||||
let day = Self.dayKey(
|
||||
for: Date(timeIntervalSince1970: Double(now) / 1_000),
|
||||
calendar: calendar
|
||||
)
|
||||
if let index = updated.firstIndex(where: { $0.deviceId == deviceID && $0.bookName == bookName }) {
|
||||
updated[index].readTime += elapsedMilliseconds
|
||||
updated[index].lastRead = now
|
||||
updated[index].dailyReadTimes[day, default: 0] += elapsedMilliseconds
|
||||
} else {
|
||||
updated.append(.init(
|
||||
deviceId: deviceID,
|
||||
bookName: bookName,
|
||||
readTime: elapsedMilliseconds,
|
||||
lastRead: now,
|
||||
dailyReadTimes: [day: elapsedMilliseconds]
|
||||
))
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
public func statistics(
|
||||
_ records: [ReadRecord],
|
||||
now: Date = Date(),
|
||||
calendar: Calendar = .current
|
||||
) -> ReadingStatistics {
|
||||
let dailyTotals = records.reduce(into: [String: Int64]()) { result, record in
|
||||
for (day, milliseconds) in record.dailyReadTimes where milliseconds > 0 {
|
||||
result[day, default: 0] += milliseconds
|
||||
}
|
||||
}
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let todayKey = Self.dayKey(for: today, calendar: calendar)
|
||||
let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
|
||||
var cursor: Date?
|
||||
if dailyTotals[todayKey, default: 0] > 0 {
|
||||
cursor = today
|
||||
} else if dailyTotals[Self.dayKey(for: yesterday, calendar: calendar), default: 0] > 0 {
|
||||
cursor = yesterday
|
||||
} else {
|
||||
cursor = nil
|
||||
}
|
||||
var streak = 0
|
||||
while let day = cursor,
|
||||
dailyTotals[Self.dayKey(for: day, calendar: calendar), default: 0] > 0 {
|
||||
streak += 1
|
||||
cursor = calendar.date(byAdding: .day, value: -1, to: day)
|
||||
}
|
||||
let recentDays = (-6...0).compactMap { offset -> DailyReadingTotal? in
|
||||
guard let date = calendar.date(byAdding: .day, value: offset, to: today) else { return nil }
|
||||
return DailyReadingTotal(
|
||||
date: date,
|
||||
readTime: dailyTotals[Self.dayKey(for: date, calendar: calendar), default: 0]
|
||||
)
|
||||
}
|
||||
return ReadingStatistics(
|
||||
todayReadTime: dailyTotals[todayKey, default: 0],
|
||||
currentStreak: streak,
|
||||
totalReadTime: records.reduce(0) { $0 + max($1.readTime, 0) },
|
||||
bookCount: Set(records.lazy.map(\.bookName).filter { !$0.isEmpty }).count,
|
||||
recentDays: recentDays
|
||||
)
|
||||
}
|
||||
|
||||
private static func dayKey(for date: Date, calendar: Calendar) -> String {
|
||||
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
public struct DailyReadingTotal: Hashable, Sendable {
|
||||
public let date: Date
|
||||
public let readTime: Int64
|
||||
|
||||
public init(date: Date, readTime: Int64) {
|
||||
self.date = date
|
||||
self.readTime = readTime
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReadingStatistics: Hashable, Sendable {
|
||||
public let todayReadTime: Int64
|
||||
public let currentStreak: Int
|
||||
public let totalReadTime: Int64
|
||||
public let bookCount: Int
|
||||
public let recentDays: [DailyReadingTotal]
|
||||
|
||||
public init(
|
||||
todayReadTime: Int64,
|
||||
currentStreak: Int,
|
||||
totalReadTime: Int64,
|
||||
bookCount: Int,
|
||||
recentDays: [DailyReadingTotal]
|
||||
) {
|
||||
self.todayReadTime = todayReadTime
|
||||
self.currentStreak = currentStreak
|
||||
self.totalReadTime = totalReadTime
|
||||
self.bookCount = bookCount
|
||||
self.recentDays = recentDays
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookProgress: Codable, Hashable, Sendable {
|
||||
public var name: String
|
||||
public var author: String
|
||||
public var durChapterIndex: Int
|
||||
public var durChapterPos: Int
|
||||
public var durChapterTime: Int64
|
||||
public var durChapterTitle: String?
|
||||
|
||||
public init(book: Book) {
|
||||
name = book.name
|
||||
author = book.author
|
||||
durChapterIndex = book.durChapterIndex
|
||||
durChapterPos = book.durChapterPos
|
||||
durChapterTime = book.durChapterTime
|
||||
durChapterTitle = book.durChapterTitle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
public enum ReplaceRuleImportError: Error, LocalizedError, Equatable {
|
||||
case emptyDocument
|
||||
case documentTooLarge(Int)
|
||||
case invalidDocument(String)
|
||||
case emptyPattern(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyDocument: "净化规则内容为空"
|
||||
case .documentTooLarge(let maximum): "净化规则文件超过 \(maximum / 1_024 / 1_024) MB 上限"
|
||||
case .invalidDocument(let detail): "无法解析净化规则 JSON:\(detail)"
|
||||
case .emptyPattern(let name): "净化规则“\(name)”没有匹配内容"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReplaceRuleImporter: Sendable {
|
||||
public static let maximumDocumentBytes = 10 * 1_024 * 1_024
|
||||
|
||||
public init() {}
|
||||
|
||||
public func decode(_ data: Data) throws -> [ReplaceRule] {
|
||||
guard data.count <= Self.maximumDocumentBytes else {
|
||||
throw ReplaceRuleImportError.documentTooLarge(Self.maximumDocumentBytes)
|
||||
}
|
||||
let bom = Data([0xEF, 0xBB, 0xBF])
|
||||
let cleaned = data.starts(with: bom) ? Data(data.dropFirst(3)) : data
|
||||
guard !cleaned.isEmpty else { throw ReplaceRuleImportError.emptyDocument }
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let rules = if let array = try? decoder.decode([ReplaceRule].self, from: cleaned) {
|
||||
array
|
||||
} else {
|
||||
[try decoder.decode(ReplaceRule.self, from: cleaned)]
|
||||
}
|
||||
for rule in rules { try ReplaceRuleValidator.validate(rule) }
|
||||
return rules
|
||||
} catch let error as ReplaceRuleImportError {
|
||||
throw error
|
||||
} catch {
|
||||
throw ReplaceRuleImportError.invalidDocument(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
public func merge(_ incoming: [ReplaceRule], into existing: [ReplaceRule]) -> [ReplaceRule] {
|
||||
var result = existing
|
||||
var indexes = Dictionary(uniqueKeysWithValues: result.enumerated().map { ($1.id, $0) })
|
||||
for rule in incoming {
|
||||
if let index = indexes[rule.id] {
|
||||
result[index] = rule
|
||||
} else {
|
||||
indexes[rule.id] = result.count
|
||||
result.append(rule)
|
||||
}
|
||||
}
|
||||
return result.sorted { $0.sortOrder < $1.sortOrder }
|
||||
}
|
||||
}
|
||||
|
||||
public protocol ReplaceRuleRepository: Sendable {
|
||||
func load() async throws -> [ReplaceRule]
|
||||
func save(_ rules: [ReplaceRule]) async throws
|
||||
}
|
||||
|
||||
public actor FileReplaceRuleRepository: ReplaceRuleRepository {
|
||||
private let fileURL: URL
|
||||
|
||||
public init(fileURL: URL) { self.fileURL = fileURL }
|
||||
|
||||
public func load() throws -> [ReplaceRule] {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
|
||||
return try JSONDecoder().decode([ReplaceRule].self, from: Data(contentsOf: fileURL))
|
||||
}
|
||||
|
||||
public func save(_ rules: [ReplaceRule]) throws {
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try encoder.encode(rules).write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryReplaceRuleRepository: ReplaceRuleRepository {
|
||||
private var rules: [ReplaceRule]
|
||||
public init(rules: [ReplaceRule] = []) { self.rules = rules }
|
||||
public func load() -> [ReplaceRule] { rules }
|
||||
public func save(_ rules: [ReplaceRule]) { self.rules = rules }
|
||||
}
|
||||
Reference in New Issue
Block a user