mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 04:41:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02bcea0911 | ||
|
|
67bc212ffd | ||
|
|
3322fd71db |
+11
-10
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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 ?? "无法写入数据库")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]? {
|
||||||
|
|||||||
@@ -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("作用范围") {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 书源。
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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://` 规则导入链接。来源网站改版或停止服务时,书源规则可能需要更新。
|
||||||
|
|
||||||
## 书源说明
|
## 书源说明
|
||||||
|
|||||||
@@ -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), [])
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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><p>测试简介 &amp; 内容</p></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>甲 & 乙<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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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=="))
|
||||||
@@ -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()
|
||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user