mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 21:01:22 +08:00
feat: 支持 EPUB 阅读并改善规则兼容与阅读稳定性
支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。 修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。 更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
This commit is contained in:
@@ -10,29 +10,42 @@ final class RSSLibraryModel: ObservableObject {
|
||||
@Published private(set) var isWorking = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let sourceRepository: SQLiteRssSourceRepository
|
||||
private let articleRepository: SQLiteRssArticleRepository
|
||||
private let starRepository: SQLiteRssStarRepository
|
||||
private let recordRepository: SQLiteRssReadRecordRepository
|
||||
private var sourceRepository: SQLiteRssSourceRepository { get throws { .init(database: try database) } }
|
||||
private var articleRepository: SQLiteRssArticleRepository { get throws { .init(database: try database) } }
|
||||
private var starRepository: SQLiteRssStarRepository { get throws { .init(database: try database) } }
|
||||
private var recordRepository: SQLiteRssReadRecordRepository { get throws { .init(database: try database) } }
|
||||
private let service: RSSService
|
||||
private let importer = RSSSourceImporter()
|
||||
private let database: LegadoDatabase
|
||||
private let databaseResult: Result<LegadoDatabase, Error>
|
||||
private var database: LegadoDatabase { get throws { try databaseResult.get() } }
|
||||
private var nextURL: String?
|
||||
private var page = 1
|
||||
private var order: Int64 = 0
|
||||
private struct Feed: Equatable {
|
||||
let origin: String
|
||||
let sort: String
|
||||
let key: String?
|
||||
}
|
||||
private var activeFeed: Feed?
|
||||
private var articleRequestID = UUID()
|
||||
private var loadingMoreRequestID: UUID?
|
||||
private var activeOperations = 0
|
||||
|
||||
init(database: LegadoDatabase, service: RSSService = .init()) {
|
||||
self.database = database
|
||||
sourceRepository = .init(database: database)
|
||||
articleRepository = .init(database: database)
|
||||
starRepository = .init(database: database)
|
||||
recordRepository = .init(database: database)
|
||||
databaseResult = .success(database)
|
||||
self.service = service
|
||||
}
|
||||
|
||||
static func live() -> RSSLibraryModel {
|
||||
let database = try! LegadoDatabase(url: LegadoDatabase.defaultURL())
|
||||
return .init(database: database)
|
||||
private init(databaseError: Error) {
|
||||
let error = LegadoDatabaseError.open("RSS 数据库无法打开:\(databaseError.localizedDescription)。请检查存储空间后重新启动应用。")
|
||||
databaseResult = .failure(error)
|
||||
service = .init()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
static func live(databaseURL: URL = LegadoDatabase.defaultURL()) -> RSSLibraryModel {
|
||||
do { return .init(database: try LegadoDatabase(url: databaseURL)) }
|
||||
catch { return .init(databaseError: error) }
|
||||
}
|
||||
|
||||
func load() async {
|
||||
@@ -62,15 +75,16 @@ final class RSSLibraryModel: ObservableObject {
|
||||
func importText(_ text: String) async {
|
||||
await perform {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var merged: [RssSource]
|
||||
if let url = URL(string: trimmed), ["http", "https"].contains(url.scheme?.lowercased() ?? "") {
|
||||
self.sources = try await self.importRemote(trimmed, existing: self.sources)
|
||||
merged = try await self.importRemote(trimmed, existing: self.sources)
|
||||
} else {
|
||||
let payload = try self.importer.decode(Data(trimmed.utf8))
|
||||
var merged = try self.importer.merge(payload.sources, into: self.sources)
|
||||
merged = try self.importer.merge(payload.sources, into: self.sources)
|
||||
for remote in payload.subscriptionURLs { merged = try await self.importRemote(remote, existing: merged) }
|
||||
self.sources = merged
|
||||
}
|
||||
try await self.sourceRepository.save(self.sources)
|
||||
try await self.sourceRepository.save(merged)
|
||||
self.sources = try await self.sourceRepository.all()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,31 +144,53 @@ final class RSSLibraryModel: ObservableObject {
|
||||
}
|
||||
|
||||
func refresh(_ source: RssSource, sortName: String? = nil, sortURL: String? = nil, key: String? = nil) async {
|
||||
page = 1; order = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
let name = sortName ?? source.sourceName
|
||||
let url = sortURL ?? source.sortUrl?.nilIfBlank ?? source.sourceUrl
|
||||
await perform {
|
||||
let feed = Feed(origin: source.sourceUrl, sort: name, key: key)
|
||||
let requestID = UUID()
|
||||
articleRequestID = requestID
|
||||
loadingMoreRequestID = nil
|
||||
if activeFeed != feed { articles = [] }
|
||||
activeFeed = feed
|
||||
nextURL = nil
|
||||
page = 1
|
||||
await perform(isCurrent: { self.articleRequestID == requestID }) {
|
||||
let report = try await self.service.articles(sortName: name, sortURL: url, source: source, page: 1, key: key)
|
||||
guard self.articleRequestID == requestID else { return }
|
||||
var nextOrder = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
var values = report.result.articles
|
||||
for index in values.indices { values[index].order = self.order; self.order -= 1 }
|
||||
for index in values.indices { values[index].order = nextOrder; nextOrder -= 1 }
|
||||
try await self.articleRepository.put(values)
|
||||
if source.ruleNextPage?.isEmpty == false { try await self.articleRepository.clearOld(origin: source.sourceUrl, sort: name, before: self.order) }
|
||||
if source.ruleNextPage?.isEmpty == false { try await self.articleRepository.clearOld(origin: source.sourceUrl, sort: name, before: nextOrder) }
|
||||
let articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
|
||||
guard self.articleRequestID == requestID else { return }
|
||||
self.order = nextOrder
|
||||
self.nextURL = report.result.nextURL
|
||||
self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
|
||||
self.articles = articles
|
||||
}
|
||||
}
|
||||
|
||||
func loadMore(_ source: RssSource, sortName: String? = nil, key: String? = nil) async {
|
||||
guard let nextURL, !nextURL.isEmpty else { return }
|
||||
page += 1
|
||||
let name = sortName ?? source.sourceName
|
||||
await perform {
|
||||
let report = try await self.service.articles(sortName: name, sortURL: nextURL, source: source, page: self.page, key: key)
|
||||
guard activeFeed == Feed(origin: source.sourceUrl, sort: name, key: key),
|
||||
loadingMoreRequestID == nil, let nextURL, !nextURL.isEmpty else { return }
|
||||
let requestID = articleRequestID
|
||||
loadingMoreRequestID = requestID
|
||||
defer { if loadingMoreRequestID == requestID { loadingMoreRequestID = nil } }
|
||||
let nextPage = page + 1
|
||||
await perform(isCurrent: { self.articleRequestID == requestID }) {
|
||||
let report = try await self.service.articles(sortName: name, sortURL: nextURL, source: source, page: nextPage, key: key)
|
||||
guard self.articleRequestID == requestID else { return }
|
||||
var nextOrder = self.order
|
||||
var values = report.result.articles
|
||||
for index in values.indices { values[index].order = self.order; self.order -= 1 }
|
||||
for index in values.indices { values[index].order = nextOrder; nextOrder -= 1 }
|
||||
try await self.articleRepository.append(values)
|
||||
let articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
|
||||
guard self.articleRequestID == requestID else { return }
|
||||
self.page = nextPage
|
||||
self.order = nextOrder
|
||||
self.nextURL = report.result.nextURL
|
||||
self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
|
||||
self.articles = articles
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,9 +267,11 @@ final class RSSLibraryModel: ObservableObject {
|
||||
let payload = try importer.decode(data)
|
||||
return try importer.merge(payload.sources, into: existing)
|
||||
}
|
||||
private func perform(_ operation: () async throws -> Void) async {
|
||||
isWorking = true; defer { isWorking = false }
|
||||
do { try await operation() } catch { errorMessage = error.localizedDescription }
|
||||
private func perform(isCurrent: () -> Bool = { true }, _ operation: () async throws -> Void) async {
|
||||
activeOperations += 1
|
||||
isWorking = true
|
||||
defer { activeOperations -= 1; isWorking = activeOperations > 0 }
|
||||
do { try await operation() } catch { if isCurrent() { errorMessage = error.localizedDescription } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,14 +151,20 @@ private struct RSSArticleListView: View {
|
||||
.overlay { if library.articles.isEmpty, !library.isWorking { ReadingUnavailableView("没有文章", systemImage: "newspaper") } }
|
||||
.navigationTitle(source.sourceName)
|
||||
.searchable(text: $searchKey, prompt: "源内搜索")
|
||||
.onSubmit(of: .search) { Task { await refresh() } }
|
||||
.refreshable { await refresh() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) { Button("刷新", systemImage: "arrow.clockwise") { Task { await refresh() } } }
|
||||
ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceEditorView(source: source) } label: { Label("编辑", systemImage: "pencil") } }
|
||||
ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceDebugView(source: source) } label: { Label("调试", systemImage: "stethoscope") } }
|
||||
ToolbarItem(placement: .bottomBar) { Button("加载下一页", systemImage: "arrow.down") { Task { await library.loadMore(source, sortName: activeName, key: searchKey.nilIfEmpty) } } }
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
Button("加载下一页", systemImage: "arrow.down") {
|
||||
Task { await library.loadMore(source, sortName: activeName, key: searchKey.nilIfEmpty) }
|
||||
}.disabled(library.isWorking)
|
||||
}
|
||||
}
|
||||
.task { sorts = await RSSSortResolver().resolve(source); await refresh() }
|
||||
.readingTabBarHidden()
|
||||
}
|
||||
|
||||
private var activeSort: RSSSort { sorts.indices.contains(selectedSort) ? sorts[selectedSort] : .init(name: "", url: source.sourceUrl) }
|
||||
@@ -464,6 +470,32 @@ private struct RSSBinaryDocument: FileDocument {
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: data) }
|
||||
}
|
||||
|
||||
private struct RSSPhotoToolbar: ViewModifier {
|
||||
let canSave: Bool
|
||||
let save: () -> Void
|
||||
let close: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
content.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("保存到照片", systemImage: "square.and.arrow.down", action: save).disabled(!canSave)
|
||||
}.sharedBackgroundVisibility(.hidden)
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("完成", action: close).buttonStyle(.plain)
|
||||
}.sharedBackgroundVisibility(.hidden)
|
||||
}
|
||||
} else {
|
||||
content.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("保存到照片", systemImage: "square.and.arrow.down", action: save).disabled(!canSave)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) { Button("完成", action: close) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RSSPhotoView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let value: String
|
||||
@@ -487,10 +519,7 @@ struct RSSPhotoView: View {
|
||||
}
|
||||
.navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline)
|
||||
.readingNavigationBarBackground()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) }
|
||||
ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } }
|
||||
}
|
||||
.modifier(RSSPhotoToolbar(canSave: image != nil, save: save, close: { dismiss() }))
|
||||
.task(id: value) { await load() }
|
||||
.alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user