diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index 7d73f0b3f..66c9e5ee0 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ 61C817F22A49B5D30085B1E6 /* CollectionResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3B1EDEE250242E700D8BC1E /* CollectionResponseSpec.swift */; }; 61FA14CE2B05081D00E7D423 /* TextConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FA14CD2B05081D00E7D423 /* TextConverter.swift */; }; 61FA14D02B08E24A00E7D423 /* ColorPickerStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FA14CF2B08E24A00E7D423 /* ColorPickerStackView.swift */; }; + 61E24DCC2ABB385E00D75F50 /* OpenItemsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E24DCB2ABB385E00D75F50 /* OpenItemsController.swift */; }; B300B33324291C8D00C1FE1E /* RTranslatorMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B300B33224291C8D00C1FE1E /* RTranslatorMetadata.swift */; }; B300B3352429222B00C1FE1E /* TranslatorMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B300B3342429222B00C1FE1E /* TranslatorMetadata.swift */; }; B300B3362429234C00C1FE1E /* TranslatorMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B300B3342429222B00C1FE1E /* TranslatorMetadata.swift */; }; @@ -1240,6 +1241,7 @@ 61BD13942A5831EF008A0704 /* TextKit1TextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextKit1TextView.swift; sourceTree = ""; }; 61FA14CD2B05081D00E7D423 /* TextConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextConverter.swift; sourceTree = ""; }; 61FA14CF2B08E24A00E7D423 /* ColorPickerStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerStackView.swift; sourceTree = ""; }; + 61E24DCB2ABB385E00D75F50 /* OpenItemsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenItemsController.swift; sourceTree = ""; }; B300B33224291C8D00C1FE1E /* RTranslatorMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTranslatorMetadata.swift; sourceTree = ""; }; B300B3342429222B00C1FE1E /* TranslatorMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslatorMetadata.swift; sourceTree = ""; }; B300B3372429254900C1FE1E /* SyncTranslatorsDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTranslatorsDbRequest.swift; sourceTree = ""; }; @@ -2246,6 +2248,7 @@ B305646C23FC051E003304F2 /* ObjectUserChangeObserver.swift */, B34A9F6325BF1ABB007C9A4A /* PDFDocumentExporter.swift */, B32B8A562B18A08900A9A741 /* PDFThumbnailController.swift */, + 61E24DCB2ABB385E00D75F50 /* OpenItemsController.swift */, B3C6D551261C9F2E0068B9FE /* PlaceholderTextViewDelegate.swift */, B378F4CC242CD45700B88A05 /* RepoParserDelegate.swift */, B305646A23FC051E003304F2 /* RItemLocaleController.swift */, @@ -4943,6 +4946,7 @@ B36181EC24C96B0500B30D56 /* SearchableCollection.swift in Sources */, B3830CDB255451AB00910FE0 /* TagPickerAction.swift in Sources */, B3593F40241A61C700760E20 /* ItemCell.swift in Sources */, + 61E24DCC2ABB385E00D75F50 /* OpenItemsController.swift in Sources */, B305679023FC1D9B003304F2 /* CollectionDifference+Separated.swift in Sources */, B3F6AA3A2AB30663005BC22E /* AnnotationTool.swift in Sources */, B34ACC7A2514EAAB00040C17 /* AnnotationColorGenerator.swift in Sources */, diff --git a/Zotero/Assets/en.lproj/Localizable.strings b/Zotero/Assets/en.lproj/Localizable.strings index bba3fdfdd..255993d20 100644 --- a/Zotero/Assets/en.lproj/Localizable.strings +++ b/Zotero/Assets/en.lproj/Localizable.strings @@ -142,6 +142,7 @@ "items.generating_bib" = "Generating Bibliography"; "items.creator_summary.and" = "%@ and %@"; "items.creator_summary.etal" = "%@ et al."; +"items.restore_open" = "Restore Open Items"; "lookup.title" = "Enter ISBNs, DOls, PMIDs, arXiv IDs, or ADS Bibcodes to add to your library:"; @@ -541,3 +542,4 @@ "accessibility.pdf.undo" = "Undo"; "accessibility.pdf.toggle_annotation_toolbar" = "Toggle annotation toolbar"; "accessibility.pdf.show_more_tools" = "Show more"; +"accessibility.pdf.open_items" = "Open Items"; diff --git a/Zotero/Controllers/Controllers.swift b/Zotero/Controllers/Controllers.swift index fe67dffe8..e295e1c65 100644 --- a/Zotero/Controllers/Controllers.swift +++ b/Zotero/Controllers/Controllers.swift @@ -310,6 +310,7 @@ final class UserControllers { let citationController: CitationController let webDavController: WebDavController let customUrlController: CustomURLController + let openItemsController: OpenItemsController private let isFirstLaunch: Bool private let lastBuildNumber: Int? private unowned let translatorsAndStylesController: TranslatorsAndStylesController @@ -386,6 +387,7 @@ final class UserControllers { self.translatorsAndStylesController = controllers.translatorsAndStylesController self.idleTimerController = controllers.idleTimerController self.customUrlController = CustomURLController(dbStorage: dbStorage, fileStorage: controllers.fileStorage) + openItemsController = OpenItemsController(dbStorage: dbStorage, fileStorage: controllers.fileStorage) self.lastBuildNumber = controllers.lastBuildNumber self.disposeBag = DisposeBag() } diff --git a/Zotero/Controllers/Database/Requests/ReadItemsDbRequest.swift b/Zotero/Controllers/Database/Requests/ReadItemsDbRequest.swift index 9ae2a5252..1ce039370 100644 --- a/Zotero/Controllers/Database/Requests/ReadItemsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/ReadItemsDbRequest.swift @@ -86,3 +86,15 @@ struct ReadItemsWithKeysDbRequest: DbResponseRequest { return database.objects(RItem.self).filter(.keys(self.keys, in: self.libraryId)) } } + +struct ReadItemsWithKeysFromMultipleLibrariesDbRequest: DbResponseRequest { + typealias Response = Results + + let keysByLibraryIdentifier: [LibraryIdentifier: Set] + + var needsWrite: Bool { return false } + + func process(in database: Realm) throws -> Results { + database.objects(RItem.self).filter(.keysByLibraryIdentifier(keysByLibraryIdentifier)) + } +} diff --git a/Zotero/Controllers/OpenItemsController.swift b/Zotero/Controllers/OpenItemsController.swift new file mode 100644 index 000000000..ecb156efd --- /dev/null +++ b/Zotero/Controllers/OpenItemsController.swift @@ -0,0 +1,355 @@ +// +// OpenItemsController.swift +// Zotero +// +// Created by Miltiadis Vasilakis on 20/9/23. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation +import RxSwift +import RealmSwift +import CocoaLumberjackSwift + +typealias OpenItem = OpenItemsController.Item +typealias ItemPresentation = OpenItemsController.Presentation + +protocol OpenItemsPresenter: AnyObject { + func showItem(with presentation: ItemPresentation) +} + +final class OpenItemsController { + // MARK: Types + struct Item: Hashable, Equatable, Codable { + enum Kind: Hashable, Equatable, Codable { + case pdf(libraryId: LibraryIdentifier, key: String) + case note(libraryId: LibraryIdentifier, key: String) + + // MARK: Properties + var libraryId: LibraryIdentifier { + switch self { + case .pdf(let libraryId, _), .note(let libraryId, _): + return libraryId + } + } + + var key: String { + switch self { + case .pdf(_, let key), .note(_, let key): + return key + } + } + + // MARK: Codable + enum CodingKeys: CodingKey { + case pdfKind + case noteKind + case libraryId + case key + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .pdf: + try container.encode(true, forKey: .pdfKind) + + case .note: + try container.encode(true, forKey: .noteKind) + } + + try container.encode(libraryId, forKey: .libraryId) + try container.encode(key, forKey: .key) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let libraryId = try container.decode(LibraryIdentifier.self, forKey: .libraryId) + let key = try container.decode(String.self, forKey: .key) + if (try? container.decode(Bool.self, forKey: .pdfKind)) == true { + self = .pdf(libraryId: libraryId, key: key) + } else if (try? container.decode(Bool.self, forKey: .noteKind)) == true { + self = .note(libraryId: libraryId, key: key) + } else { + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [CodingKeys.pdfKind, CodingKeys.noteKind], debugDescription: "Item kind key not found")) + } + } + } + + let kind: Kind + var userIndex: Int + var lastOpened: Date + + init(kind: Kind, userIndex: Int, lastOpened: Date = .now) { + self.kind = kind + self.userIndex = userIndex + self.lastOpened = lastOpened + } + } + + enum Presentation { + case pdf(library: Library, key: String, parentKey: String?, url: URL) + case note(library: Library, key: String, text: String, tags: [Tag], title: NoteEditorState.TitleData?) + } + + // MARK: Properties + private unowned let dbStorage: DbStorage + private unowned let fileStorage: FileStorage + // TODO: Use a better data structure, such as an ordered set + private var itemsBySessionIdentifier: [String: [Item]] = [:] + private var itemsTokenBySessionIdentifier: [String: NotificationToken] = [:] + private var observableBySessionIdentifier: [String: PublishSubject<[Item]>] = [:] + private let disposeBag: DisposeBag + + // MARK: Object Lifecycle + init(dbStorage: DbStorage, fileStorage: FileStorage) { + self.dbStorage = dbStorage + self.fileStorage = fileStorage + disposeBag = DisposeBag() + } + + // MARK: Actions + func observable(for sessionIdentifier: String) -> PublishSubject<[Item]> { + if let observable = observableBySessionIdentifier[sessionIdentifier] { + return observable + } + let observable = PublishSubject<[Item]>() + observableBySessionIdentifier[sessionIdentifier] = observable + return observable + } + + func getItems(for sessionIdentifier: String) -> [Item] { + itemsBySessionIdentifier[sessionIdentifier, default: []] + } + + func setItems(_ items: [Item], for sessionIdentifier: String, validate: Bool) { + DDLogInfo("OpenItemsController: setting items \(items) for \(sessionIdentifier)") + let existingItems = getItems(for: sessionIdentifier) + let newItems = validate ? filterValidItems(items) : items + guard newItems != existingItems else { return } + // Invalidate previous observer first. + itemsTokenBySessionIdentifier[sessionIdentifier]?.invalidate() + itemsTokenBySessionIdentifier[sessionIdentifier] = nil + itemsBySessionIdentifier[sessionIdentifier] = newItems + // Register observer for newly set items. + itemsTokenBySessionIdentifier[sessionIdentifier] = registerObserver(for: newItems) + observable(for: sessionIdentifier).on(.next(newItems)) + + func registerObserver(for items: [Item]) -> NotificationToken? { + var token: NotificationToken? + var keysByLibraryIdentifier: [LibraryIdentifier: Set] = [:] + for item in items { + let libraryId = item.kind.libraryId + let key = item.kind.key + var keys = keysByLibraryIdentifier[libraryId, default: .init()] + keys.insert(key) + keysByLibraryIdentifier[libraryId] = keys + } + do { + try dbStorage.perform(on: .main) { coordinator in + let objects = try coordinator.perform(request: ReadItemsWithKeysFromMultipleLibrariesDbRequest(keysByLibraryIdentifier: keysByLibraryIdentifier)) + token = objects.observe { [weak self] changes in + switch changes { + case .initial: + break + + case .update(_, let deletions, _, _): + if !deletions.isEmpty, let self { + // Observed items have been deleted, call setItems to validate and register new observer. + let existingItems = getItems(for: sessionIdentifier) + setItems(existingItems, for: sessionIdentifier, validate: true) + } + + case .error(let error): + DDLogError("OpenItemsController: register observer error - \(error)") + } + } + } + } catch let error { + DDLogError("OpenItemsController: can't register items observer - \(error)") + } + return token + } + } + + func open(_ kind: Item.Kind, for sessionIdentifier: String) { + DDLogInfo("OpenItemsController: opened item \(kind) for \(sessionIdentifier)") + var existingItems = getItems(for: sessionIdentifier) + if let index = existingItems.firstIndex(where: { $0.kind == kind }) { + existingItems[index].lastOpened = .now + itemsBySessionIdentifier[sessionIdentifier] = existingItems + DDLogInfo("OpenItemsController: already opened item \(kind) became most recent for \(sessionIdentifier)") + observable(for: sessionIdentifier).on(.next(existingItems)) + } else { + DDLogInfo("OpenItemsController: newly opened item \(kind) set as most recent for \(sessionIdentifier)") + let item = Item(kind: kind, userIndex: existingItems.count) + let newItems = existingItems + [item] + // setItems will produce next observable event + setItems(newItems, for: sessionIdentifier, validate: false) + } + } + + @discardableResult + func restore(_ item: Item, using presenter: OpenItemsPresenter) -> Bool { + guard let presentation = loadPresentation(for: item) else { return false } + presentItem(with: presentation, using: presenter) + return true + } + + @discardableResult + func restoreMostRecentlyOpenedItem(using presenter: OpenItemsPresenter, sessionIdentifier: String) -> Item? { + // Will restore most recent opened item still present, or none if all fail + var existingItems = getItems(for: sessionIdentifier) + DDLogInfo("OpenItemsController: restoring most recently opened item using presenter \(presenter) for \(sessionIdentifier)") + var itemsChanged: Bool = false + defer { + if itemsChanged { + observable(for: sessionIdentifier).on(.next(existingItems)) + } + } + var item: Item? + var presentation: Presentation? + let existingItemsSortedByLastOpen = itemsSortedByLastOpen(for: sessionIdentifier) + for _item in existingItemsSortedByLastOpen { + if let _presentation = loadPresentation(for: _item) { + item = _item + presentation = _presentation + break + } + DDLogWarn("OpenItemsController: removing not loaded item \(_item) for \(sessionIdentifier)") + existingItems.removeAll(where: { $0 == _item }) + itemsChanged = true + } + guard let item, let presentation else { return nil } + presentItem(with: presentation, using: presenter) + return item + } + + func deferredOpenItemsMenuElement(for sessionIdentifier: String, disableOpenItem: Bool, itemActionCallback: @escaping (Item, UIAction) -> Void) -> UIDeferredMenuElement { + UIDeferredMenuElement { [weak self] elementProvider in + guard let self else { + elementProvider([]) + return + } + var actions: [UIAction] = [] + let openItem: Item? = disableOpenItem ? itemsSortedByLastOpen(for: sessionIdentifier).first : nil + let existingItemsSortedByLastOpen = itemsSortedByUserOrder(for: sessionIdentifier) + var itemTuples: [(Item, RItem)] = filterValidItemsWithRItem(existingItemsSortedByLastOpen) + for (item, rItem) in itemTuples { + var attributes: UIMenuElement.Attributes = [] + var state: UIMenuElement.State = .off + if item == openItem { + attributes = [.disabled] + state = .on + } + let itemAction = UIAction(title: rItem.displayTitle, attributes: attributes, state: state) { action in + itemActionCallback(item, action) + } + actions.append(itemAction) + } + elementProvider(actions) + } + } + + // MARK: Helper Methods + private func itemsSortedByUserOrder(for sessionIdentifier: String) -> [Item] { + getItems(for: sessionIdentifier).sorted(by: { $0.userIndex < $1.userIndex }) + } + + private func itemsSortedByLastOpen(for sessionIdentifier: String) -> [Item] { + getItems(for: sessionIdentifier).sorted(by: { $0.lastOpened > $1.lastOpened }) + } + + private func filterValidItemsWithRItem(_ items: [Item]) -> [(Item, RItem)] { + var itemTuples: [(Item, RItem)] = [] + do { + try dbStorage.perform(on: .main) { coordinator in + for item in items { + switch item.kind { + case .pdf(let libraryId, let key), .note(let libraryId, let key): + do { + let rItem = try coordinator.perform(request: ReadItemDbRequest(libraryId: libraryId, key: key)) + itemTuples.append((item, rItem)) + } catch let itemError { + DDLogError("OpenItemsController: can't load item \(item) - \(itemError)") + } + } + } + } + } catch let error { + DDLogError("OpenItemsController: can't load multiple items - \(error)") + } + return itemTuples + } + + private func filterValidItems(_ items: [Item]) -> [Item] { + filterValidItemsWithRItem(items).map { $0.0 } + } + + private func loadPresentation(for item: Item) -> Presentation? { + switch item.kind { + case .pdf(let libraryId, let key): + return loadPDFPresentation(key: key, libraryId: libraryId) + + case .note(let libraryId, let key): + return loadNotePresentation(key: key, libraryId: libraryId) + } + + func loadPDFPresentation(key: String, libraryId: LibraryIdentifier) -> Presentation? { + var library: Library? + var url: URL? + var parentKey: String? + do { + try dbStorage.perform(on: .main) { coordinator in + library = try coordinator.perform(request: ReadLibraryDbRequest(libraryId: libraryId)) + let rItem = try coordinator.perform(request: ReadItemDbRequest(libraryId: libraryId, key: key)) + parentKey = rItem.parent?.key + guard let attachment = AttachmentCreator.attachment(for: rItem, fileStorage: fileStorage, urlDetector: nil) else { return } + switch attachment.type { + case .file(let filename, let contentType, let location, _): + switch location { + case .local, .localAndChangedRemotely: + let file = Files.attachmentFile(in: libraryId, key: key, filename: filename, contentType: contentType) + url = file.createUrl() + + case .remote, .remoteMissing: + break + } + + case .url: + break + } + } + } catch let error { + DDLogError("OpenItemsController: can't load item \(item) - \(error)") + } + guard let library, let url else { return nil } + return .pdf(library: library, key: key, parentKey: parentKey, url: url) + } + + func loadNotePresentation(key: String, libraryId: LibraryIdentifier) -> Presentation? { + var library: Library? + var note: Note? + var title: NoteEditorState.TitleData? + do { + try dbStorage.perform(on: .main) { coordinator in + library = try coordinator.perform(request: ReadLibraryDbRequest(libraryId: libraryId)) + let rItem = try coordinator.perform(request: ReadItemDbRequest(libraryId: libraryId, key: key)) + note = Note(item: rItem) + if let parent = rItem.parent { + title = NoteEditorState.TitleData(type: parent.rawType, title: parent.displayTitle) + } + } + } catch let error { + DDLogError("OpenItemsController: can't load item \(item) - \(error)") + } + guard let library, let note else { return nil } + return .note(library: library, key: note.key, text: note.text, tags: note.tags, title: title) + } + } + + private func presentItem(with presentation: Presentation, using presenter: OpenItemsPresenter) { + presenter.showItem(with: presentation) + DDLogInfo("OpenItemsController: presented item with presentation \(presentation)") + } +} diff --git a/Zotero/Extensions/Localizable.swift b/Zotero/Extensions/Localizable.swift index 8d84bbe99..c4314aeb9 100644 --- a/Zotero/Extensions/Localizable.swift +++ b/Zotero/Extensions/Localizable.swift @@ -220,6 +220,8 @@ internal enum L10n { internal static let noteAnnotation = L10n.tr("Localizable", "accessibility.pdf.note_annotation", fallback: "Note annotation") /// Create note annotation internal static let noteAnnotationTool = L10n.tr("Localizable", "accessibility.pdf.note_annotation_tool", fallback: "Create note annotation") + /// Open Items + internal static let openItems = L10n.tr("Localizable", "accessibility.pdf.open_items", fallback: "Open Items") /// Open text reader internal static let openReader = L10n.tr("Localizable", "accessibility.pdf.open_reader", fallback: "Open text reader") /// Redo @@ -795,6 +797,8 @@ internal enum L10n { } /// Remove from Collection internal static let removeFromCollectionTitle = L10n.tr("Localizable", "items.remove_from_collection_title", fallback: "Remove from Collection") + /// Restore Open Items + internal static let restoreOpen = L10n.tr("Localizable", "items.restore_open", fallback: "Restore Open Items") /// Search Items internal static let searchTitle = L10n.tr("Localizable", "items.search_title", fallback: "Search Items") /// Select All diff --git a/Zotero/Extensions/NSUserActivity+Activities.swift b/Zotero/Extensions/NSUserActivity+Activities.swift index a60cea150..66bd6be55 100644 --- a/Zotero/Extensions/NSUserActivity+Activities.swift +++ b/Zotero/Extensions/NSUserActivity+Activities.swift @@ -9,29 +9,47 @@ import Foundation struct RestoredStateData { - let key: String let libraryId: LibraryIdentifier let collectionId: CollectionIdentifier + let openItems: [OpenItem] + let restoreMostRecentlyOpenedItem: Bool } extension NSUserActivity { private static let pdfId = "org.zotero.PDFActivity" private static let mainId = "org.zotero.MainActivity" - static var mainActivity: NSUserActivity { - return NSUserActivity(activityType: self.mainId) + private static let libraryIdKey = "libraryId" + private static let collectionIdKey = "collectionId" + private static let openItemsKey = "openItems" + private static let restoreMostRecentlyOpenedItemKey = "restoreMostRecentlyOpenedItem" + + static func mainActivity(with openItems: [OpenItem]) -> NSUserActivity { + let activity = NSUserActivity(activityType: self.mainId) + activity.addUserInfoEntries(from: openItemsToUserInfo(openItems: openItems)) + let userInfo: [AnyHashable: Any] = [restoreMostRecentlyOpenedItemKey: false] + activity.addUserInfoEntries(from: userInfo) + return activity } - static func pdfActivity(for key: String, libraryId: LibraryIdentifier, collectionId: CollectionIdentifier) -> NSUserActivity { + static func pdfActivity(with openItems: [OpenItem], libraryId: LibraryIdentifier, collectionId: CollectionIdentifier) -> NSUserActivity { let activity = NSUserActivity(activityType: self.pdfId) - var pdfUserInfo: [AnyHashable: Any] = ["key": key, "libraryId": libraryIdToString(libraryId)] + activity.addUserInfoEntries(from: openItemsToUserInfo(openItems: openItems)) + var userInfo: [AnyHashable: Any] = [libraryIdKey: libraryIdToString(libraryId), restoreMostRecentlyOpenedItemKey: true] if let collectionIdData = try? JSONEncoder().encode(collectionId) { - pdfUserInfo["collectionId"] = collectionIdData + userInfo[collectionIdKey] = collectionIdData } - activity.addUserInfoEntries(from: pdfUserInfo) + activity.addUserInfoEntries(from: userInfo) return activity } - + + private static func openItemsToUserInfo(openItems: [OpenItem]) -> [AnyHashable: Any] { + var userInfo: [AnyHashable: Any] = [:] + let encoder = JSONEncoder() + userInfo[openItemsKey] = openItems.compactMap { try? encoder.encode($0) } + return userInfo + } + private static func libraryIdToString(_ libraryId: LibraryIdentifier) -> String { switch libraryId { case .custom: @@ -58,19 +76,25 @@ extension NSUserActivity { } var restoredStateData: RestoredStateData? { - guard self.activityType == NSUserActivity.pdfId, - let userInfo, - let key = userInfo["key"] as? String, - let libraryString = userInfo["libraryId"] as? String, - let libraryId = stringToLibraryId(libraryString) - else { return nil } - var collectionId: CollectionIdentifier - if let collectionIdData = userInfo["collectionId"] as? Data, - let decodedCollectionId = try? JSONDecoder().decode(CollectionIdentifier.self, from: collectionIdData) { - collectionId = decodedCollectionId - } else { - collectionId = Defaults.shared.selectedCollectionId + guard let userInfo else { return nil } + var libraryId: LibraryIdentifier = Defaults.shared.selectedLibrary + var collectionId: CollectionIdentifier = Defaults.shared.selectedCollectionId + var openItems: [OpenItem] = [] + var restoreMostRecentlyOpenedItem = false + if let libraryString = userInfo[Self.libraryIdKey] as? String, let _libraryId = stringToLibraryId(libraryString) { + libraryId = _libraryId + } + let decoder = JSONDecoder() + if let collectionIdData = userInfo[Self.collectionIdKey] as? Data, let _collectionId = try? decoder.decode(CollectionIdentifier.self, from: collectionIdData) { + collectionId = _collectionId + } + if let openItemsDataArray = userInfo[Self.openItemsKey] as? [Data] { + openItems = openItemsDataArray.compactMap { try? decoder.decode(OpenItem.self, from: $0) } + } + if let _restoreMostRecentlyOpenedItem = userInfo[Self.restoreMostRecentlyOpenedItemKey] as? Bool { + restoreMostRecentlyOpenedItem = _restoreMostRecentlyOpenedItem } - return RestoredStateData(key: key, libraryId: libraryId, collectionId: collectionId) + // TODO: Migrate old pdf activity ("key", "libraryId") to "openItems"? + return RestoredStateData(libraryId: libraryId, collectionId: collectionId, openItems: openItems, restoreMostRecentlyOpenedItem: restoreMostRecentlyOpenedItem) } } diff --git a/Zotero/Models/Predicates.swift b/Zotero/Models/Predicates.swift index 5fe87aa25..7af78be4b 100644 --- a/Zotero/Models/Predicates.swift +++ b/Zotero/Models/Predicates.swift @@ -43,6 +43,10 @@ extension NSPredicate { .library(with: libraryId)]) } + static func keysByLibraryIdentifier(_ keysByLibraryIdentifier: [LibraryIdentifier: Set]) -> NSPredicate { + NSCompoundPredicate(orPredicateWithSubpredicates: keysByLibraryIdentifier.map({ .keys($0.value, in: $0.key) })) + } + static func key(notIn keys: [String], in libraryId: LibraryIdentifier) -> NSPredicate { return NSCompoundPredicate(andPredicateWithSubpredicates: [.library(with: libraryId), .key(notIn: keys)]) } diff --git a/Zotero/Scenes/AppCoordinator.swift b/Zotero/Scenes/AppCoordinator.swift index 794c3df26..8ad35f968 100644 --- a/Zotero/Scenes/AppCoordinator.swift +++ b/Zotero/Scenes/AppCoordinator.swift @@ -126,8 +126,8 @@ extension AppCoordinator: AppDelegateCoordinatorDelegate { conflictAlertQueueController = nil controllers.userControllers?.syncScheduler.syncController.set(coordinator: nil) } else { - let controller = MainViewController(controllers: controllers) (urlContext, data) = preprocess(connectionOptions: options, session: session) + let controller = MainViewController(sessionIdentifier: session.persistentIdentifier, controllers: controllers) viewController = controller conflictReceiverAlertController = ConflictReceiverAlertController(viewController: controller) @@ -137,7 +137,7 @@ extension AppCoordinator: AppDelegateCoordinatorDelegate { DDLogInfo("AppCoordinator: show main screen logged \(isLoggedIn ? "in" : "out"); animated=\(animated)") show(viewController: viewController, in: window, animated: animated) { - process(urlContext: urlContext, data: data) + process(urlContext: urlContext, data: data, sessionIdentifier: session.persistentIdentifier) } func show(viewController: UIViewController?, in window: UIWindow, animated: Bool = false, completion: @escaping () -> Void) { @@ -160,11 +160,12 @@ extension AppCoordinator: AppDelegateCoordinatorDelegate { DDLogInfo("AppCoordinator: Preprocessing restored state - \(data)") Defaults.shared.selectedLibrary = data.libraryId Defaults.shared.selectedCollectionId = data.collectionId + controllers.userControllers?.openItemsController.setItems(data.openItems, for: session.persistentIdentifier, validate: true) } return (urlContext, data) } - func process(urlContext: UIOpenURLContext?, data: RestoredStateData?) { + func process(urlContext: UIOpenURLContext?, data: RestoredStateData?, sessionIdentifier: String) { if let urlContext, let urlController = controllers.userControllers?.customUrlController { // If scene was started from custom URL let sourceApp = urlContext.options.sourceApplication ?? "unknown" @@ -176,90 +177,57 @@ extension AppCoordinator: AppDelegateCoordinatorDelegate { } } - if let data { + if let data, data.restoreMostRecentlyOpenedItem { DDLogInfo("AppCoordinator: Processing restored state - \(data)") // If scene had state stored, restore state - showRestoredState(for: data) + showRestoredState(for: data, sessionIdentifier: sessionIdentifier) } - func showRestoredState(for data: RestoredStateData) { - DDLogInfo("AppCoordinator: show restored state - \(data.key); \(data.libraryId); \(data.collectionId)") - guard let mainController = window.rootViewController as? MainViewController else { - DDLogWarn("AppCoordinator: show restored state aborted - invalid root view controller") - return - } - guard let (url, library, optionalCollection, parentKey) = loadRestoredStateData(forKey: data.key, libraryId: data.libraryId, collectionId: data.collectionId) else { - DDLogWarn("AppCoordinator: show restored state aborted - invalid restored state data") - return - } - var collection: Collection - if let optionalCollection { - DDLogInfo("AppCoordinator: show restored state using restored collection - \(url.relativePath)") - collection = optionalCollection - // No need to set selected collection identifier here, this happened already in show main screen / preprocess - } else { - DDLogWarn("AppCoordinator: show restored state using all items collection - \(url.relativePath)") - // Collection is missing, show all items instead - collection = Collection(custom: .all) - } - mainController.showItems(for: collection, in: library) - - mainController.getDetailCoordinator { [weak self] coordinator in - guard let self else { return } - self.show( - viewControllerProvider: { - coordinator.createPDFController(key: data.key, parentKey: parentKey, library: library, url: url) - }, - by: mainController, - in: window, - animated: false - ) + func showRestoredState(for data: RestoredStateData, sessionIdentifier: String) { + guard let openItemsController = controllers.userControllers?.openItemsController else { return } + DDLogInfo("AppCoordinator: show restored state") + guard let mainController = window.rootViewController as? MainViewController else { + DDLogWarn("AppCoordinator: show restored state aborted - invalid root view controller") + return + } + guard let (library, optionalCollection) = loadRestoredStateData(libraryId: data.libraryId, collectionId: data.collectionId) else { + DDLogWarn("AppCoordinator: show restored state aborted - invalid restored state data") + return + } + var collection: Collection + if let optionalCollection { + DDLogInfo("AppCoordinator: show restored state using restored collection") + collection = optionalCollection + // No need to set selected collection identifier here, this happened already in show main screen / preprocess + } else { + DDLogWarn("AppCoordinator: show restored state using all items collection") + // Collection is missing, show all items instead + collection = Collection(custom: .all) + } + mainController.showItems(for: collection, in: library) + openItemsController.restoreMostRecentlyOpenedItem(using: self, sessionIdentifier: sessionIdentifier) + + func loadRestoredStateData(libraryId: LibraryIdentifier, collectionId: CollectionIdentifier) -> (Library, Collection?)? { + guard let dbStorage = controllers.userControllers?.dbStorage else { return nil } + + var library: Library? + var collection: Collection? + + do { + try dbStorage.perform(on: .main, with: { coordinator in + let (_collection, _library) = try coordinator.perform(request: ReadCollectionAndLibraryDbRequest(collectionId: collectionId, libraryId: libraryId)) + collection = _collection + library = _library + }) + } catch let error { + DDLogError("AppCoordinator: can't load restored data - \(error)") + return nil } - func loadRestoredStateData(forKey key: String, libraryId: LibraryIdentifier, collectionId: CollectionIdentifier) -> (URL, Library, Collection?, String?)? { - guard let dbStorage = self.controllers.userControllers?.dbStorage else { return nil } - - var url: URL? - var library: Library? - var collection: Collection? - var parentKey: String? - - do { - try dbStorage.perform(on: .main, with: { coordinator in - let item = try coordinator.perform(request: ReadItemDbRequest(libraryId: libraryId, key: key)) - parentKey = item.parent?.key - - guard let attachment = AttachmentCreator.attachment(for: item, fileStorage: self.controllers.fileStorage, urlDetector: nil) else { return } - - switch attachment.type { - case .file(let filename, let contentType, let location, _): - switch location { - case .local, .localAndChangedRemotely: - let file = Files.attachmentFile(in: libraryId, key: key, filename: filename, contentType: contentType) - url = file.createUrl() - let (_collection, _library) = try coordinator.perform(request: ReadCollectionAndLibraryDbRequest(collectionId: collectionId, libraryId: libraryId)) - collection = _collection - library = _library - - case .remote, .remoteMissing: - break - } - - default: - break - } - }) - } catch let error { - DDLogError("AppCoordinator: can't load restored data - \(error)") - return nil - } - - guard let url, let library else { - return nil - } - return (url, library, collection, parentKey) - } + guard let library else { return nil } + return (library, collection) } + } } } @@ -860,4 +828,30 @@ extension AppCoordinator: SyncRequestReceiver { } } +extension AppCoordinator: OpenItemsPresenter { + func showItem(with presentation: ItemPresentation) { + guard let window, let mainController = window.rootViewController as? MainViewController else { return } + mainController.getDetailCoordinator { [weak self] coordinator in + guard let self else { return } + self.show( + viewControllerProvider: { + switch presentation { + case .pdf(let library, let key, let parentKey, let url): + return coordinator.createPDFController(key: key, parentKey: parentKey, library: library, url: url) + + case .note(let library, let key, let text, let tags, let title): + let kind: NoteEditorKind = library.metadataEditable ? .edit(key: key) : .readOnly(key: key) + // TODO: Check if a callback is required + return coordinator.createNoteController(library: library, kind: kind, text: text, tags: tags, title: title) { _, _ in + } + } + }, + by: mainController, + in: window, + animated: false + ) + } + } +} + extension AppCoordinator: InstantPresenter {} diff --git a/Zotero/Scenes/Detail/DetailCoordinator.swift b/Zotero/Scenes/Detail/DetailCoordinator.swift index 5e2ddb331..9e9d3b53e 100644 --- a/Zotero/Scenes/Detail/DetailCoordinator.swift +++ b/Zotero/Scenes/Detail/DetailCoordinator.swift @@ -84,19 +84,30 @@ final class DetailCoordinator: Coordinator { private var transitionDelegate: EmptyTransitioningDelegate? weak var itemsTagFilterDelegate: ItemsTagFilterDelegate? weak var navigationController: UINavigationController? + var presentedRestoredControllerWindow: UIWindow? let collection: Collection let library: Library let searchItemKeys: [String]? + let sessionIdentifier: String private unowned let controllers: Controllers private let disposeBag: DisposeBag - init(library: Library, collection: Collection, searchItemKeys: [String]?, navigationController: UINavigationController, itemsTagFilterDelegate: ItemsTagFilterDelegate?, controllers: Controllers) { + init( + library: Library, + collection: Collection, + searchItemKeys: [String]?, + navigationController: UINavigationController, + itemsTagFilterDelegate: ItemsTagFilterDelegate?, + sessionIdentifier: String, + controllers: Controllers + ) { self.library = library self.collection = collection self.searchItemKeys = searchItemKeys self.navigationController = navigationController self.itemsTagFilterDelegate = itemsTagFilterDelegate + self.sessionIdentifier = sessionIdentifier self.controllers = controllers self.childCoordinators = [] self.disposeBag = DisposeBag() @@ -119,6 +130,7 @@ final class DetailCoordinator: Coordinator { syncScheduler: userControllers.syncScheduler, citationController: userControllers.citationController, fileCleanupController: userControllers.fileCleanupController, + openItemsController: userControllers.openItemsController, itemsTagFilterDelegate: self.itemsTagFilterDelegate, htmlAttributedStringConverter: self.controllers.htmlAttributedStringConverter ) @@ -135,6 +147,7 @@ final class DetailCoordinator: Coordinator { syncScheduler: SynchronizationScheduler, citationController: CitationController, fileCleanupController: AttachmentFileCleanupController, + openItemsController: OpenItemsController, itemsTagFilterDelegate: ItemsTagFilterDelegate?, htmlAttributedStringConverter: HtmlAttributedStringConverter ) -> ItemsViewController { @@ -153,7 +166,8 @@ final class DetailCoordinator: Coordinator { downloadBatchData: downloadBatchData, remoteDownloadBatchData: remoteDownloadBatchData, identifierLookupBatchData: identifierLookupBatchData, - error: nil + error: nil, + openItemsCount: openItemsController.getItems(for: sessionIdentifier).count ) let handler = ItemsActionHandler( dbStorage: dbStorage, @@ -166,7 +180,7 @@ final class DetailCoordinator: Coordinator { syncScheduler: syncScheduler, htmlAttributedStringConverter: htmlAttributedStringConverter ) - let controller = ItemsViewController(viewModel: ViewModel(initialState: state, handler: handler), controllers: self.controllers, coordinatorDelegate: self) + let controller = ItemsViewController(viewModel: ViewModel(initialState: state, handler: handler), controllers: self.controllers, coordinatorDelegate: self, presenter: self) controller.tagFilterDelegate = itemsTagFilterDelegate itemsTagFilterDelegate?.delegate = controller return controller @@ -192,7 +206,7 @@ final class DetailCoordinator: Coordinator { switch contentType { case "application/pdf": DDLogInfo("DetailCoordinator: show PDF \(attachment.key)") - self.showPdf(at: url, key: attachment.key, parentKey: parentKey, library: library) + self.showPDF(at: url, key: attachment.key, parentKey: parentKey, library: library) case "text/html": DDLogInfo("DetailCoordinator: show HTML \(attachment.key)") @@ -296,10 +310,20 @@ final class DetailCoordinator: Coordinator { return navigationController } - - private func showPdf(at url: URL, key: String, parentKey: String?, library: Library) { - let controller = createPDFController(key: key, parentKey: parentKey, library: library, url: url) - navigationController?.present(controller, animated: true, completion: nil) + + private func showPDF(at url: URL, key: String, parentKey: String?, library: Library) { + guard let navigationController else { return } + controllers.userControllers?.openItemsController.open(.pdf(libraryId: library.identifier, key: key), for: sessionIdentifier) + + let viewControllerProvider: () -> UIViewController = { + self.createPDFController(key: key, parentKey: parentKey, library: library, url: url) + } + if let presentedViewController = navigationController.presentedViewController { + guard let window = presentedViewController.view.window else { return } + show(viewControllerProvider: viewControllerProvider, by: navigationController, in: window, animated: false) + return + } + navigationController.present(viewControllerProvider(), animated: true) } private func showWebView(for url: URL) { @@ -460,6 +484,7 @@ extension DetailCoordinator: DetailItemsCoordinatorDelegate { title: title, saveCallback: saveCallback, navigationController: navigationController, + sessionIdentifier: sessionIdentifier, controllers: controllers ) coordinator.parentCoordinator = self @@ -898,15 +923,39 @@ extension DetailCoordinator: DetailNoteEditorCoordinatorDelegate { saveCallback: @escaping NoteEditorSaveCallback = { _, _ in } ) { guard let navigationController else { return } - let controller = createNoteController(library: library, kind: kind, text: text, tags: tags, title: title, saveCallback: saveCallback) + var amendedSaveCallback = saveCallback switch kind { case .itemCreation, .standaloneCreation: DDLogInfo("DetailCoordinator: show note creation") - + amendedSaveCallback = { [weak self] key, result in + switch result { + case .success(let note): + // If indeed a new note is created inform open items controller about it. + if let self { + controllers.userControllers?.openItemsController.open(.note(libraryId: library.identifier, key: note.key), for: sessionIdentifier) + } + + case .failure: + break + } + + saveCallback(key, result) + } + case .edit(let key), .readOnly(let key): DDLogInfo("DetailCoordinator: show note \(key)") + controllers.userControllers?.openItemsController.open(.note(libraryId: library.identifier, key: key), for: sessionIdentifier) + } + + let viewControllerProvider: () -> UIViewController = { + self.createNoteController(library: library, kind: kind, text: text, tags: tags, title: title, saveCallback: amendedSaveCallback) + } + if let presentedViewController = navigationController.presentedViewController { + guard let window = presentedViewController.view.window else { return } + show(viewControllerProvider: viewControllerProvider, by: navigationController, in: window, animated: false) + return } - navigationController.present(controller, animated: true) + navigationController.present(viewControllerProvider(), animated: true) } } @@ -948,3 +997,19 @@ extension DetailCoordinator: DetailCitationCoordinatorDelegate { } extension DetailCoordinator: DetailCopyBibliographyCoordinatorDelegate { } + +extension DetailCoordinator: OpenItemsPresenter { + func showItem(with presentation: ItemPresentation) { + switch presentation { + case .pdf(let library, let key, let parentKey, let url): + showPDF(at: url, key: key, parentKey: parentKey, library: library) + + case .note(let library, let key, let text, let tags, let title): + let kind: NoteEditorKind = library.metadataEditable ? .edit(key: key) : .readOnly(key: key) + // TODO: Check if a callback is required + showNote(library: library, kind: kind, text: text, tags: tags, title: title) + } + } +} + +extension DetailCoordinator: InstantPresenter { } diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift b/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift index f42a0aaf6..621105f1f 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift @@ -48,4 +48,5 @@ enum ItemsAction { case updateKeys(items: Results, deletions: [Int], insertions: [Int], modifications: [Int]) case startSync case emptyTrash + case updateOpenItems(items: [OpenItem]) } diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsState.swift b/Zotero/Scenes/Detail/Items/Models/ItemsState.swift index a3bc6a21f..28ad9319d 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemsState.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemsState.swift @@ -23,6 +23,7 @@ struct ItemsState: ViewModelState { static let attachmentsRemoved = Changes(rawValue: 1 << 4) static let filters = Changes(rawValue: 1 << 5) static let batchData = Changes(rawValue: 1 << 6) + static let openItems = Changes(rawValue: 1 << 7) } struct DownloadBatchData: Equatable { @@ -109,6 +110,7 @@ struct ItemsState: ViewModelState { var itemTitleFont: UIFont { return UIFont.preferredFont(for: .headline, weight: .regular) } + var openItemsCount: Int var tagsFilter: Set? { let tagFilter = self.filters.first(where: { filter in @@ -131,7 +133,8 @@ struct ItemsState: ViewModelState { downloadBatchData: DownloadBatchData?, remoteDownloadBatchData: DownloadBatchData?, identifierLookupBatchData: IdentifierLookupBatchData, - error: ItemsError? + error: ItemsError?, + openItemsCount: Int ) { self.collection = collection self.library = library @@ -149,6 +152,7 @@ struct ItemsState: ViewModelState { self.identifierLookupBatchData = identifierLookupBatchData self.searchTerm = searchTerm self.itemTitles = [:] + self.openItemsCount = openItemsCount } mutating func cleanup() { diff --git a/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift b/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift index 632f676ed..ebe6af9d5 100644 --- a/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift +++ b/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift @@ -200,6 +200,14 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH self.update(viewModel: viewModel) { state in state.itemTitles = [:] } + + case .updateOpenItems(let items): + update(viewModel: viewModel) { state in + if state.openItemsCount != items.count { + state.openItemsCount = items.count + state.changes = .openItems + } + } } } diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift index d64918db8..9357dbc79 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift @@ -21,6 +21,7 @@ final class ItemsViewController: UIViewController { case deselectAll case add case emptyTrash + case restoreOpenItems } @IBOutlet private weak var tableView: UITableView! @@ -37,11 +38,18 @@ final class ItemsViewController: UIViewController { weak var tagFilterDelegate: ItemsTagFilterDelegate? private weak var coordinatorDelegate: (DetailItemsCoordinatorDelegate & DetailNoteEditorCoordinatorDelegate)? - - init(viewModel: ViewModel, controllers: Controllers, coordinatorDelegate: (DetailItemsCoordinatorDelegate & DetailNoteEditorCoordinatorDelegate)) { + private weak var presenter: OpenItemsPresenter? + + init( + viewModel: ViewModel, + controllers: Controllers, + coordinatorDelegate: (DetailItemsCoordinatorDelegate & DetailNoteEditorCoordinatorDelegate), + presenter: OpenItemsPresenter + ) { self.viewModel = viewModel self.controllers = controllers self.coordinatorDelegate = coordinatorDelegate + self.presenter = presenter self.disposeBag = DisposeBag() super.init(nibName: "ItemsViewController", bundle: nil) @@ -75,6 +83,7 @@ final class ItemsViewController: UIViewController { self.setupFileObservers() self.startObservingSyncProgress() self.setupAppStateObserver() + setupOpenItemsObserving() if let term = self.viewModel.state.searchTerm, !term.isEmpty { navigationItem.searchController?.searchBar.text = term @@ -166,6 +175,10 @@ final class ItemsViewController: UIViewController { if state.changes.contains(.filters) || state.changes.contains(.batchData) { self.toolbarController.reloadToolbarItems(for: state) } + + if state.changes.contains(.openItems) { + setupRightBarButtonItems(for: state) + } if let key = state.itemKeyToDuplicate { self.coordinatorDelegate?.showItemDetail( @@ -472,7 +485,15 @@ final class ItemsViewController: UIViewController { item.isEnabled = enabled } + private func updateRestoreOpenItemsButton(withCount count: Int) { + guard let item = self.navigationItem.rightBarButtonItems?.first(where: { button in RightBarButtonItem(rawValue: button.tag) == .restoreOpenItems }) else { return } + item.image = UIImage(systemName: "\(count).square") + } + private func setupRightBarButtonItems(for state: ItemsState) { + defer { + updateRestoreOpenItemsButton(withCount: state.openItemsCount) + } let currentItems = (self.navigationItem.rightBarButtonItems ?? []).compactMap({ RightBarButtonItem(rawValue: $0.tag) }) let expectedItems = rightBarButtonItemTypes(for: state) guard currentItems != expectedItems else { return } @@ -487,6 +508,9 @@ final class ItemsViewController: UIViewController { } else { items = [.add] + selectItems } + if state.openItemsCount > 0 { + items = [.restoreOpenItems] + items + } return items func rightBarButtonSelectItemTypes(for state: ItemsState) -> [RightBarButtonItem] { @@ -553,6 +577,14 @@ final class ItemsViewController: UIViewController { action = { [weak self] _ in self?.emptyTrash() } + + case .restoreOpenItems: + image = UIImage(systemName: "0.square") + accessibilityLabel = L10n.Items.restoreOpen + action = { [weak self] _ in + guard let self, let presenter, let controller = controllers.userControllers?.openItemsController, let sessionIdentifier = view.scene?.session.persistentIdentifier else { return } + controller.restoreMostRecentlyOpenedItem(using: presenter, sessionIdentifier: sessionIdentifier) + } } let item: UIBarButtonItem @@ -610,6 +642,16 @@ final class ItemsViewController: UIViewController { }) .disposed(by: self.disposeBag) } + + private func setupOpenItemsObserving() { + guard let controller = controllers.userControllers?.openItemsController, let sessionIdentifier = view.scene?.session.persistentIdentifier else { return } + controller.observable(for: sessionIdentifier) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] items in + self?.viewModel.process(action: .updateOpenItems(items: items)) + }) + .disposed(by: disposeBag) + } } extension ItemsViewController: ItemsTableViewHandlerDelegate { diff --git a/Zotero/Scenes/Detail/PDF/PDFCoordinator.swift b/Zotero/Scenes/Detail/PDF/PDFCoordinator.swift index e5e8d3149..a9865048f 100644 --- a/Zotero/Scenes/Detail/PDF/PDFCoordinator.swift +++ b/Zotero/Scenes/Detail/PDF/PDFCoordinator.swift @@ -100,7 +100,8 @@ final class PDFCoordinator: Coordinator { guard let dbStorage = self.controllers.userControllers?.dbStorage, let userId = self.controllers.sessionController.sessionData?.userId, !username.isEmpty, - let parentNavigationController = self.parentCoordinator?.navigationController + let parentNavigationController = self.parentCoordinator?.navigationController, + let openItemsController = controllers.userControllers?.openItemsController else { return } let settings = Defaults.shared.pdfSettings @@ -129,7 +130,8 @@ final class PDFCoordinator: Coordinator { ) let controller = PDFReaderViewController( viewModel: ViewModel(initialState: state, handler: handler), - compactSize: UIDevice.current.isCompactWidth(size: parentNavigationController.view.frame.size) + compactSize: UIDevice.current.isCompactWidth(size: parentNavigationController.view.frame.size), + openItemsController: openItemsController ) controller.coordinatorDelegate = self handler.delegate = controller @@ -598,3 +600,9 @@ extension PDFCoordinator: DetailCitationCoordinatorDelegate { } extension PDFCoordinator: DetailCopyBibliographyCoordinatorDelegate { } + +extension PDFCoordinator: OpenItemsPresenter { + func showItem(with presentation: ItemPresentation) { + (parentCoordinator as? OpenItemsPresenter)?.showItem(with: presentation) + } +} diff --git a/Zotero/Scenes/Detail/PDF/Views/PDFReaderViewController.swift b/Zotero/Scenes/Detail/PDF/Views/PDFReaderViewController.swift index a2db25da6..cd85debe1 100644 --- a/Zotero/Scenes/Detail/PDF/Views/PDFReaderViewController.swift +++ b/Zotero/Scenes/Detail/PDF/Views/PDFReaderViewController.swift @@ -56,7 +56,8 @@ class PDFReaderViewController: UIViewController { return navigationController?.navigationBar.frame.height ?? 0.0 } - weak var coordinatorDelegate: (PdfReaderCoordinatorDelegate & PdfAnnotationsCoordinatorDelegate)? + private unowned let openItemsController: OpenItemsController + weak var coordinatorDelegate: (PdfReaderCoordinatorDelegate & PdfAnnotationsCoordinatorDelegate & OpenItemsPresenter)? private lazy var shareButton: UIBarButtonItem = { var menuChildren: [UIMenuElement] = [] @@ -103,6 +104,21 @@ class PDFReaderViewController: UIViewController { share.menu = UIMenu(children: [deferredMenu]) return share }() + private lazy var openItemsButton: UIBarButtonItem = { + let openItems = UIBarButtonItem(image: UIImage(systemName: "0.square"), style: .plain, target: nil, action: nil) + openItems.isEnabled = true + openItems.accessibilityLabel = L10n.Accessibility.Pdf.openItems + openItems.title = L10n.Accessibility.Pdf.openItems + if let sessionIdentifier = view.scene?.session.persistentIdentifier { + let deferredOpenItemsMenuElement = openItemsController.deferredOpenItemsMenuElement(for: sessionIdentifier, disableOpenItem: true) { [weak self] item, _ in + guard let self, let coordinatorDelegate else { return } + openItemsController.restore(item, using: coordinatorDelegate) + } + let openItemsMenu = UIMenu(title: "Open Items", options: [.displayInline], children: [deferredOpenItemsMenuElement]) + openItems.menu = UIMenu(children: [openItemsMenu]) + } + return openItems + }() private lazy var settingsButton: UIBarButtonItem = { let settings = UIBarButtonItem(image: UIImage(systemName: "gearshape"), style: .plain, target: nil, action: nil) settings.isEnabled = !viewModel.state.document.isLocked @@ -154,9 +170,10 @@ class PDFReaderViewController: UIViewController { return barButton }() - init(viewModel: ViewModel, compactSize: Bool) { + init(viewModel: ViewModel, compactSize: Bool, openItemsController: OpenItemsController) { self.viewModel = viewModel isCompactWidth = compactSize + self.openItemsController = openItemsController disposeBag = DisposeBag() super.init(nibName: nil, bundle: nil) } @@ -169,7 +186,13 @@ class PDFReaderViewController: UIViewController { super.viewDidLoad() statusBarHeight = (view.scene as? UIWindowScene)?.statusBarManager?.statusBarFrame.height ?? .zero - set(userActivity: .pdfActivity(for: viewModel.state.key, libraryId: viewModel.state.library.identifier, collectionId: Defaults.shared.selectedCollectionId)) + if let sessionIdentifier = view.scene?.session.persistentIdentifier { + set(userActivity: .pdfActivity( + with: openItemsController.getItems(for: sessionIdentifier), + libraryId: viewModel.state.library.identifier, + collectionId: Defaults.shared.selectedCollectionId + )) + } view.backgroundColor = .systemGray6 setupViews() intraDocumentNavigationHandler = IntraDocumentNavigationButtonsHandler( @@ -291,6 +314,10 @@ class PDFReaderViewController: UIViewController { func createRightBarButtonItems() -> [UIBarButtonItem] { var buttons = [settingsButton, shareButton, searchButton] + if let sessionIdentifier = view.scene?.session.persistentIdentifier, openItemsController.getItems(for: sessionIdentifier).count > 1 { + buttons.insert(openItemsButton, at: 1) + openItemsButton.image = .init(systemName: "\(openItemsController.getItems(for: sessionIdentifier).count).square") + } if viewModel.state.library.metadataEditable { buttons.append(toolbarButton) diff --git a/Zotero/Scenes/General/Models/NoteEditorAction.swift b/Zotero/Scenes/General/Models/NoteEditorAction.swift index 4aa2918d6..3974263ed 100644 --- a/Zotero/Scenes/General/Models/NoteEditorAction.swift +++ b/Zotero/Scenes/General/Models/NoteEditorAction.swift @@ -12,4 +12,5 @@ enum NoteEditorAction { case save case setTags([Tag]) case setText(String) + case updateOpenItems(items: [OpenItem]) } diff --git a/Zotero/Scenes/General/Models/NoteEditorState.swift b/Zotero/Scenes/General/Models/NoteEditorState.swift index d677d4f1b..b4351c523 100644 --- a/Zotero/Scenes/General/Models/NoteEditorState.swift +++ b/Zotero/Scenes/General/Models/NoteEditorState.swift @@ -35,6 +35,7 @@ struct NoteEditorState: ViewModelState { static let tags = Changes(rawValue: 1 << 0) static let save = Changes(rawValue: 1 << 1) + static let openItems = Changes(rawValue: 1 << 2) } struct TitleData { @@ -53,14 +54,16 @@ struct NoteEditorState: ViewModelState { var text: String var tags: [Tag] var changes: Changes + var openItemsCount: Int - init(kind: Kind, library: Library, title: TitleData?, text: String, tags: [Tag]) { + init(kind: Kind, library: Library, title: TitleData?, text: String, tags: [Tag], openItemsCount: Int) { self.kind = kind self.text = text self.tags = tags self.library = library self.title = title changes = [] + self.openItemsCount = openItemsCount } mutating func cleanup() { diff --git a/Zotero/Scenes/General/NoteEditorCoordinator.swift b/Zotero/Scenes/General/NoteEditorCoordinator.swift index 05eb27e53..f078d8af1 100644 --- a/Zotero/Scenes/General/NoteEditorCoordinator.swift +++ b/Zotero/Scenes/General/NoteEditorCoordinator.swift @@ -35,6 +35,7 @@ final class NoteEditorCoordinator: NSObject, Coordinator { private let title: NoteEditorState.TitleData? private let library: Library private let saveCallback: NoteEditorSaveCallback + private let sessionIdentifier: String private unowned let controllers: Controllers init( @@ -45,6 +46,7 @@ final class NoteEditorCoordinator: NSObject, Coordinator { title: NoteEditorState.TitleData?, saveCallback: @escaping NoteEditorSaveCallback, navigationController: NavigationViewController, + sessionIdentifier: String, controllers: Controllers ) { self.kind = kind @@ -54,6 +56,7 @@ final class NoteEditorCoordinator: NSObject, Coordinator { self.library = library self.saveCallback = saveCallback self.navigationController = navigationController + self.sessionIdentifier = sessionIdentifier self.controllers = controllers childCoordinators = [] @@ -70,12 +73,12 @@ final class NoteEditorCoordinator: NSObject, Coordinator { } func start(animated: Bool) { - guard let dbStorage = controllers.userControllers?.dbStorage else { return } + guard let dbStorage = controllers.userControllers?.dbStorage, let openItemsController = controllers.userControllers?.openItemsController else { return } - let state = NoteEditorState(kind: kind, library: library, title: title, text: initialText, tags: initialTags) + let state = NoteEditorState(kind: kind, library: library, title: title, text: initialText, tags: initialTags, openItemsCount: openItemsController.getItems(for: sessionIdentifier).count) let handler = NoteEditorActionHandler(dbStorage: dbStorage, schemaController: controllers.schemaController, saveCallback: saveCallback) let viewModel = ViewModel(initialState: state, handler: handler) - let controller = NoteEditorViewController(viewModel: viewModel) + let controller = NoteEditorViewController(viewModel: viewModel, openItemsController: openItemsController) controller.coordinatorDelegate = self navigationController?.setViewControllers([controller], animated: animated) } @@ -113,3 +116,9 @@ extension NoteEditorCoordinator: NoteEditorCoordinatorDelegate { navigationController.present(controller, animated: true, completion: nil) } } + +extension NoteEditorCoordinator: OpenItemsPresenter { + func showItem(with presentation: ItemPresentation) { + (parentCoordinator as? OpenItemsPresenter)?.showItem(with: presentation) + } +} diff --git a/Zotero/Scenes/General/ViewModels/NoteEditorActionHandler.swift b/Zotero/Scenes/General/ViewModels/NoteEditorActionHandler.swift index 7d9989c95..9c0ec6ad2 100644 --- a/Zotero/Scenes/General/ViewModels/NoteEditorActionHandler.swift +++ b/Zotero/Scenes/General/ViewModels/NoteEditorActionHandler.swift @@ -45,6 +45,14 @@ struct NoteEditorActionHandler: ViewModelActionHandler, BackgroundDbProcessingAc state.tags = tags state.changes = [.tags, .save] } + + case .updateOpenItems(let items): + update(viewModel: viewModel) { state in + if state.openItemsCount != items.count { + state.openItemsCount = items.count + state.changes = .openItems + } + } } func save(viewModel: ViewModel) { diff --git a/Zotero/Scenes/General/Views/NoteEditorViewController.swift b/Zotero/Scenes/General/Views/NoteEditorViewController.swift index 070fe8844..448141755 100644 --- a/Zotero/Scenes/General/Views/NoteEditorViewController.swift +++ b/Zotero/Scenes/General/Views/NoteEditorViewController.swift @@ -13,6 +13,11 @@ import WebKit import RxSwift final class NoteEditorViewController: UIViewController { + private enum RightBarButtonItem: Int { + case done + case restoreOpenItems + } + @IBOutlet private weak var webView: WKWebView! @IBOutlet private weak var tagsTitleLabel: UILabel! @IBOutlet private weak var tagsLabel: UILabel! @@ -22,7 +27,8 @@ final class NoteEditorViewController: UIViewController { private let disposeBag: DisposeBag private var debounceDisposeBag: DisposeBag? - weak var coordinatorDelegate: NoteEditorCoordinatorDelegate? + private unowned let openItemsController: OpenItemsController + weak var coordinatorDelegate: (NoteEditorCoordinatorDelegate & OpenItemsPresenter)? private var htmlUrl: URL? { if viewModel.state.kind.readOnly { @@ -32,26 +38,32 @@ final class NoteEditorViewController: UIViewController { } } - init(viewModel: ViewModel) { + init(viewModel: ViewModel, openItemsController: OpenItemsController) { self.viewModel = viewModel + self.openItemsController = openItemsController disposeBag = DisposeBag() super.init(nibName: "NoteEditorViewController", bundle: nil) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() + if let sessionIdentifier = view.scene?.session.persistentIdentifier { + set(userActivity: .pdfActivity(with: openItemsController.getItems(for: sessionIdentifier), libraryId: viewModel.state.library.identifier, collectionId: Defaults.shared.selectedCollectionId)) + } + if let data = viewModel.state.title { navigationItem.titleView = NoteEditorTitleView(type: data.type, title: data.title) } view.backgroundColor = .systemBackground - setupNavbarItems() + setupNavbarItems(for: viewModel.state) setupWebView() + setupOpenItemsObserving() update(tags: viewModel.state.tags) viewModel.stateObservable @@ -60,15 +72,67 @@ final class NoteEditorViewController: UIViewController { }) .disposed(by: disposeBag) - func setupNavbarItems() { - let done = UIBarButtonItem(title: L10n.done, style: .done, target: nil, action: nil) - done.rx.tap - .subscribe(with: self, onNext: { `self`, _ in - forceSaveIfNeeded() - self.navigationController?.presentingViewController?.dismiss(animated: true, completion: nil) - }) - .disposed(by: disposeBag) - navigationItem.rightBarButtonItem = done + func setupNavbarItems(for state: NoteEditorState) { + defer { + updateRestoreOpenItemsButton(withCount: state.openItemsCount) + } + let currentItems = (self.navigationItem.rightBarButtonItems ?? []).compactMap({ RightBarButtonItem(rawValue: $0.tag) }) + let expectedItems = rightBarButtonItemTypes(for: state) + guard currentItems != expectedItems else { return } + navigationItem.rightBarButtonItems = expectedItems.map({ createRightBarButtonItem($0) }).reversed() + + func rightBarButtonItemTypes(for state: NoteEditorState) -> [RightBarButtonItem] { + var items: [RightBarButtonItem] = [.done] + if state.openItemsCount > 1 { + items = [.restoreOpenItems] + items + } + return items + } + + func createRightBarButtonItem(_ type: RightBarButtonItem) -> UIBarButtonItem { + let item: UIBarButtonItem + switch type { + case .done: + let done = UIBarButtonItem(title: L10n.done, style: .done, target: nil, action: nil) + done.rx.tap + .subscribe(with: self, onNext: { `self`, _ in + forceSaveIfNeeded() + self.navigationController?.presentingViewController?.dismiss(animated: true, completion: nil) + }) + .disposed(by: disposeBag) + item = done + + case .restoreOpenItems: + let openItems: UIBarButtonItem + if let sessionIdentifier = view.scene?.session.persistentIdentifier { + let items = openItemsController.getItems(for: sessionIdentifier) + openItems = UIBarButtonItem(image: UIImage(systemName: "\(items.count).square"), style: .plain, target: nil, action: nil) + openItems.isEnabled = true + if let sessionIdentifier = view.scene?.session.persistentIdentifier { + let deferredOpenItemsMenuElement = openItemsController.deferredOpenItemsMenuElement(for: sessionIdentifier, disableOpenItem: true) { [weak self] item, _ in + guard let self, let coordinatorDelegate else { return } + openItemsController.restore(item, using: coordinatorDelegate) + } + let openItemsMenu = UIMenu(title: "Open Items", options: [.displayInline], children: [deferredOpenItemsMenuElement]) + openItems.menu = UIMenu(children: [openItemsMenu]) + } + } else { + openItems = UIBarButtonItem(image: UIImage(systemName: "0.square"), style: .plain, target: nil, action: nil) + openItems.isEnabled = false + } + openItems.accessibilityLabel = L10n.Accessibility.Pdf.openItems + openItems.title = L10n.Accessibility.Pdf.openItems + item = openItems + } + + item.tag = type.rawValue + return item + } + + func updateRestoreOpenItemsButton(withCount count: Int) { + guard let item = navigationItem.rightBarButtonItems?.first(where: { button in RightBarButtonItem(rawValue: button.tag) == .restoreOpenItems }) else { return } + item.image = UIImage(systemName: "\(count).square") + } func forceSaveIfNeeded() { guard debounceDisposeBag != nil else { return } @@ -86,6 +150,16 @@ final class NoteEditorViewController: UIViewController { webView.loadHTMLString(data, baseURL: url) } + func setupOpenItemsObserving() { + guard let sessionIdentifier = view.scene?.session.persistentIdentifier else { return } + openItemsController.observable(for: sessionIdentifier) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] items in + self?.viewModel.process(action: .updateOpenItems(items: items)) + }) + .disposed(by: disposeBag) + } + func process(state: NoteEditorState) { if state.changes.contains(.tags) { update(tags: state.tags) @@ -93,6 +167,9 @@ final class NoteEditorViewController: UIViewController { if state.changes.contains(.save) { debounceSave() } + if state.changes.contains(.openItems) { + setupNavbarItems(for: state) + } func debounceSave() { debounceDisposeBag = nil diff --git a/Zotero/Scenes/Main/Views/MainViewController.swift b/Zotero/Scenes/Main/Views/MainViewController.swift index ad3a61fda..731ece10f 100644 --- a/Zotero/Scenes/Main/Views/MainViewController.swift +++ b/Zotero/Scenes/Main/Views/MainViewController.swift @@ -24,6 +24,7 @@ protocol MainCoordinatorSyncToolbarDelegate: AnyObject { final class MainViewController: UISplitViewController { // Constants + private let sessionIdentifier: String private let controllers: Controllers private let disposeBag: DisposeBag // Variables @@ -42,7 +43,8 @@ final class MainViewController: UISplitViewController { // MARK: - Lifecycle - init(controllers: Controllers) { + init(sessionIdentifier: String, controllers: Controllers) { + self.sessionIdentifier = sessionIdentifier self.controllers = controllers self.disposeBag = DisposeBag() @@ -75,7 +77,7 @@ final class MainViewController: UISplitViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - self.set(userActivity: .mainActivity) + self.set(userActivity: .mainActivity(with: controllers.userControllers?.openItemsController.getItems(for: sessionIdentifier) ?? [])) self.didAppear = true } @@ -97,6 +99,7 @@ final class MainViewController: UISplitViewController { searchItemKeys: searchItemKeys, navigationController: navigationController, itemsTagFilterDelegate: tagFilterController, + sessionIdentifier: sessionIdentifier, controllers: self.controllers ) coordinator.start(animated: false)