feat: open-source 小浣熊阅读 for iOS

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
@@ -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 }
}