From 0be1c6a6825c274024647446f6628f23e76ab0e8 Mon Sep 17 00:00:00 2001 From: Miltiadis Vasilakis Date: Wed, 19 Feb 2025 12:56:52 +0200 Subject: [PATCH] Indicate when an item is retrieving metadata --- ZShare/ViewModels/ExtensionViewModel.swift | 4 +- Zotero/Assets/en.lproj/Localizable.strings | 1 + Zotero/Controllers/RecognizerController.swift | 38 +++- Zotero/Extensions/Localizable.swift | 2 + .../Detail/Items/Models/ItemCellModel.swift | 50 +++++- .../Detail/Items/Models/ItemsAction.swift | 1 + .../Detail/Items/Models/ItemsState.swift | 16 +- .../Items/ViewModels/ItemsActionHandler.swift | 18 +- .../Scenes/Detail/Items/Views/ItemCell.swift | 64 ++++++- .../Items/Views/ItemsTableViewHandler.swift | 5 + .../Items/Views/ItemsViewController.swift | 170 ++++++++++-------- .../Views/RItemsTableViewDataSource.swift | 6 +- .../Views/TrashTableViewDataSource.swift | 2 +- .../Trash/Views/TrashViewController.swift | 2 +- 14 files changed, 273 insertions(+), 106 deletions(-) diff --git a/ZShare/ViewModels/ExtensionViewModel.swift b/ZShare/ViewModels/ExtensionViewModel.swift index 45cb3e1f9..d2eabc32e 100644 --- a/ZShare/ViewModels/ExtensionViewModel.swift +++ b/ZShare/ViewModels/ExtensionViewModel.swift @@ -553,7 +553,7 @@ final class ExtensionViewModel { case .recognitionInProgress, .remoteRecognitionInProgress, .identifierLookupInProgress: updateState(with: .decoding) - case .translated(item: let item): + case .translated(let item): var state = self.state state.expectedItem = item state.expectedAttachment = (filename, tmpFile) @@ -562,7 +562,7 @@ final class ExtensionViewModel { state.processedAttachment = .itemWithAttachment(item: item, attachment: [:], attachmentFile: file) self.state = state - case .createdParent: + case .enqueued, .createdParent: break } } diff --git a/Zotero/Assets/en.lproj/Localizable.strings b/Zotero/Assets/en.lproj/Localizable.strings index d7a3e9493..a5dc80588 100644 --- a/Zotero/Assets/en.lproj/Localizable.strings +++ b/Zotero/Assets/en.lproj/Localizable.strings @@ -148,6 +148,7 @@ "items.generating_bib" = "Generating Bibliography"; "items.creator_summary.and" = "%@ and %@"; "items.creator_summary.etal" = "%@ et al."; +"items.retrieving_metadata" = "Retrieving Metadata"; "lookup.title" = "Enter ISBNs, DOls, PMIDs, arXiv IDs, or ADS Bibcodes to add to your library:"; diff --git a/Zotero/Controllers/RecognizerController.swift b/Zotero/Controllers/RecognizerController.swift index cfa29342c..c3a3521f8 100644 --- a/Zotero/Controllers/RecognizerController.swift +++ b/Zotero/Controllers/RecognizerController.swift @@ -96,6 +96,7 @@ final class RecognizerController { enum Kind { case failed(Error) case cancelled + case enqueued case recognitionInProgress case remoteRecognitionInProgress(data: [String: Any]) case identifierLookupInProgress(response: RemoteRecognizerResponse, identifier: String) @@ -136,6 +137,7 @@ final class RecognizerController { // Accessed only via accessQueue private static let maxConcurrentRecognizerTasks: Int = 1 private var queue: OrderedDictionary)> = [:] + private var latestUpdates: [LibraryIdentifier: [String: Update.Kind]] = [:] private var lookupWebViewHandlersByRecognizerTask: [RecognizerTask: LookupWebViewHandler] = [:] // MARK: Object Lifecycle @@ -163,28 +165,39 @@ final class RecognizerController { } // MARK: Actions - func queue(task: RecognizerTask, completion: @escaping (_ observable: Observable?) -> Void) { + func queue(task: RecognizerTask, completion: ((_ observable: Observable?) -> Void)? = nil) { accessQueue.async(flags: .barrier) { [weak self] in guard let self else { - completion(nil) + completion?(nil) return } if let (_, observable) = queue[task] { - completion(observable.asObservable()) + completion?(observable.asObservable()) return } let state: RecognizerTaskState = .enqueued let observable: PublishSubject = PublishSubject() queue[task] = (state, observable) - completion(observable.asObservable()) + completion?(observable.asObservable()) observable.subscribe(onNext: { [weak self] update in self?.updatesSubject.on(.next(update)) }).disposed(by: disposeBag) + emmitUpdate(for: task, observable: observable, kind: .enqueued) startRecognitionIfNeeded() } } + private func emmitUpdate(for task: RecognizerTask, observable: PublishSubject, kind: Update.Kind) { + let update = Update(task: task, kind: kind) + if case .createParentForItem(let libraryId, let key) = task.kind { + var libraryLatestUpdates = latestUpdates[libraryId, default: [:]] + libraryLatestUpdates[key] = kind + latestUpdates[libraryId] = libraryLatestUpdates + } + observable.on(.next(update)) + } + private func startRecognitionIfNeeded() { let runningRecognizerTasksCount = queue.filter({ switch $0.value.state { @@ -212,7 +225,7 @@ final class RecognizerController { func start(task: RecognizerTask, observable: PublishSubject) { queue[task] = (.recognitionInProgress, observable) - observable.on(.next(Update(task: task, kind: .recognitionInProgress))) + emmitUpdate(for: task, observable: observable, kind: .recognitionInProgress) pdfWorkerController.queue(work: PDFWorkerController.PDFWork(file: task.file, kind: .recognizer)) { [weak self] pdfWorkerObservable in guard let self else { return } @@ -266,7 +279,7 @@ final class RecognizerController { return } queue[task] = (.remoteRecognitionInProgress(data: data), observable) - observable.on(.next(Update(task: task, kind: .remoteRecognitionInProgress(data: data)))) + emmitUpdate(for: task, observable: observable, kind: .remoteRecognitionInProgress(data: data)) apiClient.send(request: RecognizerRequest(parameters: data)).subscribe( onSuccess: { (response: (RemoteRecognizerResponse, HTTPURLResponse)) in @@ -378,7 +391,7 @@ final class RecognizerController { enqueueNextIdentifierLookup(for: task) return } - observable.on(.next(Update(task: task, kind: .identifierLookupInProgress(response: response, identifier: identifier)))) + emmitUpdate(for: task, observable: observable, kind: .identifierLookupInProgress(response: response, identifier: identifier)) lookupWebViewHandler.lookUp(identifier: identifier) func getLookupWebViewHandler(for task: RecognizerTask) -> LookupWebViewHandler? { @@ -548,6 +561,10 @@ final class RecognizerController { func cleanup(for task: RecognizerTask, completion: @escaping (_ observable: PublishSubject?) -> Void) { let observable = queue.removeValue(forKey: task).flatMap({ $0.observable }) + if case .createParentForItem(let libraryId, let key) = task.kind, var libraryLatestUpdates = latestUpdates[libraryId] { + libraryLatestUpdates[key] = nil + latestUpdates[libraryId] = libraryLatestUpdates + } DDLogInfo("RecognizerController: \(task) - cleaned up") if let webView = lookupWebViewHandlersByRecognizerTask.removeValue(forKey: task)?.webViewHandler.webView { DispatchQueue.main.async { @@ -558,4 +575,11 @@ final class RecognizerController { startRecognitionIfNeeded() } } + + func latestUpdate(for key: String, libraryId: LibraryIdentifier) -> Update.Kind? { + return accessQueue.sync { [weak self] in + guard let self, let libraryLatestUpdates = latestUpdates[libraryId] else { return nil } + return libraryLatestUpdates[key] + } + } } diff --git a/Zotero/Extensions/Localizable.swift b/Zotero/Extensions/Localizable.swift index be6806838..e6bb79404 100644 --- a/Zotero/Extensions/Localizable.swift +++ b/Zotero/Extensions/Localizable.swift @@ -839,6 +839,8 @@ internal enum L10n { } /// Remove from Collection internal static let removeFromCollectionTitle = L10n.tr("Localizable", "items.remove_from_collection_title", fallback: "Remove from Collection") + /// Retrieving Metadata + internal static let retrievingMetadata = L10n.tr("Localizable", "items.retrieving_metadata", fallback: "Retrieving Metadata") /// Search Items internal static let searchTitle = L10n.tr("Localizable", "items.search_title", fallback: "Search Items") /// Select All diff --git a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift index ce4d68fc8..ea86f895c 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift @@ -17,25 +17,30 @@ struct ItemCellModel { case url } + struct Subtitle { + let text: String + let animated: Bool + } + let key: String let typeIconName: String let iconRenderingMode: UIImage.RenderingMode let typeName: String let title: NSAttributedString - let subtitle: String + let subtitle: Subtitle? let hasNote: Bool let tagColors: [UIColor] let tagEmojis: [String] let accessory: Accessory? let hasDetailButton: Bool - init(item: RItem, typeName: String, title: NSAttributedString, accessory: Accessory?) { + init(item: RItem, typeName: String, title: NSAttributedString, subtitle: Subtitle?, accessory: Accessory?) { key = item.key typeIconName = Self.typeIconName(for: item) iconRenderingMode = .alwaysOriginal self.typeName = typeName self.title = title - subtitle = Self.creatorSummary(for: item) + self.subtitle = subtitle hasNote = Self.hasNote(item: item) self.accessory = accessory let (colors, emojis) = Self.tagData(item: item) @@ -44,8 +49,14 @@ struct ItemCellModel { hasDetailButton = true } - init(item: RItem, typeName: String, title: NSAttributedString, accessory: ItemAccessory?, fileDownloader: AttachmentDownloader?) { - self.init(item: item, typeName: typeName, title: title, accessory: Self.createAccessory(from: accessory, fileDownloader: fileDownloader)) + init(item: RItem, typeName: String, title: NSAttributedString, accessory: ItemAccessory?, fileDownloader: AttachmentDownloader?, recognizerController: RecognizerController?) { + self.init( + item: item, + typeName: typeName, + title: title, + subtitle: Self.createSubtitle(for: item, recognizerController: recognizerController), + accessory: Self.createAccessory(from: accessory, fileDownloader: fileDownloader) + ) } init(collectionWithKey key: String, title: NSAttributedString) { @@ -54,7 +65,7 @@ struct ItemCellModel { accessory = nil typeIconName = Asset.Images.Cells.collection.name iconRenderingMode = .alwaysTemplate - subtitle = "" + subtitle = nil hasNote = false tagColors = [] tagEmojis = [] @@ -118,4 +129,31 @@ struct ItemCellModel { } return result } + + static func createSubtitle(for item: RItem, update: RecognizerController.Update.Kind?) -> Subtitle? { + guard item.parent == nil, let update else { + return Subtitle(text: creatorSummary(for: item), animated: false) + } + + let text: String + let animated: Bool + switch update { + case .failed, .cancelled, .createdParent: + text = creatorSummary(for: item) + animated = false + + case .enqueued, .recognitionInProgress, .remoteRecognitionInProgress, .identifierLookupInProgress, .translated: + text = L10n.Items.retrievingMetadata + animated = true + } + return Subtitle(text: text, animated: animated) + } + + static func createSubtitle(for item: RItem, recognizerController: RecognizerController?) -> Subtitle? { + guard item.parent == nil, let recognizerController else { + return Subtitle(text: creatorSummary(for: item), animated: false) + } + let update = recognizerController.latestUpdate(for: item.key, libraryId: item.libraryIdentifier) + return createSubtitle(for: item, update: update) + } } diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift b/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift index 461b20b79..001295d78 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift @@ -39,6 +39,7 @@ enum ItemsAction { case updateDownload(update: AttachmentDownloader.Update, batchData: ItemsState.DownloadBatchData?) case updateIdentifierLookup(update: IdentifierLookupController.Update, batchData: ItemsState.IdentifierLookupBatchData) case updateRemoteDownload(update: RemoteAttachmentDownloader.Update, batchData: ItemsState.DownloadBatchData?) + case updateMetadataRetrieval(itemKey: String, update: RecognizerController.Update.Kind) case openAttachment(attachment: Attachment, parentKey: String?) case attachmentOpened(String) case updateKeys(items: Results, deletions: [Int], insertions: [Int], modifications: [Int]) diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsState.swift b/Zotero/Scenes/Detail/Items/Models/ItemsState.swift index 5c45b1656..4bf5207b5 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemsState.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemsState.swift @@ -83,6 +83,16 @@ struct ItemsState: ViewModelState { } } + struct ItemUpdate { + enum Kind { + case accessory + case subtitle(ItemCellModel.Subtitle?) + } + + let key: String + let kind: Kind + } + let collection: Collection var library: Library @@ -102,8 +112,8 @@ struct ItemsState: ViewModelState { var changes: Changes var error: ItemsError? var itemKeyToDuplicate: String? - // Used to indicate which row should update it's attachment view. The update is done directly to cell instead of tableView reload. - var updateItemKey: String? + // Used to indicate which row should update directly a cell element (e.g. attachment view). The update is done directly to cell instead of tableView reload. + var updateItem: ItemUpdate? var attachmentToOpen: String? var downloadBatchData: DownloadBatchData? var remoteDownloadBatchData: DownloadBatchData? @@ -152,6 +162,6 @@ struct ItemsState: ViewModelState { error = nil changes = [] itemKeyToDuplicate = nil - updateItemKey = nil + updateItem = nil } } diff --git a/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift b/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift index b617758fe..fd520b564 100644 --- a/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift +++ b/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift @@ -146,6 +146,9 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { case .updateRemoteDownload(let update, let batchData): self.process(remoteDownloadUpdate: update, batchData: batchData, in: viewModel) + case .updateMetadataRetrieval(let itemKey, let update): + process(metadataRetrievalUpdate: update, itemKey: itemKey, in: viewModel) + case .openAttachment(let attachment, let parentKey): self.open(attachment: attachment, parentKey: parentKey, in: viewModel) @@ -266,7 +269,7 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { let updatedAccessory = accessory.updatedAttachment(update: { attachment in attachment.changed(location: .remote, condition: { $0 == .local }) }) else { return } self.update(viewModel: viewModel) { state in state.itemAccessories[updateKey] = updatedAccessory - state.updateItemKey = updateKey + state.updateItem = ItemsState.ItemUpdate(key: updateKey, kind: .accessory) } } } @@ -310,7 +313,7 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { guard let updatedAttachment = attachment.changed(location: .local, compressed: compressed) else { return } updateViewModel { state in state.itemAccessories[updateKey] = .attachment(attachment: updatedAttachment, parentKey: downloadUpdate.parentKey) - state.updateItemKey = updateKey + state.updateItem = ItemsState.ItemUpdate(key: updateKey, kind: .accessory) } case .progress: @@ -319,13 +322,13 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { guard let currentProgress = fileDownloader.data(for: downloadUpdate.key, parentKey: downloadUpdate.parentKey, libraryId: downloadUpdate.libraryId).progress, currentProgress < 1 else { return } updateViewModel { state in - state.updateItemKey = updateKey + state.updateItem = ItemsState.ItemUpdate(key: updateKey, kind: .accessory) } case .cancelled, .failed: DDLogInfo("ItemsActionHandler: download update \(attachment.key); \(attachment.libraryId); kind \(downloadUpdate.kind)") updateViewModel { state in - state.updateItemKey = updateKey + state.updateItem = ItemsState.ItemUpdate(key: updateKey, kind: .accessory) } } @@ -359,6 +362,13 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } + private func process(metadataRetrievalUpdate: RecognizerController.Update.Kind, itemKey: String, in viewModel: ViewModel) { + guard let item = viewModel.state.results?.filter("key == %@", itemKey).first else { return } + update(viewModel: viewModel) { state in + state.updateItem = ItemsState.ItemUpdate(key: itemKey, kind: .subtitle(ItemCellModel.createSubtitle(for: item, update: metadataRetrievalUpdate))) + } + } + private func cacheItemAccessory(for item: RItem, in viewModel: ViewModel) { // Create cached accessory only if there is nothing in cache yet. guard viewModel.state.itemAccessories[item.key] == nil, let accessory = ItemAccessory.create(from: item, fileStorage: fileStorage, urlDetector: urlDetector) else { return } diff --git a/Zotero/Scenes/Detail/Items/Views/ItemCell.swift b/Zotero/Scenes/Detail/Items/Views/ItemCell.swift index 105917907..1b059783c 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemCell.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemCell.swift @@ -33,9 +33,16 @@ final class ItemCell: UITableViewCell { self.selectedBackgroundView?.backgroundColor } + private var subtitleAnimator: UIViewPropertyAnimator? + private var subtitlePrefix: String = "" + private var subtitleAnimationSuffixDotCount = 0 + override func prepareForReuse() { super.prepareForReuse() self.key = "" + subtitleAnimator = nil + subtitlePrefix = "" + subtitleAnimationSuffixDotCount = 0 } override func awakeFromNib() { @@ -103,12 +110,10 @@ final class ItemCell: UITableViewCell { self.titleLabel.attributedText = item.title } self.titleLabel.accessibilityLabel = self.titleAccessibilityLabel(for: item) - self.subtitleLabel.text = item.subtitle.isEmpty ? " " : item.subtitle - self.subtitleLabel.accessibilityLabel = item.subtitle + set(subtitle: item.subtitle) // The label adds extra horizontal spacing so there is a negative right inset so that the label ends where the text ends exactly. // The note icon is rectangular and has 1px white space on each side, so it needs an extra negative pixel when there are no tags. self.subtitleLabel.rightInset = item.tagColors.isEmpty ? -2 : -1 - self.subtitleLabel.isHidden = item.subtitle.isEmpty && (item.hasNote || !item.tagColors.isEmpty) self.noteIcon.isHidden = !item.hasNote self.noteIcon.isAccessibilityElement = false @@ -150,4 +155,57 @@ final class ItemCell: UITableViewCell { let title = item.title.string.isEmpty ? L10n.Accessibility.untitled : item.title.string return item.typeName + ", " + title } + + func set(subtitle: ItemCellModel.Subtitle?) { + let text = subtitle?.text ?? "" + let animated = subtitle?.animated ?? false + subtitlePrefix = text + if let subtitleAnimator, subtitleAnimator.isRunning { + // Animator is already running. + if !animated { + // Stop animating subtitle, and the new subtitle prefix will be set in the label. + stopAnimatingSubtitle() + } + // Otherwise do nothing as the animation will use the new subtitle prefix. + } else { + // Animator is not running. First set new text. + subtitleLabel.text = text.isEmpty ? " " : text + subtitleLabel.accessibilityLabel = text + if !text.isEmpty, animated { + // Start animating if needed. + startAnimatingSubtitle() + } + } + subtitleLabel.isHidden = text.isEmpty && (!noteIcon.isHidden || !tagCircles.isHidden) + } + + private func startAnimatingSubtitle() { + subtitleAnimator = UIViewPropertyAnimator(duration: 0.5, curve: .linear) { [weak self] in + guard let self else { return } + // Reduce subtitle label opacity to create a fade effect. + subtitleLabel.alpha = 0.9 + } + + subtitleAnimator?.addCompletion { [weak self] _ in + guard let self else { return } + subtitleAnimationSuffixDotCount = (subtitleAnimationSuffixDotCount + 1) % 3 + subtitleLabel.text = subtitlePrefix + String(repeating: ".", count: subtitleAnimationSuffixDotCount + 1) + " " + subtitleLabel.accessibilityLabel = subtitlePrefix + // Restore opacity. + subtitleLabel.alpha = 1 + // Repeat animation. + startAnimatingSubtitle() + } + + subtitleAnimator?.startAnimation() + } + + private func stopAnimatingSubtitle() { + subtitleAnimator?.stopAnimation(true) + subtitleAnimator = nil + subtitleAnimationSuffixDotCount = 0 + subtitleLabel.text = subtitlePrefix.isEmpty ? " " : subtitlePrefix + subtitleLabel.accessibilityLabel = subtitlePrefix + subtitleLabel.alpha = 1 + } } diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift b/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift index 2ec89c874..edaf6d14f 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift @@ -186,6 +186,11 @@ final class ItemsTableViewHandler: NSObject { cell.set(accessory: accessory) } + func updateCell(key: String, withSubtitle subtitle: ItemCellModel.Subtitle?) { + guard let cell = tableView.visibleCells.first(where: { ($0 as? ItemCell)?.key == key }) as? ItemCell else { return } + cell.set(subtitle: subtitle) + } + func performTapAction(forIndexPath indexPath: IndexPath) { guard let action = dataSource.tapAction(for: indexPath) else { tableView.deselectRow(at: indexPath, animated: true) diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift index 8c00133fd..9aba4f822 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift @@ -44,11 +44,17 @@ final class ItemsViewController: BaseItemsViewController { override func viewDidLoad() { super.viewDidLoad() - dataSource = RItemsTableViewDataSource(viewModel: viewModel, fileDownloader: controllers.userControllers?.fileDownloader, schemaController: controllers.schemaController) + dataSource = RItemsTableViewDataSource( + viewModel: viewModel, + fileDownloader: controllers.userControllers?.fileDownloader, + recognizerController: controllers.userControllers?.recognizerController, + schemaController: controllers.schemaController + ) handler = ItemsTableViewHandler(tableView: tableView, delegate: self, dataSource: dataSource, dragDropController: controllers.dragDropController) toolbarController = ItemsToolbarController(viewController: self, data: toolbarData, collection: collection, library: library, delegate: self) setupRightBarButtonItems(expectedItems: rightBarButtonItemTypes(for: viewModel.state)) setupFileObservers() + setupRecognizerObserver() setupAppStateObserver() if let term = viewModel.state.searchTerm, !term.isEmpty { @@ -65,6 +71,82 @@ final class ItemsViewController: BaseItemsViewController { self?.update(state: state) }) .disposed(by: disposeBag) + + func setupFileObservers() { + NotificationCenter.default + .rx + .notification(.attachmentFileDeleted) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] notification in + if let notification = notification.object as? AttachmentFileDeletedNotification { + self?.viewModel.process(action: .updateAttachments(notification)) + } + }) + .disposed(by: disposeBag) + + let downloader = controllers.userControllers?.fileDownloader + downloader?.observable + .observe(on: MainScheduler.asyncInstance) + .subscribe(onNext: { [weak self, weak downloader] update in + guard let self else { return } + process( + downloadUpdate: update, + toOpen: viewModel.state.attachmentToOpen, + downloader: downloader, + dataUpdate: { batchData in + self.viewModel.process(action: .updateDownload(update: update, batchData: batchData)) + }, + attachmentWillOpen: { update in + self.viewModel.process(action: .attachmentOpened(update.key)) + } + ) + }) + .disposed(by: disposeBag) + + let identifierLookupController = controllers.userControllers?.identifierLookupController + identifierLookupController?.observable + .observe(on: MainScheduler.asyncInstance) + .subscribe(onNext: { [weak self, weak identifierLookupController] update in + guard let self, let identifierLookupController else { return } + let batchData = ItemsState.IdentifierLookupBatchData(batchData: identifierLookupController.batchData) + viewModel.process(action: .updateIdentifierLookup(update: update, batchData: batchData)) + }) + .disposed(by: disposeBag) + + let remoteDownloader = controllers.userControllers?.remoteFileDownloader + remoteDownloader?.observable + .observe(on: MainScheduler.asyncInstance) + .subscribe(onNext: { [weak self, weak remoteDownloader] update in + guard let self, let remoteDownloader else { return } + let batchData = ItemsState.DownloadBatchData(batchData: remoteDownloader.batchData) + viewModel.process(action: .updateRemoteDownload(update: update, batchData: batchData)) + }) + .disposed(by: disposeBag) + } + + func setupRecognizerObserver() { + let recognizerController = controllers.userControllers?.recognizerController + recognizerController?.updates + .observe(on: MainScheduler.asyncInstance) + .subscribe(onNext: { [weak viewModel] update in + guard let viewModel, case .createParentForItem(let libraryId, let key) = update.task.kind, viewModel.state.library.identifier == libraryId else { return } + viewModel.process(action: .updateMetadataRetrieval(itemKey: key, update: update.kind)) + }) + .disposed(by: disposeBag) + } + + func setupAppStateObserver() { + NotificationCenter.default + .rx + .notification(UIContentSizeCategory.didChangeNotification) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] _ in + guard let self else { return } + viewModel.process(action: .clearTitleCache) + handler?.reloadAll() + }) + .disposed(by: disposeBag) + } } deinit { @@ -78,9 +160,15 @@ final class ItemsViewController: BaseItemsViewController { self.startObserving(results: results) } else if state.changes.contains(.attachmentsRemoved) { handler?.attachmentAccessoriesChanged() - } else if let key = state.updateItemKey { - let accessory = state.itemAccessories[key].flatMap({ ItemCellModel.createAccessory(from: $0, fileDownloader: controllers.userControllers?.fileDownloader) }) - handler?.updateCell(key: key, withAccessory: accessory) + } else if let itemUpdate = state.updateItem { + switch itemUpdate.kind { + case .accessory: + let accessory = state.itemAccessories[itemUpdate.key].flatMap({ ItemCellModel.createAccessory(from: $0, fileDownloader: controllers.userControllers?.fileDownloader) }) + handler?.updateCell(key: itemUpdate.key, withAccessory: accessory) + + case .subtitle(let subtitle): + handler?.updateCell(key: itemUpdate.key, withSubtitle: subtitle) + } } if state.changes.contains(.editing) { @@ -171,13 +259,7 @@ final class ItemsViewController: BaseItemsViewController { file.mimeType == "application/pdf", let recognizerController = controllers.userControllers?.recognizerController else { return } - recognizerController.queue(task: RecognizerController.RecognizerTask(file: file, kind: .createParentForItem(libraryId: library.identifier, key: key))) { [weak self] observable in - guard let self else { return } - observable?.subscribe { _ in - // TODO: Implement update of UI according to updates - } - .disposed(by: disposeBag) - } + recognizerController.queue(task: RecognizerController.RecognizerTask(file: file, kind: .createParentForItem(libraryId: library.identifier, key: key))) case .duplicate: guard let key = selectedKeys.first else { return } @@ -308,72 +390,6 @@ final class ItemsViewController: BaseItemsViewController { ) } - // MARK: - Setups - - private func setupAppStateObserver() { - NotificationCenter.default - .rx - .notification(UIContentSizeCategory.didChangeNotification) - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] _ in - self?.viewModel.process(action: .clearTitleCache) - self?.handler?.reloadAll() - }) - .disposed(by: disposeBag) - } - - private func setupFileObservers() { - NotificationCenter.default - .rx - .notification(.attachmentFileDeleted) - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] notification in - if let notification = notification.object as? AttachmentFileDeletedNotification { - self?.viewModel.process(action: .updateAttachments(notification)) - } - }) - .disposed(by: self.disposeBag) - - let downloader = controllers.userControllers?.fileDownloader - downloader?.observable - .observe(on: MainScheduler.asyncInstance) - .subscribe(onNext: { [weak self, weak downloader] update in - guard let self else { return } - process( - downloadUpdate: update, - toOpen: viewModel.state.attachmentToOpen, - downloader: downloader, - dataUpdate: { batchData in - self.viewModel.process(action: .updateDownload(update: update, batchData: batchData)) - }, - attachmentWillOpen: { update in - self.viewModel.process(action: .attachmentOpened(update.key)) - } - ) - }) - .disposed(by: disposeBag) - - let identifierLookupController = controllers.userControllers?.identifierLookupController - identifierLookupController?.observable - .observe(on: MainScheduler.asyncInstance) - .subscribe(onNext: { [weak self, weak identifierLookupController] update in - guard let self, let identifierLookupController else { return } - let batchData = ItemsState.IdentifierLookupBatchData(batchData: identifierLookupController.batchData) - viewModel.process(action: .updateIdentifierLookup(update: update, batchData: batchData)) - }) - .disposed(by: self.disposeBag) - - let remoteDownloader = controllers.userControllers?.remoteFileDownloader - remoteDownloader?.observable - .observe(on: MainScheduler.asyncInstance) - .subscribe(onNext: { [weak self, weak remoteDownloader] update in - guard let self, let remoteDownloader else { return } - let batchData = ItemsState.DownloadBatchData(batchData: remoteDownloader.batchData) - viewModel.process(action: .updateRemoteDownload(update: update, batchData: batchData)) - }) - .disposed(by: disposeBag) - } - private func rightBarButtonItemTypes(for state: ItemsState) -> [RightBarButtonItem] { let selectItems = rightBarButtonSelectItemTypes(for: state) return state.library.metadataEditable ? [.add] + selectItems : selectItems diff --git a/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift b/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift index 859c4ca9a..9ab7bf30e 100644 --- a/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift +++ b/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift @@ -41,13 +41,15 @@ final class RItemsTableViewDataSource: NSObject { private unowned let viewModel: ViewModel private unowned let schemaController: SchemaController private unowned let fileDownloader: AttachmentDownloader? + private unowned let recognizerController: RecognizerController? private var snapshot: Results? weak var handler: ItemsTableViewHandler? - init(viewModel: ViewModel, fileDownloader: AttachmentDownloader?, schemaController: SchemaController) { + init(viewModel: ViewModel, fileDownloader: AttachmentDownloader?, recognizerController: RecognizerController?, schemaController: SchemaController) { self.viewModel = viewModel self.fileDownloader = fileDownloader + self.recognizerController = recognizerController self.schemaController = schemaController } @@ -229,7 +231,7 @@ extension RItemsTableViewDataSource { let title = createTitleIfNeeded() let accessory = accessory(forKey: item.key) let typeName = schemaController.localized(itemType: item.rawType) ?? item.rawType - return ItemCellModel(item: item, typeName: typeName, title: title, accessory: accessory, fileDownloader: fileDownloader) + return ItemCellModel(item: item, typeName: typeName, title: title, accessory: accessory, fileDownloader: fileDownloader, recognizerController: recognizerController) func createTitleIfNeeded() -> NSAttributedString { if let title = viewModel.state.itemTitles[item.key] { diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift index 3840d19d6..6313c580d 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift @@ -155,7 +155,7 @@ extension TrashTableViewDataSource { let data = viewModel.state.itemDataCache[key] if let item = object as? RItem { let typeName = schemaController.localized(itemType: item.rawType) ?? item.rawType - return ItemCellModel(item: item, typeName: typeName, title: data?.title ?? NSAttributedString(), accessory: data?.accessory, fileDownloader: fileDownloader) + return ItemCellModel(item: item, typeName: typeName, title: data?.title ?? NSAttributedString(), accessory: data?.accessory, fileDownloader: fileDownloader, recognizerController: nil) } else { return ItemCellModel(collectionWithKey: object.key, title: data?.title ?? NSAttributedString()) } diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift index 87cad44a8..384cb29f4 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -82,7 +82,7 @@ final class TrashViewController: BaseItemsViewController { self?.viewModel.process(action: .updateAttachments(notification)) } }) - .disposed(by: self.disposeBag) + .disposed(by: disposeBag) } }