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,434 @@
#if SWIFT_PACKAGE
import LegadoCore
#endif
import Foundation
import SwiftUI
@MainActor
final class SourceLibraryModel: ObservableObject {
@Published private(set) var sources: [BookSource] = []
@Published private(set) var isWorking = false
@Published var notice: Notice?
@Published var pendingImport: PendingImport?
struct PendingImport: Identifiable, Sendable {
struct Item: Identifiable, Sendable {
let source: BookSource
let existing: BookSource?
var id: String { source.id }
var state: String {
guard let existing else { return "新增" }
return source.lastUpdateTime > existing.lastUpdateTime ? "更新" : "已有"
}
}
struct InvalidItem: Identifiable, Sendable {
let id = UUID()
let source: BookSource
let issues: [BookSourceIssue]
}
let id = UUID()
let items: [Item]
let invalidItems: [InvalidItem]
var validCount: Int { items.count }
var invalidCount: Int { invalidItems.count }
var totalCount: Int { validCount + invalidCount }
}
struct Notice: Identifiable {
enum Kind { case success, error }
let id = UUID()
let kind: Kind
let title: String
let detail: String
}
private let repository: any BookSourceRepository
private let importer = BookSourceImporter()
private let importTransport: any HTTPTransport
var groups: [String] {
Array(Set(sources.flatMap(\.groups))).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
init(repository: any BookSourceRepository, importTransport: any HTTPTransport = URLSessionHTTPTransport.shared) {
self.repository = repository
self.importTransport = importTransport
}
static func live() -> SourceLibraryModel {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado", isDirectory: true)
let legacy = base.appendingPathComponent("book-sources.json")
guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else {
return SourceLibraryModel(repository: FileBookSourceRepository(fileURL: legacy))
}
return SourceLibraryModel(repository: SQLiteBookSourceRepository(database: database, legacyFileURL: legacy))
}
static func demo(sources: [BookSource]) -> SourceLibraryModel {
SourceLibraryModel(repository: InMemoryBookSourceRepository(sources: sources))
}
func load() async {
guard sources.isEmpty else { return }
isWorking = true
defer { isWorking = false }
do {
sources = try await repository.load()
} catch {
notice = .init(kind: .error, title: "无法读取书源", detail: error.localizedDescription)
}
}
func importFiles(_ urls: [URL]) async {
isWorking = true
defer { isWorking = false }
var combined = sources
var inserted = 0
var replaced = 0
var warnings = 0
do {
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
let result = try await Task.detached(priority: .userInitiated) { [importer, existing = combined] in
try importer.prepare(data: Data(contentsOf: url), existing: existing, duplicatePolicy: .replace)
}.value
combined = result.sources
inserted += result.inserted
replaced += result.replaced
warnings += result.warnings.count
}
try await repository.save(combined)
sources = combined
notice = .init(
kind: .success,
title: "书源导入完成",
detail: "新增 \(inserted) 个,覆盖 \(replaced) 个,警告 \(warnings) 条"
)
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
}
}
func importText(_ text: String) async { await importRemoteURL(text) }
func importRemoteURL(_ value: String) async {
isWorking = true
defer { isWorking = false }
do {
var budget = 100
let batch = try await resolveImport(value, depth: 0, visited: [], budget: &budget)
guard batch.invalidItems.isEmpty else {
throw BookSourceImportError.invalidSources(batch.invalidItems.flatMap(\.issues))
}
try await applyImport(JSONEncoder().encode(batch.sources), successTitle: "书源导入完成")
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
}
}
func remove(at offsets: IndexSet) async {
var updated = sources
updated.remove(atOffsets: offsets)
await persist(updated, successTitle: "已删除书源")
}
func remove(sourceIDs: Set<String>) async {
guard !sourceIDs.isEmpty else { return }
await persist(sources.filter { !sourceIDs.contains($0.id) }, successTitle: "已删除 \(sourceIDs.count) 个书源")
}
func setEnabled(_ enabled: Bool, for sourceID: String) async {
var updated = sources
guard let index = updated.firstIndex(where: { $0.id == sourceID }) else { return }
updated[index].enabled = enabled
await persist(updated, successTitle: nil)
}
func applySilentSubscription(_ data: Data) async throws {
let incoming = try await Task.detached(priority: .userInitiated) { [importer] in
try importer.decode(data)
}.value
var updated = sources
for var source in incoming {
if let index = updated.firstIndex(where: { $0.bookSourceUrl == source.bookSourceUrl }) {
guard updated[index].lastUpdateTime < source.lastUpdateTime else { continue }
source.bookSourceGroup = updated[index].bookSourceGroup
updated[index] = source
} else { updated.append(source) }
}
try await repository.save(updated)
sources = updated
}
func previewFiles(_ urls: [URL]) async {
isWorking = true
defer { isWorking = false }
do {
var incoming: [String: BookSource] = [:]
var invalidItems: [PendingImport.InvalidItem] = []
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
let data = try await Task.detached(priority: .userInitiated) { [importer] in
try importer.normalizedData(Data(contentsOf: url))
}.value
var budget = 100
let batch = try await resolveImport(String(decoding: data, as: UTF8.self), depth: 0, visited: [], budget: &budget)
invalidItems.append(contentsOf: batch.invalidItems)
for source in batch.sources {
incoming[source.id] = source
}
}
// UIDocumentPicker is still dismissing when its completion runs.
// Presenting the review sheet in that same frame is ignored by
// SwiftUI, so stage it after the picker has left the hierarchy.
try? await Task.sleep(nanoseconds: 300_000_000)
stage(Array(incoming.values), invalidItems: invalidItems)
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
}
}
func previewText(_ text: String) async { await previewInput(text) }
func previewRemoteURL(_ value: String) async { await previewInput(value) }
func previewInput(_ value: String) async {
isWorking = true
defer { isWorking = false }
do {
var budget = 100
let batch = try await resolveImport(value, depth: 0, visited: [], budget: &budget)
stage(batch.sources, invalidItems: batch.invalidItems)
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
}
}
private nonisolated func resolveImport(_ value: String, depth: Int, visited: Set<String>, budget: inout Int) async throws -> (
sources: [BookSource], invalidItems: [PendingImport.InvalidItem]
) {
guard depth < 8 else { throw BookSourceImportError.invalidDocument("订阅清单嵌套过深") }
let input = try RuleImportInput.parse(value)
let data: Data
var visited = visited
switch input {
case .json(let json): data = Data(json.utf8)
case .remote(let url, _):
guard budget > 0 else { throw BookSourceImportError.invalidDocument("订阅清单的总链接数超过 100") }
budget -= 1
guard visited.insert(url.absoluteString).inserted else {
throw BookSourceImportError.invalidDocument("订阅清单出现循环链接")
}
let response = try await importTransport.data(for: input.request()!)
guard (200...299).contains(response.statusCode) else {
throw BookSourceImportError.invalidDocument("服务器返回 HTTP \(response.statusCode)")
}
data = response.data
}
let normalized = try importer.normalizedData(data)
if case .array(let urls) = (try JSONValue.decode(data: normalized))["sourceUrls"] {
guard !urls.isEmpty, urls.count <= 100 else { throw BookSourceImportError.invalidDocument("订阅清单需包含 1–100 个链接") }
var sources: [BookSource] = []
var invalid: [PendingImport.InvalidItem] = []
for item in urls {
guard case .string(let url) = item else { throw BookSourceImportError.invalidDocument("sourceUrls 必须是链接数组") }
let batch = try await resolveImport(url, depth: depth + 1, visited: visited, budget: &budget)
sources += batch.sources
invalid += batch.invalidItems
}
return (sources, invalid)
}
return try validatedSources(from: normalized)
}
func cancelPendingImport() { pendingImport = nil }
func commitPendingImport(
selectedIDs: Set<String>,
keepOriginalName: Bool,
keepOriginalGroup: Bool,
keepOriginalEnabled: Bool,
customGroup: String?,
addCustomGroup: Bool
) async {
guard let pendingImport else { return }
var updated = sources
var inserted = 0
var replaced = 0
for item in pendingImport.items where selectedIDs.contains(item.id) {
var source = item.source
if let existing = item.existing {
if keepOriginalName { source.bookSourceName = existing.bookSourceName }
if keepOriginalGroup { source.bookSourceGroup = existing.bookSourceGroup }
if keepOriginalEnabled {
source.enabled = existing.enabled
source.enabledExplore = existing.enabledExplore
}
source.customOrder = existing.customOrder
}
if let customGroup, !BookSource.parseGroups(customGroup).isEmpty {
if addCustomGroup { source.addGroups(customGroup) }
else { source.bookSourceGroup = BookSource.joinGroups(BookSource.parseGroups(customGroup)) }
}
if let index = updated.firstIndex(where: { $0.id == source.id }) {
updated[index] = source
replaced += 1
} else {
updated.append(source)
inserted += 1
}
}
do {
try await repository.save(updated)
sources = updated
self.pendingImport = nil
notice = .init(
kind: .success,
title: "书源导入完成",
detail: "新增 \(inserted) 个,更新 \(replaced) 个"
)
} catch {
notice = .init(kind: .error, title: "保存失败", detail: error.localizedDescription)
return
}
}
func setExploreEnabled(_ enabled: Bool, for sourceID: String) async {
var updated = sources
guard let index = updated.firstIndex(where: { $0.id == sourceID }) else { return }
updated[index].enabledExplore = enabled
await persist(updated, successTitle: nil)
}
func saveSource(_ source: BookSource, replacing oldSourceID: String? = nil) async -> Bool {
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
guard errors.isEmpty else {
notice = .init(kind: .error, title: "书源无效", detail: errors.map(\.message).joined(separator: ";"))
return false
}
let oldSourceID = oldSourceID ?? source.id
var updated = sources
if updated.contains(where: { $0.id == oldSourceID }) {
if source.id != oldSourceID, updated.contains(where: { $0.id == source.id }) {
notice = .init(kind: .error, title: "地址冲突", detail: "该地址已有书源,请使用导入预览选择更新。")
return false
}
guard let replacement = updated.firstIndex(where: { $0.id == oldSourceID }) else { return false }
updated[replacement] = source
} else if let index = updated.firstIndex(where: { $0.id == source.id }) {
updated[index] = source
} else {
updated.append(source)
}
return await persist(updated, successTitle: "书源已保存")
}
func setEnabled(_ enabled: Bool, for sourceIDs: Set<String>) async {
guard !sourceIDs.isEmpty else { return }
var updated = sources
for index in updated.indices where sourceIDs.contains(updated[index].id) {
updated[index].enabled = enabled
}
await persist(updated, successTitle: enabled ? "已批量启用" : "已批量停用")
}
func addGroup(_ group: String, to sourceIDs: Set<String>) async {
let values = BookSource.parseGroups(group)
guard !values.isEmpty, !sourceIDs.isEmpty else { return }
var updated = sources
for index in updated.indices where sourceIDs.contains(updated[index].id) {
updated[index].addGroups(values.joined(separator: ","))
}
await persist(updated, successTitle: "已加入分组 \(values.joined(separator: "、"))")
}
func removeGroup(_ group: String, from sourceIDs: Set<String>) async {
guard !sourceIDs.isEmpty else { return }
var updated = sources
for index in updated.indices where sourceIDs.contains(updated[index].id) {
updated[index].removeGroups(group)
}
await persist(updated, successTitle: "已移出分组")
}
func renameGroup(_ oldValue: String, to newValue: String) async {
guard !BookSource.parseGroups(newValue).isEmpty else { return }
var updated = sources
for index in updated.indices where updated[index].hasGroup(oldValue) {
updated[index].replaceGroup(oldValue, with: newValue)
}
await persist(updated, successTitle: "分组已重命名")
}
func deleteGroup(_ group: String) async {
var updated = sources
for index in updated.indices where updated[index].hasGroup(group) {
updated[index].removeGroups(group)
}
await persist(updated, successTitle: "已删除分组 \(group)")
}
func handleDeepLink(_ url: URL) async {
if url.isFileURL {
await previewFiles([url])
return
}
await previewInput(url.absoluteString)
}
@discardableResult
private func persist(_ updated: [BookSource], successTitle: String?) async -> Bool {
do {
try await repository.save(updated)
sources = updated
if let successTitle {
notice = .init(kind: .success, title: successTitle, detail: "")
}
return true
} catch {
notice = .init(kind: .error, title: "保存失败", detail: error.localizedDescription)
return false
}
}
private func applyImport(_ data: Data, successTitle: String) async throws {
let result = try await Task.detached(priority: .userInitiated) { [importer, existing = sources] in
try importer.prepare(data: data, existing: existing, duplicatePolicy: .replace)
}.value
try await repository.save(result.sources)
sources = result.sources
notice = .init(
kind: .success,
title: successTitle,
detail: "新增 \(result.inserted) 个,覆盖 \(result.replaced) 个,警告 \(result.warnings.count) 条"
)
}
private nonisolated func validatedSources(from data: Data) throws -> (
sources: [BookSource],
invalidItems: [PendingImport.InvalidItem]
) {
let result = try importer.inspect(data)
return (result.sources, result.rejected.map { .init(source: $0.source, issues: $0.issues) })
}
private func stage(
_ incoming: [BookSource],
invalidItems: [PendingImport.InvalidItem] = []
) {
let existingByID = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) })
let unique = incoming.reduce(into: [String: BookSource]()) { $0[$1.id] = $1 }
pendingImport = .init(items: Array(unique.values)
.sorted { $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending }
.map { .init(source: $0, existing: existingByID[$0.id]) },
invalidItems: invalidItems)
}
}