3 Commits
Author SHA1 Message Date
Aminark 02bcea0911 chore: update AltStore source for v0.1.12 2026-10-24 01:02:38 +08:00
Aminark 67bc212ffd feat: 支持 EPUB 阅读并改善规则兼容与阅读稳定性
支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。
修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。
更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
2026-10-24 00:58:11 +08:00
Aminark 3322fd71db chore: update AltStore source for v0.1.11 2026-10-22 11:07:21 +08:00
59 changed files with 2342 additions and 255 deletions
+11 -10
View File
@@ -207,7 +207,7 @@ struct ReadingPhotoPickerController: UIViewControllerRepresentable {
} }
} }
private struct ReadingTXTFileImporterModifier: ViewModifier { private struct ReadingLocalBookFileImporterModifier: ViewModifier {
@Binding var isPresented: Bool @Binding var isPresented: Bool
let onFiles: ([LocalBookImportFile]) -> Void let onFiles: ([LocalBookImportFile]) -> Void
let onError: (Error) -> Void let onError: (Error) -> Void
@@ -219,7 +219,7 @@ private struct ReadingTXTFileImporterModifier: ViewModifier {
pickerHasDismissed = true pickerHasDismissed = true
deliverFilesWhenReady() deliverFilesWhenReady()
}) { }) {
ReadingTXTFilePicker { files in ReadingLocalBookFilePicker { files in
pickedFiles = files pickedFiles = files
isPresented = false isPresented = false
deliverFilesWhenReady() deliverFilesWhenReady()
@@ -239,7 +239,7 @@ private struct ReadingTXTFileImporterModifier: ViewModifier {
guard pickerHasDismissed, !pickedFiles.isEmpty else { return } guard pickerHasDismissed, !pickedFiles.isEmpty else { return }
let files = pickedFiles let files = pickedFiles
pickedFiles = [] pickedFiles = []
guard files.allSatisfy({ $0.url.pathExtension.lowercased() == "txt" && !$0.url.hasDirectoryPath }) else { guard files.allSatisfy({ LocalBookService.supports($0.url) && !$0.url.hasDirectoryPath }) else {
onError(LocalBookError.unsupportedFormat) onError(LocalBookError.unsupportedFormat)
return return
} }
@@ -247,15 +247,16 @@ private struct ReadingTXTFileImporterModifier: ViewModifier {
} }
} }
private struct ReadingTXTFilePicker: UIViewControllerRepresentable { private struct ReadingLocalBookFilePicker: UIViewControllerRepresentable {
let onPick: @MainActor ([LocalBookImportFile]) -> Void let onPick: @MainActor ([LocalBookImportFile]) -> Void
let onCancel: @MainActor () -> Void let onCancel: @MainActor () -> Void
func makeCoordinator() -> Coordinator { Coordinator(parent: self) } func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIViewController(context: Context) -> UIDocumentPickerViewController { func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
// Plain text includes UTF-8/UTF-16 TXT, while .text and .data also allow JSON. // Keep source JSON out of the local-book picker.
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.plainText], asCopy: true) let epub = UTType(filenameExtension: "epub") ?? UTType(importedAs: "org.idpf.epub-container")
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.plainText, epub], asCopy: true)
picker.allowsMultipleSelection = true picker.allowsMultipleSelection = true
picker.shouldShowFileExtensions = true picker.shouldShowFileExtensions = true
picker.delegate = context.coordinator picker.delegate = context.coordinator
@@ -266,9 +267,9 @@ private struct ReadingTXTFilePicker: UIViewControllerRepresentable {
@MainActor @MainActor
final class Coordinator: NSObject, UIDocumentPickerDelegate { final class Coordinator: NSObject, UIDocumentPickerDelegate {
let parent: ReadingTXTFilePicker let parent: ReadingLocalBookFilePicker
init(parent: ReadingTXTFilePicker) { self.parent = parent } init(parent: ReadingLocalBookFilePicker) { self.parent = parent }
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { parent.onCancel() } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { parent.onCancel() }
@@ -281,12 +282,12 @@ private struct ReadingTXTFilePicker: UIViewControllerRepresentable {
enum ReadingSheetDetent { case medium, large } enum ReadingSheetDetent { case medium, large }
extension View { extension View {
func readingTXTFileImporter( func readingLocalBookFileImporter(
isPresented: Binding<Bool>, isPresented: Binding<Bool>,
onFiles: @escaping ([LocalBookImportFile]) -> Void, onFiles: @escaping ([LocalBookImportFile]) -> Void,
onError: @escaping (Error) -> Void onError: @escaping (Error) -> Void
) -> some View { ) -> some View {
modifier(ReadingTXTFileImporterModifier(isPresented: isPresented, onFiles: onFiles, onError: onError)) modifier(ReadingLocalBookFileImporterModifier(isPresented: isPresented, onFiles: onFiles, onError: onError))
} }
func readingPhotoPicker( func readingPhotoPicker(
+13 -4
View File
@@ -300,11 +300,20 @@ struct BookCoverArtwork: View {
} }
var body: some View { var body: some View {
AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in Group {
if case .success(let image) = phase { if let remoteURL, remoteURL.hasPrefix("data:image/"),
image.resizable().scaledToFill() let comma = remoteURL.firstIndex(of: ","),
let data = Data(base64Encoded: String(remoteURL[remoteURL.index(after: comma)...])),
let image = UIImage(data: data) {
Image(uiImage: image).resizable().scaledToFill()
} else { } else {
placeholder AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in
if case .success(let image) = phase {
image.resizable().scaledToFill()
} else {
placeholder
}
}
} }
} }
.frame(width: width, height: height) .frame(width: width, height: height)
@@ -126,7 +126,7 @@ struct FileManagementView: View {
Button("完成") { selecting = false; selection.removeAll() } Button("完成") { selecting = false; selection.removeAll() }
} else { } else {
Menu { Menu {
Button("导入 TXT 文件", systemImage: "square.and.arrow.down") { importsFile = true } Button("导入 TXT / EPUB", systemImage: "square.and.arrow.down") { importsFile = true }
if tab == 0 { if tab == 0 {
Button("选择文件", systemImage: "checkmark.circle") { selecting = true } Button("选择文件", systemImage: "checkmark.circle") { selecting = true }
.disabled(displayedFiles.isEmpty) .disabled(displayedFiles.isEmpty)
@@ -163,7 +163,7 @@ struct FileManagementView: View {
private var importAndRenameView: some View { private var importAndRenameView: some View {
fileList fileList
.readingTXTFileImporter(isPresented: $importsFile) { files in .readingLocalBookFileImporter(isPresented: $importsFile) { files in
Task { await model.importFiles(files, library: library) } Task { await model.importFiles(files, library: library) }
} onError: { error in } onError: { error in
model.errorMessage = error.localizedDescription model.errorMessage = error.localizedDescription
@@ -177,7 +177,7 @@ struct FileManagementView: View {
Task { await model.run(library) { try await library.renameLocalFile(at: url, to: name) } } Task { await model.run(library) { try await library.renameLocalFile(at: url, to: name) } }
} }
} message: { _ in } message: { _ in
Text("保留 TXT 格式,书架中的书名、书签和阅读进度不变。") Text("保留原文件格式,书架中的书名、书签和阅读进度不变。")
} }
} }
@@ -239,8 +239,8 @@ struct FileManagementView: View {
} }
Section("文件") { Section("文件") {
if displayedFiles.isEmpty { if displayedFiles.isEmpty {
ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT,文件会复制到应用中。")) ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT 或 EPUB,文件会复制到应用中。"))
Button("导入 TXT 文件", systemImage: "plus") { importsFile = true } Button("导入 TXT / EPUB", systemImage: "plus") { importsFile = true }
} }
ForEach(displayedFiles) { file in ForEach(displayedFiles) { file in
Group { Group {
+1 -1
View File
@@ -360,7 +360,7 @@ final class BookLibraryModel: ObservableObject {
func addLocalFile(at url: URL) async throws { func addLocalFile(at url: URL) async throws {
guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return } guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return }
let report = try await Task.detached { [localService] in try localService.openTextFile(at: url) }.value let report = try await Task.detached { [localService] in try localService.openFile(at: url) }.value
guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return } guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return }
guard await persist(books + [report.book]) else { guard await persist(books + [report.book]) else {
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库") throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
+6 -2
View File
@@ -33,16 +33,20 @@ struct LocalBookDetailView: View {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
VStack(spacing: 14) { VStack(spacing: 14) {
BookCoverArtwork(title: book.name, author: book.author, width: 108, height: 154) BookCoverArtwork(title: book.name, author: book.author, remoteURL: book.customCoverUrl ?? book.coverUrl, width: 108, height: 154)
Text(book.name).font(.title2.bold()) Text(book.name).font(.title2.bold())
if !book.author.isEmpty { Text(book.author).font(.subheadline).foregroundStyle(.secondary) }
HStack { HStack {
StatusPill(text: "本地 TXT", systemImage: "doc.text.fill") StatusPill(text: "本地 " + (URL(string: book.bookUrl)?.pathExtension.uppercased() ?? "TXT"), systemImage: "doc.text.fill")
if let charset = book.charset { StatusPill(text: charset, color: ReadingTheme.coral, systemImage: "character") } if let charset = book.charset { StatusPill(text: charset, color: ReadingTheme.coral, systemImage: "character") }
} }
Text(book.originName).font(.caption).foregroundStyle(.secondary) Text(book.originName).font(.caption).foregroundStyle(.secondary)
} }
.padding(.top, 12) .padding(.top, 12)
if let intro = book.intro, !intro.isEmpty {
ReadingCard { Text(intro).font(.subheadline).frame(maxWidth: .infinity, alignment: .leading) }
}
ReadingCard { ReadingCard {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack { HStack {
+1 -1
View File
@@ -151,6 +151,6 @@ struct SourceSwitchView: View {
.padding(.vertical, 5) .padding(.vertical, 5)
} }
.disabled(model.switchingID != nil) .disabled(model.switchingID != nil)
.accessibilityIdentifier("sourceSwitch.candidate.\(candidate.book.origin)") .accessibilityIdentifier("sourceSwitch.candidate.\(candidate.id)")
} }
} }
+69 -31
View File
@@ -10,29 +10,42 @@ final class RSSLibraryModel: ObservableObject {
@Published private(set) var isWorking = false @Published private(set) var isWorking = false
@Published var errorMessage: String? @Published var errorMessage: String?
private let sourceRepository: SQLiteRssSourceRepository private var sourceRepository: SQLiteRssSourceRepository { get throws { .init(database: try database) } }
private let articleRepository: SQLiteRssArticleRepository private var articleRepository: SQLiteRssArticleRepository { get throws { .init(database: try database) } }
private let starRepository: SQLiteRssStarRepository private var starRepository: SQLiteRssStarRepository { get throws { .init(database: try database) } }
private let recordRepository: SQLiteRssReadRecordRepository private var recordRepository: SQLiteRssReadRecordRepository { get throws { .init(database: try database) } }
private let service: RSSService private let service: RSSService
private let importer = RSSSourceImporter() 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 nextURL: String?
private var page = 1 private var page = 1
private var order: Int64 = 0 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()) { init(database: LegadoDatabase, service: RSSService = .init()) {
self.database = database databaseResult = .success(database)
sourceRepository = .init(database: database)
articleRepository = .init(database: database)
starRepository = .init(database: database)
recordRepository = .init(database: database)
self.service = service self.service = service
} }
static func live() -> RSSLibraryModel { private init(databaseError: Error) {
let database = try! LegadoDatabase(url: LegadoDatabase.defaultURL()) let error = LegadoDatabaseError.open("RSS 数据库无法打开:\(databaseError.localizedDescription)。请检查存储空间后重新启动应用。")
return .init(database: database) 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 { func load() async {
@@ -62,15 +75,16 @@ final class RSSLibraryModel: ObservableObject {
func importText(_ text: String) async { func importText(_ text: String) async {
await perform { await perform {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
var merged: [RssSource]
if let url = URL(string: trimmed), ["http", "https"].contains(url.scheme?.lowercased() ?? "") { 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 { } else {
let payload = try self.importer.decode(Data(trimmed.utf8)) 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) } 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 { 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 name = sortName ?? source.sourceName
let url = sortURL ?? source.sortUrl?.nilIfBlank ?? source.sourceUrl 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) 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 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) 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.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 { 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 let name = sortName ?? source.sourceName
await perform { guard activeFeed == Feed(origin: source.sourceUrl, sort: name, key: key),
let report = try await self.service.articles(sortName: name, sortURL: nextURL, source: source, page: self.page, 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 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) 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.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) let payload = try importer.decode(data)
return try importer.merge(payload.sources, into: existing) return try importer.merge(payload.sources, into: existing)
} }
private func perform(_ operation: () async throws -> Void) async { private func perform(isCurrent: () -> Bool = { true }, _ operation: () async throws -> Void) async {
isWorking = true; defer { isWorking = false } activeOperations += 1
do { try await operation() } catch { errorMessage = error.localizedDescription } isWorking = true
defer { activeOperations -= 1; isWorking = activeOperations > 0 }
do { try await operation() } catch { if isCurrent() { errorMessage = error.localizedDescription } }
} }
} }
+34 -5
View File
@@ -151,14 +151,20 @@ private struct RSSArticleListView: View {
.overlay { if library.articles.isEmpty, !library.isWorking { ReadingUnavailableView("没有文章", systemImage: "newspaper") } } .overlay { if library.articles.isEmpty, !library.isWorking { ReadingUnavailableView("没有文章", systemImage: "newspaper") } }
.navigationTitle(source.sourceName) .navigationTitle(source.sourceName)
.searchable(text: $searchKey, prompt: "源内搜索") .searchable(text: $searchKey, prompt: "源内搜索")
.onSubmit(of: .search) { Task { await refresh() } }
.refreshable { await refresh() } .refreshable { await refresh() }
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { Button("刷新", systemImage: "arrow.clockwise") { Task { await refresh() } } } 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 { RSSSourceEditorView(source: source) } label: { Label("编辑", systemImage: "pencil") } }
ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceDebugView(source: source) } label: { Label("调试", systemImage: "stethoscope") } } 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() } .task { sorts = await RSSSortResolver().resolve(source); await refresh() }
.readingTabBarHidden()
} }
private var activeSort: RSSSort { sorts.indices.contains(selectedSort) ? sorts[selectedSort] : .init(name: "", url: source.sourceUrl) } 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) } 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 { struct RSSPhotoView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let value: String let value: String
@@ -487,10 +519,7 @@ struct RSSPhotoView: View {
} }
.navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline) .navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline)
.readingNavigationBarBackground() .readingNavigationBarBackground()
.toolbar { .modifier(RSSPhotoToolbar(canSave: image != nil, save: save, close: { dismiss() }))
ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) }
ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } }
}
.task(id: value) { await load() } .task(id: value) { await load() }
.alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") } .alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") }
} }
@@ -0,0 +1,74 @@
import SwiftUI
import ImageIO
struct EPUBIllustrationsView: View {
@Environment(\.dismiss) private var dismiss
let book: Book
let chapter: BookChapter
@State private var images: [EPUBIllustration] = []
@State private var isLoading = true
@State private var errorMessage: String?
var body: some View {
ReadingNavigationStack {
ScrollView {
LazyVStack(spacing: 24) {
if isLoading {
ProgressView("正在读取插图…").padding()
} else if let errorMessage {
ReadingUnavailableView("无法读取插图", systemImage: "photo", description: Text(errorMessage))
} else if images.isEmpty {
ReadingUnavailableView("本章没有插图", systemImage: "photo", description: Text(chapter.title))
} else {
ForEach(images) { image in
VStack(spacing: 8) {
EPUBIllustrationImage(data: image.data)
Text("插图 \(image.id)\(image.caption.isEmpty ? "" : " · " + image.caption)")
.font(.caption).foregroundStyle(.secondary)
}
}
}
}.padding()
}
.background(ReadingTheme.canvas)
.navigationTitle("本章插图")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } }
.task(id: chapter.id) {
isLoading = true
errorMessage = nil
do {
images = try await Task.detached { try LocalBookService().loadIllustrations(book: book, chapter: chapter) }.value
} catch { errorMessage = error.localizedDescription }
isLoading = false
}
}
}
}
private struct EPUBIllustrationImage: View {
let data: Data
@State private var image: UIImage?
var body: some View {
Group {
if let image {
Image(uiImage: image).resizable().scaledToFit().accessibilityLabel("书籍插图")
} else {
Text("图片无法显示").foregroundStyle(.secondary)
}
}
.task {
// Decode a bounded thumbnail off the main thread, even for huge source dimensions.
image = await Task.detached {
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 2_048,
kCGImageSourceCreateThumbnailWithTransform: true
] as CFDictionary) else { return Optional<UIImage>.none }
return UIImage(cgImage: thumbnail)
}.value
}
}
}
+20 -11
View File
@@ -129,7 +129,8 @@ struct ReaderScrollView: UIViewRepresentable {
let layout = view.collectionViewLayout as! ReaderContinuousLayout let layout = view.collectionViewLayout as! ReaderContinuousLayout
let geometry = ReaderContinuousLayout.geometry( let geometry = ReaderContinuousLayout.geometry(
blocks: newBlocks, width: view.bounds.width, blocks: newBlocks, width: view.bounds.width,
top: CGFloat(config.paddingTop), bottom: CGFloat(config.paddingBottom), viewportHeight: view.bounds.height top: CGFloat(config.paddingTop), bottom: CGFloat(config.paddingBottom),
viewportHeight: view.bounds.height, scale: view.traitCollection.displayScale
) )
let targetY = anchor.flatMap { offset(for: $0, blocks: newBlocks, frames: geometry.frames) } let targetY = anchor.flatMap { offset(for: $0, blocks: newBlocks, frames: geometry.frames) }
let correction = targetY.map { $0 - view.contentOffset.y } ?? 0 let correction = targetY.map { $0 - view.contentOffset.y } ?? 0
@@ -277,10 +278,14 @@ struct ReaderScrollView: UIViewRepresentable {
private func captureAnchor() -> Anchor? { private func captureAnchor() -> Anchor? {
guard let view, !blocks.isEmpty else { return nil } guard let view, !blocks.isEmpty else { return nil }
let layout = view.collectionViewLayout as! ReaderContinuousLayout let layout = view.collectionViewLayout as! ReaderContinuousLayout
let y = max(view.contentOffset.y, layout.frames.first?.minY ?? 0) // The reading position starts below the configured top padding.
guard let row = layout.row(at: y) else { return nil } // Preserve that reference point when earlier chapters are inserted.
let y = max(view.contentOffset.y + CGFloat(max(parent.configuration.paddingTop, 0)),
layout.frames.first?.minY ?? 0)
let scale = max(view.traitCollection.displayScale, 1)
guard let row = layout.row(at: (y * scale).rounded() / scale) else { return nil }
let block = blocks[row], chapter = block.chapter let block = blocks[row], chapter = block.chapter
let localY = y - layout.frames[row].minY + block.top let localY = max(y - layout.frames[row].minY, 0) + block.top
let glyph = chapter.manager.glyphIndex(for: CGPoint(x: 0, y: localY + 0.5), in: chapter.container) let glyph = chapter.manager.glyphIndex(for: CGPoint(x: 0, y: localY + 0.5), in: chapter.container)
let safeGlyph = min(glyph, max(chapter.manager.numberOfGlyphs - 1, 0)) let safeGlyph = min(glyph, max(chapter.manager.numberOfGlyphs - 1, 0))
let character = chapter.manager.characterIndexForGlyph(at: safeGlyph) let character = chapter.manager.characterIndexForGlyph(at: safeGlyph)
@@ -385,17 +390,21 @@ final class ReaderContinuousLayout: UICollectionViewLayout {
private var size = CGSize.zero private var size = CGSize.zero
override var collectionViewContentSize: CGSize { size } override var collectionViewContentSize: CGSize { size }
static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat) -> (frames: [CGRect], size: CGSize) { static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat, scale: CGFloat) -> (frames: [CGRect], size: CGSize) {
var y = top let scale = max(scale, 1)
// Keep insertion offsets in whole physical pixels. UIKit rounds scroll
// offsets, so fractional chapter heights otherwise accumulate drift.
var pixels = (top * scale).rounded()
let frames = blocks.map { block -> CGRect in let frames = blocks.map { block -> CGRect in
let frame = CGRect(x: 0, y: y, width: width, height: block.height) let height = ceil(block.height * scale)
y += block.height let frame = CGRect(x: 0, y: pixels / scale, width: width, height: height / scale)
pixels += height
return frame return frame
} }
let finalChapter = blocks.last?.chapter.chapter.id let finalChapter = blocks.last?.chapter.chapter.id
let finalStart = blocks.indices.first(where: { blocks[$0].chapter.chapter.id == finalChapter }) let finalStart = blocks.indices.first(where: { blocks[$0].chapter.chapter.id == finalChapter })
.map { frames[$0].minY } ?? top .map { frames[$0].minY } ?? top
return (frames, CGSize(width: width, height: max(y, finalStart + viewportHeight) + bottom)) return (frames, CGSize(width: width, height: max(pixels / scale, finalStart + viewportHeight) + bottom))
} }
func install(frames: [CGRect], size: CGSize, correction: CGFloat) { func install(frames: [CGRect], size: CGSize, correction: CGFloat) {
@@ -412,9 +421,9 @@ final class ReaderContinuousLayout: UICollectionViewLayout {
var low = 0, high = frames.count var low = 0, high = frames.count
while low < high { while low < high {
let middle = (low + high) / 2 let middle = (low + high) / 2
if frames[middle].maxY <= y { low = middle + 1 } else { high = middle } if frames[middle].minY <= y { low = middle + 1 } else { high = middle }
} }
return min(low, frames.count - 1) return max(low - 1, 0)
} }
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
+12 -1
View File
@@ -18,6 +18,7 @@ struct ReaderView: View {
@StateObject private var speech = ReaderSpeechController() @StateObject private var speech = ReaderSpeechController()
@State private var showsTableOfContents = false @State private var showsTableOfContents = false
@State private var showsBookmarks = false @State private var showsBookmarks = false
@State private var illustrationChapter: BookChapter?
@State private var showsAutoReadControls = false @State private var showsAutoReadControls = false
@State private var showsStyleControls = false @State private var showsStyleControls = false
@State private var isAutoReading = false @State private var isAutoReading = false
@@ -257,6 +258,9 @@ struct ReaderView: View {
} }
.sheet(isPresented: $showsTableOfContents) { tableOfContents } .sheet(isPresented: $showsTableOfContents) { tableOfContents }
.sheet(isPresented: $showsBookmarks) { bookmarksSheet } .sheet(isPresented: $showsBookmarks) { bookmarksSheet }
.sheet(item: $illustrationChapter) { chapter in
EPUBIllustrationsView(book: book, chapter: chapter)
}
.sheet(isPresented: $showsAutoReadControls) { autoReadControls } .sheet(isPresented: $showsAutoReadControls) { autoReadControls }
.sheet(isPresented: $showsStyleControls) { styleControls } .sheet(isPresented: $showsStyleControls) { styleControls }
.sheet(isPresented: $showsContentSearch) { contentSearchSheet } .sheet(isPresented: $showsContentSearch) { contentSearchSheet }
@@ -698,6 +702,7 @@ struct ReaderView: View {
|| showsReaderMenu || showsReaderMenu
|| showsTableOfContents || showsTableOfContents
|| showsBookmarks || showsBookmarks
|| illustrationChapter != nil
|| showsAutoReadControls || showsAutoReadControls
|| showsStyleControls || showsStyleControls
|| showsContentSearch || showsContentSearch
@@ -834,6 +839,12 @@ struct ReaderView: View {
showsReaderMenu = false showsReaderMenu = false
showsContentSearch = true showsContentSearch = true
} }
if book.origin == "local", URL(string: book.bookUrl)?.pathExtension.lowercased() == "epub" {
Button("本章插图", systemImage: "photo.on.rectangle") {
showsReaderMenu = false
illustrationChapter = model.currentChapter
}
}
Button("书签列表", systemImage: "bookmark") { Button("书签列表", systemImage: "bookmark") {
showsReaderMenu = false showsReaderMenu = false
showsBookmarks = true showsBookmarks = true
@@ -1575,7 +1586,7 @@ struct ReaderView: View {
Text("正文") Text("正文")
} footer: { } footer: {
if !model.canEditContent { if !model.canEditContent {
Text("本地 TXT 直接来自原文件;为避免破坏章节偏移,本章编辑与网络刷新仅对网络书籍开放。") Text("本地 TXT / EPUB 直接读取原文件。本章编辑与网络刷新仅对网络书籍开放。")
} }
} }
@@ -59,7 +59,12 @@ struct SyntaxCodeEditor: UIViewRepresentable {
UIBarButtonItem(title: symbol == "\n" ? "↵" : symbol == "\t" ? "⇥" : symbol, UIBarButtonItem(title: symbol == "\n" ? "↵" : symbol == "\t" ? "⇥" : symbol,
primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.insertText(symbol) }) primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.insertText(symbol) })
} + [UIBarButtonItem(systemItem: .flexibleSpace), UIBarButtonItem(title: "完成", primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.resignFirstResponder() })] } + [UIBarButtonItem(systemItem: .flexibleSpace), UIBarButtonItem(title: "完成", primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.resignFirstResponder() })]
view.inputAccessoryView = bar bar.items?.last?.accessibilityIdentifier = "syntaxCodeEditor.done"
if #available(iOS 26.0, *) {
view.inputAccessoryView = CodeInputAccessory(items: bar.items ?? [])
} else {
view.inputAccessoryView = bar
}
view.text = text view.text = text
context.coordinator.highlight(view) context.coordinator.highlight(view)
return view return view
@@ -184,6 +189,66 @@ struct SyntaxCodeEditor: UIViewRepresentable {
} }
} }
// Keep Done visible while allowing the editing shortcuts to scroll on narrow keyboards.
private final class CodeInputAccessory: UIInputView {
init(items: [UIBarButtonItem]) {
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 44), inputViewStyle: .keyboard)
autoresizingMask = [.flexibleWidth]
backgroundColor = .secondarySystemGroupedBackground
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
scroll.delaysContentTouches = false
scroll.translatesAutoresizingMaskIntoConstraints = false
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = 4
stack.translatesAutoresizingMaskIntoConstraints = false
let actions = items.filter { $0.primaryAction != nil }
var done: UIButton?
for (index, item) in actions.enumerated() {
let button = UIButton(type: .system)
if let action = item.primaryAction { button.addAction(action, for: .touchUpInside) }
button.setImage(item.image, for: .normal)
button.setTitle(item.title, for: .normal)
button.tintColor = UIColor(ReadingTheme.accent)
button.titleLabel?.font = .systemFont(ofSize: 16)
button.accessibilityIdentifier = item.accessibilityIdentifier
button.accessibilityLabel = item.accessibilityLabel
button.translatesAutoresizingMaskIntoConstraints = false
if index == actions.count - 1 {
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
done = button
addSubview(button)
} else {
if index < 3 { button.accessibilityLabel = ["查找与替换", "撤销", "重做"][index] }
stack.addArrangedSubview(button)
button.widthAnchor.constraint(equalToConstant: 36).isActive = true
}
}
addSubview(scroll)
scroll.addSubview(stack)
guard let done else { return }
NSLayoutConstraint.activate([
done.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
done.widthAnchor.constraint(equalToConstant: 60),
done.topAnchor.constraint(equalTo: topAnchor),
done.bottomAnchor.constraint(equalTo: bottomAnchor),
scroll.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
scroll.trailingAnchor.constraint(equalTo: done.leadingAnchor, constant: -4),
scroll.topAnchor.constraint(equalTo: topAnchor),
scroll.bottomAnchor.constraint(equalTo: bottomAnchor),
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor),
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor)
])
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: 44) }
}
struct RuleCodeFieldView: View { struct RuleCodeFieldView: View {
let title: String let title: String
let path: String let path: String
@@ -92,9 +92,10 @@ struct ReplaceRuleEditorView: View {
RuleCodeFieldView(title: "匹配内容", path: "pattern", text: $rule.pattern, language: .regex) RuleCodeFieldView(title: "匹配内容", path: "pattern", text: $rule.pattern, language: .regex)
} label: { fieldPreview("匹配内容", value: rule.pattern) } } label: { fieldPreview("匹配内容", value: rule.pattern) }
NavigationLink { NavigationLink {
RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement, language: .regex) RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement,
language: rule.isRegex && rule.replacement.hasPrefix("@js:") ? .javascript : .regex)
} label: { fieldPreview("替换为(留空即删除)", value: rule.replacement) } } label: { fieldPreview("替换为(留空即删除)", value: rule.replacement) }
Text("正则替换使用 $1、$2 引用捕获组;普通文本替换按字面匹配。") Text("正则替换使用 $1、$2 引用捕获组;以 @js: 开头可运行 JavaScript,result 为当前匹配文本,可读取 book 和 chapter。普通文本替换按字面匹配。")
.font(.footnote).foregroundStyle(.secondary) .font(.footnote).foregroundStyle(.secondary)
} }
Section("作用范围") { Section("作用范围") {
+2 -1
View File
@@ -64,7 +64,8 @@ final class SearchViewModel: ObservableObject {
pendingSourceIDs = Set(pendingSources.map(\.id)) pendingSourceIDs = Set(pendingSources.map(\.id))
pagedSources = pendingSources.filter { pagedSources = pendingSources.filter {
let url = $0.searchUrl ?? "" let url = $0.searchUrl ?? ""
return url.contains("{{page}}") || url.contains("searchPage") || url.contains("<") return url.range(of: #"\{\{[^}]*\bpage\b[^}]*\}\}"#, options: .regularExpression) != nil
|| url.contains("searchPage") || url.contains("<")
} }
sourceNames = Dictionary(pendingSources.map { ($0.id, $0.bookSourceName) }, uniquingKeysWith: { first, _ in first }) sourceNames = Dictionary(pendingSources.map { ($0.id, $0.bookSourceName) }, uniquingKeysWith: { first, _ in first })
accumulator = .init() accumulator = .init()
+1
View File
@@ -80,6 +80,7 @@ private struct OpenSourceLicensesView: View {
license("小浣熊阅读 / Legado", resource: "Legado-LICENSE", detail: "GNU GPL v3") license("小浣熊阅读 / Legado", resource: "Legado-LICENSE", detail: "GNU GPL v3")
license("SwiftSoup", resource: "SwiftSoup-LICENSE", detail: "MIT License") license("SwiftSoup", resource: "SwiftSoup-LICENSE", detail: "MIT License")
license("Kanna", resource: "Kanna-LICENSE", detail: "MIT License") license("Kanna", resource: "Kanna-LICENSE", detail: "MIT License")
license("ZIPFoundation", resource: "ZIPFoundation-LICENSE", detail: "MIT License")
}.navigationTitle("开源许可").navigationBarTitleDisplayMode(.inline) }.navigationTitle("开源许可").navigationBarTitleDisplayMode(.inline)
} }
+18
View File
@@ -22,6 +22,24 @@
<string>public.data</string> <string>public.data</string>
</array> </array>
</dict> </dict>
<dict>
<key>CFBundleTypeName</key><string>EPUB 电子书</string>
<key>CFBundleTypeRole</key><string>Viewer</string>
<key>LSHandlerRank</key><string>Alternate</string>
<key>LSItemContentTypes</key><array><string>org.idpf.epub-container</string></array>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key><string>org.idpf.epub-container</string>
<key>UTTypeDescription</key><string>EPUB 电子书</string>
<key>UTTypeConformsTo</key><array><string>public.data</string></array>
<key>UTTypeTagSpecification</key><dict>
<key>public.filename-extension</key><array><string>epub</string></array>
<key>public.mime-type</key><string>application/epub+zip</string>
</dict>
</dict>
</array> </array>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
+10
View File
@@ -52,6 +52,7 @@ struct LegadoApp: App {
await bookLibrary.load() await bookLibrary.load()
await replaceRuleLibrary.load() await replaceRuleLibrary.load()
await readingActivity.load() await readingActivity.load()
await rssLibrary.load()
await ruleSubscriptions.load() await ruleSubscriptions.load()
if UserDefaults.standard.bool(forKey: "auto_refresh") { if UserDefaults.standard.bool(forKey: "auto_refresh") {
await bookLibrary.checkForUpdates(sources: sourceLibrary.sources, onlyRead: UserDefaults.standard.bool(forKey: "onlyUpdateRead")) await bookLibrary.checkForUpdates(sources: sourceLibrary.sources, onlyRead: UserDefaults.standard.bool(forKey: "onlyUpdateRead"))
@@ -60,6 +61,15 @@ struct LegadoApp: App {
} }
.onOpenURL { url in .onOpenURL { url in
Task { Task {
if url.isFileURL && url.pathExtension.lowercased() == "epub" {
let file = LocalBookImportFile(url: url)
await bookLibrary.load()
await bookLibrary.importPickedLocalFiles([file])
sourceLibrary.notice = .init(kind: bookLibrary.errorMessage == nil ? .success : .error,
title: bookLibrary.errorMessage == nil ? "EPUB 已加入书架" : "导入失败",
detail: bookLibrary.errorMessage ?? url.lastPathComponent)
return
}
await sourceLibrary.load() await sourceLibrary.load()
if url.scheme?.lowercased() == "legado", url.path.lowercased() == "/replacerule" { if url.scheme?.lowercased() == "legado", url.path.lowercased() == "/replacerule" {
await replaceRuleLibrary.load() await replaceRuleLibrary.load()
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+4 -2
View File
@@ -227,7 +227,7 @@ private struct BookshelfView: View {
} }
.background(ReadingTheme.canvas) .background(ReadingTheme.canvas)
.navigationBarHidden(true) .navigationBarHidden(true)
.readingTXTFileImporter(isPresented: $showsLocalImporter) { files in .readingLocalBookFileImporter(isPresented: $showsLocalImporter) { files in
pendingLocalFiles = files pendingLocalFiles = files
} onError: { error in } onError: { error in
library.reportError(error) library.reportError(error)
@@ -449,7 +449,7 @@ private struct BookshelfBookDestination: View {
let source: BookSource? let source: BookSource?
var body: some View { var body: some View {
if ProcessInfo.processInfo.arguments.contains("-uiDemo") { if ProcessInfo.processInfo.arguments.contains("-uiDemo"), book.origin != "local" {
ReaderView(demoBook: book, chapters: DemoContent.readerChapters, initialIndex: 0, content: DemoContent.readerText) ReaderView(demoBook: book, chapters: DemoContent.readerChapters, initialIndex: 0, content: DemoContent.readerText)
} else { } else {
BookshelfReadView(book: book, source: source) BookshelfReadView(book: book, source: source)
@@ -912,6 +912,8 @@ struct SearchView: View {
} label: { } label: {
Label("搜索选项", systemImage: "slider.horizontal.3") Label("搜索选项", systemImage: "slider.horizontal.3")
} }
.accessibilityLabel("搜索选项")
.accessibilityIdentifier("search.options")
} }
} }
.sheet(isPresented: $showsScope) { .sheet(isPresented: $showsScope) {
+11
View File
@@ -1,5 +1,16 @@
# 更新记录 # 更新记录
## 0.1.12(2026-10-24)
- 增加无 DRM EPUB 2/3 导入和阅读,识别元数据、封面、NCX/nav 目录及同页章节锚点,并按书脊顺序保留未列入目录的正文。
- EPUB 沿用阅读进度、书签、朗读和阅读样式;正文中的插图可从「更多 → 本章插图」查看。
- 本地文件管理支持 EPUB 重命名、原格式导出、删除及从系统文件应用打开。
- 改善 JSON 规则模板、候选选择、组合替换和请求参数兼容,修复 JavaScript 数字类型与动态净化替换。
- 修复 RSS 翻页失败后的重试、切换订阅时的过期结果和重复翻页请求。
- 改善连续滚动中的章节插入定位,调整 iOS 26 代码编辑工具栏。
- 应用版本为 `0.1.12`,构建号为 `18`。
## 0.1.11(2026-10-22) ## 0.1.11(2026-10-22)
- 修复书架和文件管理中的本地 TXT 选择、导入确认与文件访问权限,支持大小写扩展名和多选,并排除 JSON 书源。 - 修复书架和文件管理中的本地 TXT 选择、导入确认与文件访问权限,支持大小写扩展名和多选,并排除 JSON 书源。
+30 -4
View File
@@ -14,6 +14,7 @@
B10000000000000000000029 /* ReaderScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000029 /* ReaderScrollView.swift */; }; B10000000000000000000029 /* ReaderScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000029 /* ReaderScrollView.swift */; };
F10000000000000000000001 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000001 /* SwiftSoup */; }; F10000000000000000000001 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000001 /* SwiftSoup */; };
F10000000000000000000002 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000002 /* Kanna */; }; F10000000000000000000002 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000002 /* Kanna */; };
F10000000000000000000003 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000003 /* ZIPFoundation */; };
A10000000000000000000001 /* LegadoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* LegadoApp.swift */; }; A10000000000000000000001 /* LegadoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* LegadoApp.swift */; };
A10000000000000000000002 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* RootTabView.swift */; }; A10000000000000000000002 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* RootTabView.swift */; };
A10000000000000000000003 /* SourceLibraryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* SourceLibraryModel.swift */; }; A10000000000000000000003 /* SourceLibraryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* SourceLibraryModel.swift */; };
@@ -123,6 +124,9 @@
AA1100000000000000000008 = {isa = PBXBuildFile; fileRef = AA2200000000000000000008; }; AA1100000000000000000008 = {isa = PBXBuildFile; fileRef = AA2200000000000000000008; };
AA1100000000000000000009 = {isa = PBXBuildFile; fileRef = AA2200000000000000000009; }; AA1100000000000000000009 = {isa = PBXBuildFile; fileRef = AA2200000000000000000009; };
AA1100000000000000000010 = {isa = PBXBuildFile; fileRef = AA2200000000000000000010; }; AA1100000000000000000010 = {isa = PBXBuildFile; fileRef = AA2200000000000000000010; };
EB1000000000000000000001 /* EPUBBook.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2000000000000000000001; };
EB1000000000000000000002 /* ZIPFoundation-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = EB2000000000000000000002; };
EB1000000000000000000003 /* EPUBIllustrationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2000000000000000000003; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -245,6 +249,9 @@
AA2200000000000000000008 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/LegadoCore/Import/QRCodeDecoder.swift; sourceTree = SOURCE_ROOT; }; AA2200000000000000000008 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/LegadoCore/Import/QRCodeDecoder.swift; sourceTree = SOURCE_ROOT; };
AA2200000000000000000009 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/RuleEditorUITests.swift; sourceTree = SOURCE_ROOT; }; AA2200000000000000000009 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/RuleEditorUITests.swift; sourceTree = SOURCE_ROOT; };
AA2200000000000000000010 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/LiveReadingFlowUITests.swift; sourceTree = SOURCE_ROOT; }; AA2200000000000000000010 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/LiveReadingFlowUITests.swift; sourceTree = SOURCE_ROOT; };
EB2000000000000000000001 /* EPUBBook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/LegadoCore/LocalBook/EPUBBook.swift; sourceTree = SOURCE_ROOT; };
EB2000000000000000000002 /* ZIPFoundation-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = App/Resources/ZIPFoundation-LICENSE.txt; sourceTree = SOURCE_ROOT; };
EB2000000000000000000003 /* EPUBIllustrationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/Features/Reader/EPUBIllustrationsView.swift; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -256,6 +263,7 @@
files = ( files = (
F10000000000000000000001 /* SwiftSoup in Frameworks */, F10000000000000000000001 /* SwiftSoup in Frameworks */,
F10000000000000000000002 /* Kanna in Frameworks */, F10000000000000000000002 /* Kanna in Frameworks */,
F10000000000000000000003 /* ZIPFoundation in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -353,6 +361,7 @@
A20000000000000000000029 /* ReaderSpeechController.swift */, A20000000000000000000029 /* ReaderSpeechController.swift */,
A20000000000000000000028 /* ReaderAppearance.swift */, A20000000000000000000028 /* ReaderAppearance.swift */,
A2000000000000000000002B /* ReaderSupport.swift */, A2000000000000000000002B /* ReaderSupport.swift */,
EB2000000000000000000003 /* EPUBIllustrationsView.swift */,
); );
path = Features/Reader; path = Features/Reader;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -527,6 +536,7 @@
children = ( children = (
F21000000000000000000002 /* LocalFileService.swift */, F21000000000000000000002 /* LocalFileService.swift */,
C20000000000000000000018 /* LocalBookService.swift */, C20000000000000000000018 /* LocalBookService.swift */,
EB2000000000000000000001 /* EPUBBook.swift */,
); );
path = LocalBook; path = LocalBook;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -589,6 +599,7 @@
E20000000000000000000008 /* Legado-LICENSE.txt */, E20000000000000000000008 /* Legado-LICENSE.txt */,
E20000000000000000000009 /* SwiftSoup-LICENSE.txt */, E20000000000000000000009 /* SwiftSoup-LICENSE.txt */,
E2000000000000000000000A /* Kanna-LICENSE.txt */, E2000000000000000000000A /* Kanna-LICENSE.txt */,
EB2000000000000000000002 /* ZIPFoundation-LICENSE.txt */,
); );
path = Resources; path = Resources;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -622,6 +633,7 @@
packageProductDependencies = ( packageProductDependencies = (
F20000000000000000000001 /* SwiftSoup */, F20000000000000000000001 /* SwiftSoup */,
F20000000000000000000002 /* Kanna */, F20000000000000000000002 /* Kanna */,
F20000000000000000000003 /* ZIPFoundation */,
); );
productName = Legado; productName = Legado;
productReference = D20000000000000000000001 /* Legado.app */; productReference = D20000000000000000000001 /* Legado.app */;
@@ -659,6 +671,7 @@
packageReferences = ( packageReferences = (
F30000000000000000000001 /* XCRemoteSwiftPackageReference "SwiftSoup" */, F30000000000000000000001 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */, F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */,
F30000000000000000000003 /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
); );
productRefGroup = D40000000000000000000002 /* Products */; productRefGroup = D40000000000000000000002 /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -677,6 +690,7 @@
910000000000000000000008 /* Legado-LICENSE.txt in Resources */, 910000000000000000000008 /* Legado-LICENSE.txt in Resources */,
A10000000000000000000020 /* Assets.xcassets in Resources */, A10000000000000000000020 /* Assets.xcassets in Resources */,
A1000000000000000000001D /* txtTocRule.json in Resources */, A1000000000000000000001D /* txtTocRule.json in Resources */,
EB1000000000000000000002 /* ZIPFoundation-LICENSE.txt in Resources */,
A1000000000000000000001E /* httpTTS.json in Resources */, A1000000000000000000001E /* httpTTS.json in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -770,6 +784,8 @@
C10000000000000000000016 /* BookExploreService.swift in Sources */, C10000000000000000000016 /* BookExploreService.swift in Sources */,
C10000000000000000000017 /* BookSourceDebugger.swift in Sources */, C10000000000000000000017 /* BookSourceDebugger.swift in Sources */,
C10000000000000000000018 /* LocalBookService.swift in Sources */, C10000000000000000000018 /* LocalBookService.swift in Sources */,
EB1000000000000000000003 /* EPUBIllustrationsView.swift in Sources */,
EB1000000000000000000001 /* EPUBBook.swift in Sources */,
C10000000000000000000019 /* ContentSanitizer.swift in Sources */, C10000000000000000000019 /* ContentSanitizer.swift in Sources */,
C1000000000000000000001A /* ReplaceRuleRepository.swift in Sources */, C1000000000000000000001A /* ReplaceRuleRepository.swift in Sources */,
C1000000000000000000001B /* ReadingActivityRepository.swift in Sources */, C1000000000000000000001B /* ReadingActivityRepository.swift in Sources */,
@@ -864,12 +880,12 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17; CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.1.11; MARKETING_VERSION = 0.1.12;
OTHER_LDFLAGS = "$(inherited) -lsqlite3"; OTHER_LDFLAGS = "$(inherited) -lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado; PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -885,11 +901,11 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17; CURRENT_PROJECT_VERSION = 18;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.1.11; MARKETING_VERSION = 0.1.12;
OTHER_LDFLAGS = "$(inherited) -lsqlite3"; OTHER_LDFLAGS = "$(inherited) -lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado; PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -930,6 +946,11 @@
repositoryURL = "https://github.com/tid-kijyun/Kanna.git"; repositoryURL = "https://github.com/tid-kijyun/Kanna.git";
requirement = { kind = exactVersion; version = 6.1.0; }; requirement = { kind = exactVersion; version = 6.1.0; };
}; };
F30000000000000000000003 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/weichsel/ZIPFoundation.git";
requirement = { kind = exactVersion; version = 0.9.20; };
};
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@@ -943,6 +964,11 @@
package = F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */; package = F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */;
productName = Kanna; productName = Kanna;
}; };
F20000000000000000000003 /* ZIPFoundation */ = {
isa = XCSwiftPackageProductDependency;
package = F30000000000000000000003 /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
productName = ZIPFoundation;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = D70000000000000000000001 /* Project object */; rootObject = D70000000000000000000001 /* Project object */;
@@ -1,5 +1,5 @@
{ {
"originHash" : "5cf2eb5dea5b10523b3afc86a164d9a3315a7269a1aefdfe8c91f0530c3f3522", "originHash" : "a20ba014518540ad01fe4db62ea950304db8c0b8eb880b62b87a5f1d19569a3c",
"pins" : [ "pins" : [
{ {
"identity" : "kanna", "identity" : "kanna",
@@ -18,6 +18,15 @@
"revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface", "revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface",
"version" : "2.13.5" "version" : "2.13.5"
} }
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation.git",
"state" : {
"revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
"version" : "0.9.20"
}
} }
], ],
"version" : 3 "version" : 3
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="2610" version="1.7">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES"><BuildActionEntries><BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="YES"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="B50000000000000000000050" BuildableName="LegadoReaderUITests.xctest" BlueprintName="LegadoReaderUITests" ReferencedContainer="container:Legado.xcodeproj"/></BuildActionEntry></BuildActionEntries></BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="NO">
<Testables><TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="B50000000000000000000050" BuildableName="LegadoReaderUITests.xctest" BlueprintName="LegadoReaderUITests" ReferencedContainer="container:Legado.xcodeproj"/><SelectedTests><Test Identifier="RuleEditorUITests/testRSSSearchSubmissionPaginationAndFavorites()"/></SelectedTests></TestableReference></Testables>
<EnvironmentVariables><EnvironmentVariable key="LEGADO_RSS_UI_FIXTURE_URL" value="http://127.0.0.1:18763/source.json" isEnabled="YES"/></EnvironmentVariables>
</TestAction>
</Scheme>
+10 -1
View File
@@ -1,5 +1,5 @@
{ {
"originHash" : "7f0bb8624f715c1c036934addf347a13f5c877af397c008dd678bb519c74d937", "originHash" : "9d25b5f7ff3ac25767fea9b5152f5607bdb185299d3ebda18cd9b9255a772731",
"pins" : [ "pins" : [
{ {
"identity" : "kanna", "identity" : "kanna",
@@ -18,6 +18,15 @@
"revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface", "revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface",
"version" : "2.13.5" "version" : "2.13.5"
} }
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation.git",
"state" : {
"revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
"version" : "0.9.20"
}
} }
], ],
"version" : 3 "version" : 3
+7 -5
View File
@@ -12,14 +12,16 @@ let package = Package(
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/scinfu/SwiftSoup.git", exact: "2.13.5"), .package(url: "https://github.com/scinfu/SwiftSoup.git", exact: "2.13.5"),
.package(url: "https://github.com/tid-kijyun/Kanna.git", exact: "6.1.0") .package(url: "https://github.com/tid-kijyun/Kanna.git", exact: "6.1.0"),
.package(url: "https://github.com/weichsel/ZIPFoundation.git", exact: "0.9.20")
], ],
targets: [ targets: [
.target( .target(
name: "LegadoCore", name: "LegadoCore",
dependencies: [ dependencies: [
.product(name: "SwiftSoup", package: "SwiftSoup"), .product(name: "SwiftSoup", package: "SwiftSoup"),
.product(name: "Kanna", package: "Kanna") .product(name: "Kanna", package: "Kanna"),
.product(name: "ZIPFoundation", package: "ZIPFoundation")
], ],
linkerSettings: [ linkerSettings: [
.linkedFramework("JavaScriptCore"), .linkedFramework("JavaScriptCore"),
@@ -29,7 +31,7 @@ let package = Package(
), ),
.testTarget( .testTarget(
name: "LegadoCoreTests", name: "LegadoCoreTests",
dependencies: ["LegadoCore"], dependencies: ["LegadoCore", .product(name: "ZIPFoundation", package: "ZIPFoundation")],
resources: [.copy("Fixtures")] resources: [.copy("Fixtures")]
), ),
.target( .target(
@@ -42,7 +44,7 @@ let package = Package(
"Rules/ReplaceRuleManagementView.swift", "Rules/ReplaceRuleEditorView.swift", "Rules/RuleSubscriptionView.swift", "Rules/TxtTocRuleManagementView.swift", "Rules/Editor", "Rules/ReplaceRuleManagementView.swift", "Rules/ReplaceRuleEditorView.swift", "Rules/RuleSubscriptionView.swift", "Rules/TxtTocRuleManagementView.swift", "Rules/Editor",
"Books/BookDetailView.swift", "Books/LocalBookDetailView.swift", "Books/SourceSwitchView.swift", "Books/BookDetailView.swift", "Books/LocalBookDetailView.swift", "Books/SourceSwitchView.swift",
"Reader/ReaderAppearance.swift", "Reader/ReaderSpeechController.swift", "Reader/ReaderSupport.swift", "Reader/ReaderAppearance.swift", "Reader/ReaderSpeechController.swift", "Reader/ReaderSupport.swift",
"Reader/ReaderTextView.swift", "Reader/ReaderScrollView.swift", "Reader/ReaderView.swift" "Reader/EPUBIllustrationsView.swift", "Reader/ReaderTextView.swift", "Reader/ReaderScrollView.swift", "Reader/ReaderView.swift"
], ],
sources: [ sources: [
"Sources/SourceLibraryModel.swift", "Rules/ReplaceRuleLibraryModel.swift", "Search/SearchViewModel.swift", "Sources/SourceLibraryModel.swift", "Rules/ReplaceRuleLibraryModel.swift", "Search/SearchViewModel.swift",
@@ -52,7 +54,7 @@ let package = Package(
), ),
.testTarget( .testTarget(
name: "LegadoAppModelsTests", name: "LegadoAppModelsTests",
dependencies: ["LegadoAppModels", "LegadoCore"] dependencies: ["LegadoAppModels", "LegadoCore", .product(name: "ZIPFoundation", package: "ZIPFoundation")]
) )
] ]
) )
+6 -3
View File
@@ -20,7 +20,7 @@
项目仍在边用边改,书源兼容和阅读体验会继续完善。 项目仍在边用边改,书源兼容和阅读体验会继续完善。
**小浣熊阅读不提供书源或在线书库。** 书源需由用户自行从网络公开分享获取,或自行编写后导入;也可以直接阅读本地 TXT。 **小浣熊阅读不提供书源或在线书库。** 书源需由用户自行从网络公开分享获取,或自行编写后导入;也可以直接阅读本地 TXT / EPUB。
## 下载安装 ## 下载安装
@@ -42,7 +42,7 @@
| 快速搜书 | 多书源并发搜索,结果陆续展示;重复搜索可先显示有效缓存,同书多源合并 | | 快速搜书 | 多书源并发搜索,结果陆续展示;重复搜索可先显示有效缓存,同书多源合并 |
| 书架管理 | 列表、网格、分组、章节更新、换源和本机阅读进度 | | 书架管理 | 列表、网格、分组、章节更新、换源和本机阅读进度 |
| 阅读排版 | 字号、间距、边距、日夜主题、滚动与分页阅读、目录和书签 | | 阅读排版 | 字号、间距、边距、日夜主题、滚动与分页阅读、目录和书签 |
| 本地 TXT | 导入本地文件、自定义目录规则和章节阅读 | | 本地 TXT / EPUB | TXT 自定义目录规则;EPUB 2/3 目录、封面、分章阅读与插图查看 |
| 语音朗读 | 系统朗读与 HTTP TTS 配置 | | 语音朗读 | 系统朗读与 HTTP TTS 配置 |
| 阅读工具 | 替换净化、阅读记录、时长统计和缓存管理 | | 阅读工具 | 替换净化、阅读记录、时长统计和缓存管理 |
| RSS 订阅 | 订阅源管理与内容阅读 | | RSS 订阅 | 订阅源管理与内容阅读 |
@@ -85,9 +85,12 @@
1. 自行准备你有权使用的阅读书源,安装后进入「我的 → 书源管理」导入。 1. 自行准备你有权使用的阅读书源,安装后进入「我的 → 书源管理」导入。
2. 在书架点击搜索,输入书名或作者,打开书籍详情后开始阅读。 2. 在书架点击搜索,输入书名或作者,打开书籍详情后开始阅读。
3. 将书籍加入书架,方便下次继续阅读;也可以从书架直接导入本地 TXT。 3. 将书籍加入书架,方便下次继续阅读;也可以从书架直接导入本地 TXT 或 EPUB。
4. 在阅读页调整排版、主题、书签和朗读设置。 4. 在阅读页调整排版、主题、书签和朗读设置。
EPUB 导入入口:书架 → 更多 → 本地书,或「我的 → 文件管理 → 导入 TXT / EPUB」。支持无 DRM 的 EPUB 2/3,识别书名、作者、封面、NCX/nav 目录和章节锚点。正文使用现有阅读器重新排版,支持阅读进度、书签、朗读和样式设置;插图从阅读菜单「更多 → 本章插图」查看。暂不还原原书 CSS、固定版式和交互内容。
应用支持 `legado://` 规则导入链接。来源网站改版或停止服务时,书源规则可能需要更新。 应用支持 `legado://` 规则导入链接。来源网站改版或停止服务时,书源规则可能需要更新。
## 书源说明 ## 书源说明
+10 -4
View File
@@ -115,9 +115,9 @@ public struct ContentSanitizer: Sendable {
var diagnostics: [RuleDiagnostic] = [] var diagnostics: [RuleDiagnostic] = []
for rule in ordered where rule.scopeTitle && applies(rule, to: book) { for rule in ordered where rule.scopeTitle && applies(rule, to: book) {
let result = apply(rule, to: title) let result = apply(rule, to: title, book: book, chapter: chapter)
diagnostics += result.diagnostics diagnostics += result.diagnostics
if result.value != title { if result.value != title, !result.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
title = result.value title = result.value
effective.append(rule.id) effective.append(rule.id)
} }
@@ -146,7 +146,7 @@ public struct ContentSanitizer: Sendable {
} }
for rule in ordered where rule.scopeContent && applies(rule, to: book) { for rule in ordered where rule.scopeContent && applies(rule, to: book) {
let result = apply(rule, to: body) let result = apply(rule, to: body, book: book, chapter: chapter)
diagnostics += result.diagnostics diagnostics += result.diagnostics
if result.value != body { if result.value != body {
body = result.value body = result.value
@@ -172,10 +172,16 @@ public struct ContentSanitizer: Sendable {
return (!book.name.isEmpty && scope.contains(book.name)) || (!book.origin.isEmpty && scope.contains(book.origin)) return (!book.name.isEmpty && scope.contains(book.name)) || (!book.origin.isEmpty && scope.contains(book.origin))
} }
private func apply(_ rule: ReplaceRule, to input: String) -> (value: String, diagnostics: [RuleDiagnostic]) { private func apply(_ rule: ReplaceRule, to input: String, book: Book, chapter: BookChapter) -> (value: String, diagnostics: [RuleDiagnostic]) {
guard !rule.pattern.isEmpty else { return (input, []) } guard !rule.pattern.isEmpty else { return (input, []) }
do { do {
if rule.isRegex { if rule.isRegex {
if rule.replacement.hasPrefix("@js:") {
let evaluate = JavaScriptRuleEvaluator().replacementEvaluator(
script: String(rule.replacement.dropFirst(4)), book: book, chapter: chapter)
return (try regex.replacingStrict(pattern: rule.pattern, in: input,
timeoutMilliseconds: rule.timeoutMillisecond, replacement: evaluate), [])
}
return (try regex.replacingStrict(pattern: rule.pattern, in: input, with: rule.replacement, timeoutMilliseconds: rule.timeoutMillisecond), []) return (try regex.replacingStrict(pattern: rule.pattern, in: input, with: rule.replacement, timeoutMilliseconds: rule.timeoutMillisecond), [])
} }
return (input.replacingOccurrences(of: rule.pattern, with: rule.replacement), []) return (input.replacingOccurrences(of: rule.pattern, with: rule.replacement), [])
+399
View File
@@ -0,0 +1,399 @@
import Foundation
import ImageIO
import SwiftSoup
import ZIPFoundation
public enum EPUBError: Error, LocalizedError, Sendable {
case invalidArchive, invalidPackage, missingResource(String), encrypted, resourceTooLarge, invalidChapter
public var errorDescription: String? {
switch self {
case .invalidArchive: "EPUB 文件已损坏或不是有效的压缩包"
case .invalidPackage: "EPUB 缺少有效的书籍信息或阅读顺序"
case .missingResource(let path): "EPUB 中缺少文件:\(path)"
case .encrypted: "此 EPUB 的正文或图片已加密,暂不支持读取"
case .resourceTooLarge: "EPUB 单个章节或资源过大,无法安全读取"
case .invalidChapter: "EPUB 章节或目录锚点无效,请重新打开书籍"
}
}
}
public struct EPUBIllustration: Identifiable, Sendable {
public let id: Int
public let caption: String
public let data: Data
}
/// Reads resources on demand without unpacking untrusted archive paths to disk.
/// Navigation follows the spine; EPUB 2 NCX and EPUB 3 nav supply chapter labels/anchors.
struct EPUBBook {
private struct Item {
let id: String
let path: String
let mediaType: String
let properties: Set<String>
}
private struct Reference {
let path: String
let fragment: String?
let title: String
}
private let archive: Archive
private let entries: [String: Entry]
private let package: EPUBXMLNode
private let packagePath: String
private let manifest: [Item]
private let spine: [Item]
private let encryptedPaths: Set<String>
private static let resourceLimit = 32 * 1_024 * 1_024
init(url: URL) throws {
let archive: Archive
do { archive = try Archive(url: url, accessMode: .read) }
catch { throw EPUBError.invalidArchive }
var entries: [String: Entry] = [:]
for entry in archive {
guard entries.count < 50_000 else { throw EPUBError.resourceTooLarge }
// Directories and symlinks are never used as book resources.
guard entry.type == .file else { continue }
guard Self.safePath(entry.path), entries[entry.path] == nil else { throw EPUBError.invalidArchive }
entries[entry.path] = entry
}
self.archive = archive
self.entries = entries
let container = try EPUBXMLNode.parse(Self.read("META-INF/container.xml", archive: archive, entries: entries))
guard let rootfile = container.descendants("rootfile").first,
let fullPath = rootfile.attributes["full-path"],
let path = Self.resolve(fullPath, relativeTo: "")?.path else { throw EPUBError.invalidPackage }
packagePath = path
package = try EPUBXMLNode.parse(Self.read(path, archive: archive, entries: entries))
guard package.name == "package", let manifestNode = package.children.first(where: { $0.name == "manifest" }),
let spineNode = package.children.first(where: { $0.name == "spine" }) else { throw EPUBError.invalidPackage }
manifest = manifestNode.children.compactMap { node in
guard node.name == "item", let id = node.attributes["id"], let href = node.attributes["href"],
let ref = Self.resolve(href, relativeTo: path) else { return nil }
return Item(id: id, path: ref.path, mediaType: node.attributes["media-type"] ?? "",
properties: Set((node.attributes["properties"] ?? "").split(whereSeparator: \.isWhitespace).map(String.init)))
}
let items = manifest
let readable = spineNode.children.filter { $0.name == "itemref" && $0.attributes["linear"] != "no" }
spine = try readable.map { node in
guard let item = items.first(where: { $0.id == node.attributes["idref"] }),
["application/xhtml+xml", "text/html"].contains(item.mediaType), entries[item.path] != nil else {
throw EPUBError.invalidPackage
}
return item
}
guard !spine.isEmpty else { throw EPUBError.invalidPackage }
if entries["META-INF/encryption.xml"] != nil {
let encryption = try EPUBXMLNode.parse(Self.read("META-INF/encryption.xml", archive: archive, entries: entries))
encryptedPaths = Set(encryption.descendants("CipherReference").compactMap {
$0.attributes["URI"].flatMap { Self.resolve($0, relativeTo: "")?.path }
})
} else { encryptedPaths = [] }
// Font obfuscation is common and does not prevent text reading.
guard !spine.contains(where: { encryptedPaths.contains($0.path) }) else { throw EPUBError.encrypted }
}
func report(url: URL, originalName: String) throws -> LocalBookImportReport {
let metadata = package.children.first { $0.name == "metadata" }
let title = metadata?.descendants("title").first?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let authors = metadata?.descendants("creator").map { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } ?? []
var book = Book(bookUrl: url.absoluteString, name: title.isEmpty ? (originalName as NSString).deletingPathExtension : title,
author: authors.joined(separator: "、"), origin: "local")
book.originName = originalName
book.intro = MetadataTextSanitizer().clean(metadata?.descendants("description").first?.text)
book.canUpdate = false
var configuration = BookReadConfig()
configuration.useReplaceRule = false
book.readConfig = configuration
let coverID = metadata?.descendants("meta").first { $0.attributes["name"] == "cover" }?.attributes["content"]
if let cover = manifest.first(where: { $0.properties.contains("cover-image") }) ?? manifest.first(where: { $0.id == coverID }),
["image/jpeg", "image/png", "image/gif", "image/webp"].contains(cover.mediaType),
let entry = entries[cover.path], entry.uncompressedSize <= 2 * 1_024 * 1_024,
let data = try? read(cover.path), let thumbnail = Self.coverThumbnail(data) {
// A bounded embedded thumbnail survives file renames, backups and cache eviction.
book.coverUrl = "data:image/png;base64,\(thumbnail.base64EncodedString())"
}
let chapters = try chapters(book: book)
book.totalChapterNum = chapters.count
book.latestChapterTitle = chapters.last?.title
return .init(book: book, chapters: chapters)
}
func chapters(book: Book) throws -> [BookChapter] {
let references = navigation()
var chapters: [BookChapter] = []
for item in spine where !item.properties.contains("nav") {
let document = try document(item.path)
guard let body = document.body() else { continue }
let elements = try body.getAllElements().array()
var positions: [String: Int] = [:]
for (index, element) in elements.enumerated() {
for key in [element.id(), (try? element.attr("name")) ?? ""] where !key.isEmpty && positions[key] == nil {
positions[key] = index
}
}
var seen = Set<String>()
let refs = references.filter { $0.path == item.path }
.filter { ref in
guard ref.fragment == nil || positions[ref.fragment!] != nil else { return false }
return seen.insert(ref.fragment ?? "").inserted
}
.sorted { (positions[$0.fragment ?? ""] ?? -1) < (positions[$1.fragment ?? ""] ?? -1) }
let documentTitle = try document.title().trimmingCharacters(in: .whitespacesAndNewlines)
let heading = try body.select("h1,h2,h3").first()?.text() ?? ""
let fallback = !documentTitle.isEmpty ? documentTitle : (!heading.isEmpty ? heading : "第\(chapters.count + 1)章")
var boundaries = refs
if boundaries.isEmpty {
boundaries = [Reference(path: item.path, fragment: nil, title: fallback)]
} else if let first = boundaries.first, let fragment = first.fragment {
let prefix = try render(body: body, from: nil, to: fragment).text
if !prefix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
boundaries.insert(Reference(path: item.path, fragment: nil, title: fallback), at: 0)
}
}
for (index, ref) in boundaries.enumerated() {
var chapter = BookChapter(url: Self.referenceURL(path: item.path, fragment: ref.fragment),
title: ref.title.isEmpty ? fallback : ref.title, bookUrl: book.bookUrl, index: chapters.count)
chapter.resourceUrl = item.path
chapter.startFragmentId = ref.fragment
chapter.endFragmentId = boundaries.indices.contains(index + 1) ? boundaries[index + 1].fragment : nil
chapters.append(chapter)
}
}
guard !chapters.isEmpty else { throw EPUBError.invalidPackage }
return chapters
}
func content(chapter: BookChapter) throws -> ChapterContentReport {
let (body, _) = try body(chapter: chapter)
let value = try render(body: body, from: chapter.startFragmentId, to: chapter.endFragmentId)
return .init(title: chapter.title, content: value.text, pagesVisited: 1)
}
func illustrations(chapter: BookChapter) throws -> [EPUBIllustration] {
let (body, path) = try body(chapter: chapter)
let value = try render(body: body, from: chapter.startFragmentId, to: chapter.endFragmentId)
var totalBytes = 0
return try value.images.enumerated().map { index, image in
guard let ref = Self.resolve(image.src, relativeTo: path),
let item = manifest.first(where: { $0.path == ref.path }), item.mediaType.hasPrefix("image/") else {
throw EPUBError.missingResource(image.src)
}
let data = try read(ref.path)
totalBytes += data.count
guard totalBytes <= Self.resourceLimit else { throw EPUBError.resourceTooLarge }
return EPUBIllustration(id: index + 1, caption: image.alt, data: data)
}
}
private func body(chapter: BookChapter) throws -> (Element, String) {
guard let path = chapter.resourceUrl, spine.contains(where: { $0.path == path }),
let body = try document(path).body() else { throw EPUBError.invalidChapter }
return (body, path)
}
private func document(_ path: String) throws -> Document {
let data = try read(path)
let text: String
if data.starts(with: [0xff, 0xfe]) || data.starts(with: [0xfe, 0xff]), let utf16 = String(data: data, encoding: .utf16) {
text = utf16
} else if let utf8 = String(data: data, encoding: .utf8) { text = utf8 }
else { throw EPUBError.invalidPackage }
return try SwiftSoup.parse(text)
}
private func navigation() -> [Reference] {
if let nav = manifest.first(where: { $0.properties.contains("nav") }), let doc = try? document(nav.path),
let nodes = try? doc.select("nav").array() {
let toc = nodes.first { node in
let type = (try? node.attr("epub:type")) ?? ""
return type.split(whereSeparator: \.isWhitespace).contains("toc") || (try? node.attr("role")) == "doc-toc"
}
if let links = try? toc?.select("a[href]").array() {
let refs = links.compactMap { element -> Reference? in
guard let href = try? element.attr("href"), let ref = Self.resolve(href, relativeTo: nav.path) else { return nil }
return Reference(path: ref.path, fragment: ref.fragment, title: (try? element.text()) ?? "")
}
if !refs.isEmpty { return refs }
}
}
let tocID = package.children.first { $0.name == "spine" }?.attributes["toc"]
if let ncx = manifest.first(where: { $0.id == tocID }) ?? manifest.first(where: { $0.mediaType == "application/x-dtbncx+xml" }),
let data = try? read(ncx.path), let root = try? EPUBXMLNode.parse(data) {
return root.descendants("navPoint").compactMap { node in
guard let href = node.children.first(where: { $0.name == "content" })?.attributes["src"],
let ref = Self.resolve(href, relativeTo: ncx.path) else { return nil }
return Reference(path: ref.path, fragment: ref.fragment,
title: node.children.first(where: { $0.name == "navLabel" })?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? "")
}
}
return []
}
/// Walk DOM boundaries rather than slicing serialized HTML: anchors can be nested
/// in different parents, and all text after the end anchor belongs to the next chapter.
private func render(body: Element, from start: String?, to end: String?) throws -> (text: String, images: [(src: String, alt: String)]) {
var active = start == nil
var foundStart = start == nil
var foundEnd = end == nil
var finished = false
var output = ""
var images: [(src: String, alt: String)] = []
let blocks: Set<String> = ["p", "div", "section", "article", "li", "ul", "ol", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "tr", "hr"]
func visit(_ node: Node, depth: Int) throws {
guard depth < 512 else { throw EPUBError.invalidPackage }
guard !finished else { return }
guard let element = node as? Element else {
if active, let text = node as? TextNode {
output += text.getWholeText().replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
}
return
}
let id = element.id()
let name = try element.attr("name")
if let end, id == end || name == end { active = false; finished = true; foundEnd = true; return }
if let start, id == start || name == start { active = true; foundStart = true }
let tag = element.tagName().lowercased()
let style = try element.attr("style").lowercased().replacingOccurrences(of: #"\s+"#, with: "", options: .regularExpression)
if ["script", "style", "noscript", "rt", "rp", "title"].contains(tag) || element.hasAttr("hidden") || style.contains("display:none") { return }
if active, blocks.contains(tag) || tag == "br" { output += "\n" }
if active, tag == "img" || tag == "image" {
var src = try element.attr(tag == "img" ? "src" : "xlink:href")
if src.isEmpty, tag == "image" { src = try element.attr("href") }
let alt = try element.attr("alt")
if !src.isEmpty {
images.append((src, alt))
output += "\n【插图 \(images.count)\(alt.isEmpty ? "" : ":" + alt)】\n"
}
}
for child in element.getChildNodes() { try visit(child, depth: depth + 1) }
if active, blocks.contains(tag) { output += "\n" }
if active, tag == "td" || tag == "th" { output += " " }
}
try visit(body, depth: 0)
guard foundStart && foundEnd else { throw EPUBError.invalidChapter }
let text = output.components(separatedBy: .newlines).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }.joined(separator: "\n")
return (text, images)
}
private func read(_ path: String) throws -> Data {
guard !encryptedPaths.contains(path) else { throw EPUBError.encrypted }
return try Self.read(path, archive: archive, entries: entries)
}
private static func read(_ path: String, archive: Archive, entries: [String: Entry]) throws -> Data {
guard let entry = entries[path] else { throw EPUBError.missingResource(path) }
guard entry.uncompressedSize <= resourceLimit else { throw EPUBError.resourceTooLarge }
var result = Data()
let checksum = try archive.extract(entry) { chunk in
guard result.count + chunk.count <= resourceLimit else { throw EPUBError.resourceTooLarge }
result.append(chunk)
}
guard checksum == entry.checksum, result.count == entry.uncompressedSize else { throw EPUBError.invalidArchive }
return result
}
private static func coverThumbnail(_ data: Data) -> Data? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 512,
kCGImageSourceCreateThumbnailWithTransform: true
] as CFDictionary) else { return nil }
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(output, "public.png" as CFString, 1, nil) else { return nil }
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else { return nil }
return output as Data
}
private static func safePath(_ path: String) -> Bool {
!path.isEmpty && !path.hasPrefix("/") && !path.contains("\\") && !path.contains("\0") && !path.split(separator: "/").contains("..")
}
private static func resolve(_ href: String, relativeTo base: String) -> (path: String, fragment: String?)? {
guard !href.hasPrefix("/"), !href.contains("\\"),
URLComponents(string: href)?.scheme == nil else { return nil }
let parts = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
guard let pathPart = String(parts[0]).removingPercentEncoding else { return nil }
var components = pathPart.isEmpty ? base.split(separator: "/").map(String.init) : base.split(separator: "/").dropLast().map(String.init)
if !pathPart.isEmpty {
for part in pathPart.split(separator: "/") {
if part == "." { continue }
if part == ".." { guard !components.isEmpty else { return nil }; components.removeLast() }
else { components.append(String(part)) }
}
}
let path = components.joined(separator: "/")
guard safePath(path), !pathPart.hasPrefix("/") else { return nil }
let fragment = parts.count > 1 ? String(parts[1]).removingPercentEncoding : nil
return (path, fragment?.isEmpty == false ? fragment : nil)
}
private static func referenceURL(path: String, fragment: String?) -> String {
var url = URLComponents()
url.scheme = "epub"
url.host = "book"
url.path = "/" + path
url.fragment = fragment
return url.string ?? path
}
}
/// Small namespace-independent XML tree for package metadata and navigation.
private final class EPUBXMLNode: NSObject, XMLParserDelegate {
var name = ""
var attributes: [String: String] = [:]
var children: [EPUBXMLNode] = []
var text = ""
private var stack: [EPUBXMLNode] = []
private var count = 0
func descendants(_ name: String) -> [EPUBXMLNode] {
children.flatMap { ($0.name == name ? [$0] : []) + $0.descendants(name) }
}
static func parse(_ data: Data) throws -> EPUBXMLNode {
let delegate = EPUBXMLNode()
let parser = XMLParser(data: data)
parser.shouldResolveExternalEntities = false
parser.delegate = delegate
guard parser.parse(), let root = delegate.children.first else { throw EPUBError.invalidPackage }
return root
}
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) {
count += 1
guard stack.count < 128, count <= 50_000 else { parser.abortParsing(); return }
let node = EPUBXMLNode()
node.name = elementName.split(separator: ":").last.map(String.init) ?? elementName
node.attributes = attributeDict
(stack.last ?? self).children.append(node)
stack.append(node)
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
for node in stack {
guard node.text.utf8.count + string.utf8.count <= 4 * 1_024 * 1_024 else { parser.abortParsing(); return }
node.text += string
}
}
func parser(_ parser: XMLParser, foundInternalEntityDeclarationWithName name: String, value: String?) {
parser.abortParsing()
}
func parser(_ parser: XMLParser, foundExternalEntityDeclarationWithName name: String, publicID: String?, systemID: String?) {
parser.abortParsing()
}
func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
if let value = String(data: CDATABlock, encoding: .utf8) { self.parser(parser, foundCharacters: value) }
}
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
_ = stack.popLast()
}
}
@@ -10,7 +10,7 @@ public enum LocalBookError: Error, LocalizedError, Sendable {
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
case .unsupportedFormat: "当前仅支持 TXT 本地书" case .unsupportedFormat: "当前支持 TXT 和 EPUB 本地书"
case .oversized: "本地书超过当前配置允许的大小" case .oversized: "本地书超过当前配置允许的大小"
case .unreadableEncoding: "无法识别 TXT 编码" case .unreadableEncoding: "无法识别 TXT 编码"
case .missingFile: "本地书文件已丢失" case .missingFile: "本地书文件已丢失"
@@ -104,6 +104,49 @@ public struct LocalBookService: Sendable {
LocalBookService(tocRules: TxtTocRuleDefaults.load()) LocalBookService(tocRules: TxtTocRuleDefaults.load())
} }
public static func supports(_ url: URL) -> Bool {
url.isFileURL && ["txt", "epub"].contains(url.pathExtension.lowercased())
}
public func openFile(at url: URL) throws -> LocalBookImportReport {
guard Self.supports(url) else { throw LocalBookError.unsupportedFormat }
if url.pathExtension.lowercased() == "epub" {
try validateFile(url)
return try EPUBBook(url: url).report(url: url, originalName: url.lastPathComponent)
}
return try openTextFile(at: url)
}
public func importFile(at source: URL, into directory: URL) throws -> LocalBookImportReport {
guard Self.supports(source) else { throw LocalBookError.unsupportedFormat }
try validateFile(source)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destination = directory.appendingPathComponent(UUID().uuidString + "." + source.pathExtension.lowercased())
try FileManager.default.copyItem(at: source, to: destination)
do {
if source.pathExtension.lowercased() == "epub" {
return try EPUBBook(url: destination).report(url: destination, originalName: source.lastPathComponent)
}
return try textFileReport(data: Data(contentsOf: destination, options: .mappedIfSafe), sourceURL: source, destination: destination)
} catch {
try? FileManager.default.removeItem(at: destination)
throw error
}
}
public func loadIllustrations(book: Book, chapter: BookChapter) throws -> [EPUBIllustration] {
guard let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" else { return [] }
try validateFile(url)
return try EPUBBook(url: url).illustrations(chapter: chapter)
}
private func validateFile(_ url: URL) throws {
guard FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
guard values.isRegularFile == true else { throw LocalBookError.missingFile }
guard Int64(values.fileSize ?? 0) <= maximumFileSize else { throw LocalBookError.oversized }
}
public func importTextFile(at sourceURL: URL, into directory: URL) throws -> LocalBookImportReport { public func importTextFile(at sourceURL: URL, into directory: URL) throws -> LocalBookImportReport {
guard sourceURL.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat } guard sourceURL.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
@@ -181,6 +224,10 @@ public struct LocalBookService: Sendable {
} }
public func loadChapters(book: Book) throws -> ChapterListReport { public func loadChapters(book: Book) throws -> ChapterListReport {
if let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" {
try validateFile(url)
return try .init(chapters: EPUBBook(url: url).chapters(book: book))
}
let decoded = try read(book) let decoded = try read(book)
let selectionVolumeState = TxtVolumeState() let selectionVolumeState = TxtVolumeState()
let probeText = textProbe(decoded.data, encoding: decoded.encoding) let probeText = textProbe(decoded.data, encoding: decoded.encoding)
@@ -201,6 +248,10 @@ public struct LocalBookService: Sendable {
} }
public func loadContent(book: Book, chapter: BookChapter) throws -> ChapterContentReport { public func loadContent(book: Book, chapter: BookChapter) throws -> ChapterContentReport {
if let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" {
try validateFile(url)
return try EPUBBook(url: url).content(chapter: chapter)
}
guard let url = URL(string: book.bookUrl), url.isFileURL, guard let url = URL(string: book.bookUrl), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile } FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
@@ -32,7 +32,7 @@ public struct StagedLocalFile: Sendable {
public actor LocalFileService { public actor LocalFileService {
public let directory: URL public let directory: URL
public init(directory: URL) { self.directory = directory.standardizedFileURL } public init(directory: URL) { self.directory = URL(fileURLWithPath: directory.path, isDirectory: true).standardizedFileURL }
public func list() throws -> [LocalFileEntry] { public func list() throws -> [LocalFileEntry] {
guard FileManager.default.fileExists(atPath: directory.path) else { return [] } guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
@@ -41,13 +41,13 @@ public actor LocalFileService {
options: [.skipsHiddenFiles] options: [.skipsHiddenFiles]
).compactMap { url in ).compactMap { url in
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey]) let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey])
guard values.isRegularFile == true, values.isSymbolicLink != true, url.pathExtension.lowercased() == "txt" else { return nil } guard values.isRegularFile == true, values.isSymbolicLink != true, LocalBookService.supports(url) else { return nil }
return .init(url: url, bytes: Int64(values.fileSize ?? 0), modified: values.contentModificationDate) return .init(url: url, bytes: Int64(values.fileSize ?? 0), modified: values.contentModificationDate)
}.sorted { $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending } }.sorted { $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending }
} }
public func importFile(from source: URL) throws -> URL { public func importFile(from source: URL) throws -> URL {
guard source.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat } guard LocalBookService.supports(source) else { throw LocalBookError.unsupportedFormat }
let values = try source.resourceValues(forKeys: [.isRegularFileKey]) let values = try source.resourceValues(forKeys: [.isRegularFileKey])
guard values.isRegularFile == true else { throw LocalFileError.missingFile } guard values.isRegularFile == true else { throw LocalFileError.missingFile }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
@@ -55,7 +55,7 @@ public actor LocalFileService {
var target = directory.appendingPathComponent(source.lastPathComponent) var target = directory.appendingPathComponent(source.lastPathComponent)
var suffix = 2 var suffix = 2
while FileManager.default.fileExists(atPath: target.path) { while FileManager.default.fileExists(atPath: target.path) {
target = directory.appendingPathComponent("\(stem) (\(suffix)).txt") target = directory.appendingPathComponent("\(stem) (\(suffix)).\(source.pathExtension)")
suffix += 1 suffix += 1
} }
try FileManager.default.copyItem(at: source, to: target) try FileManager.default.copyItem(at: source, to: target)
@@ -69,7 +69,8 @@ public actor LocalFileService {
!trimmed.contains(where: { $0 == "/" || $0 == "\\" || $0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }) else { !trimmed.contains(where: { $0 == "/" || $0 == "\\" || $0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }) else {
throw LocalFileError.invalidName throw LocalFileError.invalidName
} }
let filename = trimmed.lowercased().hasSuffix(".txt") ? trimmed : trimmed + ".txt" let suffix = "." + source.pathExtension
let filename = trimmed.lowercased().hasSuffix(suffix.lowercased()) ? trimmed : trimmed + suffix
guard filename.utf8.count <= 255 else { throw LocalFileError.invalidName } guard filename.utf8.count <= 255 else { throw LocalFileError.invalidName }
let target = directory.appendingPathComponent(filename) let target = directory.appendingPathComponent(filename)
if source.standardizedFileURL == target.standardizedFileURL { return source } if source.standardizedFileURL == target.standardizedFileURL { return source }
@@ -83,7 +84,7 @@ public actor LocalFileService {
guard FileManager.default.fileExists(atPath: source.path) else { return nil } guard FileManager.default.fileExists(atPath: source.path) else { return nil }
let trash = directory.appendingPathComponent(".Trash", isDirectory: true) let trash = directory.appendingPathComponent(".Trash", isDirectory: true)
try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true)
let staged = trash.appendingPathComponent(UUID().uuidString + ".txt") let staged = trash.appendingPathComponent(UUID().uuidString + "." + source.pathExtension)
try FileManager.default.moveItem(at: source, to: staged) try FileManager.default.moveItem(at: source, to: staged)
return .init(original: source, staged: staged) return .init(original: source, staged: staged)
} }
@@ -104,7 +105,7 @@ public actor LocalFileService {
} }
private func validate(_ url: URL, allowMissing: Bool = false) throws { private func validate(_ url: URL, allowMissing: Bool = false) throws {
guard url.isFileURL, url.pathExtension.lowercased() == "txt", guard url.isFileURL, LocalBookService.supports(url),
url.standardizedFileURL.deletingLastPathComponent() == directory, url.standardizedFileURL.deletingLastPathComponent() == directory,
url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else { url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else {
throw LocalFileError.outsideLibrary throw LocalFileError.outsideLibrary
@@ -131,7 +132,7 @@ public actor BookFileExporter {
} }
public func originalFile(_ source: URL, filename: String) throws -> BookFileExport { public func originalFile(_ source: URL, filename: String) throws -> BookFileExport {
let target = try destination(filename: filename) let target = try destination(filename: filename, extension: source.pathExtension.lowercased())
do { do {
try FileManager.default.copyItem(at: source, to: target) try FileManager.default.copyItem(at: source, to: target)
return .init(url: target, cachedChapters: 0, totalChapters: 0) return .init(url: target, cachedChapters: 0, totalChapters: 0)
@@ -178,11 +179,11 @@ public actor BookFileExporter {
try FileManager.default.removeItem(at: export.url.deletingLastPathComponent()) try FileManager.default.removeItem(at: export.url.deletingLastPathComponent())
} }
private func destination(filename: String) throws -> URL { private func destination(filename: String, extension fileExtension: String = "txt") throws -> URL {
let folder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true) let folder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let safe = filename.components(separatedBy: CharacterSet(charactersIn: "/\\:").union(.controlCharacters)).joined(separator: "_") let safe = filename.components(separatedBy: CharacterSet(charactersIn: "/\\:").union(.controlCharacters)).joined(separator: "_")
let stem = String(safe.replacingOccurrences(of: ".txt", with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70)) let stem = String(safe.replacingOccurrences(of: "." + fileExtension, with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70))
return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + ".txt") return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + "." + fileExtension)
} }
} }
@@ -548,17 +548,32 @@ public struct LegadoURLBuilder: Sendable {
case "{", ",": case "{", ",":
output.append(character) output.append(character)
expectingKey = true expectingKey = true
case ":": case ":", "=":
output.append(character) // Gson also accepts = and => between an object key and value.
// Quoted strings are consumed above, so Cookie/URL values stay intact.
output.append(":")
if character == "=", peek() == ">" { index += 1 }
expectingKey = false expectingKey = false
if quoteUnquotedValues { do {
var start = index var start = index
while start < characters.count, characters[start].isWhitespace { start += 1 } while start < characters.count, characters[start].isWhitespace { start += 1 }
if start < characters.count, !["\"", "'", "{", "[", "}", ","].contains(characters[start]) { if start < characters.count, !["\"", "'", "{", "[", "}", ","].contains(characters[start]) {
var end = start var end = start
while end < characters.count, characters[end] != ",", characters[end] != "}" { end += 1 } if quoteUnquotedValues {
output.append(escapedJSONString(String(characters[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines))) // @put values are rules, including unquoted JSONPath expressions.
index = end while end < characters.count, characters[end] != ",", characters[end] != "}" { end += 1 }
} else {
while end < characters.count, !characters[end].isWhitespace,
![",", "}", "]", "{", "[", ":", ";", "=", "/", "#", "\\"].contains(characters[end]) { end += 1 }
}
if end > start {
let token = String(characters[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
let literal = token.lowercased()
let isLiteral = ["true", "false", "null"].contains(literal)
|| token.range(of: #"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$"#, options: .regularExpression) != nil
output.append(!quoteUnquotedValues && isLiteral ? literal : escapedJSONString(token))
index = end
}
} }
} }
case "}", "]": case "}", "]":
@@ -580,7 +595,7 @@ public struct LegadoURLBuilder: Sendable {
while peekPosition < characters.count, characters[peekPosition].isWhitespace { while peekPosition < characters.count, characters[peekPosition].isWhitespace {
peekPosition += 1 peekPosition += 1
} }
if peekPosition < characters.count, characters[peekPosition] == ":" { if peekPosition < characters.count, [":", "="].contains(characters[peekPosition]) {
output.removeLast() output.removeLast()
output.append(escapedJSONString(name)) output.append(escapedJSONString(name))
index = position index = position
@@ -611,7 +626,7 @@ public struct LegadoURLBuilder: Sendable {
let parsed: JSONValue let parsed: JSONValue
if case .string(let source) = value { if case .string(let source) = value {
do { do {
parsed = try JSONValue.decode(data: Data(source.utf8)) parsed = try Self.decodeLenientJSON(source)
} catch { } catch {
throw RuleEngineError.malformedRule(rule: rule, detail: "headers JSON 无效") throw RuleEngineError.malformedRule(rule: rule, detail: "headers JSON 无效")
} }
@@ -84,6 +84,13 @@ public struct RSSRuleParser: Sendable {
private func flatten(_ values: [JSONValue]) -> [JSONValue] { private func flatten(_ values: [JSONValue]) -> [JSONValue] {
values.flatMap { value in values.flatMap { value in
if case .array(let items) = value { return items } if case .array(let items) = value { return items }
// Text rules serialize selected JSON arrays. Expand them before
// evaluating article fields, just as for an already parsed array.
if case .string(let text) = value,
text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("["),
let items = try? JSONDecoder().decode([JSONValue].self, from: Data(text.utf8)) {
return items
}
return [value] return [value]
} }
} }
@@ -17,6 +17,48 @@ public struct JSONPathEvaluator: Sendable {
public init() {} public init() {}
// Legado's JSON rules also allow literal templates such as /book/{$.id}.
// Balance braces and quoted strings instead of stopping at the first }.
func evaluateRule(_ rule: String, in root: JSONValue) throws -> [JSONValue] {
var cursor = rule.startIndex
var output = ""
var foundTemplate = false
while let opening = rule.range(of: "{$.", range: cursor..<rule.endIndex) {
foundTemplate = true
output += rule[cursor..<opening.lowerBound]
let start = rule.index(after: opening.lowerBound)
var end = start
var depth = 1
var quote: Character?
var escaped = false
while end < rule.endIndex {
let character = rule[end]
if escaped { escaped = false }
else if character == "\\" { escaped = true }
else if let activeQuote = quote {
if character == activeQuote { quote = nil }
} else if character == "\"" || character == "'" { quote = character }
else if character == "{" { depth += 1 }
else if character == "}" {
depth -= 1
if depth == 0 { break }
}
end = rule.index(after: end)
}
guard end < rule.endIndex else {
throw RuleEngineError.malformedRule(rule: rule, detail: "JSONPath 模板大括号未闭合")
}
let values = try evaluate(path: String(rule[start..<end]), in: root)
output += RuleEvaluation(values: values).strings.joined(separator: "\n")
cursor = rule.index(after: end)
}
if foundTemplate {
output += rule[cursor...]
return [.string(output)]
}
return try evaluate(path: rule.hasPrefix("$") ? rule : "$." + rule, in: root)
}
public func evaluate(path: String, in root: JSONValue) throws -> [JSONValue] { public func evaluate(path: String, in root: JSONValue) throws -> [JSONValue] {
let segments = try parse(path) let segments = try parse(path)
var current = [root] var current = [root]
@@ -59,7 +101,10 @@ public struct JSONPathEvaluator: Sendable {
guard next < rule.endIndex else { guard next < rule.endIndex else {
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 不能以点结尾") throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 不能以点结尾")
} }
if rule[next] == "." { if rule[next] == "[" {
// Legacy sources use $.[*] as well as $[*].
index = next
} else if rule[next] == "." {
index = rule.index(after: next) index = rule.index(after: next)
let parsed = try parseDotSelector(rule, from: index, original: path) let parsed = try parseDotSelector(rule, from: index, original: path)
segments.append(.recursive(parsed.selector)) segments.append(.recursive(parsed.selector))
@@ -84,7 +84,8 @@ public extension JSONValue {
if value is NSNull { return .null } if value is NSNull { return .null }
if let value = value as? JSONValue { return value } if let value = value as? JSONValue { return value }
if let value = value as? String { return .string(value) } if let value = value as? String { return .string(value) }
if let value = value as? Bool { return .bool(value) } // NSNumber(0/1) also casts to Bool. Inspect its CF type first so
// JavaScript page arithmetic and numeric IDs remain numbers.
if let value = value as? NSNumber { if let value = value as? NSNumber {
if CFGetTypeID(value) == CFBooleanGetTypeID() { return .bool(value.boolValue) } if CFGetTypeID(value) == CFBooleanGetTypeID() { return .bool(value.boolValue) }
return .number(value.doubleValue) return .number(value.doubleValue)
@@ -208,6 +208,33 @@ public struct JavaScriptRuleEvaluator: Sendable {
script: String, script: String,
result: JSONValue, result: JSONValue,
executionContext: RuleExecutionContext executionContext: RuleExecutionContext
) throws -> JavaScriptRuleResult {
try evaluate(script: script, result: result, executionContext: executionContext,
virtualMachine: JSVirtualMachine(), stringifyResult: false)
}
/// A rule shares java.get/put storage, but each match gets fresh JS globals.
/// Keep this closure on the synchronous replacement call's thread.
func replacementEvaluator(script: String, book: Book, chapter: BookChapter) -> (String) throws -> String {
var virtualMachine: JSVirtualMachine?
var executionContext = RuleExecutionContext(book: book, chapter: chapter)
return { match in
if virtualMachine == nil { virtualMachine = JSVirtualMachine() }
let result = try autoreleasepool {
try evaluate(script: script, result: .string(match), executionContext: executionContext,
virtualMachine: virtualMachine!, stringifyResult: true)
}
executionContext.variables = result.variables
return result.value.stringValue
}
}
private func evaluate(
script: String,
result: JSONValue,
executionContext: RuleExecutionContext,
virtualMachine: JSVirtualMachine,
stringifyResult: Bool
) throws -> JavaScriptRuleResult { ) throws -> JavaScriptRuleResult {
guard script.utf8.count <= maximumScriptLength else { guard script.utf8.count <= maximumScriptLength else {
throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 规则超过长度上限") throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 规则超过长度上限")
@@ -215,8 +242,7 @@ public struct JavaScriptRuleEvaluator: Sendable {
try rejectUnboundedExecution(script) try rejectUnboundedExecution(script)
let box = JSVariableBox(executionContext.variables) let box = JSVariableBox(executionContext.variables)
let vm = JSVirtualMachine() guard let context = JSContext(virtualMachine: virtualMachine) else {
guard let context = JSContext(virtualMachine: vm) else {
throw RuleEngineError.invalidInput(rule: script, expected: "无法创建 JavaScriptCore 上下文") throw RuleEngineError.invalidInput(rule: script, expected: "无法创建 JavaScriptCore 上下文")
} }
var exceptionMessage: String? var exceptionMessage: String?
@@ -271,7 +297,28 @@ public struct JavaScriptRuleEvaluator: Sendable {
let normalized = evaluated.flatMap { let normalized = evaluated.flatMap {
context.objectForKeyedSubscript("__legadoUnboxString")?.call(withArguments: [$0]) context.objectForKeyedSubscript("__legadoUnboxString")?.call(withArguments: [$0])
} }
let value = JSONValue.bridge(normalized?.toObject()) let value: JSONValue
if stringifyResult {
// Rhino unwraps undefined to null before RegexExtensions.kt calls
// toString(). Preserve strings directly, rather than JSON encoding.
if let evaluated, !evaluated.isUndefined, !evaluated.isNull {
let text = evaluated.toString() ?? "null"
if evaluated.isNumber {
// The reference runs Rhino in interpreted mode, yielding JVM Doubles.
let number = evaluated.toDouble()
value = .string(number.isFinite ? String(number) : text)
} else {
value = .string(text)
}
} else {
value = .string("null")
}
} else {
value = JSONValue.bridge(normalized?.toObject())
}
if let exceptionMessage {
throw RuleEngineError.malformedRule(rule: script, detail: "JavaScript 执行失败:\(exceptionMessage)")
}
guard value.stringValue.utf8.count <= maximumResultLength else { guard value.stringValue.utf8.count <= maximumResultLength else {
throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 结果超过长度上限") throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 结果超过长度上限")
} }
@@ -974,53 +1021,48 @@ public struct JavaScriptRuleEvaluator: Sendable {
return resolveJavaScriptRuleURL(direct.stringValue, enabled: isURL, context: executionContext) return resolveJavaScriptRuleURL(direct.stringValue, enabled: isURL, context: executionContext)
} }
let plan = try RuleExpressionParser().parse(trimmedRule) let jsonContent: JSONValue?
if case .string(let text) = content { jsonContent = try? JSONValue.decode(data: Data(text.utf8)) }
else { jsonContent = content }
let plan = try RuleExpressionParser().parse(trimmedRule, inputLooksLikeJSON: jsonContent != nil)
var branchResults: [[String]] = [] var branchResults: [[String]] = []
for branch in plan.branches { for branch in plan.branches {
var values: [String] var values: [String]
switch branch.mode { if branch.expression.isEmpty {
case .css: values = [content.stringValue]
values = try HTMLRuleEvaluator().extractCSS( } else {
branch.expression, switch branch.mode {
html: content.stringValue, case .css:
baseURL: executionContext.baseURL, values = try HTMLRuleEvaluator().extractCSS(
isFragment: executionContext.isHTMLFragment branch.expression,
) html: content.stringValue,
case .xpath: baseURL: executionContext.baseURL,
values = try HTMLRuleEvaluator().extractXPath(branch.expression, html: content.stringValue, isFragment: executionContext.isHTMLFragment) isFragment: executionContext.isHTMLFragment
case .jsonPath:
let json: JSONValue
if case .string(let text) = content {
json = try JSONValue.decode(data: Data(text.utf8))
} else {
json = content
}
let path = branch.expression.hasPrefix("$") ? branch.expression : "." + branch.expression
let normalizedPath = path.hasPrefix("$") ? path : "$" + path
values = try JSONPathEvaluator().evaluate(path: normalizedPath, in: json)
.map(\.stringValue)
case .regex:
values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue)
.map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" }
case .javaScript:
values = [try evaluate(
script: branch.expression,
result: content,
executionContext: executionContext
).value.stringValue]
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript")
}
if let pattern = branch.replacePattern, !pattern.isEmpty {
values = try values.map {
try RegexEvaluator().replacing(
pattern: pattern,
in: $0,
with: branch.replacement,
firstOnly: branch.replaceFirst
) )
case .xpath:
values = try HTMLRuleEvaluator().extractXPath(branch.expression, html: content.stringValue, isFragment: executionContext.isHTMLFragment)
case .jsonPath:
let json: JSONValue
if case .string(let text) = content {
json = try JSONValue.decode(data: Data(text.utf8))
} else {
json = content
}
values = RuleEvaluation(values: try JSONPathEvaluator().evaluateRule(branch.expression, in: json)).strings
case .regex:
values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue)
.map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" }
case .javaScript:
values = [try evaluate(
script: branch.expression,
result: content,
executionContext: executionContext
).value.stringValue]
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript")
} }
} }
if plan.combination == .fallback, values.joined(separator: "\n").isEmpty { values = [] }
branchResults.append(values) branchResults.append(values)
if plan.combination == .fallback, !values.isEmpty { break } if plan.combination == .fallback, !values.isEmpty { break }
} }
@@ -1037,7 +1079,11 @@ public struct JavaScriptRuleEvaluator: Sendable {
branchResults.compactMap { $0.indices.contains(index) ? $0[index] : nil } branchResults.compactMap { $0.indices.contains(index) ? $0[index] : nil }
} }
} }
return resolveJavaScriptRuleURL(combined.joined(separator: "\n"), enabled: isURL, context: executionContext) var result = combined.joined(separator: "\n")
if let branch = plan.branches.first, let pattern = branch.replacePattern, !pattern.isEmpty {
result = try RegexEvaluator().replacing(pattern: pattern, in: result, with: branch.replacement, firstOnly: branch.replaceFirst)
}
return resolveJavaScriptRuleURL(result, enabled: isURL, context: executionContext)
} }
private func resolveJavaScriptRuleURL( private func resolveJavaScriptRuleURL(
@@ -112,6 +112,25 @@ public struct RegexEvaluator: Sendable {
public func replacingStrict(pattern: String, in input: String, with replacement: String, public func replacingStrict(pattern: String, in input: String, with replacement: String,
timeoutMilliseconds: Int64 = 3_000) throws -> String { timeoutMilliseconds: Int64 = 3_000) throws -> String {
try replacingStrict(pattern: pattern, in: input, timeoutMilliseconds: timeoutMilliseconds) { expression, match in
expression.replacementString(for: match, in: input, offset: 0, template: replacement)
}
}
/// Legado's JS replacement still expands capture references, while quoting
/// backslashes returned by the script (quoteReplacementJs in the reference).
func replacingStrict(pattern: String, in input: String, timeoutMilliseconds: Int64 = 3_000,
replacement: (String) throws -> String) throws -> String {
let original = input as NSString
return try replacingStrict(pattern: pattern, in: input, timeoutMilliseconds: timeoutMilliseconds) { expression, match in
let template = try replacement(original.substring(with: match.range))
.replacingOccurrences(of: "\\", with: "\\\\")
return expression.replacementString(for: match, in: input, offset: 0, template: template)
}
}
private func replacingStrict(pattern: String, in input: String, timeoutMilliseconds: Int64,
replacement: (NSRegularExpression, NSTextCheckingResult) throws -> String) throws -> String {
guard pattern.count <= limits.maximumPatternLength, input.utf16.count <= limits.maximumInputLength, guard pattern.count <= limits.maximumPatternLength, input.utf16.count <= limits.maximumInputLength,
!hasObviousNestedQuantifier(pattern) else { !hasObviousNestedQuantifier(pattern) else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则超过资源限制或包含高风险嵌套量词") throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则超过资源限制或包含高风险嵌套量词")
@@ -145,30 +164,51 @@ public struct RegexEvaluator: Sendable {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)") throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)")
} }
let untouched = original.substring(with: NSRange(location: offset, length: match.range.location - offset)) let untouched = original.substring(with: NSRange(location: offset, length: match.range.location - offset))
let replaced = expression.replacementString(for: match, in: input, offset: 0, template: replacement) let replaced = try replacement(expression, match)
output += untouched + replaced guard DispatchTime.now().uptimeNanoseconds < deadline else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)")
}
outputLength += untouched.utf16.count + replaced.utf16.count outputLength += untouched.utf16.count + replaced.utf16.count
offset = NSMaxRange(match.range) offset = NSMaxRange(match.range)
guard outputLength <= limits.maximumInputLength * 2 else { guard outputLength <= limits.maximumInputLength * 2 else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制") throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制")
} }
output += untouched + replaced
} }
output += original.substring(from: offset) let tail = original.substring(from: offset)
guard outputLength + tail.utf16.count <= limits.maximumInputLength * 2 else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制")
}
output += tail
return output return output
} }
private func hasObviousNestedQuantifier(_ pattern: String) -> Bool { private func hasObviousNestedQuantifier(_ pattern: String) -> Bool {
let risky = #"\(((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*)\)([+*]|\{[^}]+\})"# // Reject clear cases such as (a+)+ and (\w*)*. A '+' inside a character
guard let expression = try? NSRegularExpression(pattern: risky) else { return false } // class/escape is not a quantifier, and bounded or delimited repeated
return expression.matches(in: pattern, range: NSRange(pattern.startIndex..., in: pattern)).contains { match in // groups are not proof of catastrophic backtracking. Other expressions
guard let innerRange = Range(match.range(at: 1), in: pattern), // remain protected by enumerateMatches' progress/deadline checks.
let outerRange = Range(match.range(at: 2), in: pattern) else { return true } let atom = #"^(?:\?:)?(?:\\[dDsSwW]|\\[^pPkKgG0-9]|\[(?:\\.|[^\]\\])*\]|[^\[\]()\\?*+{}|^$])(?:[+*]|\{[0-9]+,\})\??$"#
let inner = String(pattern[innerRange]) guard let repeatedAtom = try? NSRegularExpression(pattern: atom),
let outer = String(pattern[outerRange]) let unbounded = try? NSRegularExpression(pattern: #"^(?:[+*]|\{[0-9]+,\})(?!\+)"#) else { return false }
if outer.range(of: #"^\{[1-9]\}$"#, options: .regularExpression) != nil { return false } var groups: [String.Index] = []
if !pattern.contains("(?s"), var classDepth = 0
inner.range(of: #"^(?:\\n[^\[\]()*+?\\]*\.\*|\.\+\\n)$"#, options: .regularExpression) != nil { return false } var escaped = false
return true for index in pattern.indices {
let character = pattern[index]
if escaped { escaped = false; continue }
if character == "\\" { escaped = true; continue }
if character == "[" { classDepth += 1; continue }
if character == "]", classDepth > 0 { classDepth -= 1; continue }
guard classDepth == 0 else { continue }
if character == "(" { groups.append(pattern.index(after: index)) }
if character == ")", let start = groups.popLast() {
let inner = String(pattern[start..<index])
let suffix = String(pattern[pattern.index(after: index)...])
if repeatedAtom.firstMatch(in: inner, range: NSRange(inner.startIndex..., in: inner)) != nil,
unbounded.firstMatch(in: suffix, range: NSRange(suffix.startIndex..., in: suffix)) != nil { return true }
}
} }
return false
} }
} }
+33 -34
View File
@@ -57,11 +57,11 @@ public struct RuleEngine: Sendable {
return literalEvaluation(rendered.rule, context: rendered.context) return literalEvaluation(rendered.rule, context: rendered.context)
} }
if rendered.context.variables != context.variables { if rendered.context.variables != context.variables {
var result = await evaluatePrepared(rendered.rule, in: input, context: rendered.context) var result = await evaluatePrepared(rendered.rule, in: input, listMode: listMode, context: rendered.context)
result.variables = rendered.context.variables.merging(result.variables) { _, new in new } result.variables = rendered.context.variables.merging(result.variables) { _, new in new }
return result return result
} }
return await evaluatePrepared(rendered.rule, in: input, context: rendered.context) return await evaluatePrepared(rendered.rule, in: input, listMode: listMode, context: rendered.context)
} catch let error as RuleEngineError { } catch let error as RuleEngineError {
return .init(diagnostics: [error.diagnostic], variables: context.variables) return .init(diagnostics: [error.diagnostic], variables: context.variables)
} catch { } catch {
@@ -72,6 +72,7 @@ public struct RuleEngine: Sendable {
private func evaluatePrepared( private func evaluatePrepared(
_ rule: String, _ rule: String,
in input: JSONValue, in input: JSONValue,
listMode: Bool,
context: RuleExecutionContext context: RuleExecutionContext
) async -> RuleEvaluation { ) async -> RuleEvaluation {
do { do {
@@ -80,7 +81,8 @@ public struct RuleEngine: Sendable {
var diagnostics: [RuleDiagnostic] = [] var diagnostics: [RuleDiagnostic] = []
for branch in plan.branches { for branch in plan.branches {
do { do {
let values = try await evaluate(branch, in: input, context: context) var values = try await evaluate(branch, in: input, context: context)
if plan.combination == .fallback, isEmpty(values, listMode: listMode) { values = [] }
results.append(values) results.append(values)
if plan.combination == .fallback, !values.isEmpty { break } if plan.combination == .fallback, !values.isEmpty { break }
} catch let error as RuleEngineError { } catch let error as RuleEngineError {
@@ -97,7 +99,7 @@ public struct RuleEngine: Sendable {
} }
} }
return .init( return .init(
values: combine(results, with: plan.combination), values: try applyingReplacement(to: combine(results, with: plan.combination), plan: plan, listMode: listMode),
diagnostics: diagnostics, diagnostics: diagnostics,
variables: context.variables variables: context.variables
) )
@@ -174,6 +176,11 @@ public struct RuleEngine: Sendable {
listMode: Bool, listMode: Bool,
context: RuleExecutionContext context: RuleExecutionContext
) async -> RuleEvaluation { ) async -> RuleEvaluation {
if let root = try? JSONValue.decode(data: Data(input.utf8)),
let plan = try? parser.parse(rule, inputLooksLikeJSON: true),
plan.branches.allSatisfy({ $0.mode == .jsonPath }) {
return await evaluatePrepared(rule, in: root, listMode: listMode, context: context)
}
if rule.hasPrefix(":"), rule.count > 1 { if rule.hasPrefix(":"), rule.count > 1 {
do { do {
let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty } let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty }
@@ -217,7 +224,7 @@ public struct RuleEngine: Sendable {
} }
} }
return .init( return .init(
values: combine(results, with: plan.combination), values: try applyingReplacement(to: combine(results, with: plan.combination), plan: plan, listMode: listMode),
diagnostics: diagnostics, diagnostics: diagnostics,
variables: context.variables variables: context.variables
) )
@@ -241,15 +248,11 @@ public struct RuleEngine: Sendable {
try Task.checkCancellation() try Task.checkCancellation()
let rawValues: [JSONValue] let rawValues: [JSONValue]
if branch.expression.isEmpty { if branch.expression.isEmpty {
return try [JSONValue.string(regex.replacing( return [input]
pattern: branch.replacePattern ?? "", in: input.stringValue,
with: branch.replacement, firstOnly: branch.replaceFirst
))]
} }
switch branch.mode { switch branch.mode {
case .jsonPath: case .jsonPath:
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)" rawValues = try jsonPath.evaluateRule(branch.expression, in: input)
rawValues = try jsonPath.evaluate(path: path, in: input)
case .regex: case .regex:
let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue) let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue)
rawValues = matches.map { .array($0.map(JSONValue.string)) } rawValues = matches.map { .array($0.map(JSONValue.string)) }
@@ -266,15 +269,7 @@ public struct RuleEngine: Sendable {
case .webJavaScript: case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript") throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
} }
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return rawValues } return rawValues
return try rawValues.map {
.string(try regex.replacing(
pattern: pattern,
in: $0.stringValue,
with: branch.replacement,
firstOnly: branch.replaceFirst
))
}
} }
private func evaluateTextBranch( private func evaluateTextBranch(
@@ -287,10 +282,7 @@ public struct RuleEngine: Sendable {
try Task.checkCancellation() try Task.checkCancellation()
let raw: [String] let raw: [String]
if branch.expression.isEmpty { if branch.expression.isEmpty {
return try [regex.replacing( return [input]
pattern: branch.replacePattern ?? "", in: input,
with: branch.replacement, firstOnly: branch.replaceFirst
)]
} }
switch branch.mode { switch branch.mode {
case .css: case .css:
@@ -308,8 +300,7 @@ public struct RuleEngine: Sendable {
} catch { } catch {
throw RuleEngineError.invalidInput(rule: branch.expression, expected: "JSON") throw RuleEngineError.invalidInput(rule: branch.expression, expected: "JSON")
} }
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)" raw = try jsonPath.evaluateRule(branch.expression, in: root).map(\.stringValue)
raw = try jsonPath.evaluate(path: path, in: root).map(\.stringValue)
case .regex: case .regex:
raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in
captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? "" captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? ""
@@ -323,14 +314,22 @@ public struct RuleEngine: Sendable {
case .webJavaScript: case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript") throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
} }
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return raw } return raw
return try raw.map { }
try regex.replacing(
pattern: pattern, private func isEmpty(_ values: [JSONValue], listMode: Bool) -> Bool {
in: $0, let result = RuleEvaluation(values: values)
with: branch.replacement, return listMode
firstOnly: branch.replaceFirst ? result.listItems(rule: "").filter { $0 != .null }.isEmpty
) : result.strings.joined(separator: "\n").isEmpty
}
private func applyingReplacement(to values: [JSONValue], plan: RulePlan, listMode: Bool) throws -> [JSONValue] {
guard let branch = plan.branches.first, let pattern = branch.replacePattern, !pattern.isEmpty else { return values }
let strings = RuleEvaluation(values: values).strings
let inputs = listMode ? strings : [strings.joined(separator: "\n")]
return try inputs.map {
.string(try regex.replacing(pattern: pattern, in: $0, with: branch.replacement, firstOnly: branch.replaceFirst))
} }
} }
@@ -52,8 +52,12 @@ public struct RuleExpressionParser: Sendable {
guard !normalized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { guard !normalized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw RuleEngineError.malformedRule(rule: source, detail: "规则不能为空") throw RuleEngineError.malformedRule(rule: source, detail: "规则不能为空")
} }
let split = try splitAtTopLevel(normalized) // ## belongs to the whole selector stage. Regex/replacement text may
let branches = try split.parts.map { try parseBranch($0, inputLooksLikeJSON: inputLooksLikeJSON) } // contain quotes, brackets and && that are not selector operators.
let replacementStart = normalized.range(of: "##")?.lowerBound ?? normalized.endIndex
let split = try splitAtTopLevel(String(normalized[..<replacementStart]))
let suffix = String(normalized[replacementStart...])
let branches = try split.parts.map { try parseBranch($0 + suffix, inputLooksLikeJSON: inputLooksLikeJSON) }
return .init(source: source, combination: split.combination, branches: branches) return .init(source: source, combination: split.combination, branches: branches)
} }
+6
View File
@@ -21,6 +21,12 @@
- 用途:XPath 解析。 - 用途:XPath 解析。
- 许可:MIT,完整版权及许可见 [Kanna-LICENSE.txt](App/Resources/Kanna-LICENSE.txt)。 - 许可:MIT,完整版权及许可见 [Kanna-LICENSE.txt](App/Resources/Kanna-LICENSE.txt)。
## ZIPFoundation
- 项目:<https://github.com/weichsel/ZIPFoundation>,固定版本 `0.9.20`。
- 用途:按需读取 EPUB ZIP 容器与校验资源 CRC。
- 许可:MIT,完整版权及许可见 [ZIPFoundation-LICENSE.txt](App/Resources/ZIPFoundation-LICENSE.txt)。
## 测试数据与演示内容 ## 测试数据与演示内容
测试中的规则样本仅用于兼容验证,不打包为 App 默认书源。具体说明见 [测试样本说明](Tests/LegadoCoreTests/Fixtures/README.md)。演示模式使用虚构书籍元数据和项目内演示文本;发布截图不使用个人书架、登录信息或付费章节。 测试中的规则样本仅用于兼容验证,不打包为 App 默认书源。具体说明见 [测试样本说明](Tests/LegadoCoreTests/Fixtures/README.md)。演示模式使用虚构书籍元数据和项目内演示文本;发布截图不使用个人书架、登录信息或付费章节。
@@ -0,0 +1,81 @@
import Foundation
import Testing
import ZIPFoundation
import LegadoCore
@testable import LegadoAppModels
@MainActor
struct EPUBReadingRegressionTests {
private func fixture(at root: URL) throws -> URL {
let url = root.appendingPathComponent("示例.epub")
let archive = try Archive(url: url, accessMode: .create)
let files = [
("mimetype", "application/epub+zip"),
("META-INF/container.xml", #"<container><rootfiles><rootfile full-path="book.opf"/></rootfiles></container>"#),
("book.opf", #"<package><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>回归书籍</dc:title><dc:creator>测试作者</dc:creator></metadata><manifest><item id="first" href="first.xhtml" media-type="application/xhtml+xml"/><item id="last" href="last.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="first"/><itemref idref="last"/></spine></package>"#),
("first.xhtml", "<html><head><title>第一章</title></head><body><h1>第一章</h1><p>第一章测试正文</p></body></html>"),
("last.xhtml", "<html><head><title>第二章</title></head><body><h1>第二章</h1><p>第二章测试正文</p></body></html>")
]
for (path, value) in files {
let data = Data(value.utf8)
try archive.addEntry(with: path, type: .file, uncompressedSize: Int64(data.count), compressionMethod: .deflate) { position, size in
data.subdata(in: Int(position)..<Int(position) + size)
}
}
return url
}
@Test func importedEPUBReadsNavigatesAndResumesAfterRenamingAndReloadingShelf() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let url = try fixture(at: root)
let repository = InMemoryBookRepository()
let managed = root.appendingPathComponent("managed", isDirectory: true)
let library = BookLibraryModel(repository: repository, localDirectory: managed, chapterRepository: InMemoryBookChapterRepository())
await library.load()
await library.importLocalFiles([url])
#expect(library.errorMessage == nil)
let book = try #require(library.books.first)
let shelfReader = BookshelfReadViewModel()
await shelfReader.load(book: book, source: nil)
#expect(shelfReader.errorMessage == nil && shelfReader.chapters.count == 2)
let reader = ReaderViewModel(book: book, chapters: shelfReader.chapters, initialIndex: 0, cache: .init(directory: root.appendingPathComponent("cache")))
#expect(!reader.canEditContent && !reader.usesReplaceRules)
await reader.load(rules: [])
#expect(reader.content == "第一章测试正文")
await reader.prepareContinuousReading(rules: [])
#expect(reader.scrollChapters.map(\.content) == ["第一章测试正文", "第二章测试正文"])
reader.goNext()
await reader.load(rules: [])
#expect(reader.content == "第二章测试正文")
await library.updateProgress(for: book, chapter: try #require(reader.currentChapter), position: 4)
try await library.renameLocalFile(at: URL(string: book.bookUrl)!, to: "改名")
let reloaded = BookLibraryModel(repository: repository, localDirectory: managed, chapterRepository: InMemoryBookChapterRepository())
await reloaded.load()
let saved = try #require(reloaded.books.first)
#expect(saved.name == "回归书籍" && saved.originName == "改名.epub")
#expect(saved.durChapterIndex == 1 && saved.durChapterPos == 4)
let chapters = try LocalBookService().loadChapters(book: saved).chapters
let resumed = ReaderViewModel(book: saved, chapters: chapters, initialIndex: saved.durChapterIndex)
await resumed.load(rules: [])
#expect(resumed.currentIndex == 1 && resumed.requestedPosition == 4)
#expect(resumed.content == "第二章测试正文")
#expect(try await reloaded.localFiles.list().count == 1)
}
@Test func corruptEPUBImportRollsBackFileAndContinuesOtherImports() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let good = try fixture(at: root)
let bad = root.appendingPathComponent("损坏.epub")
try Data("bad epub".utf8).write(to: bad)
let library = BookLibraryModel(repository: InMemoryBookRepository(), localDirectory: root.appendingPathComponent("managed", isDirectory: true), chapterRepository: InMemoryBookChapterRepository())
await library.load()
await library.importLocalFiles([bad, good])
#expect(library.books.count == 1)
#expect(library.errorMessage?.contains("损坏.epub") == true)
#expect(try await library.localFiles.list().map { $0.url.lastPathComponent } == ["示例.epub"])
}
}
@@ -5,6 +5,26 @@ import LegadoCore
@MainActor @MainActor
struct SearchRegressionTests { struct SearchRegressionTests {
@Test func arithmeticPageExpressionCanLoadTheSecondPage() async throws {
let transport = GatedModelSearchTransport()
await transport.release()
let model = model(transport: transport, repository: InMemorySearchBookRepository())
defer { model.cancel(reset: true) }
var source = source("formula")
source.searchUrl = #"/search,{"method":"POST","body":"keyword={{key}}&page={{page-1}}"}"#
model.search(keyword: "书", sources: [source])
try await waitUntil { model.state == .finished }
#expect(model.hasMore)
model.loadMore()
try await waitUntil { !model.isLoadingMore }
let requests = await transport.requests()
#expect(requests.count == 2)
#expect(requests.compactMap(\.httpBody).map { String(decoding: $0, as: UTF8.self) } == [
"keyword=%E4%B9%A6&page=0", "keyword=%E4%B9%A6&page=1"
])
#expect(!model.hasMore)
}
@Test func cachedResultsAppearBeforeNetworkAndFreshResultsReplaceTheirMetadata() async throws { @Test func cachedResultsAppearBeforeNetworkAndFreshResultsReplaceTheirMetadata() async throws {
let source = source("slow") let source = source("slow")
let cached = SearchBook(bookURL: source.id + "/cached", origin: source.id, originName: "源", type: 0, name: "书", author: "作者") let cached = SearchBook(bookURL: source.id + "/cached", origin: source.id, originName: "源", type: 0, name: "书", author: "作者")
@@ -0,0 +1,89 @@
import Foundation
import Testing
@testable import LegadoCore
// Opt-in only: point LEGADO_SOURCE_PROBE_FILE at a downloaded JSON source pack.
// No remote source or response is included in the application or normal test run.
struct BookSourceRemoteProbe {
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SOURCE_CHAIN_FILE"] != nil))
func sampleReadingPipelines() async throws {
let path = try #require(ProcessInfo.processInfo.environment["LEGADO_SOURCE_CHAIN_FILE"])
let sources = try BookSourceImporter().decode(Data(contentsOf: URL(fileURLWithPath: path)))
var reports: [BookSourceDebugReport] = []
let transport = ProbeTransport()
let keyword = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_KEYWORD"] ?? "斗罗大陆"
for source in sources {
var report = await BookSourceDebugger(transport: transport).run(source: source, keyword: keyword)
report.contentPreview = nil
reports.append(report)
print("SOURCE_CHAIN \(source.bookSourceName) " + String(decoding: try JSONEncoder().encode(report), as: UTF8.self))
}
if let output = ProcessInfo.processInfo.environment["LEGADO_SOURCE_CHAIN_OUTPUT"] {
try JSONEncoder().encode(reports).write(to: URL(fileURLWithPath: output))
try JSONEncoder().encode(await transport.records()).write(to: URL(fileURLWithPath: output + ".requests.json"))
}
#expect(reports.count == sources.count)
}
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_FILE"] != nil))
func sampleSearches() async throws {
guard let path = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_FILE"] else { return }
let sources = try BookSourceImporter().decode(Data(contentsOf: URL(fileURLWithPath: path)))
let transport = ProbeTransport()
let service = BookSearchService(transport: transport, networkPolicy: .init(), history: .init())
let keyword = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_KEYWORD"] ?? "斗罗大陆"
let page = Int(ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_PAGE"] ?? "1") ?? 1
var rows: [[String: JSONValue]] = []
for await outcome in service.searchOutcomes(keyword: keyword, page: page, sources: sources, maximumConcurrentSources: 3, includeDisabled: true) {
let source = try #require(sources.first { $0.id == outcome.sourceID })
let row: [String: JSONValue] = [
"name": .string(source.bookSourceName), "source": .string(source.id),
"count": .number(Double(outcome.books.count)),
"books": .array(outcome.books.prefix(3).map { .object([
"name": .string($0.name), "url": .string($0.bookURL), "author": .string($0.author),
"cover": .string($0.coverURL ?? ""), "wordCount": .string($0.wordCount ?? "")
]) }),
"diagnostics": .array(outcome.diagnostics.map { .object(["code": .string($0.code), "message": .string($0.message), "rule": .string($0.rule)]) })
]
rows.append(row)
print("SOURCE_PROBE " + JSONValue.object(row).stringValue)
}
let report = JSONValue.object(["keyword": .string(keyword), "page": .number(Double(page)), "outcomes": .array(rows.map(JSONValue.object)), "requests": .array(await transport.records())])
if let output = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_OUTPUT"] {
try JSONEncoder().encode(report).write(to: URL(fileURLWithPath: output))
}
#expect(rows.count == Set(sources.map(\.id)).count)
}
}
private actor ProbeTransport: HTTPTransport {
private var values: [JSONValue] = []
private let transport = URLSessionHTTPTransport()
func data(for request: URLRequest) async throws -> HTTPResponse {
do {
let response = try await transport.data(for: request)
var record: [String: JSONValue] = [
"url": .string(request.url?.absoluteString ?? ""),
"finalURL": .string(response.finalURL.absoluteString),
"status": .number(Double(response.statusCode)),
"bytes": .number(Double(response.data.count)),
"preview": .string(String(response.decodedText.prefix(240)))
]
if let directory = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_SNAPSHOT_DIR"] {
let folder = URL(fileURLWithPath: directory, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let file = folder.appendingPathComponent(UUID().uuidString + ".response")
try response.data.write(to: file)
record["snapshot"] = .string(file.path)
}
values.append(.object(record))
return response
} catch {
values.append(.object(["url": .string(request.url?.absoluteString ?? ""), "error": .string(error.localizedDescription)]))
throw error
}
}
func records() -> [JSONValue] { values }
}
+193
View File
@@ -0,0 +1,193 @@
import Foundation
import Testing
import ZIPFoundation
@testable import LegadoCore
struct EPUBBookTests {
private let service = LocalBookService()
private let png = Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABgCAIAAAAip+O/AAABxklEQVR4nNXCA3YjAABF0becsW12bNdIGptt7Nq2bXeMjc06/j2XI8UG6RwtMUrnWGmtdI6XmaRzotwsnZMVFumcqrRK53SVTTpnqu3SOVvjkM45g1M6540u6VyodUvnoskjnUtmr3QuW3zSuWL1S+eqLSCda/agdK47QtK54QxL56arTjq33PXSue2JSOeONyqdu76YdO7549K5H0hI50EwKZ2HoZR0isJp6Tyqy0jncX1WOk8iOek8jeal8yxWkM7zeIN0XiQapfMy2SSdV6lm6bxOt0jnTaZVOm+zbdJ5l2uXzvt8h3Q+FDql87GhSzqfGrul87mpRzpfmnulU9zSJ52S1n7plLYNSKesfVA65R1D0qnoHJZOZdeIdKq6R6VT3TMmnZrecekY+iakY+yflE7twJR0TIPT0jEPzUjHMjwrHevInHRso/PSsY8tSMcxvigd58SSdFyTy9JxT61IxzO9Kh3vzJp0fLPr0vHPbUgnML8pneDClnRCi9vSCS/tSKdueVc69St70oms7ksnunYgndj6oXTiG1+lk9j8Jp3k1nfppLZ/SCe981M6md1f0snu/ZZObv+PdPIHf6VTOPwn/T/zbe3/96qaXAAAAABJRU5ErkJggg==")!
private func archive(_ files: [(String, Data)]) throws -> URL {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("示例.EPUB")
let archive = try Archive(url: url, accessMode: .create)
for (path, data) in files {
try archive.addEntry(with: path, type: .file, uncompressedSize: Int64(data.count), compressionMethod: path == "mimetype" ? .none : .deflate) { position, size in
data.subdata(in: Int(position)..<Int(position) + size)
}
}
return url
}
private func fixture(epub3: Bool = true, navigation: Bool = true, extras: [(String, Data)] = [], chapter: String? = nil) throws -> URL {
let navItem = epub3
? #"<item id="nav" href="目录/nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>"#
: #"<item id="nav" href="目录/toc.ncx" media-type="application/x-dtbncx+xml"/>"#
let package = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="\(epub3 ? "3.0" : "2.0")">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>测试书籍</dc:title><dc:creator>作者甲</dc:creator><dc:creator>作者乙</dc:creator>
<dc:description>&lt;p&gt;测试简介 &amp;amp; 内容&lt;/p&gt;</dc:description>
<meta name="cover" content="cover"/>
</metadata>
<manifest>
<item id="last" href="Text/end.xhtml" media-type="application/xhtml+xml"/>
\(navigation ? navItem : "")
<item id="main" href="Text/中文%20章节.xhtml" media-type="application/xhtml+xml"/>
<item id="intro" href="Text/intro.xhtml" media-type="application/xhtml+xml"/>
<item id="cover" href="Images/封面.png" media-type="image/png" properties="cover-image"/>
<item id="extra" href="Text/extra.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine toc="nav"><itemref idref="intro"/><itemref idref="main"/><itemref idref="extra"/><itemref idref="last"/></spine>
</package>
"""
let body = chapter ?? """
<html><head><title>正文</title><style>不要显示样式</style></head><body>
<p>锚点前的引言</p><section><h1 id="one">第一章</h1><p>甲 &amp; 乙<em>相遇</em>。</p>
<script>不要显示脚本</script><p style="display: none">隐藏内容</p>
<p><ruby>字<rt>zi</rt><rp>(zi)</rp></ruby>形</p><img src="../Images/封面.png" alt="风景"/></section>
<section><div><h1 id="two">第二章</h1></div><p>第二章正文</p></section>
</body></html>
"""
let nav = """
<html xmlns:epub="http://www.idpf.org/2007/ops"><body>
<nav epub:type="landmarks"><a href="../Text/end.xhtml">错误地标</a></nav>
<nav epub:type="toc"><ol><li><span>第一卷</span><ol>
<li><a href="../Text/中文%20章节.xhtml#one">第一章</a></li>
<li><a href="../Text/中文%20章节.xhtml#two">第二章</a></li></ol></li>
<li><a href="../Text/end.xhtml">尾声</a></li></ol></nav></body></html>
"""
let ncx = """
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/"><navMap>
<navPoint><navLabel><text>第一卷</text></navLabel>
<navPoint><navLabel><text>第一章</text></navLabel><content src="../Text/中文%20章节.xhtml#one"/></navPoint>
<navPoint><navLabel><text>第二章</text></navLabel><content src="../Text/中文%20章节.xhtml#two"/></navPoint>
</navPoint><navPoint><navLabel><text>尾声</text></navLabel><content src="../Text/end.xhtml"/></navPoint>
</navMap></ncx>
"""
var files: [(String, Data)] = [
("mimetype", Data("application/epub+zip".utf8)),
("META-INF/container.xml", Data(#"<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OPS/book.opf" media-type="application/oebps-package+xml"/></rootfiles></container>"#.utf8)),
("OPS/book.opf", Data(package.utf8)),
("OPS/Text/intro.xhtml", Data("<html><head><title>序言</title></head><body><p>开篇内容</p></body></html>".utf8)),
("OPS/Text/中文 章节.xhtml", Data(body.utf8)),
("OPS/Text/extra.xhtml", Data("<html><body><h1>未列入目录</h1><p>不应遗漏正文</p></body></html>".utf8)),
("OPS/Text/end.xhtml", Data("<html><body><p>完结正文</p></body></html>".utf8)),
("OPS/Images/封面.png", png)
]
if navigation { files.append((epub3 ? "OPS/目录/nav.xhtml" : "OPS/目录/toc.ncx", Data((epub3 ? nav : ncx).utf8))) }
return try archive(files + extras)
}
@Test(arguments: [true, false]) func readsEPUB2And3MetadataSpineAnchorsAndIllustrations(epub3: Bool) throws {
let url = try fixture(epub3: epub3)
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.openFile(at: url)
#expect(report.book.name == "测试书籍")
#expect(report.book.author == "作者甲、作者乙")
#expect(report.book.intro == "测试简介 & 内容")
#expect(report.book.coverUrl?.hasPrefix("data:image/png;base64,") == true)
#expect(report.book.readConfig?.useReplaceRule == false)
#expect(!report.book.canUpdate)
#expect(report.chapters.map(\.title) == ["序言", "正文", "第一章", "第二章", "未列入目录", "尾声"])
#expect(report.chapters.map(\.index) == Array(0..<6))
#expect(Set(report.chapters.map(\.id)).count == 6)
#expect(try service.loadChapters(book: report.book).chapters == report.chapters)
let contents = try report.chapters.map { try service.loadContent(book: report.book, chapter: $0).content }
#expect(contents[0] == "开篇内容")
#expect(contents[1] == "锚点前的引言")
#expect(contents[2] == "第一章\n甲 & 乙相遇。\n字形\n【插图 1:风景】")
#expect(contents[3] == "第二章\n第二章正文")
#expect(contents[4].contains("不应遗漏正文"))
#expect(contents[5] == "完结正文")
let images = try service.loadIllustrations(book: report.book, chapter: report.chapters[2])
#expect(images.count == 1 && images.first?.data == png && images.first?.caption == "风景")
#expect(try service.loadIllustrations(book: report.book, chapter: report.chapters[3]).isEmpty)
}
@Test func fallsBackToSpineWithoutNavigationAndDoesNotUseManifestOrder() throws {
let url = try fixture(navigation: false)
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.openFile(at: url)
#expect(report.chapters.count == 4)
#expect(report.chapters.first?.title == "序言")
let body = try service.loadContent(book: report.book, chapter: report.chapters[1]).content
#expect(body.contains("锚点前的引言") && body.contains("第一章") && body.contains("第二章正文"))
}
@Test func importsCopyPreservingMetadataAndOriginalFile() throws {
let url = try fixture()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.importFile(at: url, into: url.deletingLastPathComponent().appendingPathComponent("managed"))
let destination = try #require(URL(string: report.book.bookUrl))
#expect(destination != url && destination.pathExtension == "epub")
#expect(report.book.originName == "示例.EPUB")
#expect(try Data(contentsOf: destination) == Data(contentsOf: url))
#expect(try service.loadContent(book: report.book, chapter: report.chapters[2]).content.contains("甲 & 乙"))
}
@Test func fileManagementPreservesEPUBOnRenameExportAndRemoval() async throws {
let url = try fixture()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let root = url.deletingLastPathComponent()
let files = LocalFileService(directory: root.appendingPathComponent("managed"))
let first = try await files.importFile(from: url)
let second = try await files.importFile(from: url)
#expect(second.lastPathComponent == "示例 (2).EPUB")
let renamed = try await files.rename(first, to: "新名字")
#expect(renamed.pathExtension == "EPUB")
#expect(try await files.list().count == 2)
let exporter = BookFileExporter(directory: root.appendingPathComponent("exports"))
let export = try await exporter.originalFile(renamed, filename: "测试书籍.epub")
#expect(export.url.lastPathComponent == "测试书籍.epub")
#expect(try Data(contentsOf: export.url) == Data(contentsOf: url))
let staged = try #require(try await files.stageRemoval(renamed))
try await files.restore(staged)
#expect(try service.openFile(at: renamed).book.name == "测试书籍")
let removed = try #require(try await files.stageRemoval(renamed))
try await files.finishRemoval(removed)
#expect(try await files.list().count == 1)
}
@Test func rejectsCorruptionTraversalEncryptionAndOversizeWithoutLeavingImportedFiles() throws {
let good = try fixture()
defer { try? FileManager.default.removeItem(at: good.deletingLastPathComponent()) }
#expect(throws: LocalBookError.self) { try LocalBookService(maximumFileSize: 1).openFile(at: good) }
let broken = good.deletingLastPathComponent().appendingPathComponent("损坏.epub")
try Data("不是压缩包".utf8).write(to: broken)
let managed = good.deletingLastPathComponent().appendingPathComponent("managed")
#expect(throws: EPUBError.self) { try service.importFile(at: broken, into: managed) }
#expect(try FileManager.default.contentsOfDirectory(atPath: managed.path).isEmpty)
let malicious = try fixture(extras: [("../escape", Data("outside".utf8))])
defer { try? FileManager.default.removeItem(at: malicious.deletingLastPathComponent()) }
#expect(throws: EPUBError.self) { try service.openFile(at: malicious) }
let encrypted = try fixture(extras: [("META-INF/encryption.xml", Data(#"<encryption><EncryptedData><CipherData><CipherReference URI="OPS/Text/中文%20章节.xhtml"/></CipherData></EncryptedData></encryption>"#.utf8))])
defer { try? FileManager.default.removeItem(at: encrypted.deletingLastPathComponent()) }
#expect(throws: EPUBError.self) { try service.openFile(at: encrypted) }
let fontOnly = try fixture(extras: [("META-INF/encryption.xml", Data(#"<encryption><EncryptedData><CipherData><CipherReference URI="OPS/Fonts/font.otf"/></CipherData></EncryptedData></encryption>"#.utf8))])
defer { try? FileManager.default.removeItem(at: fontOnly.deletingLastPathComponent()) }
#expect(try service.openFile(at: fontOnly).chapters.count == 6)
}
@Test func rejectsForgedChapterResourceAndMissingAnchors() throws {
let url = try fixture()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.openFile(at: url)
var chapter = report.chapters[2]
chapter.resourceUrl = "../../outside"
#expect(throws: EPUBError.self) { try service.loadContent(book: report.book, chapter: chapter) }
chapter = report.chapters[2]
chapter.startFragmentId = "missing"
#expect(throws: EPUBError.self) { try service.loadContent(book: report.book, chapter: chapter) }
}
@Test func ignoresBadNavigationAnchorsAndOrdersValidAnchorsByDocumentPosition() throws {
let url = try fixture(chapter: "<html><body><h1 id='two'>第二章</h1><p>先读</p><h1 id='one'>第一章</h1><p>后读</p></body></html>")
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.openFile(at: url)
#expect(report.chapters.map(\.title) == ["序言", "第二章", "第一章", "未列入目录", "尾声"])
#expect(try service.loadContent(book: report.book, chapter: report.chapters[1]).content == "第二章\n先读")
}
}
@@ -393,6 +393,20 @@ struct LegadoDatabaseTests {
#expect(report.result.nextURL == "https://rss.example/list?page=1") #expect(report.result.nextURL == "https://rss.example/list?page=1")
} }
@Test func rssJSONListWithoutWildcardExtractsIndividualArticles() async throws {
var source = RssSource(sourceUrl: "https://rss.example", sourceName: "JSON 订阅")
source.ruleArticles = "$.items"
source.ruleTitle = "$.title"
source.ruleLink = "$.link"
let report = try await RSSRuleParser().parse(
sortName: "全部", sortURL: "https://rss.example/feed", redirectURL: "https://rss.example/feed",
body: #"{"items":[{"title":"第一篇","link":"/one"},{"title":"第二篇","link":"/two"}]}"#,
source: source
)
#expect(report.result.articles.map(\.title) == ["第一篇", "第二篇"])
#expect(report.result.articles.map(\.link) == ["https://rss.example/one", "https://rss.example/two"])
}
@Test func rssSourceImporterSupportsObjectArrayManifestAndUpdateSelection() throws { @Test func rssSourceImporterSupportsObjectArrayManifestAndUpdateSelection() throws {
let importer = RSSSourceImporter() let importer = RSSSourceImporter()
let object = try importer.decode(Data(#"{"sourceUrl":"https://one","sourceName":"一"}"#.utf8)) let object = try importer.decode(Data(#"{"sourceUrl":"https://one","sourceName":"一"}"#.utf8))
@@ -0,0 +1,141 @@
import Foundation
import Testing
@testable import LegadoCore
struct ReplaceRuleExecutionTests {
private func process(_ content: String, rules: [ReplaceRule], title: String = "第十二章") -> ContentProcessingReport {
let book = Book(bookUrl: "book", name: "测试书", origin: "https://source.test")
let chapter = BookChapter(url: "chapter", title: title, bookUrl: book.bookUrl, index: 11)
return ContentSanitizer().process(book: book, chapter: chapter, content: content, rules: rules,
removeDuplicateTitle: false, resegment: false)
}
@Test func evaluatesJavaScriptForEveryMatchIncludingZeroWidthMatches() {
let width = ReplaceRule(id: 1, name: "全角", pattern: "[A-Z0-9]",
replacement: "@js:String.fromCharCode(result.charCodeAt(0) - 65248)")
var punctuation = ReplaceRule(id: 2, name: "标点", pattern: #"(?<=说道)(?=“)|\.{3,}"#,
replacement: #"@js:if (result === '') { ':' } else { '…' }"#)
punctuation.sortOrder = 1
let result = process("ABC123\n他说道“等待...”", rules: [punctuation, width])
#expect(result.content == "ABC123\n他说道:“等待…”")
#expect(result.effectiveRuleIDs == [1, 2])
#expect(result.diagnostics.isEmpty)
}
@Test func bindsBookAndChapterAndSharesOnlyJavaVariablesBetweenMatches() {
let rule = ReplaceRule(id: 1, name: "上下文", pattern: "[ab]", replacement: """
@js:n = typeof n === 'undefined' ? 1 : n + 1;
java.put('count', String(Number(java.get('count') || 0) + 1));
[book.name, book.getName(), chapter.title, chapter.getIndex(), result, n, java.get('count')].join(':')
""")
let report = process("a b", rules: [rule])
#expect(report.content == "测试书:测试书:第十二章:11:a:1:1 测试书:测试书:第十二章:11:b:1:2")
#expect(report.diagnostics.isEmpty)
// A new application of the same rule must not inherit another chapter's variables.
#expect(process("a", rules: [rule]).content == "测试书:测试书:第十二章:11:a:1:1")
}
@Test(arguments: [
(#"'$1'"#, "x"),
(#"String.fromCharCode(92) + 'n'"#, #"\n"#),
("null", "null"),
("undefined", "null"),
("['a', 'b']", "a,b"),
("123", "123.0"),
("false", "false")
])
func convertsScriptCompletionAndPreservesReplacementEscaping(_ sample: (String, String)) {
let rule = ReplaceRule(id: 1, name: "返回值", pattern: "(x)", replacement: "@js:" + sample.0)
let report = process("x", rules: [rule])
#expect(report.content == sample.1)
#expect(report.diagnostics.isEmpty)
}
@Test func failedScriptDiscardsAllPartialReplacementsAndLaterRulesStillRun() {
let failing = ReplaceRule(id: 1, name: "失败", pattern: "[ab]",
replacement: "@js:if (result === 'b') { throw new Error('bad match') }; 'changed'")
var following = ReplaceRule(id: 2, name: "后续", pattern: "tail", replacement: "后续成功")
following.sortOrder = 1
let report = process("ab tail", rules: [failing, following])
#expect(report.content == "ab 后续成功")
#expect(report.effectiveRuleIDs == [2])
#expect(report.diagnostics.count == 1)
#expect(report.diagnostics.first?.code == "rule.malformed")
}
@Test func skipsDisabledExcludedAndUnmatchedScriptsAndKeepsLiteralReplacementsLiteral() {
var excluded = ReplaceRule(id: 1, name: "排除", pattern: "x", replacement: "@js:throw new Error('excluded')")
excluded.excludeScope = "测试书"
var disabled = excluded
disabled.id = 2
disabled.excludeScope = nil
disabled.isEnabled = false
let unmatched = ReplaceRule(id: 3, name: "未匹配", pattern: "z", replacement: "@js:throw new Error('unmatched')")
var literal = ReplaceRule(id: 4, name: "普通文本", pattern: "x", replacement: "@js:'literal'")
literal.isRegex = false
let report = process("x", rules: [excluded, disabled, unmatched, literal])
#expect(report.content == "@js:'literal'")
#expect(report.diagnostics.isEmpty)
}
@Test func preservesLastNonblankTitleButAllowsDeletingBody() {
var rename = ReplaceRule(id: 1, name: "标题", pattern: "十二", replacement: "12")
rename.scopeTitle = true
rename.scopeContent = false
var erase = ReplaceRule(id: 2, name: "空白", pattern: "^.+$", replacement: "@js:'  \\n'")
erase.scopeTitle = true
erase.sortOrder = 1
let report = process("正文", rules: [erase, rename])
#expect(report.title == "第12章")
#expect(report.content == "  \n")
#expect(report.effectiveRuleIDs == [1, 2])
erase.replacement = ""
erase.scopeContent = false
let titleOnly = process("正文", rules: [erase])
#expect(titleOnly.title == "第十二章")
#expect(titleOnly.effectiveRuleIDs.isEmpty)
}
@Test(arguments: [
(#"(?:\n[^\n]+){0,15}$"#, "正文\nPS:说明\n感谢支持", "正文"),
(#"^(?:[^\n\w]\w+){0,2}"#, ",第一章,开始\n正文", "\n正文"),
(#""((?:[\w,。]|‘\w+’|\b[%\+\-])+)["]"#, ""你好,世界。"", "“你好,世界。”"),
(#"(?:[+*]|\\)+"#, #"+*\"#, "")
])
func acceptsBoundedDelimitedAndEscapedQuantifiers(_ sample: (String, String, String)) throws {
let replacement = sample.0.hasPrefix(""") ? "“$1”" : ""
#expect(try RegexEvaluator().replacingStrict(pattern: sample.0, in: sample.1, with: replacement) == sample.2)
}
@Test(arguments: ["(a+)+$", #"(\w*)*"#, "(?:[ab]+){2,}"])
func retainsChecksForClearlyNestedUnboundedRepetition(_ pattern: String) {
#expect(throws: RuleEngineError.self) {
try RegexEvaluator().replacingStrict(pattern: pattern, in: "aaa!", with: "changed")
}
}
@Test func dynamicReplacementRespectsMatchOutputAndWholeOperationTimeLimits() throws {
let limited = RegexEvaluator(limits: .init(maximumInputLength: 4, maximumMatches: 2))
#expect(throws: RuleEngineError.self) {
try limited.replacingStrict(pattern: "a", in: "aaa", replacement: { _ in "b" })
}
#expect(try limited.replacingStrict(pattern: "a", in: "aa", replacement: { _ in "b" }) == "bb")
// Include untouched tail in the output limit, not only replaced spans.
#expect(throws: RuleEngineError.self) {
try limited.replacingStrict(pattern: "a", in: "abcd", replacement: { _ in "1234567" })
}
#expect(throws: RuleEngineError.self) {
try limited.replacingStrict(pattern: "a", in: "a", timeoutMilliseconds: 1, replacement: { _ in
Thread.sleep(forTimeInterval: 0.02)
return "b"
})
}
}
@Test func rejectsKnownUnboundedJavaScriptAndReportsErrorWithoutChangingInput() {
let rule = ReplaceRule(id: 1, name: "循环", pattern: "x", replacement: "@js:while(true){}")
let report = process("x", rules: [rule])
#expect(report.content == "x")
#expect(report.diagnostics.first?.code == "rule.resource_limit")
}
}
@@ -0,0 +1,89 @@
import Foundation
import Testing
@testable import LegadoCore
// The payloads and host below are synthetic and never use network access.
struct SampledRequestRegressionTests {
@Test func equalsSeparatedSourceHeaderReachesSearchTransport() async throws {
var source = source()
source.header = #"{"type"="app"}"#
source.searchUrl = #"/search,{"method":"POST","body":"keyword={{key}}&page={{page-1}}"}"#
let transport = SampledRequestTransport()
let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source)
#expect(result.diagnostics.isEmpty)
#expect(result.books.count == 1)
let request = try #require(await transport.requests().first)
#expect(request.value(forHTTPHeaderField: "type") == "app")
#expect(request.httpBody.map { String(decoding: $0, as: UTF8.self) } == "keyword=%E6%96%97%E7%BD%97%E5%A4%A7%E9%99%86&page=0")
}
@Test func stringEncodedLenientHeadersReachSearchTransport() async throws {
var source = source()
source.searchUrl = #"/search?word={{key}},{"headers":"{os:'pc'}"}"#
let transport = SampledRequestTransport()
let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source)
#expect(result.diagnostics.isEmpty)
#expect(result.books.count == 1)
let request = try #require(await transport.requests().first)
#expect(request.value(forHTTPHeaderField: "os") == "pc")
}
@Test func unquotedKeywordInObjectBodyPreservesJSONValueTypes() async throws {
var source = source()
source.searchUrl = #"/search,{"method":"POST","body":{"keyword":{{key}},"page_index":{{page}},"page_size":20,"enabled":true,"cursor":null}}"#
let transport = SampledRequestTransport()
let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source)
#expect(result.diagnostics.isEmpty)
#expect(result.books.count == 1)
let request = try #require(await transport.requests().first)
let body = try JSONValue.decode(data: #require(request.httpBody))
#expect(body["keyword"] == .string("斗罗大陆"))
#expect(body["page_index"] == .number(1))
#expect(body["page_size"] == .number(20))
#expect(body["enabled"] == .bool(true))
#expect(body["cursor"] == .null)
#expect(request.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("application/json") == true)
}
@Test func lenientSeparatorsDoNotRewriteQuotedCookieOrURLValues() throws {
let value = try LegadoURLBuilder.decodeLenientJSON(#"{type=>app, 'Cookie':'token=a=b; mode=pc', referer:'https://fixture.invalid/?a=b', retry:2, enabled:false, empty:null}"#)
#expect(value["type"] == .string("app"))
#expect(value["Cookie"] == .string("token=a=b; mode=pc"))
#expect(value["referer"] == .string("https://fixture.invalid/?a=b"))
#expect(value["retry"] == .number(2))
#expect(value["enabled"] == .bool(false))
#expect(value["empty"] == .null)
}
@Test func unquotedWebViewFlagUsedByAudioSourceParses() throws {
let request = try LegadoURLBuilder().prepare("https://request.fixture.invalid/audio,{webView:“true”}")
#expect(request.useWebView)
}
@Test func lenientValuesKeepCommentsAndPutRuleExpressionsWorking() throws {
let options = try LegadoURLBuilder.decodeLenientJSON("{retry: 2 /* comment */, enabled: FALSE, word: 小说}")
#expect(options["retry"] == .number(2))
#expect(options["enabled"] == .bool(false))
#expect(options["word"] == .string("小说"))
let put = try LegadoURLBuilder.decodeLenientJSON("{id:$.books[0].id, name:name}", quoteUnquotedValues: true)
#expect(put["id"] == .string("$.books[0].id"))
#expect(put["name"] == .string("name"))
#expect(throws: (any Error).self) { try LegadoURLBuilder.decodeLenientJSON("{broken:}") }
}
private func source() -> BookSource {
var source = BookSource(bookSourceUrl: "https://request.fixture.invalid", bookSourceName: "请求兼容测试")
source.ruleSearch = .init(bookList: "$.books", name: "$.name", bookUrl: "$.url")
return source
}
}
private actor SampledRequestTransport: HTTPTransport {
private var values: [URLRequest] = []
func data(for request: URLRequest) async throws -> HTTPResponse {
values.append(request)
return .init(data: Data(#"{"books":[{"name":"测试书","url":"/book/42"}]}"#.utf8),
statusCode: 200, headers: [:], finalURL: try #require(request.url))
}
func requests() -> [URLRequest] { values }
}
@@ -0,0 +1,104 @@
import Foundation
import Testing
@testable import LegadoCore
// All responses and hosts are synthetic; these tests never contact source websites.
struct SampledSourceRegressionTests {
@Test func JavaScriptNumbersZeroAndOneRemainNumeric() throws {
let evaluator = JavaScriptRuleEvaluator()
let value = try evaluator.evaluate(script: "({zero: 0, one: 1, yes: true, no: false, list: [0, 1, true]})", result: .null, executionContext: .init()).value
#expect(value["zero"] == .number(0))
#expect(value["one"] == .number(1))
#expect(value["yes"] == .bool(true))
#expect(value["no"] == .bool(false))
#expect(value["list"] == .array([.number(0), .number(1), .bool(true)]))
}
@Test func JavaScriptGetStringUsesTheSameJSONTemplateAndReplacementSemantics() async throws {
let input = try JSONValue.decode(data: Data(#"{"id":42,"name":"测试小说","alternate":"备用小说"}"#.utf8))
let value = await RuleEngine().evaluate("@js:java.getString('/book/{$.id}') + ':' + java.getString('$.name||$.alternate##小说')", in: input)
#expect(value.diagnostics.isEmpty)
#expect(value.strings == ["/book/42:测试"])
}
@Test func legacyJSONTemplatesKeepEachSearchBookURLDistinct() async throws {
var source = BookSource(bookSourceUrl: "https://fixture.invalid", bookSourceName: "旧式 JSON 模板")
source.searchUrl = "/search?word={{key}}"
source.ruleSearch = .init(bookList: "data.bookList", name: "name", bookUrl: "/pc/book/{$.id}/catalog")
let outcome = await BookSearchService(transport: SampledResponseTransport(body:
#"{"data":{"bookList":[{"id":42,"name":"测试甲"},{"id":43,"name":"测试乙"}]}}"#
)).search(keyword: "测试", page: 1, source: source)
#expect(outcome.diagnostics.isEmpty)
#expect(outcome.books.map(\.bookURL) == ["https://fixture.invalid/pc/book/42/catalog", "https://fixture.invalid/pc/book/43/catalog"])
}
@Test func legacyJSONRootWildcardAndRecursiveURLTemplate() async throws {
var source = BookSource(bookSourceUrl: "https://fixture.invalid", bookSourceName: "旧式 JSON 列表")
source.searchUrl = "/search?word={{key}}"
source.ruleSearch = .init(bookList: "$.[*]", name: "$..name", bookUrl: "https://fixture.invalid{$..url}")
let outcome = await BookSearchService(transport: SampledResponseTransport(body:
#"[{"name":"测试甲","url":"/book/42"},{"name":"测试乙","url":"/book/43"}]"#
)).search(keyword: "测试", page: 1, source: source)
#expect(outcome.diagnostics.isEmpty)
#expect(outcome.books.map(\.bookURL) == ["https://fixture.invalid/book/42", "https://fixture.invalid/book/43"])
}
@Test func legacyJSONTemplatesRespectQuotedBracesAndReplacement() async throws {
let input = try JSONValue.decode(data: Data(#"{"id":42,"words":1200,"meta":{"label}":"玄幻"},"tags":["甲","乙"]}"#.utf8))
let value = await RuleEngine().evaluate(#"{$.meta['label}']}/{$.words}字/{$.tags[*]}##1200##1.2千"#, in: input)
#expect(value.diagnostics.isEmpty)
#expect(value.strings == ["玄幻/1.2千字/甲\n乙"])
let fromText = await RuleEngine().evaluate("/book/{$.id}", inText: input.stringValue)
#expect(fromText.diagnostics.isEmpty)
#expect(fromText.strings == ["/book/42"])
}
@Test func JSONFallbackSkipsEmptyArraysAndEmptyNames() async throws {
let input = try JSONValue.decode(data: Data(#"{"empty":[],"books":[{"name":"备用书"}],"name":"","alternate":"备用书","zero":0}"#.utf8))
for useText in [false, true] {
let list = if useText {
await RuleEngine().evaluate("$.empty||$.books", inText: input.stringValue, listMode: true)
} else {
await RuleEngine().evaluate("$.empty||$.books", in: input, listMode: true)
}
#expect(list.diagnostics.isEmpty)
#expect(list.listItems(rule: "$.empty||$.books").count == 1)
let name = await RuleEngine().evaluate("$.name||$.alternate", in: input)
#expect(name.strings == ["备用书"])
let zero = await RuleEngine().evaluate("$.zero||$.alternate", in: input)
#expect(zero.strings == ["0"])
}
}
@Test func replacementAppliesToTheCombinedSelectorResult() async throws {
// Search names in the sampled fallback sources and multi-part metadata
// use one trailing ## expression for the entire selector combination.
let input = try JSONValue.decode(data: Data(#"{"name":"测试小说","fallback":"备用小说","a":"A1","b":"B2"}"#.utf8))
let fallback = await RuleEngine().evaluate("$.name||$.fallback##小说", in: input)
#expect(fallback.strings == ["测试"])
let combined = await RuleEngine().evaluate("$.a&&$.b##[0-9]", in: input)
#expect(combined.strings.joined(separator: "\n") == "A\nB")
let anchored = await RuleEngine().evaluate(#"$.a&&$.b##^A1\nB2$##整段"#, in: input)
#expect(anchored.strings == ["整段"])
let list = await RuleEngine().evaluate("$.a&&$.b##[0-9]", in: input, listMode: true)
#expect(list.strings == ["A", "B"])
let html = await RuleEngine().evaluate("h1@text||h2@text##小说", inText: "<h1>测试小说</h1>")
#expect(html.strings == ["测试"])
}
@Test func replacementTextIsNotParsedAsSelectorSyntax() async {
let result = await RuleEngine().evaluate(#"h1@text##["']|[()]"#, inText: "<h1>书名(测试)'</h1>")
#expect(result.diagnostics.isEmpty)
#expect(result.strings == ["书名测试"])
let replacement = await RuleEngine().evaluate("h1@text##书##A&&B", inText: "<h1>书</h1>")
#expect(replacement.diagnostics.isEmpty)
#expect(replacement.strings == ["A&&B"])
}
}
private struct SampledResponseTransport: HTTPTransport {
let body: String
func data(for request: URLRequest) async throws -> HTTPResponse {
.init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: try #require(request.url))
}
}
@@ -246,6 +246,54 @@ final class ReaderScrollGeometryTests: XCTestCase {
XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY, accuracy: 1 / fixture.window.screen.scale) XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY, accuracy: 1 / fixture.window.screen.scale)
} }
func testRepeatedShortChapterPrependingDoesNotAccumulateOffsetDrift() async throws {
func short(_ index: Int) -> ReaderScrollChapter {
.init(id: index, title: "第\(index + 1)章 · 连续阅读", content: "短章正文,继续向下可以自然读到下一章。")
}
let fixture = host(chapters: (20...22).map(short), active: 20)
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
for first in stride(from: 19, through: 0, by: -1) {
fixture.coordinator.parent = surface(chapters: (first...22).map(short), active: 20)
fixture.coordinator.update(fixture.view)
try await Task.sleep(nanoseconds: 10_000_000)
}
let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout
let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 20 })
XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY,
accuracy: 1 / fixture.window.screen.scale)
XCTAssertTrue(fixture.view.accessibilityValue?.contains("chapter=20 ") == true,
fixture.view.accessibilityValue ?? "Missing viewport report")
}
func testFractionalPixelBoundariesBelongToTheFollowingRow() {
let layout = ReaderContinuousLayout()
let frames = (0..<100).map { CGRect(x: 0, y: CGFloat($0 * 5) / 3, width: 390, height: 5.0 / 3) }
layout.install(frames: frames, size: CGSize(width: 390, height: 500.0 / 3), correction: 0)
for index in frames.indices {
XCTAssertEqual(layout.row(at: frames[index].minY), index)
}
}
func testPrependingPreservesTheChapterBelowTopPadding() async throws {
func short(_ index: Int) -> ReaderScrollChapter {
.init(id: index, title: "短章\(index)", content: "短章正文")
}
let fixture = host(chapters: [short(20)], active: 20)
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
// A short initial chapter can settle at offset zero with its heading
// still below the reading area's top padding.
fixture.view.setContentOffset(.zero, animated: false)
fixture.coordinator.parent = surface(chapters: (19...21).map(short), active: 20)
fixture.coordinator.update(fixture.view)
try await Task.sleep(nanoseconds: 10_000_000)
let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout
let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 20 })
XCTAssertEqual(layout.frames[row].minY - fixture.view.contentOffset.y, 16,
accuracy: 1 / fixture.window.screen.scale)
XCTAssertTrue(fixture.view.accessibilityValue?.contains("chapter=20 ") == true,
fixture.view.accessibilityValue ?? "Missing viewport report")
}
private func chapter(_ index: Int) -> ReaderScrollChapter { private func chapter(_ index: Int) -> ReaderScrollChapter {
.init(id: index, title: "第\(index)章", content: String(repeating: "正文包含中文、英文 words 与家庭表情👨‍👩‍👧‍👦,用于验证连续阅读的排版和位置。\n", count: 100)) .init(id: index, title: "第\(index)章", content: String(repeating: "正文包含中文、英文 words 与家庭表情👨‍👩‍👧‍👦,用于验证连续阅读的排版和位置。\n", count: 100))
} }
@@ -88,3 +88,159 @@ final class WebReaderBridgeTests: XCTestCase {
XCTAssertEqual(result as? String, "已保存|规则正文") XCTAssertEqual(result as? String, "已保存|规则正文")
} }
} }
private actor RSSPaginationTransport: HTTPTransport {
private var requests: [URL] = []
private var shouldFailSecondPage = true
func data(for request: URLRequest) async throws -> HTTPResponse {
let url = try XCTUnwrap(request.url)
requests.append(url)
if url.host == "unavailable.test" { throw URLError(.cannotConnectToHost) }
let page = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
.first { $0.name == "page" }?.value ?? "1"
if page == "2", shouldFailSecondPage {
shouldFailSecondPage = false
throw URLError(.networkConnectionLost)
}
let body = "{\"items\":[{\"title\":\"Page \(page)\",\"link\":\"https://feed.test/article/\(page)\"}]}"
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
}
func requestedPages() -> [String] {
requests.compactMap { URLComponents(url: $0, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "page" }?.value }
}
func requestCount() -> Int { requests.count }
}
private actor RSSDelayedTransport: HTTPTransport {
let pausedHost: String
let pausedPage: String
private var requestTotal = 0
private var pending: CheckedContinuation<Void, Never>?
init(host: String, page: String) { pausedHost = host; pausedPage = page }
func data(for request: URLRequest) async throws -> HTTPResponse {
let url = try XCTUnwrap(request.url)
let page = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
.first { $0.name == "page" }?.value ?? "1"
requestTotal += 1
if url.host == pausedHost, page == pausedPage {
await withCheckedContinuation { continuation in
pending = continuation
}
}
let body = "{\"items\":[{\"title\":\"Page \(page)\",\"link\":\"https://feed.test/article/\(page)\"}]}"
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
}
func waitUntilPaused() async throws {
for _ in 0..<500 {
if pending != nil { return }
try await Task.sleep(nanoseconds: 10_000_000)
}
throw URLError(.timedOut)
}
func resume() { pending?.resume(); pending = nil }
func requestCount() -> Int { requestTotal }
}
@MainActor
final class RSSLibraryRegressionTests: XCTestCase {
func testUnavailableDatabaseDoesNotCrashOrPublishUnsavedImports() async throws {
let blocked = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try Data("existing file".utf8).write(to: blocked)
defer { try? FileManager.default.removeItem(at: blocked) }
let model = RSSLibraryModel.live(databaseURL: blocked.appendingPathComponent("rss.sqlite3"))
XCTAssertNotNil(model.errorMessage)
await model.load()
XCTAssertNotNil(model.errorMessage)
let saved = await model.save(source(), replacing: nil)
XCTAssertFalse(saved)
await model.importText(#"{"sourceUrl":"https://feed.test","sourceName":"未保存的订阅"}"#)
XCTAssertTrue(model.sources.isEmpty)
XCTAssertFalse(model.isWorking)
XCTAssertEqual(try Data(contentsOf: blocked), Data("existing file".utf8))
}
private func source(host: String = "feed.test") -> RssSource {
var source = RssSource(sourceUrl: "https://\(host)", sourceName: host)
source.sortUrl = "https://\(host)/list?page={{page}}"
source.ruleArticles = "$.items"
source.ruleTitle = "$.title"
source.ruleLink = "$.link"
source.ruleNextPage = "PAGE"
return source
}
func testFailedNextPageRetriesTheSamePageWithoutSkippingArticles() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
let transport = RSSPaginationTransport()
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
let feed = source()
await model.refresh(feed)
XCTAssertEqual(model.articles.map(\.title), ["Page 1"])
await model.loadMore(feed)
XCTAssertNotNil(model.errorMessage)
XCTAssertEqual(model.articles.map(\.title), ["Page 1"])
await model.loadMore(feed)
let pages = await transport.requestedPages()
XCTAssertEqual(pages, ["1", "2", "2"])
XCTAssertEqual(model.articles.map(\.title), ["Page 1", "Page 2"])
}
func testFailedSourceChangeDoesNotReusePreviousSourcesArticlesOrNextPage() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
let transport = RSSPaginationTransport()
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
await model.refresh(source())
let unavailable = source(host: "unavailable.test")
await model.refresh(unavailable)
XCTAssertTrue(model.articles.isEmpty)
let count = await transport.requestCount()
await model.loadMore(unavailable)
let afterLoadMore = await transport.requestCount()
XCTAssertEqual(afterLoadMore, count)
}
func testSlowPreviousSourceCannotReplaceTheCurrentFeed() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
let transport = RSSDelayedTransport(host: "slow.test", page: "1")
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
let slow = Task { await model.refresh(source(host: "slow.test")) }
try await transport.waitUntilPaused()
await model.refresh(source())
XCTAssertTrue(model.isWorking)
await transport.resume()
await slow.value
XCTAssertEqual(model.articles.map(\.origin), ["https://feed.test"])
XCTAssertFalse(model.isWorking)
}
func testRepeatedLoadMoreWhileWaitingRequestsOnlyOnePage() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
let transport = RSSDelayedTransport(host: "feed.test", page: "2")
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
let feed = source()
await model.refresh(feed)
let pending = Task { await model.loadMore(feed) }
try await transport.waitUntilPaused()
await model.loadMore(feed)
let count = await transport.requestCount()
XCTAssertEqual(count, 2)
await transport.resume()
await pending.value
XCTAssertEqual(model.articles.map(\.title), ["Page 1", "Page 2"])
}
}
@@ -80,11 +80,14 @@ final class DarkModeUITests: XCTestCase {
let keyboard = try luminances(app.keyboards.firstMatch, in: app) let keyboard = try luminances(app.keyboards.firstMatch, in: app)
XCTAssertLessThan(keyboard[keyboard.count / 2], 0.15) XCTAssertLessThan(keyboard[keyboard.count / 2], 0.15)
} }
let accessory = app.buttons["完成"] let accessory = app.buttons["syntaxCodeEditor.done"]
let accessoryFrame = accessory.frame.intersection(app.frame) XCTAssertTrue(accessory.exists && accessory.isHittable)
if accessory.isHittable && !accessoryFrame.isNull && accessoryFrame.width > 20 && accessoryFrame.height > 20 { try assertReadableDarkFill(accessory, in: app)
try assertReadableDarkFill(accessory, in: app) app.buttons["@"].tap()
} XCTAssertTrue((app.textViews["syntaxCodeEditor"].value as? String)?.contains("@") == true)
accessory.tap()
let dismissed = expectation(for: NSPredicate { _, _ in !accessory.exists || !accessory.isHittable }, evaluatedWith: nil)
wait(for: [dismissed], timeout: 5)
} }
func testDarkSettingsRulesAndManagementPages() throws { func testDarkSettingsRulesAndManagementPages() throws {
@@ -146,8 +149,8 @@ final class DarkModeUITests: XCTestCase {
let app = launch(["-uiDemoTab", "search", "-themeMode", "2", "-uiAutomationSearch", "深色外观检查"]) let app = launch(["-uiDemoTab", "search", "-themeMode", "2", "-uiAutomationSearch", "深色外观检查"])
let cancelSearch = app.buttons.matching(NSPredicate(format: "label IN %@", ["取消", "Cancel"])).firstMatch let cancelSearch = app.buttons.matching(NSPredicate(format: "label IN %@", ["取消", "Cancel"])).firstMatch
if cancelSearch.waitForExistence(timeout: 3) { cancelSearch.tap() } if cancelSearch.waitForExistence(timeout: 3) { cancelSearch.tap() }
XCTAssertTrue(app.buttons["搜索选项"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["search.options"].waitForExistence(timeout: 10))
app.buttons["搜索选项"].tap() app.buttons["search.options"].tap()
app.buttons["分组或书源"].tap() app.buttons["分组或书源"].tap()
XCTAssertTrue(app.navigationBars["分组或书源"].waitForExistence(timeout: 5)) XCTAssertTrue(app.navigationBars["分组或书源"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Search group filter") try captureDarkPage(app, name: "Search group filter")
@@ -108,7 +108,10 @@ final class LiveReadingFlowUITests: XCTestCase {
showReaderMenu() showReaderMenu()
app.buttons["换源"].tap() app.buttons["换源"].tap()
XCTAssertTrue(app.navigationBars["为《极道天魔》换源"].waitForExistence(timeout: 10)) XCTAssertTrue(app.navigationBars["为《极道天魔》换源"].waitForExistence(timeout: 10))
let replacement = app.buttons["sourceSwitch.candidate." + replacementID] let replacement = app.buttons.matching(NSPredicate(
format: "identifier BEGINSWITH %@ AND label CONTAINS %@",
"sourceSwitch.candidate." + replacementID + "|", "滚开"
)).firstMatch
XCTAssertTrue(replacement.waitForExistence(timeout: 240)) XCTAssertTrue(replacement.waitForExistence(timeout: 240))
attach("07-source-candidates") attach("07-source-candidates")
replacement.tap() replacement.tap()
@@ -354,8 +354,13 @@ final class ReaderScrollGestureTests: XCTestCase {
app.launch() app.launch()
let reader = app.collectionViews["reader.continuous"] let reader = app.collectionViews["reader.continuous"]
XCTAssertTrue(reader.waitForExistence(timeout: 20)) XCTAssertTrue(reader.waitForExistence(timeout: 20))
var lastInitialState = "<not reported>"
let ready = expectation(for: NSPredicate { _, _ in let ready = expectation(for: NSPredicate { _, _ in
let text = reader.value as? String ?? "" let text = reader.value as? String ?? "<missing>"
if text != lastInitialState {
print("[ContinuousReaderInitial] \(text)")
lastInitialState = text
}
return text.contains("chapter=20 ") && !text.contains("chapters=1 ") return text.contains("chapter=20 ") && !text.contains("chapters=1 ")
}, evaluatedWith: nil) }, evaluatedWith: nil)
wait(for: [ready], timeout: 20) wait(for: [ready], timeout: 20)
@@ -2,34 +2,89 @@ import XCTest
@MainActor @MainActor
final class RuleEditorUITests: XCTestCase { final class RuleEditorUITests: XCTestCase {
func testBookshelfTXTFilePickerImportsTXTAndDisablesJSON() throws { func testRSSSearchSubmissionPaginationAndFavorites() throws {
try exerciseLocalTXTImport(fromFileManagement: false) guard let fixture = ProcessInfo.processInfo.environment["LEGADO_RSS_UI_FIXTURE_URL"] else {
} throw XCTSkip("Set LEGADO_RSS_UI_FIXTURE_URL to the local RSS fixture source URL")
}
func testFileManagementTXTFilePickerImportsTXTAndDisablesJSON() throws {
try exerciseLocalTXTImport(fromFileManagement: true)
}
private func exerciseLocalTXTImport(fromFileManagement: Bool) throws {
continueAfterFailure = false continueAfterFailure = false
let app = XCUIApplication() let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-themeMode", "1", "-bookshelfLayout", "grid3"] app.launchArguments = ["-uiDemo", "-uiDemoTab", "rss", "-themeMode", "1"]
app.launch()
XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 20))
app.buttons["RSS操作"].tap()
app.buttons["URL或粘贴JSON"].tap()
let editor = app.textViews.firstMatch
XCTAssertTrue(editor.waitForExistence(timeout: 5))
editor.tap()
editor.typeText(fixture)
app.navigationBars["导入RSS源"].buttons["导入"].tap()
let source = app.staticTexts["RSS 全流程测试"]
XCTAssertTrue(source.waitForExistence(timeout: 10))
source.tap()
XCTAssertTrue(app.staticTexts["首页文章"].waitForExistence(timeout: 10))
let search = app.searchFields["源内搜索"]
if !search.exists { app.swipeDown() }
XCTAssertTrue(search.waitForExistence(timeout: 5))
search.tap()
search.typeText("needle\n")
let result = app.staticTexts["搜索结果 needle 第1页"]
XCTAssertTrue(result.waitForExistence(timeout: 10))
let nextPage = app.buttons["加载下一页"]
let ready = expectation(for: NSPredicate { _, _ in nextPage.exists && nextPage.isEnabled }, evaluatedWith: nil)
wait(for: [ready], timeout: 10)
nextPage.tap()
XCTAssertTrue(app.staticTexts["搜索结果 needle 第2页"].waitForExistence(timeout: 10))
result.tap()
XCTAssertTrue(app.navigationBars["文章"].waitForExistence(timeout: 5))
if app.buttons["取消收藏"].exists { app.buttons["取消收藏"].tap() }
XCTAssertTrue(app.buttons["收藏"].waitForExistence(timeout: 5))
app.buttons["收藏"].tap()
XCTAssertTrue(app.buttons["取消收藏"].waitForExistence(timeout: 5))
app.navigationBars["文章"].buttons.element(boundBy: 0).tap()
let cancelSearch = app.navigationBars["RSS 全流程测试"].buttons.matching(NSPredicate(
format: "label IN %@", ["Cancel", "取消", "Close", "关闭"]
)).firstMatch
if cancelSearch.exists { cancelSearch.tap() }
app.navigationBars["RSS 全流程测试"].buttons["RSS"].tap()
XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 5))
app.segmentedControls.buttons["收藏"].tap()
XCTAssertTrue(result.waitForExistence(timeout: 5))
attach(app, name: "RSS search article saved to favorites")
app.terminate()
app.launch()
XCTAssertTrue(app.staticTexts["RSS 全流程测试"].waitForExistence(timeout: 10))
app.segmentedControls.buttons["收藏"].tap()
XCTAssertTrue(result.waitForExistence(timeout: 5))
}
func testBookshelfFilePickerImportsTXTAndEPUBAndDisablesJSON() throws {
try exerciseLocalBookImport(fromFileManagement: false)
}
func testFileManagementFilePickerImportsTXTAndEPUBAndDisablesJSON() throws {
try exerciseLocalBookImport(fromFileManagement: true)
}
private func exerciseLocalBookImport(fromFileManagement: Bool) throws {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-themeMode", "1", "-bookshelfLayout", "grid3", "-pageAnim", "0"]
if fromFileManagement { app.launchArguments += ["-uiDemoTab", "settings", "-uiDemoFiles"] } if fromFileManagement { app.launchArguments += ["-uiDemoTab", "settings", "-uiDemoFiles"] }
app.launch() app.launch()
let importTitle = fromFileManagement ? "导入 TXT 文件" : "本地书" let importTitle = fromFileManagement ? "导入 TXT / EPUB" : "本地书"
let more = fromFileManagement let more = fromFileManagement
? app.navigationBars["文件管理"].buttons["更多"].firstMatch ? app.navigationBars["文件管理"].buttons["更多"].firstMatch
: app.buttons["更多"].firstMatch : app.buttons["更多"].firstMatch
XCTAssertTrue(more.waitForExistence(timeout: 20)) XCTAssertTrue(more.waitForExistence(timeout: 20))
more.tap() more.tap()
app.buttons[importTitle].tap() app.buttons[importTitle].firstMatch.tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10)) XCTAssertTrue(cancel.waitForExistence(timeout: 10))
cancel.tap() cancel.tap()
let closed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil) let closed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil)
wait(for: [closed], timeout: 10) wait(for: [closed], timeout: 10)
more.tap() more.tap()
app.buttons[importTitle].tap() app.buttons[importTitle].firstMatch.tap()
XCTAssertTrue(cancel.waitForExistence(timeout: 10)) XCTAssertTrue(cancel.waitForExistence(timeout: 10))
let pickerFiles = app.collectionViews["File View"].cells let pickerFiles = app.collectionViews["File View"].cells
@@ -66,6 +121,10 @@ final class RuleEditorUITests: XCTestCase {
let uppercase = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch let uppercase = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch
XCTAssertTrue(uppercase.waitForExistence(timeout: 5)) XCTAssertTrue(uppercase.waitForExistence(timeout: 5))
uppercase.tap() uppercase.tap()
let epub = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "EPUB-Book")).firstMatch
XCTAssertTrue(epub.waitForExistence(timeout: 5))
XCTAssertTrue(epub.isEnabled)
epub.tap()
XCTAssertTrue(open.waitForExistence(timeout: 5)) XCTAssertTrue(open.waitForExistence(timeout: 5))
XCTAssertTrue(open.isEnabled) XCTAssertTrue(open.isEnabled)
attach(app, name: "TXT selectable and JSON excluded") attach(app, name: "TXT selectable and JSON excluded")
@@ -78,14 +137,34 @@ final class RuleEditorUITests: XCTestCase {
XCTAssertTrue(app.navigationBars["导入本地书"].waitForExistence(timeout: 15)) XCTAssertTrue(app.navigationBars["导入本地书"].waitForExistence(timeout: 15))
XCTAssertFalse(app.staticTexts["JSON-Source.json"].exists) XCTAssertFalse(app.staticTexts["JSON-Source.json"].exists)
XCTAssertFalse(app.staticTexts["JSON-Uppercase.JSON"].exists) XCTAssertFalse(app.staticTexts["JSON-Uppercase.JSON"].exists)
XCTAssertTrue(app.staticTexts["TXT-Book.txt"].exists) XCTAssertTrue(app.staticTexts["TXT-Book.txt"].waitForExistence(timeout: 10), app.debugDescription)
XCTAssertTrue(app.staticTexts["TXT-Uppercase.TXT"].exists) XCTAssertTrue(app.staticTexts["TXT-Uppercase.TXT"].waitForExistence(timeout: 10), app.debugDescription)
app.buttons["导入"].tap() app.buttons["导入"].tap()
} }
// Repeated runs preserve managed files and correctly add a numeric suffix. // Repeated runs preserve managed files and correctly add a numeric suffix.
XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Book")).firstMatch.waitForExistence(timeout: 15)) XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Book")).firstMatch.waitForExistence(timeout: 15))
XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch.waitForExistence(timeout: 15)) XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch.waitForExistence(timeout: 15))
attach(app, name: "Local TXT books imported onto shelf") let epubBook = app.staticTexts["EPUB 导入测试"].firstMatch
XCTAssertTrue(epubBook.waitForExistence(timeout: 15))
attach(app, name: "Local TXT and EPUB books imported onto shelf")
epubBook.tap()
let reader = app.textViews["reader.text"]
XCTAssertTrue(reader.waitForExistence(timeout: 15))
XCTAssertTrue((reader.value as? String)?.contains("这是 EPUB 导入后的第一章正文") == true)
reader.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
app.buttons["更多"].firstMatch.tap()
app.buttons["本章插图"].tap()
XCTAssertTrue(app.navigationBars["本章插图"].waitForExistence(timeout: 5))
XCTAssertTrue(app.images["书籍插图"].waitForExistence(timeout: 10))
app.buttons["完成"].tap()
reader.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
app.buttons["目录"].tap()
app.staticTexts["第二章 EPUB"].firstMatch.tap()
let loaded = expectation(for: NSPredicate { _, _ in
(reader.value as? String)?.contains("目录锚点跳转成功") == true
}, evaluatedWith: nil)
wait(for: [loaded], timeout: 10)
attach(app, name: "EPUB second chapter after TOC navigation")
} }
func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws { func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws {
@@ -146,7 +225,7 @@ final class RuleEditorUITests: XCTestCase {
attach(app, name: "JSON import review after file picker dismissal") attach(app, name: "JSON import review after file picker dismissal")
app.buttons["导入"].tap() app.buttons["导入"].tap()
if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() } if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() }
XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10)) XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10), app.debugDescription)
XCTAssertTrue(app.staticTexts["文件导入大写扩展名测试"].exists) XCTAssertTrue(app.staticTexts["文件导入大写扩展名测试"].exists)
} }
+13 -5
View File
@@ -41,7 +41,7 @@ sh scripts/self-check.sh
阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。 阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。
书源和本地 TXT 文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值): 书源和本地 TXT / EPUB 文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值):
```sh ```sh
LEGADO_TEST_DEVICE="模拟器 UUID" LEGADO_TEST_DEVICE="模拟器 UUID"
@@ -49,12 +49,14 @@ sh scripts/prepare-source-import-fixture.sh "$LEGADO_TEST_DEVICE"
xcodebuild -project Legado.xcodeproj -scheme LegadoReaderUITests \ xcodebuild -project Legado.xcodeproj -scheme LegadoReaderUITests \
-destination "platform=iOS Simulator,id=$LEGADO_TEST_DEVICE" \ -destination "platform=iOS Simulator,id=$LEGADO_TEST_DEVICE" \
-only-testing:LegadoReaderUITests/RuleEditorUITests/testJSONFilePickerOffersOpenAndImportsSelectedFiles \ -only-testing:LegadoReaderUITests/RuleEditorUITests/testJSONFilePickerOffersOpenAndImportsSelectedFiles \
-only-testing:LegadoReaderUITests/RuleEditorUITests/testBookshelfTXTFilePickerImportsTXTAndDisablesJSON \ -only-testing:LegadoReaderUITests/RuleEditorUITests/testBookshelfFilePickerImportsTXTAndEPUBAndDisablesJSON \
-only-testing:LegadoReaderUITests/RuleEditorUITests/testFileManagementTXTFilePickerImportsTXTAndDisablesJSON \ -only-testing:LegadoReaderUITests/RuleEditorUITests/testFileManagementFilePickerImportsTXTAndEPUBAndDisablesJSON \
-collect-test-diagnostics never CODE_SIGNING_ALLOWED=NO test -collect-test-diagnostics never CODE_SIGNING_ALLOWED=NO test
``` ```
独立测试应用提供 JSON 书源和 `.txt`、`.TXT` 小说样本,不会被打包进正式 App。此回归检查取消后重新打开、书源 JSON 多选、本地 TXT 可选且 JSON 不可选、导入确认及最终保存。 独立测试应用提供 JSON 书源和 `.txt`、`.TXT`、`.epub` 小说样本,不会被打包进正式 App。此回归检查取消后重新打开、书源 JSON 多选、本地 TXT / EPUB 可选且 JSON 不可选、导入确认、最终保存、EPUB 正文、插图与目录跳转。
RSS 的导入、源内搜索、分页、正文、收藏以及重启恢复可通过本地自造响应回归,避免依赖第三方网站。先在一个终端运行 `python3 scripts/fixtures/rss-flow-server.py`,再在 Xcode 选择 `LegadoRSSFlowUITests` Scheme 和模拟器运行;也可用 `xcodebuild -project Legado.xcodeproj -scheme LegadoRSSFlowUITests -destination 'platform=iOS Simulator,id=模拟器UUID' CODE_SIGNING_ALLOWED=NO test`。该服务仅监听本机 `127.0.0.1:18763`,测试结束后按 Ctrl-C 停止。普通 UI Scheme 默认跳过此用例。
## 兼容性验证 ## 兼容性验证
@@ -101,7 +103,7 @@ source_release_tag=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[
gh release download "$source_release_tag" --repo Aminark/legado-ios \ gh release download "$source_release_tag" --repo Aminark/legado-ios \
--pattern '*.ipa' --dir "$source_update_dir" --pattern '*.ipa' --dir "$source_update_dir"
python3 scripts/update-altstore-source.py \ python3 scripts/update-altstore-source.py \
--ipa "$source_update_dir/legado-ios-v0.1.11-build17-unsigned.ipa" \ --ipa "$source_update_dir/legado-ios-v0.1.12-build18-unsigned.ipa" \
--release "$source_update_dir/release.json" --release "$source_update_dir/release.json"
python3 -m unittest discover -s scripts/tests -v python3 -m unittest discover -s scripts/tests -v
``` ```
@@ -111,3 +113,9 @@ python3 -m unittest discover -s scripts/tests -v
发布后检查固定地址可匿名访问、图标和截图能正常加载,并下载源中 IPA 再核对大小、摘要和版本。客户端的添加源、安装、更新与签名流程需分别实测;源文件校验通过不代表已完成真机安装验证。 发布后检查固定地址可匿名访问、图标和截图能正常加载,并下载源中 IPA 再核对大小、摘要和版本。客户端的添加源、安装、更新与签名流程需分别实测;源文件校验通过不代表已完成真机安装验证。
格式说明:[AltStore 源规范](https://faq.altstore.io/developers/make-a-source)、[SideStore 源字段](https://sidestore.io/sidestore-source-types/interfaces/Source.html)、[LiveContainer 源用法](https://livecontainer.github.io/docs/guides/sources)。 格式说明:[AltStore 源规范](https://faq.altstore.io/developers/make-a-source)、[SideStore 源字段](https://sidestore.io/sidestore-source-types/interfaces/Source.html)、[LiveContainer 源用法](https://livecontainer.github.io/docs/guides/sources)。
## EPUB 回归
`swift test --disable-sandbox --filter EPUB` 覆盖 EPUB 2/3 元数据、中文资源路径、同页锚点、无目录降级、书脊顺序、插图、损坏与加密文件、重命名导出、导入失败回滚和阅读进度恢复。样本均由测试自行生成,不依赖网络或第三方书籍。
解析器按需读取 ZIP 资源,不解压到磁盘;单资源上限为 32 MiB,正文阅读不执行脚本或加载外部资源。字体混淆不影响文字阅读,加密正文会显示明确错误。原书 CSS、固定版式和交互内容不在当前重排阅读模式范围内。
+11 -11
View File
@@ -36,20 +36,20 @@
], ],
"versions": [ "versions": [
{ {
"version": "0.1.10", "version": "0.1.12",
"buildVersion": "16", "buildVersion": "18",
"date": "2026-10-20T15:34:03Z", "date": "2026-10-23T17:02:37Z",
"localizedDescription": "小浣熊阅读 **0.1.10**,构建号 **16**,支持 **iOS / iPadOS 15.0 及以上**。\n\n## 更新内容\n\n- 修复书源文件选择及导入流程:支持 JSON 多选,调整文件读取权限和导入预览展示时机(#1)。\n- 支持进入书源分组查看书源、打开详情及切换启用状态。\n- 修复阅读外观中的背景图片和字体选择;限制背景图片解码尺寸,降低内存占用。\n- 完善背景图片与预设主题切换,关闭背景图时保留已选图片。\n- 修复退出后重新进入阅读器的书签状态,避免重复添加书签。\n\n## 下载安装\n\n下载附件 **`legado-ios-v0.1.10-build16-unsigned.ipa`**,使用自己的签名工具安装。支持 TrollStore 的设备可自行安装;本项目暂不提供 App Store 或 TestFlight 分发。\n\n`Source code (zip / tar.gz)` 是源码,不能直接安装。附件 `SHA256SUMS` 可用于核对 IPA 完整性。\n\n## 验证与兼容\n\n本次通过 299 项核心及页面模型测试、12 项 iOS 阅读器测试、4 项文件导入与阅读操作 UI 回归,以及模拟器构建。IPA 由本次源码重新进行 iPhoneOS Release 构建。\n\niOS 15.7 真机尚未复测。若在该系统上仍遇到文件无法选择的问题,请继续在 [Issue #1](https://github.com/Aminark/legado-ios/issues/1) 反馈。\n\n**小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库;书源需自行导入。**\n", "localizedDescription": "小浣熊阅读 **0.1.12**,构建号 **18**,支持 **iOS / iPadOS 15.0 及以上**。\n\n## 更新内容\n\n- 增加无 DRM EPUB 2/3 导入与阅读,识别书名、作者、封面、NCX/nav 目录及章节锚点,按书脊顺序保留正文。\n- EPUB 支持阅读进度、书签、朗读和阅读样式;在阅读菜单「更多 → 本章插图」查看插图。\n- 本地文件管理支持 EPUB 重命名、原格式导出、删除,以及从系统文件应用打开。\n- 改善 JSON 规则模板、候选选择、组合替换和请求参数兼容,修复 JavaScript 数字类型及动态净化替换。\n- 修复 RSS 翻页失败重试、切换订阅时的过期结果和重复翻页请求。\n- 改善连续滚动中的章节插入定位,调整 iOS 26 代码编辑与图片查看工具栏。\n\n## 下载安装\n\n下载附件 **`legado-ios-v0.1.12-build18-unsigned.ipa`**,使用自己的签名工具安装。支持 TrollStore 的设备可自行尝试安装;目前不提供 App Store 或 TestFlight 分发。\n\n`Source code (zip / tar.gz)` 是源码,不能直接安装。附件 `SHA256SUMS` 用于核对 IPA 完整性。\n\n## 兼容与验证\n\nEPUB 正文使用现有阅读器重新排版,暂不还原原书 CSS、固定版式及交互内容,不支持加密正文。\n\n安装包由对应源码重新进行 iPhoneOS Release 构建,并核对包内版本及完整性。本轮文件导入界面回归仍有待确认项,尚未完成 UI 回归和真机安装验证;如遇问题,请在 [Issues](https://github.com/Aminark/legado-ios/issues) 附上设备、系统版本及复现步骤。\n\n**小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库;书源需自行导入。**\n",
"downloadURL": "https://github.com/Aminark/legado-ios/releases/download/v0.1.10/legado-ios-v0.1.10-build16-unsigned.ipa", "downloadURL": "https://github.com/Aminark/legado-ios/releases/download/v0.1.12/legado-ios-v0.1.12-build18-unsigned.ipa",
"size": 12080513, "size": 12638772,
"minOSVersion": "15.0" "minOSVersion": "15.0"
} }
], ],
"version": "0.1.10", "version": "0.1.12",
"versionDate": "2026-10-20T15:34:03Z", "versionDate": "2026-10-23T17:02:37Z",
"versionDescription": "小浣熊阅读 **0.1.10**,构建号 **16**,支持 **iOS / iPadOS 15.0 及以上**。\n\n## 更新内容\n\n- 修复书源文件选择及导入流程:支持 JSON 多选,调整文件读取权限和导入预览展示时机(#1)。\n- 支持进入书源分组查看书源、打开详情及切换启用状态。\n- 修复阅读外观中的背景图片和字体选择;限制背景图片解码尺寸,降低内存占用。\n- 完善背景图片与预设主题切换,关闭背景图时保留已选图片。\n- 修复退出后重新进入阅读器的书签状态,避免重复添加书签。\n\n## 下载安装\n\n下载附件 **`legado-ios-v0.1.10-build16-unsigned.ipa`**,使用自己的签名工具安装。支持 TrollStore 的设备可自行安装;本项目暂不提供 App Store 或 TestFlight 分发。\n\n`Source code (zip / tar.gz)` 是源码,不能直接安装。附件 `SHA256SUMS` 可用于核对 IPA 完整性。\n\n## 验证与兼容\n\n本次通过 299 项核心及页面模型测试、12 项 iOS 阅读器测试、4 项文件导入与阅读操作 UI 回归,以及模拟器构建。IPA 由本次源码重新进行 iPhoneOS Release 构建。\n\niOS 15.7 真机尚未复测。若在该系统上仍遇到文件无法选择的问题,请继续在 [Issue #1](https://github.com/Aminark/legado-ios/issues/1) 反馈。\n\n**小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库;书源需自行导入。**\n", "versionDescription": "小浣熊阅读 **0.1.12**,构建号 **18**,支持 **iOS / iPadOS 15.0 及以上**。\n\n## 更新内容\n\n- 增加无 DRM EPUB 2/3 导入与阅读,识别书名、作者、封面、NCX/nav 目录及章节锚点,按书脊顺序保留正文。\n- EPUB 支持阅读进度、书签、朗读和阅读样式;在阅读菜单「更多 → 本章插图」查看插图。\n- 本地文件管理支持 EPUB 重命名、原格式导出、删除,以及从系统文件应用打开。\n- 改善 JSON 规则模板、候选选择、组合替换和请求参数兼容,修复 JavaScript 数字类型及动态净化替换。\n- 修复 RSS 翻页失败重试、切换订阅时的过期结果和重复翻页请求。\n- 改善连续滚动中的章节插入定位,调整 iOS 26 代码编辑与图片查看工具栏。\n\n## 下载安装\n\n下载附件 **`legado-ios-v0.1.12-build18-unsigned.ipa`**,使用自己的签名工具安装。支持 TrollStore 的设备可自行尝试安装;目前不提供 App Store 或 TestFlight 分发。\n\n`Source code (zip / tar.gz)` 是源码,不能直接安装。附件 `SHA256SUMS` 用于核对 IPA 完整性。\n\n## 兼容与验证\n\nEPUB 正文使用现有阅读器重新排版,暂不还原原书 CSS、固定版式及交互内容,不支持加密正文。\n\n安装包由对应源码重新进行 iPhoneOS Release 构建,并核对包内版本及完整性。本轮文件导入界面回归仍有待确认项,尚未完成 UI 回归和真机安装验证;如遇问题,请在 [Issues](https://github.com/Aminark/legado-ios/issues) 附上设备、系统版本及复现步骤。\n\n**小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库;书源需自行导入。**\n",
"downloadURL": "https://github.com/Aminark/legado-ios/releases/download/v0.1.10/legado-ios-v0.1.10-build16-unsigned.ipa", "downloadURL": "https://github.com/Aminark/legado-ios/releases/download/v0.1.12/legado-ios-v0.1.12-build18-unsigned.ipa",
"size": 12080513, "size": 12638772,
"appPermissions": { "appPermissions": {
"entitlements": [], "entitlements": [],
"privacy": { "privacy": {
@@ -23,6 +23,10 @@ final class FixtureAppDelegate: UIResponder, UIApplicationDelegate {
try? Data("第一章 开始\n这是用于验证本地 TXT 导入的小说正文。\n第二章 后续\n正文继续。".utf8) try? Data("第一章 开始\n这是用于验证本地 TXT 导入的小说正文。\n第二章 后续\n正文继续。".utf8)
.write(to: documents.appendingPathComponent(name), options: .atomic) .write(to: documents.appendingPathComponent(name), options: .atomic)
} }
if let epub = Bundle.main.url(forResource: "EPUB-Book", withExtension: "epub"),
let data = try? Data(contentsOf: epub) {
try? data.write(to: documents.appendingPathComponent("EPUB-Book.epub"), options: .atomic)
}
let controller = UIViewController() let controller = UIViewController()
controller.view.backgroundColor = .systemBackground controller.view.backgroundColor = .systemBackground
let label = UILabel() let label = UILabel()
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""Generate an original EPUB 3 fixture for Files import and reader UI tests."""
import base64
import sys
import zipfile
from pathlib import Path
output = Path(sys.argv[1])
output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, "w") as archive:
archive.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED)
files = {
"META-INF/container.xml": '<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OPS/book.opf"/></rootfiles></container>',
"OPS/book.opf": '''<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="id">legado-ui-epub</dc:identifier><dc:title>EPUB 导入测试</dc:title><dc:creator>测试作者</dc:creator><dc:language>zh</dc:language><meta property="dcterms:modified">2026-10-23T00:00:00Z</meta></metadata>
<manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/><item id="body" href="chapter.xhtml" media-type="application/xhtml+xml"/><item id="image" href="cover.png" media-type="image/png" properties="cover-image"/></manifest>
<spine><itemref idref="body"/></spine></package>''',
"OPS/nav.xhtml": '''<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>目录</title></head><body><nav epub:type="toc"><ol><li><a href="chapter.xhtml#one">第一章 EPUB</a></li><li><a href="chapter.xhtml#two">第二章 EPUB</a></li></ol></nav></body></html>''',
"OPS/chapter.xhtml": '''<html xmlns="http://www.w3.org/1999/xhtml"><head><title>EPUB 正文</title></head><body><section><h1 id="one">第一章 EPUB</h1><p>这是 EPUB 导入后的第一章正文。</p><img src="cover.png" alt="测试插图"/></section><section><h1 id="two">第二章 EPUB</h1><p>这是 EPUB 的第二章正文,目录锚点跳转成功。</p></section></body></html>''',
}
for name, contents in files.items():
archive.writestr(name, contents.encode("utf-8"), compress_type=zipfile.ZIP_DEFLATED)
archive.writestr("OPS/cover.png", base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABgCAIAAAAip+O/AAABxklEQVR4nNXCA3YjAABF0becsW12bNdIGptt7Nq2bXeMjc06/j2XI8UG6RwtMUrnWGmtdI6XmaRzotwsnZMVFumcqrRK53SVTTpnqu3SOVvjkM45g1M6540u6VyodUvnoskjnUtmr3QuW3zSuWL1S+eqLSCda/agdK47QtK54QxL56arTjq33PXSue2JSOeONyqdu76YdO7549K5H0hI50EwKZ2HoZR0isJp6Tyqy0jncX1WOk8iOek8jeal8yxWkM7zeIN0XiQapfMy2SSdV6lm6bxOt0jnTaZVOm+zbdJ5l2uXzvt8h3Q+FDql87GhSzqfGrul87mpRzpfmnulU9zSJ52S1n7plLYNSKesfVA65R1D0qnoHJZOZdeIdKq6R6VT3TMmnZrecekY+iakY+yflE7twJR0TIPT0jEPzUjHMjwrHevInHRso/PSsY8tSMcxvigd58SSdFyTy9JxT61IxzO9Kh3vzJp0fLPr0vHPbUgnML8pneDClnRCi9vSCS/tSKdueVc69St70oms7ksnunYgndj6oXTiG1+lk9j8Jp3k1nfppLZ/SCe981M6md1f0snu/ZZObv+PdPIHf6VTOPwn/T/zbe3/96qaXAAAAABJRU5ErkJggg=="))
+44
View File
@@ -0,0 +1,44 @@
"""Local, synthetic RSS responses for the LegadoRSSFlowUITests scheme."""
import http.server
import json
import urllib.parse
BASE_URL = "http://127.0.0.1:18763"
class Fixture(http.server.BaseHTTPRequestHandler):
def do_GET(self):
parsed = urllib.parse.urlsplit(self.path)
query = urllib.parse.parse_qs(parsed.query)
key = query.get("key", [""])[0]
page = query.get("page", ["1"])[0]
if parsed.path == "/source.json":
payload = {
"sourceUrl": BASE_URL,
"sourceName": "RSS 全流程测试",
"sortUrl": "全部::" + BASE_URL + "/feed?page={{page}}",
"searchUrl": BASE_URL + "/feed?key={{key}}&page={{page}}",
"ruleArticles": "$.items",
"ruleTitle": "$.title",
"ruleLink": "$.link",
"ruleDescription": "$.description",
"ruleNextPage": "PAGE",
}
else:
title = f"搜索结果 {key} 第{page}页" if key else "首页文章"
payload = {"items": [{
"title": title,
"link": BASE_URL + "/article/" + key + "/" + page,
"description": "<p>这是用于全流程测试的自造订阅正文。</p>",
}]}
body = json.dumps(payload, ensure_ascii=False).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
http.server.ThreadingHTTPServer(("127.0.0.1", 18763), Fixture).serve_forever()
+2 -1
View File
@@ -40,6 +40,7 @@ xcrun --sdk iphonesimulator swiftc -parse-as-library -swift-version 6 \
-module-cache-path "$legado_fixture_repo/DerivedData/ModuleCache.noindex" \ -module-cache-path "$legado_fixture_repo/DerivedData/ModuleCache.noindex" \
"$legado_fixture_repo/scripts/fixtures/SourceImportFixture.swift" \ "$legado_fixture_repo/scripts/fixtures/SourceImportFixture.swift" \
-o "$legado_fixture_app/SourceImportFixtures" -o "$legado_fixture_app/SourceImportFixtures"
python3 "$legado_fixture_repo/scripts/fixtures/make-epub.py" "$legado_fixture_app/EPUB-Book.epub"
xcrun simctl install "$legado_fixture_device" "$legado_fixture_app" xcrun simctl install "$legado_fixture_device" "$legado_fixture_app"
xcrun simctl launch --terminate-running-process "$legado_fixture_device" com.epiboly.legado.source-import-fixtures xcrun simctl launch --terminate-running-process "$legado_fixture_device" com.epiboly.legado.source-import-fixtures
printf 'JSON and TXT import fixtures are ready in Files > On My iPhone > 书源导入测试.\n' printf 'JSON, TXT and EPUB import fixtures are ready in Files > On My iPhone > 书源导入测试.\n'