mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-23 04:11:22 +08:00
fix(import): 修复本地 TXT 文件选择与导入
- 书架和文件管理统一使用 TXT 文件选择器,支持大小写扩展名及多选,排除 JSON 书源 - 保留所选文件访问权限至异步导入完成,等待选择器关闭后展示导入确认 - 补充双入口文件选择回归、导入校验和 TXT 测试样本
This commit is contained in:
@@ -207,9 +207,88 @@ struct ReadingPhotoPickerController: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadingTXTFileImporterModifier: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
let onFiles: ([LocalBookImportFile]) -> Void
|
||||
let onError: (Error) -> Void
|
||||
@State private var pickedFiles: [LocalBookImportFile] = []
|
||||
@State private var pickerHasDismissed = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.sheet(isPresented: $isPresented, onDismiss: {
|
||||
pickerHasDismissed = true
|
||||
deliverFilesWhenReady()
|
||||
}) {
|
||||
ReadingTXTFilePicker { files in
|
||||
pickedFiles = files
|
||||
isPresented = false
|
||||
deliverFilesWhenReady()
|
||||
} onCancel: {
|
||||
pickedFiles = []
|
||||
isPresented = false
|
||||
}
|
||||
.onAppear {
|
||||
pickedFiles = []
|
||||
pickerHasDismissed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deliverFilesWhenReady() {
|
||||
// Providers may return their copies before or after dismissing Files.
|
||||
guard pickerHasDismissed, !pickedFiles.isEmpty else { return }
|
||||
let files = pickedFiles
|
||||
pickedFiles = []
|
||||
guard files.allSatisfy({ $0.url.pathExtension.lowercased() == "txt" && !$0.url.hasDirectoryPath }) else {
|
||||
onError(LocalBookError.unsupportedFormat)
|
||||
return
|
||||
}
|
||||
onFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadingTXTFilePicker: UIViewControllerRepresentable {
|
||||
let onPick: @MainActor ([LocalBookImportFile]) -> Void
|
||||
let onCancel: @MainActor () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
// Plain text includes UTF-8/UTF-16 TXT, while .text and .data also allow JSON.
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.plainText], asCopy: true)
|
||||
picker.allowsMultipleSelection = true
|
||||
picker.shouldShowFileExtensions = true
|
||||
picker.delegate = context.coordinator
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: UIDocumentPickerViewController, context: Context) {}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||||
let parent: ReadingTXTFilePicker
|
||||
|
||||
init(parent: ReadingTXTFilePicker) { self.parent = parent }
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { parent.onCancel() }
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
parent.onPick(urls.map { LocalBookImportFile(url: $0) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReadingSheetDetent { case medium, large }
|
||||
|
||||
extension View {
|
||||
func readingTXTFileImporter(
|
||||
isPresented: Binding<Bool>,
|
||||
onFiles: @escaping ([LocalBookImportFile]) -> Void,
|
||||
onError: @escaping (Error) -> Void
|
||||
) -> some View {
|
||||
modifier(ReadingTXTFileImporterModifier(isPresented: isPresented, onFiles: onFiles, onError: onError))
|
||||
}
|
||||
|
||||
func readingPhotoPicker(
|
||||
isPresented: Binding<Bool>,
|
||||
onData: @escaping (Data) -> Void,
|
||||
|
||||
@@ -26,9 +26,9 @@ private final class FileManagementModel: ObservableObject {
|
||||
await reload(library)
|
||||
}
|
||||
|
||||
func importFiles(_ urls: [URL], library: BookLibraryModel) async {
|
||||
func importFiles(_ files: [LocalBookImportFile], library: BookLibraryModel) async {
|
||||
await run(library) {
|
||||
await library.importLocalFiles(urls)
|
||||
await library.importPickedLocalFiles(files)
|
||||
errorMessage = library.errorMessage
|
||||
library.clearError()
|
||||
}
|
||||
@@ -136,6 +136,7 @@ struct FileManagementView: View {
|
||||
}
|
||||
}
|
||||
} label: { Image(systemName: "ellipsis.circle") }
|
||||
.accessibilityLabel("更多")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,11 +163,10 @@ struct FileManagementView: View {
|
||||
|
||||
private var importAndRenameView: some View {
|
||||
fileList
|
||||
.fileImporter(isPresented: $importsFile, allowedContentTypes: [.text, .plainText, .data], allowsMultipleSelection: true) { result in
|
||||
switch result {
|
||||
case .success(let urls): Task { await model.importFiles(urls, library: library) }
|
||||
case .failure(let error): model.errorMessage = error.localizedDescription
|
||||
}
|
||||
.readingTXTFileImporter(isPresented: $importsFile) { files in
|
||||
Task { await model.importFiles(files, library: library) }
|
||||
} onError: { error in
|
||||
model.errorMessage = error.localizedDescription
|
||||
}
|
||||
.alert("重命名文件", isPresented: Binding(get: { renameURL != nil }, set: { if !$0 { renameURL = nil } }), presenting: renameURL) { url in
|
||||
TextField("文件名", text: $newName)
|
||||
|
||||
@@ -4,6 +4,20 @@ import SwiftUI
|
||||
import LegadoCore
|
||||
#endif
|
||||
|
||||
final class LocalBookImportFile: @unchecked Sendable {
|
||||
let url: URL
|
||||
private let hasSecurityScope: Bool
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
hasSecurityScope = url.startAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
deinit {
|
||||
if hasSecurityScope { url.stopAccessingSecurityScopedResource() }
|
||||
}
|
||||
}
|
||||
|
||||
enum AppBookChapterRepository {
|
||||
static func live() -> any BookChapterRepository {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
@@ -318,10 +332,15 @@ final class BookLibraryModel: ObservableObject {
|
||||
}
|
||||
|
||||
func importLocalFiles(_ urls: [URL]) async {
|
||||
await importPickedLocalFiles(urls.map { LocalBookImportFile(url: $0) })
|
||||
}
|
||||
|
||||
func importPickedLocalFiles(_ files: [LocalBookImportFile]) async {
|
||||
// Keep the picker grants alive through the review and asynchronous copy.
|
||||
defer { withExtendedLifetime(files) {} }
|
||||
var failures: [String] = []
|
||||
for url in urls {
|
||||
let granted = url.startAccessingSecurityScopedResource()
|
||||
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
||||
for file in files {
|
||||
let url = file.url
|
||||
do {
|
||||
let stored = try await localFiles.importFile(from: url)
|
||||
do {
|
||||
|
||||
+17
-24
@@ -146,7 +146,7 @@ private struct BookshelfView: View {
|
||||
@EnvironmentObject private var library: BookLibraryModel
|
||||
@EnvironmentObject private var sources: SourceLibraryModel
|
||||
@State private var showsLocalImporter = false
|
||||
@State private var pendingLocalURLs: [URL] = []
|
||||
@State private var pendingLocalFiles: [LocalBookImportFile] = []
|
||||
@State private var selectedGroupID = BookGroup.idAll
|
||||
@State private var showsGroupManager = false
|
||||
@AppStorage("bookshelfLayout") private var bookshelfLayout = "grid3"
|
||||
@@ -222,26 +222,17 @@ private struct BookshelfView: View {
|
||||
}
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationBarHidden(true)
|
||||
.fileImporter(
|
||||
isPresented: $showsLocalImporter,
|
||||
allowedContentTypes: [.text, .plainText, .data],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
pendingLocalURLs = urls.filter { $0.pathExtension.lowercased() == "txt" }
|
||||
if pendingLocalURLs.isEmpty, !urls.isEmpty {
|
||||
library.reportError(LocalBookError.unsupportedFormat)
|
||||
}
|
||||
case .failure(let error): library.reportError(error)
|
||||
}
|
||||
.readingTXTFileImporter(isPresented: $showsLocalImporter) { files in
|
||||
pendingLocalFiles = files
|
||||
} onError: { error in
|
||||
library.reportError(error)
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { !pendingLocalURLs.isEmpty },
|
||||
set: { if !$0 { pendingLocalURLs = [] } }
|
||||
get: { !pendingLocalFiles.isEmpty },
|
||||
set: { if !$0 { pendingLocalFiles = [] } }
|
||||
)) {
|
||||
LocalBookImportReviewView(urls: pendingLocalURLs) {
|
||||
pendingLocalURLs = []
|
||||
LocalBookImportReviewView(files: pendingLocalFiles) {
|
||||
pendingLocalFiles = []
|
||||
}
|
||||
.environmentObject(library)
|
||||
}
|
||||
@@ -646,15 +637,17 @@ private struct BookGroupSelectView: View {
|
||||
private struct LocalBookImportReviewView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: BookLibraryModel
|
||||
let urls: [URL]
|
||||
let files: [LocalBookImportFile]
|
||||
let onFinish: () -> Void
|
||||
@State private var selectedPaths: Set<String>
|
||||
@State private var isImporting = false
|
||||
|
||||
init(urls: [URL], onFinish: @escaping () -> Void) {
|
||||
self.urls = urls
|
||||
private var urls: [URL] { files.map(\.url) }
|
||||
|
||||
init(files: [LocalBookImportFile], onFinish: @escaping () -> Void) {
|
||||
self.files = files
|
||||
self.onFinish = onFinish
|
||||
_selectedPaths = State(initialValue: Set(urls.map(\.path)))
|
||||
_selectedPaths = State(initialValue: Set(files.map { $0.url.path }))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -690,9 +683,9 @@ private struct LocalBookImportReviewView: View {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("导入") {
|
||||
isImporting = true
|
||||
let selected = urls.filter { selectedPaths.contains($0.path) }
|
||||
let selected = files.filter { selectedPaths.contains($0.url.path) }
|
||||
Task {
|
||||
await library.importLocalFiles(selected)
|
||||
await library.importPickedLocalFiles(selected)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,38 @@ import LegadoCore
|
||||
|
||||
@MainActor
|
||||
struct LocalFileManagementTests {
|
||||
@Test func pickedTXTImportsButJSONFilesCannotBecomeLocalBooks() 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 inputs = [
|
||||
("小说.TXT", "第一章 开始\n本地小说的正文"),
|
||||
("书源.json", #"{"bookSourceName":"书源"}"#),
|
||||
("大写书源.JSON", #"{"bookSourceName":"书源"}"#)
|
||||
]
|
||||
let files = try inputs.map { name, text in
|
||||
let url = root.appendingPathComponent(name)
|
||||
try Data(text.utf8).write(to: url)
|
||||
return LocalBookImportFile(url: url)
|
||||
}
|
||||
let model = BookLibraryModel(repository: InMemoryBookRepository(),
|
||||
localDirectory: root.appendingPathComponent("managed"),
|
||||
chapterRepository: InMemoryBookChapterRepository())
|
||||
await model.load()
|
||||
|
||||
await model.importPickedLocalFiles(files)
|
||||
|
||||
let book = try #require(model.books.first)
|
||||
#expect(model.books.count == 1)
|
||||
#expect(book.origin == "local")
|
||||
#expect(book.originName == "小说.TXT")
|
||||
#expect(model.errorMessage?.contains("书源.json") == true)
|
||||
#expect(model.errorMessage?.contains("大写书源.JSON") == true)
|
||||
#expect(try await model.localFiles.list().count == 1)
|
||||
let imported = try #require(URL(string: book.bookUrl))
|
||||
#expect(try Data(contentsOf: imported) == Data(inputs[0].1.utf8))
|
||||
}
|
||||
|
||||
private func fixture(fails: Bool = false) async throws -> (URL, Book, BookLibraryModel) {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
@@ -2,6 +2,92 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class RuleEditorUITests: XCTestCase {
|
||||
func testBookshelfTXTFilePickerImportsTXTAndDisablesJSON() throws {
|
||||
try exerciseLocalTXTImport(fromFileManagement: false)
|
||||
}
|
||||
|
||||
func testFileManagementTXTFilePickerImportsTXTAndDisablesJSON() throws {
|
||||
try exerciseLocalTXTImport(fromFileManagement: true)
|
||||
}
|
||||
|
||||
private func exerciseLocalTXTImport(fromFileManagement: Bool) throws {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo", "-themeMode", "1", "-bookshelfLayout", "grid3"]
|
||||
if fromFileManagement { app.launchArguments += ["-uiDemoTab", "settings", "-uiDemoFiles"] }
|
||||
app.launch()
|
||||
let importTitle = fromFileManagement ? "导入 TXT 文件" : "本地书"
|
||||
let more = fromFileManagement
|
||||
? app.navigationBars["文件管理"].buttons["更多"].firstMatch
|
||||
: app.buttons["更多"].firstMatch
|
||||
XCTAssertTrue(more.waitForExistence(timeout: 20))
|
||||
more.tap()
|
||||
app.buttons[importTitle].tap()
|
||||
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
|
||||
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
|
||||
cancel.tap()
|
||||
let closed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil)
|
||||
wait(for: [closed], timeout: 10)
|
||||
more.tap()
|
||||
app.buttons[importTitle].tap()
|
||||
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
|
||||
|
||||
let pickerFiles = app.collectionViews["File View"].cells
|
||||
let txt = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Book")).firstMatch
|
||||
let fixtureJSON = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "JSON-Source")).firstMatch
|
||||
let fixtures = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "书源导入测试")).firstMatch
|
||||
// The file-management page underneath the picker can also contain TXT.
|
||||
// JSON exists only inside our fixture folder in the actual picker.
|
||||
if !fixtureJSON.waitForExistence(timeout: 3) {
|
||||
if !fixtures.exists {
|
||||
let browse = app.buttons.matching(NSPredicate(format: "label IN %@", ["Browse", "浏览", "Locations", "位置"])).firstMatch
|
||||
if browse.exists { browse.tap() }
|
||||
if !fixtures.waitForExistence(timeout: 2) {
|
||||
let local = app.descendants(matching: .any).matching(NSPredicate(
|
||||
format: "label CONTAINS %@ OR label CONTAINS %@ OR label == %@", "我的 iPhone", "我的iPhone", "On My iPhone"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(local.waitForExistence(timeout: 5))
|
||||
local.tap()
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(fixtures.waitForExistence(timeout: 10), "Run sh scripts/prepare-source-import-fixture.sh <simulator-UUID>")
|
||||
fixtures.tap()
|
||||
}
|
||||
XCTAssertTrue(txt.waitForExistence(timeout: 10))
|
||||
let open = app.buttons.matching(NSPredicate(format: "label IN %@", ["Open", "打开", "Import", "导入"])).firstMatch
|
||||
for name in ["JSON-Source", "JSON-Uppercase"] {
|
||||
let json = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", name)).firstMatch
|
||||
XCTAssertTrue(json.waitForExistence(timeout: 5))
|
||||
XCTAssertFalse(json.isEnabled)
|
||||
json.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
XCTAssertFalse(open.exists && open.isEnabled, "JSON must not be selectable as a local book")
|
||||
}
|
||||
txt.tap()
|
||||
let uppercase = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch
|
||||
XCTAssertTrue(uppercase.waitForExistence(timeout: 5))
|
||||
uppercase.tap()
|
||||
XCTAssertTrue(open.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(open.isEnabled)
|
||||
attach(app, name: "TXT selectable and JSON excluded")
|
||||
open.tap()
|
||||
|
||||
if fromFileManagement {
|
||||
XCTAssertTrue(app.navigationBars["文件管理"].waitForExistence(timeout: 15))
|
||||
app.tabBars.buttons["书架"].tap()
|
||||
} else {
|
||||
XCTAssertTrue(app.navigationBars["导入本地书"].waitForExistence(timeout: 15))
|
||||
XCTAssertFalse(app.staticTexts["JSON-Source.json"].exists)
|
||||
XCTAssertFalse(app.staticTexts["JSON-Uppercase.JSON"].exists)
|
||||
XCTAssertTrue(app.staticTexts["TXT-Book.txt"].exists)
|
||||
XCTAssertTrue(app.staticTexts["TXT-Uppercase.TXT"].exists)
|
||||
app.buttons["导入"].tap()
|
||||
}
|
||||
// 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-Uppercase")).firstMatch.waitForExistence(timeout: 15))
|
||||
attach(app, name: "Local TXT books imported onto shelf")
|
||||
}
|
||||
|
||||
func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
|
||||
+4
-2
@@ -41,7 +41,7 @@ sh scripts/self-check.sh
|
||||
|
||||
阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。
|
||||
|
||||
书源文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值):
|
||||
书源和本地 TXT 文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值):
|
||||
|
||||
```sh
|
||||
LEGADO_TEST_DEVICE="模拟器 UUID"
|
||||
@@ -49,10 +49,12 @@ sh scripts/prepare-source-import-fixture.sh "$LEGADO_TEST_DEVICE"
|
||||
xcodebuild -project Legado.xcodeproj -scheme LegadoReaderUITests \
|
||||
-destination "platform=iOS Simulator,id=$LEGADO_TEST_DEVICE" \
|
||||
-only-testing:LegadoReaderUITests/RuleEditorUITests/testJSONFilePickerOffersOpenAndImportsSelectedFiles \
|
||||
-only-testing:LegadoReaderUITests/RuleEditorUITests/testBookshelfTXTFilePickerImportsTXTAndDisablesJSON \
|
||||
-only-testing:LegadoReaderUITests/RuleEditorUITests/testFileManagementTXTFilePickerImportsTXTAndDisablesJSON \
|
||||
-collect-test-diagnostics never CODE_SIGNING_ALLOWED=NO test
|
||||
```
|
||||
|
||||
独立测试应用只提供 `.json`、`.JSON` 和 `.txt` 样本,不会被打包进正式 App。此回归检查取消后重新打开、JSON 多选后的打开按钮、导入预检及最终保存。
|
||||
独立测试应用提供 JSON 书源和 `.txt`、`.TXT` 小说样本,不会被打包进正式 App。此回归检查取消后重新打开、书源 JSON 多选、本地 TXT 可选且 JSON 不可选、导入确认及最终保存。
|
||||
|
||||
## 兼容性验证
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ final class FixtureAppDelegate: UIResponder, UIApplicationDelegate {
|
||||
try? data.write(to: documents.appendingPathComponent(name), options: .atomic)
|
||||
}
|
||||
}
|
||||
for name in ["TXT-Book.txt", "TXT-Uppercase.TXT"] {
|
||||
try? Data("第一章 开始\n这是用于验证本地 TXT 导入的小说正文。\n第二章 后续\n正文继续。".utf8)
|
||||
.write(to: documents.appendingPathComponent(name), options: .atomic)
|
||||
}
|
||||
let controller = UIViewController()
|
||||
controller.view.backgroundColor = .systemBackground
|
||||
let label = UILabel()
|
||||
|
||||
@@ -42,4 +42,4 @@ xcrun --sdk iphonesimulator swiftc -parse-as-library -swift-version 6 \
|
||||
-o "$legado_fixture_app/SourceImportFixtures"
|
||||
xcrun simctl install "$legado_fixture_device" "$legado_fixture_app"
|
||||
xcrun simctl launch --terminate-running-process "$legado_fixture_device" com.epiboly.legado.source-import-fixtures
|
||||
printf 'JSON file import fixtures are ready in Files > On My iPhone > 书源导入测试.\n'
|
||||
printf 'JSON and TXT import fixtures are ready in Files > On My iPhone > 书源导入测试.\n'
|
||||
|
||||
Reference in New Issue
Block a user