mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-23 04:11:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user