From 51974bf0e584630371b45d16eed815a993ec02aa Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Thu, 18 Jul 2024 12:42:12 +0200 Subject: [PATCH 01/23] Move deleted collections to trash instead of deleting them immediately --- Zotero.xcodeproj/project.pbxproj | 6 ++++ .../MarkCollectionsAsTrashedDbRequest.swift | 28 +++++++++++++++++++ Zotero/Models/UpdatableObject.swift | 16 ++++++----- .../ViewModels/CollectionsActionHandler.swift | 2 +- 4 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 Zotero/Controllers/Database/Requests/MarkCollectionsAsTrashedDbRequest.swift diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index 07d6ac20d..4376a033c 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -977,6 +977,8 @@ B3BD86BC258CBCD600EF6674 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BD86BB258CBCD600EF6674 /* SearchBar.swift */; }; B3BF7EE728A51BDC00A5A659 /* DeleteTagFromItemDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF7EE628A51BDC00A5A659 /* DeleteTagFromItemDbRequest.swift */; }; B3BF7EE928A51EDA00A5A659 /* EditTagsForItemDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF7EE828A51EDA00A5A659 /* EditTagsForItemDbRequest.swift */; }; + B3C3EC232C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC222C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift */; }; + B3C3EC242C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC222C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift */; }; B3C43C2028589F300007076D /* NotePreviewGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C43C1F28589F300007076D /* NotePreviewGenerator.swift */; }; B3C43C212858A84A0007076D /* NotePreviewGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C43C1F28589F300007076D /* NotePreviewGenerator.swift */; }; B3C6AB28248E1B720009AC96 /* SyncBatchProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C6AB27248E1B720009AC96 /* SyncBatchProcessor.swift */; }; @@ -1978,6 +1980,7 @@ B3BD86BB258CBCD600EF6674 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; B3BF7EE628A51BDC00A5A659 /* DeleteTagFromItemDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteTagFromItemDbRequest.swift; sourceTree = ""; }; B3BF7EE828A51EDA00A5A659 /* EditTagsForItemDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditTagsForItemDbRequest.swift; sourceTree = ""; }; + B3C3EC222C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkCollectionsAsTrashedDbRequest.swift; sourceTree = ""; }; B3C43C1F28589F300007076D /* NotePreviewGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotePreviewGenerator.swift; sourceTree = ""; }; B3C6AB27248E1B720009AC96 /* SyncBatchProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncBatchProcessor.swift; sourceTree = ""; }; B3C6AB2A248E3EB90009AC96 /* ApiOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiOperation.swift; sourceTree = ""; }; @@ -2414,6 +2417,7 @@ B3F4F2A02728015700685E1A /* MarkAttachmentsNotUploadedDbRequest.swift */, B305645623FC051E003304F2 /* MarkAttachmentUploadedDbRequest.swift */, B305645A23FC051E003304F2 /* MarkCollectionAndItemsAsDeletedDbRequest.swift */, + B3C3EC222C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift */, B35FC1EF2628490C00858772 /* MarkFileAsDownloadedDbRequest.swift */, B305643923FC051E003304F2 /* MarkForResyncDbAction.swift */, B305646623FC051E003304F2 /* MarkGroupAsLocalOnlyDbRequest.swift */, @@ -4943,6 +4947,7 @@ B37AA28228990F6300A1C643 /* ItemDetailTitleContentView.swift in Sources */, B3486B8A26CFAFEA0036A267 /* SingleCitationState.swift in Sources */, B34A73002670C9ED00A7B186 /* SyncRepoResponseDbRequest.swift in Sources */, + B3C3EC242C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift in Sources */, B30566A823FC051F003304F2 /* Convertible.swift in Sources */, B3868540270DC3AA0068A022 /* WebDavScheme.swift in Sources */, B32370402C0DC65600170779 /* LookupWebViewHandler.swift in Sources */, @@ -5637,6 +5642,7 @@ B3902DE72480DBC1007EE48E /* ComponentDate.swift in Sources */, B305677323FC0B5D003304F2 /* Array+Utils.swift in Sources */, B305673423FC092A003304F2 /* SyncSettingsSyncAction.swift in Sources */, + B3C3EC232C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift in Sources */, B30566EC23FC082D003304F2 /* CrashUploadRequest.swift in Sources */, B361A3022511F9BE00271173 /* LinkMode.swift in Sources */, B3FC74342721858200F55531 /* WebDavSessionStorage.swift in Sources */, diff --git a/Zotero/Controllers/Database/Requests/MarkCollectionsAsTrashedDbRequest.swift b/Zotero/Controllers/Database/Requests/MarkCollectionsAsTrashedDbRequest.swift new file mode 100644 index 000000000..82253a4be --- /dev/null +++ b/Zotero/Controllers/Database/Requests/MarkCollectionsAsTrashedDbRequest.swift @@ -0,0 +1,28 @@ +// +// MarkCollectionsAsTrashedDbRequest.swift +// Zotero +// +// Created by Michal Rentka on 18.07.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +import RealmSwift + +struct MarkCollectionsAsTrashedDbRequest: DbRequest { + let keys: [String] + let libraryId: LibraryIdentifier + let trashed: Bool + + var needsWrite: Bool { return true } + + func process(in database: Realm) throws { + let collections = database.objects(RCollection.self).filter(.keys(self.keys, in: self.libraryId)) + collections.forEach { item in + item.trash = trashed + item.changeType = .user + item.changes.append(RObjectChange.create(changes: RCollectionChanges.trash)) + } + } +} diff --git a/Zotero/Models/UpdatableObject.swift b/Zotero/Models/UpdatableObject.swift index b1cb1cc65..0b00bc938 100644 --- a/Zotero/Models/UpdatableObject.swift +++ b/Zotero/Models/UpdatableObject.swift @@ -51,28 +51,30 @@ extension Updatable { extension RCollection: Updatable { var updateParameters: [String: Any]? { - guard self.isChanged else { return nil } + guard isChanged else { return nil } - var parameters: [String: Any] = ["key": self.key, - "version": self.version] + var parameters: [String: Any] = ["key": key, "version": version] - let changes = self.changedFields + let changes = changedFields if changes.contains(.name) { - parameters["name"] = self.name + parameters["name"] = name } if changes.contains(.parent) { - if let key = self.parentKey { + if let key = parentKey { parameters["parentCollection"] = key } else { parameters["parentCollection"] = false } } + if changes.contains(.trash) { + parameters["deleted"] = trash + } return parameters } var selfOrChildChanged: Bool { - return self.isChanged + return isChanged } func markAsChanged(in database: Realm) { diff --git a/Zotero/Scenes/Master/Collections/ViewModels/CollectionsActionHandler.swift b/Zotero/Scenes/Master/Collections/ViewModels/CollectionsActionHandler.swift index bc43375f2..f9b053046 100644 --- a/Zotero/Scenes/Master/Collections/ViewModels/CollectionsActionHandler.swift +++ b/Zotero/Scenes/Master/Collections/ViewModels/CollectionsActionHandler.swift @@ -295,7 +295,7 @@ struct CollectionsActionHandler: ViewModelActionHandler, BackgroundDbProcessingA } private func delete(object: Obj.Type, keys: [String], in viewModel: ViewModel) { - let request = MarkObjectsAsDeletedDbRequest(keys: keys, libraryId: viewModel.state.library.identifier) + let request = MarkCollectionsAsTrashedDbRequest(keys: keys, libraryId: viewModel.state.library.identifier, trashed: true) self.perform(request: request) { [weak viewModel] error in guard let error = error, let viewModel = viewModel else { return } From 29348a76b3ebec02d0947f327af7db456287cafb Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Fri, 19 Jul 2024 16:12:17 +0200 Subject: [PATCH 02/23] WIP: trash controller --- Zotero.xcodeproj/project.pbxproj | 58 +++++ .../Requests/ReadCollectionsDbRequest.swift | 6 +- .../Extensions/OrderedDictionary+Utils.swift | 27 +++ .../Detail/Items/Models/ItemCellModel.swift | 20 +- .../Items/ViewModels/ItemsActionHandler.swift | 22 +- .../Detail/Trash/Models/TrashAction.swift | 13 ++ .../Detail/Trash/Models/TrashObject.swift | 144 ++++++++++++ .../Detail/Trash/Models/TrashState.swift | 43 ++++ .../Trash/ViewModels/TrashActionHandler.swift | 210 ++++++++++++++++++ .../Trash/Views/TrashViewController.swift | 29 +++ .../Scenes/General/Models/ItemAccessory.swift | 16 ++ 11 files changed, 559 insertions(+), 29 deletions(-) create mode 100644 Zotero/Extensions/OrderedDictionary+Utils.swift create mode 100644 Zotero/Scenes/Detail/Trash/Models/TrashAction.swift create mode 100644 Zotero/Scenes/Detail/Trash/Models/TrashObject.swift create mode 100644 Zotero/Scenes/Detail/Trash/Models/TrashState.swift create mode 100644 Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift create mode 100644 Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index 4376a033c..e31fa8568 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -857,6 +857,8 @@ B398A915270C6A4300968EE8 /* WebDavController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B398A914270C6A4300968EE8 /* WebDavController.swift */; }; B398A917270C6A5B00968EE8 /* WebDavSessionStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B398A916270C6A5B00968EE8 /* WebDavSessionStorage.swift */; }; B398D6C02A77F9C60049A296 /* FontSizeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B398D6BF2A77F9C60049A296 /* FontSizeView.swift */; }; + B39ADE712C4AAB090006FA79 /* OrderedDictionary+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39ADE702C4AAB030006FA79 /* OrderedDictionary+Utils.swift */; }; + B39ADE722C4AAB090006FA79 /* OrderedDictionary+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39ADE702C4AAB030006FA79 /* OrderedDictionary+Utils.swift */; }; B39AF554290033CD001F400F /* TableOfContentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39AF553290033CD001F400F /* TableOfContentsViewController.swift */; }; B39B18E8223947050019F467 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39B18E7223947050019F467 /* main.swift */; }; B39C9AB7252B589F00462D27 /* Threading+Extras.swift in Sources */ = {isa = PBXBuildFile; fileRef = B305650423FC051E003304F2 /* Threading+Extras.swift */; }; @@ -979,6 +981,11 @@ B3BF7EE928A51EDA00A5A659 /* EditTagsForItemDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF7EE828A51EDA00A5A659 /* EditTagsForItemDbRequest.swift */; }; B3C3EC232C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC222C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift */; }; B3C3EC242C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC222C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift */; }; + B3C3EC2A2C492A870062705A /* TrashViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC292C492A870062705A /* TrashViewController.swift */; }; + B3C3EC2C2C492A970062705A /* TrashActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC2B2C492A970062705A /* TrashActionHandler.swift */; }; + B3C3EC2E2C492A9F0062705A /* TrashState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC2D2C492A9F0062705A /* TrashState.swift */; }; + B3C3EC302C492AAC0062705A /* TrashAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC2F2C492AAC0062705A /* TrashAction.swift */; }; + B3C3EC322C492CEA0062705A /* TrashObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC312C492CEA0062705A /* TrashObject.swift */; }; B3C43C2028589F300007076D /* NotePreviewGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C43C1F28589F300007076D /* NotePreviewGenerator.swift */; }; B3C43C212858A84A0007076D /* NotePreviewGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C43C1F28589F300007076D /* NotePreviewGenerator.swift */; }; B3C6AB28248E1B720009AC96 /* SyncBatchProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C6AB27248E1B720009AC96 /* SyncBatchProcessor.swift */; }; @@ -1873,6 +1880,7 @@ B398A914270C6A4300968EE8 /* WebDavController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebDavController.swift; sourceTree = ""; }; B398A916270C6A5B00968EE8 /* WebDavSessionStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDavSessionStorage.swift; sourceTree = ""; }; B398D6BF2A77F9C60049A296 /* FontSizeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontSizeView.swift; sourceTree = ""; }; + B39ADE702C4AAB030006FA79 /* OrderedDictionary+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrderedDictionary+Utils.swift"; sourceTree = ""; }; B39AF553290033CD001F400F /* TableOfContentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsViewController.swift; sourceTree = ""; }; B39B18E7223947050019F467 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; B39C7BDD251237D600C2CCF1 /* ReadUpdatedItemUpdateParametersSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadUpdatedItemUpdateParametersSpec.swift; sourceTree = ""; }; @@ -1981,6 +1989,11 @@ B3BF7EE628A51BDC00A5A659 /* DeleteTagFromItemDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteTagFromItemDbRequest.swift; sourceTree = ""; }; B3BF7EE828A51EDA00A5A659 /* EditTagsForItemDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditTagsForItemDbRequest.swift; sourceTree = ""; }; B3C3EC222C48FD970062705A /* MarkCollectionsAsTrashedDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkCollectionsAsTrashedDbRequest.swift; sourceTree = ""; }; + B3C3EC292C492A870062705A /* TrashViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashViewController.swift; sourceTree = ""; }; + B3C3EC2B2C492A970062705A /* TrashActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashActionHandler.swift; sourceTree = ""; }; + B3C3EC2D2C492A9F0062705A /* TrashState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashState.swift; sourceTree = ""; }; + B3C3EC2F2C492AAC0062705A /* TrashAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashAction.swift; sourceTree = ""; }; + B3C3EC312C492CEA0062705A /* TrashObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashObject.swift; sourceTree = ""; }; B3C43C1F28589F300007076D /* NotePreviewGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotePreviewGenerator.swift; sourceTree = ""; }; B3C6AB27248E1B720009AC96 /* SyncBatchProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncBatchProcessor.swift; sourceTree = ""; }; B3C6AB2A248E3EB90009AC96 /* ApiOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiOperation.swift; sourceTree = ""; }; @@ -2599,6 +2612,7 @@ B305650223FC051E003304F2 /* Extensions */ = { isa = PBXGroup; children = ( + B39ADE702C4AAB030006FA79 /* OrderedDictionary+Utils.swift */, B305651123FC051E003304F2 /* Array+Utils.swift */, B30B550A24B85CC900F94B59 /* Assets.swift */, B30B550F24B8646E00F94B59 /* Assets+SwiftUI.swift */, @@ -3215,6 +3229,7 @@ B3ADAE402833AD6700D46271 /* Lookup */, B31941D824531F6600BF6296 /* PDF */, B3486B8026CFAFEA0036A267 /* SingleCitation */, + B3C3EC282C492A570062705A /* Trash */, B3593F61241A62DD00760E20 /* DetailCoordinator.swift */, ); path = Detail; @@ -3783,6 +3798,42 @@ path = ViewModels; sourceTree = ""; }; + B3C3EC252C492A570062705A /* Models */ = { + isa = PBXGroup; + children = ( + B3C3EC2F2C492AAC0062705A /* TrashAction.swift */, + B3C3EC2D2C492A9F0062705A /* TrashState.swift */, + B3C3EC312C492CEA0062705A /* TrashObject.swift */, + ); + path = Models; + sourceTree = ""; + }; + B3C3EC262C492A570062705A /* ViewModels */ = { + isa = PBXGroup; + children = ( + B3C3EC2B2C492A970062705A /* TrashActionHandler.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + B3C3EC272C492A570062705A /* Views */ = { + isa = PBXGroup; + children = ( + B3C3EC292C492A870062705A /* TrashViewController.swift */, + ); + path = Views; + sourceTree = ""; + }; + B3C3EC282C492A570062705A /* Trash */ = { + isa = PBXGroup; + children = ( + B3C3EC252C492A570062705A /* Models */, + B3C3EC262C492A570062705A /* ViewModels */, + B3C3EC272C492A570062705A /* Views */, + ); + path = Trash; + sourceTree = ""; + }; B3DCDED72408F5150039ED0D /* General */ = { isa = PBXGroup; children = ( @@ -4828,6 +4879,7 @@ B3593F28241A61C700760E20 /* ItemDetailFieldCell.swift in Sources */, B361A3012511F98700271173 /* LinkMode.swift in Sources */, B3429B8124BDE73A008359FC /* UIDevice+Extensions.swift in Sources */, + B3C3EC322C492CEA0062705A /* TrashObject.swift in Sources */, B3229FD128C0A07500DAF3B7 /* EditAnnotationRectsDbRequest.swift in Sources */, B3DF9AD22747AAD2007933CB /* ApiRequest.swift in Sources */, B3DCDF0E240912500039ED0D /* SinglePickerActionHandler.swift in Sources */, @@ -4860,6 +4912,7 @@ B310091D272C0126003FC743 /* DeleteWebDavDeletionsDbRequest.swift in Sources */, B3593F36241A61C700760E20 /* ItemDetailSectionView.swift in Sources */, B305661423FC051E003304F2 /* Licenses.swift in Sources */, + B3C3EC2A2C492A870062705A /* TrashViewController.swift in Sources */, B3E4463C248FBBA3007FE8AB /* RLink.swift in Sources */, B3DF9AD82747AB63007933CB /* ApiLogParameters.swift in Sources */, B305660423FC051E003304F2 /* Alamofire+RxSwift.swift in Sources */, @@ -5153,6 +5206,7 @@ B3F9A4BE2B04CEC300684030 /* ReaderSettingsSegmentedCell.swift in Sources */, B379D9322BB30E6600AF5025 /* FullSyncDebugger.swift in Sources */, B30565D023FC051E003304F2 /* CreateCollectionDbRequest.swift in Sources */, + B3C3EC2C2C492A970062705A /* TrashActionHandler.swift in Sources */, B30565C123FC051E003304F2 /* MarkObjectsAsDeletedDbRequest.swift in Sources */, B3830CDC255451AB00910FE0 /* TagPickerState.swift in Sources */, B39D336823FFD96C00EF2ACB /* Note.swift in Sources */, @@ -5227,6 +5281,7 @@ B305661123FC051E003304F2 /* AuthorizeUploadRequest.swift in Sources */, B3E8FE052714292E00F51458 /* StorageSettingsView.swift in Sources */, B36CBD4C25DD397D003C4613 /* ConflictAlertQueueController.swift in Sources */, + B3C3EC302C492AAC0062705A /* TrashAction.swift in Sources */, B357DEE624165E0B00E06153 /* DebugLogFormatter.swift in Sources */, B30565DF23FC051E003304F2 /* ReadSearchesDbRequest.swift in Sources */, B30566AD23FC051F003304F2 /* ItemResponse.swift in Sources */, @@ -5297,6 +5352,7 @@ B3486B8926CFAFEA0036A267 /* SingleCitationActionHandler.swift in Sources */, B3A351DF271577D0002E597A /* WebDavTestWriteRequest.swift in Sources */, B3593F26241A61C700760E20 /* ItemDetailAbstractCell.swift in Sources */, + B3C3EC2E2C492A9F0062705A /* TrashState.swift in Sources */, B3D0793627CCF63800C454D6 /* AnnotationBoundingBoxCalculator.swift in Sources */, B30566A223FC051F003304F2 /* Attachment.swift in Sources */, B30565BF23FC051E003304F2 /* CheckItemIsChangedDbRequest.swift in Sources */, @@ -5374,6 +5430,7 @@ B3BC1F60254322D200BA3388 /* ItemDetailAbstractEditCell.swift in Sources */, B3422F44289A9F2400C53DD2 /* ItemDetailAttachmentContentView.swift in Sources */, B30566C523FC051F003304F2 /* Tag.swift in Sources */, + B39ADE712C4AAB090006FA79 /* OrderedDictionary+Utils.swift in Sources */, B31EEE5724EBCAF100E3B3AD /* RRect.swift in Sources */, B3E8FE562714320900F51458 /* SavingSettingsState.swift in Sources */, B30565B423FC051E003304F2 /* MarkForResyncDbAction.swift in Sources */, @@ -5707,6 +5764,7 @@ B353F1FC242E23680062EE24 /* ResetTranslatorsDbRequest.swift in Sources */, B37080532AA7216E006F56B9 /* Localizable.swift in Sources */, B3AB43A927E8A81D006F3E4E /* Dictionary+Extensions.swift in Sources */, + B39ADE722C4AAB090006FA79 /* OrderedDictionary+Utils.swift in Sources */, B36459E0264411AC00A0C2C0 /* ViewModel.swift in Sources */, B32EBFC3276A892E0003897E /* BackgroundUploader.swift in Sources */, B3902DE62480DBB7007EE48E /* DateParser.swift in Sources */, diff --git a/Zotero/Controllers/Database/Requests/ReadCollectionsDbRequest.swift b/Zotero/Controllers/Database/Requests/ReadCollectionsDbRequest.swift index 05eba1b21..770e0c401 100644 --- a/Zotero/Controllers/Database/Requests/ReadCollectionsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/ReadCollectionsDbRequest.swift @@ -15,18 +15,20 @@ struct ReadCollectionsDbRequest: DbResponseRequest { let libraryId: LibraryIdentifier let excludedKeys: Set + let trash: Bool var needsWrite: Bool { return false } - init(libraryId: LibraryIdentifier, excludedKeys: Set = []) { + init(libraryId: LibraryIdentifier, trash: Bool = false, excludedKeys: Set = []) { self.libraryId = libraryId + self.trash = trash self.excludedKeys = excludedKeys } func process(in database: Realm) throws -> Results { let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [.notSyncState(.dirty, in: self.libraryId), .deleted(false), - .isTrash(false), + .isTrash(trash), .key(notIn: self.excludedKeys)]) return database.objects(RCollection.self).filter(predicate) } diff --git a/Zotero/Extensions/OrderedDictionary+Utils.swift b/Zotero/Extensions/OrderedDictionary+Utils.swift new file mode 100644 index 000000000..4293b3dc9 --- /dev/null +++ b/Zotero/Extensions/OrderedDictionary+Utils.swift @@ -0,0 +1,27 @@ +// +// OrderedDictionary+Utils.swift +// Zotero +// +// Created by Michal Rentka on 19.07.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import OrderedCollections + +extension OrderedDictionary { + /// Finds an insertion index for given element in array. The array has to be sorted! Implemented as binary search. + /// - parameter element: Element to be found/inserted + /// - parameter areInIncreasingOrder: sorting function to be used to compare elements in array. + /// - returns: Insertion index into sorted array. + func index(of element: Value, sortedBy areInIncreasingOrder: (Value, Value) -> Bool) -> Int { + var (low, high) = (0, self.count - 1) + while low <= high { + switch (low + high) / 2 { + case let mid where areInIncreasingOrder(element, self.values[mid]): high = mid - 1 + case let mid where areInIncreasingOrder(self.values[mid], element): low = mid + 1 + case let mid: return mid // element found at mid + } + } + return low // element not found, should be inserted here + } +} diff --git a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift index 2c88f988d..e24dfaed2 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift @@ -29,26 +29,30 @@ struct ItemCellModel { init(item: RItem, typeName: String, title: NSAttributedString, accessory: Accessory?) { self.key = item.key - let contentType: String? = item.rawType == ItemTypes.attachment ? item.fields.filter(.key(FieldKeys.Item.Attachment.contentType)).first?.value : nil - self.typeIconName = ItemTypes.iconName(for: item.rawType, contentType: contentType) + self.typeIconName = Self.typeIconName(for: item) self.typeName = typeName self.title = title - self.subtitle = ItemCellModel.subtitle(for: item) - self.hasNote = ItemCellModel.hasNote(item: item) + self.subtitle = Self.creatorSummary(for: item) + self.hasNote = Self.hasNote(item: item) self.accessory = accessory - let (colors, emojis) = ItemCellModel.tagData(item: item) + let (colors, emojis) = Self.tagData(item: item) self.tagColors = colors self.tagEmojis = emojis } - fileprivate static func hasNote(item: RItem) -> Bool { + static func hasNote(item: RItem) -> Bool { return !item.children .filter(.items(type: ItemTypes.note, notSyncState: .dirty)) .filter(.isTrash(false)) .isEmpty } - fileprivate static func tagData(item: RItem) -> ([UIColor], [String]) { + static func typeIconName(for item: RItem) -> String { + let contentType: String? = item.rawType == ItemTypes.attachment ? item.fields.filter(.key(FieldKeys.Item.Attachment.contentType)).first?.value : nil + return ItemTypes.iconName(for: item.rawType, contentType: contentType) + } + + static func tagData(item: RItem) -> ([UIColor], [String]) { var colors: [UIColor] = [] var emojis: [String] = [] for tag in item.tags { @@ -65,7 +69,7 @@ struct ItemCellModel { return (colors, emojis) } - private static func subtitle(for item: RItem) -> String { + static func creatorSummary(for item: RItem) -> String { guard item.creatorSummary != nil || item.parsedYear != 0 else { return "" } var result = item.creatorSummary ?? "" if !result.isEmpty { diff --git a/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift b/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift index 947047691..10f702929 100644 --- a/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift +++ b/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift @@ -392,7 +392,7 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH 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 = self.accessory(for: item) else { return } + guard viewModel.state.itemAccessories[item.key] == nil, let accessory = ItemAccessory.create(from: item, fileStorage: fileStorage, urlDetector: urlDetector) else { return } self.update(viewModel: viewModel) { state in state.itemAccessories[item.key] = accessory } @@ -680,22 +680,6 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH // MARK: - Helpers - private func accessory(for item: RItem) -> ItemAccessory? { - if let attachment = AttachmentCreator.mainAttachment(for: item, fileStorage: self.fileStorage) { - return .attachment(attachment: attachment, parentKey: (item.key != attachment.key) ? item.key : nil) - } - - if let urlString = item.urlString, self.urlDetector.isUrl(string: urlString), let url = URL(string: urlString) { - return .url(url) - } - - if let doi = item.doi { - return .doi(doi) - } - - return nil - } - /// Updates the `keys` array which mirrors `Results` identifiers. Updates `selectedItems` if needed. Updates `attachments` if needed. private func processUpdate(items: Results, deletions: [Int], insertions: [Int], modifications: [Int], in viewModel: ViewModel) { self.update(viewModel: viewModel) { state in @@ -718,13 +702,13 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH modifications.forEach { idx in let item = items[idx] - state.itemAccessories[item.key] = self.accessory(for: item) + state.itemAccessories[item.key] = ItemAccessory.create(from: item, fileStorage: fileStorage, urlDetector: urlDetector) state.itemTitles[item.key] = self.htmlAttributedStringConverter.convert(text: item.displayTitle, baseAttributes: [.font: state.itemTitleFont]) } for idx in insertions { let item = items[idx] - state.itemAccessories[item.key] = self.accessory(for: item) + state.itemAccessories[item.key] = ItemAccessory.create(from: item, fileStorage: fileStorage, urlDetector: urlDetector) state.itemTitles[item.key] = self.htmlAttributedStringConverter.convert(text: item.displayTitle, baseAttributes: [.font: state.itemTitleFont]) if !shouldRebuildKeys { diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift b/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift new file mode 100644 index 000000000..7b13f8454 --- /dev/null +++ b/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift @@ -0,0 +1,13 @@ +// +// TrashAction.swift +// Zotero +// +// Created by Michal Rentka on 18.07.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +enum TrashAction { + case loadData +} diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift new file mode 100644 index 000000000..aaea5ddcd --- /dev/null +++ b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift @@ -0,0 +1,144 @@ +// +// File.swift +// Zotero +// +// Created by Michal Rentka on 18.07.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +struct TrashKey: Hashable { + enum Kind: Hashable { + case collection + case item + } + + let type: Kind + let key: String +} + +struct TrashObject { + struct ItemSortData { + let title: String + let type: String + let creatorSummary: String + let publisher: String? + let publicationTitle: String? + let year: Int? + let date: Date? + let dateAdded: Date + } + + struct ItemCellData { + let typeIconName: String + let subtitle: String + let accessory: ItemCellModel.Accessory? + let tagColors: [UIColor] + let tagEmojis: [String] + let hasNote: Bool + } + + enum Kind { + case collection + case item(cellData: ItemCellData, sortData: ItemSortData) + } + + let type: Kind + let key: String + let libraryId: LibraryIdentifier + let title: String + let dateModified: Date + + var trashKey: TrashKey { + let keyType: TrashKey.Kind + switch type { + case .collection: + keyType = .collection + + case .item: + keyType = .item + } + return TrashKey(type: keyType, key: key) + } + + var sortTitle: String { + switch type { + case .collection: + return title + + case .item(_, let sortData): + return sortData.title + } + } + + var sortType: String? { + switch type { + case .item(_, let sortData): + return sortData.type + + case .collection: + return nil + } + } + + var creatorSummary: String? { + switch type { + case .item(_, let sortData): + return sortData.creatorSummary + + case .collection: + return nil + } + } + + var publisher: String? { + switch type { + case .item(_, let sortData): + return sortData.publisher + + case .collection: + return nil + } + } + + var publicationTitle: String? { + switch type { + case .item(_, let sortData): + return sortData.publicationTitle + + case .collection: + return nil + } + } + + var year: Int? { + switch type { + case .item(_, let sortData): + return sortData.year + + case .collection: + return nil + } + } + + var date: Date? { + switch type { + case .item(_, let sortData): + return sortData.date + + case .collection: + return nil + } + } + + var dateAdded: Date? { + switch type { + case .item(_, let sortData): + return sortData.dateAdded + + case .collection: + return nil + } + } +} diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift new file mode 100644 index 000000000..aaada96b1 --- /dev/null +++ b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift @@ -0,0 +1,43 @@ +// +// TrashState.swift +// Zotero +// +// Created by Michal Rentka on 18.07.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation +import OrderedCollections + +import RealmSwift + +struct TrashState: ViewModelState { + enum Error: Swift.Error { + case dataLoading + } + + var library: Library + var libraryToken: NotificationToken? + var itemResults: Results? + var itemsToken: NotificationToken? + var collectionResults: Results? + var collectionsToken: NotificationToken? + var objects: OrderedDictionary + var error: Error? + + init(libraryId: LibraryIdentifier) { + objects = [:] + + switch libraryId { + case .custom: + library = Library(identifier: libraryId, name: L10n.Libraries.myLibrary, metadataEditable: true, filesEditable: true) + + case .group: + library = Library(identifier: libraryId, name: L10n.unknown, metadataEditable: false, filesEditable: false) + } + } + + mutating func cleanup() { + error = nil + } +} diff --git a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift new file mode 100644 index 000000000..6b1501c8e --- /dev/null +++ b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift @@ -0,0 +1,210 @@ +// +// TrashActionHandler.swift +// Zotero +// +// Created by Michal Rentka on 18.07.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation +import OrderedCollections + +import CocoaLumberjackSwift +import RealmSwift +import RxSwift + +struct TrashActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionHandler { + typealias State = TrashState + typealias Action = TrashAction + + unowned let dbStorage: DbStorage + private unowned let fileStorage: FileStorage + private unowned let urlDetector: UrlDetector + + var backgroundQueue: DispatchQueue + + init(dbStorage: DbStorage, fileStorage: FileStorage, urlDetector: UrlDetector) { + self.dbStorage = dbStorage + self.fileStorage = fileStorage + self.urlDetector = urlDetector + backgroundQueue = DispatchQueue(label: "org.zotero.Zotero.TrashActionHandler.queue", qos: .userInteractive) + } + + func process(action: TrashAction, in viewModel: ViewModel) { + switch action { + case .loadData: + loadData(in: viewModel) + } + } + + private func loadData(in viewModel: ViewModel) { + do { + let sortType = Defaults.shared.itemsSortType + let items = try dbStorage.perform(request: ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier, sortType: sortType), on: .main) + let collectionsRequest = ReadCollectionsDbRequest(libraryId: viewModel.state.library.identifier, trash: true) + let collections = (try dbStorage.perform(request: collectionsRequest, on: .main)).sorted(by: collectionSortDescriptor(for: sortType)) + + var objects: OrderedDictionary = [:] + for object in items.compactMap({ trashObject(from: $0) }) { + objects[object.trashKey] = object + } + for collection in collections { + guard let object = trashObject(from: collection) else { continue } + let index = objects.index(of: object, sortedBy: { areInIncreasingOrder(lObject: $0, rObject: $1, sortType: sortType) }) + objects.updateValue(object, forKey: object.trashKey, insertingAt: index) + } + + update(viewModel: viewModel) { state in + state.objects = objects + } + } catch let error { + DDLogInfo("TrashActionHandler: can't load initial data - \(error)") + update(viewModel: viewModel) { state in + state.error = .dataLoading + } + } + + func areInIncreasingOrder(lObject: TrashObject, rObject: TrashObject, sortType: ItemsSortType) -> Bool { + let initialResult: ComparisonResult + + switch sortType.field { + case .creator: + initialResult = compare(lValue: lObject.creatorSummary, rValue: rObject.creatorSummary) + + case .date: + initialResult = compare(lValue: lObject.date, rValue: rObject.date) + + case .dateAdded: + initialResult = compare(lValue: lObject.dateAdded, rValue: rObject.dateAdded) + + case .dateModified: + initialResult = compare(lValue: lObject.dateModified, rValue: rObject.dateModified) + + case .itemType: + initialResult = compare(lValue: lObject.sortType, rValue: rObject.sortType) + + case .publicationTitle: + initialResult = compare(lValue: lObject.publicationTitle, rValue: rObject.publicationTitle) + + case .publisher: + initialResult = compare(lValue: lObject.publisher, rValue: rObject.publisher) + + case .year: + initialResult = compare(lValue: lObject.year, rValue: rObject.year) + + case .title: + return isInIncreasingOrder(result: compare(lValue: lObject.sortTitle, rValue: rObject.sortTitle), ascending: sortType.ascending, comparedSame: nil) + } + + return isInIncreasingOrder(result: initialResult, ascending: sortType.ascending, comparedSame: { compare(lValue: lObject.sortTitle, rValue: rObject.sortTitle) }) + + func isInIncreasingOrder(result: ComparisonResult, ascending: Bool, comparedSame: (() -> ComparisonResult)?) -> Bool { + switch result { + case .orderedSame: + if let result = comparedSame?() { + return ascending ? result == .orderedAscending : result == .orderedDescending + } + return true + + case .orderedAscending: + return ascending + + case .orderedDescending: + return !ascending + } + } + + func compare(lValue: String?, rValue: String?) -> ComparisonResult { + if let lValue, let rValue { + return lValue.compare(rValue, options: [.numeric], locale: Locale.autoupdatingCurrent) + } + if let lValue { + return .orderedAscending + } + return .orderedDescending + } + + func compare(lValue: Int?, rValue: Int?) -> ComparisonResult { + if let lValue, let rValue { + if lValue == rValue { + return .orderedSame + } + return lValue < rValue ? .orderedAscending : .orderedDescending + } + if let lValue { + return .orderedAscending + } + return .orderedDescending + } + + func compare(lValue: Date?, rValue: Date?) -> ComparisonResult { + if let lValue, let rValue { + return lValue.compare(rValue) + } + if let lValue { + return .orderedAscending + } + return .orderedDescending + } + } + + func collectionSortDescriptor(for sortType: ItemsSortType) -> [RealmSwift.SortDescriptor] { + switch sortType.field { + case .dateModified: + return [ + SortDescriptor(keyPath: "dateModified", ascending: sortType.ascending), + SortDescriptor(keyPath: "name", ascending: sortType.ascending) + ] + + case .title, .creator, .date, .dateAdded, .itemType, .publisher, .publicationTitle, .year: + return [SortDescriptor(keyPath: "name", ascending: sortType.ascending)] + } + } + + func trashObject(from collection: RCollection) -> TrashObject? { + guard let libraryId = collection.libraryId else { return nil } + return TrashObject(type: .collection, key: collection.key, libraryId: libraryId, title: collection.name, dateModified: collection.dateModified) + } + + func trashObject(from item: RItem) -> TrashObject? { + guard let libraryId = item.libraryId else { return nil } + let accessory = ItemAccessory.create(from: item, fileStorage: fileStorage, urlDetector: urlDetector).flatMap({ convertToItemCellModelAccessory(accessory: $0) }) + let creatorSummary = ItemCellModel.creatorSummary(for: item) + let (tagColors, tagEmojis) = ItemCellModel.tagData(item: item) + let hasNote = ItemCellModel.hasNote(item: item) + let cellData = TrashObject.ItemCellData( + typeIconName: ItemCellModel.typeIconName(for: item), + subtitle: creatorSummary, + accessory: accessory, + tagColors: tagColors, + tagEmojis: tagEmojis, + hasNote: hasNote + ) + let sortData = TrashObject.ItemSortData( + title: item.sortTitle, + type: item.localizedType, + creatorSummary: creatorSummary, + publisher: item.publisher, + publicationTitle: item.publicationTitle, + year: item.hasParsedYear ? item.parsedYear : nil, + date: item.parsedDate, + dateAdded: item.dateAdded + ) + return TrashObject(type: .item(cellData: cellData, sortData: sortData), key: item.key, libraryId: libraryId, title: item.displayTitle, dateModified: item.dateModified) + } + + func convertToItemCellModelAccessory(accessory: ItemAccessory?) -> ItemCellModel.Accessory? { + guard let accessory else { return nil } + switch accessory { + case .attachment(let attachment, _): + return .attachment(.stateFrom(type: attachment.type, progress: nil, error: nil)) + + case .doi: + return .doi + + case .url: + return .url + } + } + } +} diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift new file mode 100644 index 000000000..ac811534a --- /dev/null +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -0,0 +1,29 @@ +// +// TrashViewController.swift +// Zotero +// +// Created by Michal Rentka on 18.07.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +final class TrashViewController: UIViewController { + private let viewModel: ViewModel + + init(viewModel: ViewModel) { + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + viewModel.process(action: .loadData) + } +} diff --git a/Zotero/Scenes/General/Models/ItemAccessory.swift b/Zotero/Scenes/General/Models/ItemAccessory.swift index e74079a10..3da224c18 100644 --- a/Zotero/Scenes/General/Models/ItemAccessory.swift +++ b/Zotero/Scenes/General/Models/ItemAccessory.swift @@ -37,4 +37,20 @@ extension ItemAccessory { return nil } } + + static func create(from item: RItem, fileStorage: FileStorage, urlDetector: UrlDetector) -> ItemAccessory? { + if let attachment = AttachmentCreator.mainAttachment(for: item, fileStorage: fileStorage) { + return .attachment(attachment: attachment, parentKey: (item.key != attachment.key) ? item.key : nil) + } + + if let urlString = item.urlString, urlDetector.isUrl(string: urlString), let url = URL(string: urlString) { + return .url(url) + } + + if let doi = item.doi { + return .doi(doi) + } + + return nil + } } From 14f6b45c18c93f096cc9531e0aef5b3416f8953c Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Tue, 17 Sep 2024 11:27:40 +0200 Subject: [PATCH 03/23] WIP --- Zotero/Scenes/Detail/DetailCoordinator.swift | 151 +++++++++++-------- 1 file changed, 92 insertions(+), 59 deletions(-) diff --git a/Zotero/Scenes/Detail/DetailCoordinator.swift b/Zotero/Scenes/Detail/DetailCoordinator.swift index 42101526f..5581dc297 100644 --- a/Zotero/Scenes/Detail/DetailCoordinator.swift +++ b/Zotero/Scenes/Detail/DetailCoordinator.swift @@ -118,67 +118,100 @@ final class DetailCoordinator: Coordinator { func start(animated: Bool) { guard let userControllers = controllers.userControllers else { return } DDLogInfo("DetailCoordinator: show items for \(collection.id); \(libraryId)") - let controller = createItemsViewController( - collection: collection, - libraryId: libraryId, - dbStorage: userControllers.dbStorage, - fileDownloader: userControllers.fileDownloader, - remoteFileDownloader: userControllers.remoteFileDownloader, - identifierLookupController: userControllers.identifierLookupController, - syncScheduler: userControllers.syncScheduler, - citationController: userControllers.citationController, - fileCleanupController: userControllers.fileCleanupController, - itemsTagFilterDelegate: itemsTagFilterDelegate, - htmlAttributedStringConverter: controllers.htmlAttributedStringConverter - ) + + let controller: UIViewController + switch collection.identifier { + case .custom(let type): + switch type { + case .trash: + controller = createTrashViewController(libraryId: libraryId, dbStorage: userControllers.dbStorage, fileStorage: controllers.fileStorage, urlDetector: controllers.urlDetector) + + case .all, .publications, .unfiled: + controller = createItemsViewController( + collection: collection, + libraryId: libraryId, + dbStorage: userControllers.dbStorage, + fileDownloader: userControllers.fileDownloader, + remoteFileDownloader: userControllers.remoteFileDownloader, + identifierLookupController: userControllers.identifierLookupController, + syncScheduler: userControllers.syncScheduler, + citationController: userControllers.citationController, + fileCleanupController: userControllers.fileCleanupController, + itemsTagFilterDelegate: itemsTagFilterDelegate, + htmlAttributedStringConverter: controllers.htmlAttributedStringConverter + ) + } + + case .collection, .search: + controller = createItemsViewController( + collection: collection, + libraryId: libraryId, + dbStorage: userControllers.dbStorage, + fileDownloader: userControllers.fileDownloader, + remoteFileDownloader: userControllers.remoteFileDownloader, + identifierLookupController: userControllers.identifierLookupController, + syncScheduler: userControllers.syncScheduler, + citationController: userControllers.citationController, + fileCleanupController: userControllers.fileCleanupController, + itemsTagFilterDelegate: itemsTagFilterDelegate, + htmlAttributedStringConverter: controllers.htmlAttributedStringConverter + ) + } + navigationController?.setViewControllers([controller], animated: animated) - } - private func createItemsViewController( - collection: Collection, - libraryId: LibraryIdentifier, - dbStorage: DbStorage, - fileDownloader: AttachmentDownloader, - remoteFileDownloader: RemoteAttachmentDownloader, - identifierLookupController: IdentifierLookupController, - syncScheduler: SynchronizationScheduler, - citationController: CitationController, - fileCleanupController: AttachmentFileCleanupController, - itemsTagFilterDelegate: ItemsTagFilterDelegate?, - htmlAttributedStringConverter: HtmlAttributedStringConverter - ) -> ItemsViewController { - itemsTagFilterDelegate?.clearSelection() - - let searchTerm = self.searchItemKeys?.joined(separator: " ") - let downloadBatchData = ItemsState.DownloadBatchData(batchData: fileDownloader.batchData) - let remoteDownloadBatchData = ItemsState.DownloadBatchData(batchData: remoteFileDownloader.batchData) - let identifierLookupBatchData = ItemsState.IdentifierLookupBatchData(batchData: identifierLookupController.batchData) - let state = ItemsState( - collection: collection, - libraryId: libraryId, - sortType: .default, - searchTerm: searchTerm, - filters: [], - downloadBatchData: downloadBatchData, - remoteDownloadBatchData: remoteDownloadBatchData, - identifierLookupBatchData: identifierLookupBatchData, - error: nil - ) - let handler = ItemsActionHandler( - dbStorage: dbStorage, - fileStorage: self.controllers.fileStorage, - schemaController: self.controllers.schemaController, - urlDetector: self.controllers.urlDetector, - fileDownloader: fileDownloader, - citationController: citationController, - fileCleanupController: fileCleanupController, - syncScheduler: syncScheduler, - htmlAttributedStringConverter: htmlAttributedStringConverter - ) - let controller = ItemsViewController(viewModel: ViewModel(initialState: state, handler: handler), controllers: self.controllers, coordinatorDelegate: self) - controller.tagFilterDelegate = itemsTagFilterDelegate - itemsTagFilterDelegate?.delegate = controller - return controller + func createTrashViewController(libraryId: LibraryIdentifier, dbStorage: DbStorage, fileStorage: FileStorage, urlDetector: UrlDetector) -> TrashViewController { + let state = TrashState(libraryId: libraryId) + let handler = TrashActionHandler(dbStorage: dbStorage, fileStorage: fileStorage, urlDetector: urlDetector) + return TrashViewController(viewModel: ViewModel(initialState: state, handler: handler)) + } + + func createItemsViewController( + collection: Collection, + libraryId: LibraryIdentifier, + dbStorage: DbStorage, + fileDownloader: AttachmentDownloader, + remoteFileDownloader: RemoteAttachmentDownloader, + identifierLookupController: IdentifierLookupController, + syncScheduler: SynchronizationScheduler, + citationController: CitationController, + fileCleanupController: AttachmentFileCleanupController, + itemsTagFilterDelegate: ItemsTagFilterDelegate?, + htmlAttributedStringConverter: HtmlAttributedStringConverter + ) -> ItemsViewController { + itemsTagFilterDelegate?.clearSelection() + + let searchTerm = self.searchItemKeys?.joined(separator: " ") + let downloadBatchData = ItemsState.DownloadBatchData(batchData: fileDownloader.batchData) + let remoteDownloadBatchData = ItemsState.DownloadBatchData(batchData: remoteFileDownloader.batchData) + let identifierLookupBatchData = ItemsState.IdentifierLookupBatchData(batchData: identifierLookupController.batchData) + let state = ItemsState( + collection: collection, + libraryId: libraryId, + sortType: .default, + searchTerm: searchTerm, + filters: [], + downloadBatchData: downloadBatchData, + remoteDownloadBatchData: remoteDownloadBatchData, + identifierLookupBatchData: identifierLookupBatchData, + error: nil + ) + let handler = ItemsActionHandler( + dbStorage: dbStorage, + fileStorage: self.controllers.fileStorage, + schemaController: self.controllers.schemaController, + urlDetector: self.controllers.urlDetector, + fileDownloader: fileDownloader, + citationController: citationController, + fileCleanupController: fileCleanupController, + syncScheduler: syncScheduler, + htmlAttributedStringConverter: htmlAttributedStringConverter + ) + let controller = ItemsViewController(viewModel: ViewModel(initialState: state, handler: handler), controllers: self.controllers, coordinatorDelegate: self) + controller.tagFilterDelegate = itemsTagFilterDelegate + itemsTagFilterDelegate?.delegate = controller + return controller + } } func showAttachment(key: String, parentKey: String?, libraryId: LibraryIdentifier) { From 6502e391e0e447a6c4ad21998703f560f7c245eb Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Wed, 18 Sep 2024 15:18:19 +0200 Subject: [PATCH 04/23] WIP: Trash items controller --- Zotero.xcodeproj/project.pbxproj | 2 - .../IdentifierLookupController.swift | 2 +- .../Detail/Items/Models/ItemCellModel.swift | 20 ++ .../Items/Views/ItemsTableViewHandler.swift | 258 +++++++----------- .../Items/Views/ItemsViewController.swift | 200 ++++++++++---- .../Trash/Views/TrashViewController.swift | 89 +++++- 6 files changed, 345 insertions(+), 226 deletions(-) diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index e31fa8568..153a967d9 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -858,7 +858,6 @@ B398A917270C6A5B00968EE8 /* WebDavSessionStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B398A916270C6A5B00968EE8 /* WebDavSessionStorage.swift */; }; B398D6C02A77F9C60049A296 /* FontSizeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B398D6BF2A77F9C60049A296 /* FontSizeView.swift */; }; B39ADE712C4AAB090006FA79 /* OrderedDictionary+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39ADE702C4AAB030006FA79 /* OrderedDictionary+Utils.swift */; }; - B39ADE722C4AAB090006FA79 /* OrderedDictionary+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39ADE702C4AAB030006FA79 /* OrderedDictionary+Utils.swift */; }; B39AF554290033CD001F400F /* TableOfContentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39AF553290033CD001F400F /* TableOfContentsViewController.swift */; }; B39B18E8223947050019F467 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39B18E7223947050019F467 /* main.swift */; }; B39C9AB7252B589F00462D27 /* Threading+Extras.swift in Sources */ = {isa = PBXBuildFile; fileRef = B305650423FC051E003304F2 /* Threading+Extras.swift */; }; @@ -5764,7 +5763,6 @@ B353F1FC242E23680062EE24 /* ResetTranslatorsDbRequest.swift in Sources */, B37080532AA7216E006F56B9 /* Localizable.swift in Sources */, B3AB43A927E8A81D006F3E4E /* Dictionary+Extensions.swift in Sources */, - B39ADE722C4AAB090006FA79 /* OrderedDictionary+Utils.swift in Sources */, B36459E0264411AC00A0C2C0 /* ViewModel.swift in Sources */, B32EBFC3276A892E0003897E /* BackgroundUploader.swift in Sources */, B3902DE62480DBB7007EE48E /* DateParser.swift in Sources */, diff --git a/Zotero/Controllers/IdentifierLookupController.swift b/Zotero/Controllers/IdentifierLookupController.swift index 83111bce3..02ab53d40 100644 --- a/Zotero/Controllers/IdentifierLookupController.swift +++ b/Zotero/Controllers/IdentifierLookupController.swift @@ -177,7 +177,7 @@ final class IdentifierLookupController { setupObservers() } - + // MARK: Actions func initialize(libraryId: LibraryIdentifier, collectionKeys: Set, completion: @escaping ([LookupData]?) -> Void) { accessQueue.async(flags: .barrier) { [weak self] in diff --git a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift index e24dfaed2..423895bb4 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift @@ -40,6 +40,26 @@ struct ItemCellModel { self.tagEmojis = emojis } + 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)) + } + + static func createAccessory(from accessory: ItemAccessory?, fileDownloader: AttachmentDownloader?) -> ItemCellModel.Accessory? { + return accessory.flatMap({ accessory -> ItemCellModel.Accessory in + switch accessory { + case .attachment(let attachment, let parentKey): + let (progress, error) = fileDownloader?.data(for: attachment.key, parentKey: parentKey, libraryId: attachment.libraryId) ?? (nil, nil) + return .attachment(.stateFrom(type: attachment.type, progress: progress, error: error)) + + case .doi: + return .doi + + case .url: + return .url + } + }) + } + static func hasNote(item: RItem) -> Bool { return !item.children .filter(.items(type: ItemTypes.note, notSyncState: .dirty)) diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift b/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift index 2f7277cd2..2dda78e3d 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift @@ -10,13 +10,22 @@ import UIKit import CocoaLumberjackSwift import RealmSwift -import RxCocoa import RxSwift protocol ItemsTableViewHandlerDelegate: AnyObject { var isInViewHierarchy: Bool { get } - + var library: Library { get } + var isEditing: Bool { get } + var selectedItems: Set { get } + var isTrash: Bool { get } + var collectionKey: String? { get } + + func model(for item: RItem) -> ItemCellModel + func accessory(forKey key: String) -> ItemAccessory? func process(action: ItemAction.Kind, for item: RItem, completionAction: ((Bool) -> Void)?) + func process(tapAction action: ItemsTableViewHandler.TapAction) + func process(dragAndDropAction action: ItemsTableViewHandler.DragAndDropAction) + func createContextMenuActions(for item: RItem) -> [ItemAction] } final class ItemsTableViewHandler: NSObject { @@ -27,109 +36,58 @@ final class ItemsTableViewHandler: NSObject { case doi(String) case url(URL) case selectItem(String) + case deselectItem(String) + } + + enum DragAndDropAction { + case moveItems(keys: Set, toKey: String) + case tagItem(key: String, libraryId: LibraryIdentifier, tags: Set) } private static let cellId = "ItemCell" private unowned let tableView: UITableView - private unowned let viewModel: ViewModel private unowned let delegate: ItemsTableViewHandlerDelegate private unowned let dragDropController: DragDropController - let tapObserver: PublishSubject private let disposeBag: DisposeBag private var snapshot: Results? private var reloadAnimationsDisabled: Bool - private weak var fileDownloader: AttachmentDownloader? - private weak var schemaController: SchemaController? - init(tableView: UITableView, viewModel: ViewModel, delegate: ItemsTableViewHandlerDelegate, dragDropController: DragDropController, - fileDownloader: AttachmentDownloader?, schemaController: SchemaController?) { + init( + tableView: UITableView, + delegate: ItemsTableViewHandlerDelegate, + dragDropController: DragDropController + ) { self.tableView = tableView - self.viewModel = viewModel self.delegate = delegate self.dragDropController = dragDropController - self.fileDownloader = fileDownloader - self.schemaController = schemaController - self.reloadAnimationsDisabled = false - self.tapObserver = PublishSubject() - self.disposeBag = DisposeBag() + reloadAnimationsDisabled = false + disposeBag = DisposeBag() super.init() - self.setupTableView() - self.setupKeyboardObserving() + setupTableView() + setupKeyboardObserving() } deinit { DDLogInfo("ItemsTableViewHandler deinitialized") } - private func createContextMenuActions(for item: RItem, state: ItemsState) -> [ItemAction] { - if state.collection.identifier.isTrash { - return [ItemAction(type: .restore), ItemAction(type: .delete)] - } - - var actions: [ItemAction] = [] - - // Add citation for valid types - if !CitationController.invalidItemTypes.contains(item.rawType) { - actions.append(contentsOf: [ItemAction(type: .copyCitation), ItemAction(type: .copyBibliography), ItemAction(type: .share)]) - } - - // Add parent creation for standalone attachments - if item.rawType == ItemTypes.attachment, item.parent == nil { - actions.append(ItemAction(type: .createParent)) - } - - // Add download/remove downloaded option for attachments - if let accessory = state.itemAccessories[item.key], let location = accessory.attachment?.location { - switch location { - case .local: - actions.append(ItemAction(type: .removeDownload)) - - case .remote: - actions.append(ItemAction(type: .download)) - - case .localAndChangedRemotely: - actions.append(ItemAction(type: .download)) - actions.append(ItemAction(type: .removeDownload)) - - case .remoteMissing: - break - } - } - - guard state.library.metadataEditable else { return actions } - - actions.append(ItemAction(type: .addToCollection)) - - // Add removing from collection only if item is in current collection. - if case .collection(let key) = state.collection.identifier, item.collections.filter(.key(key)).first != nil { - actions.append(ItemAction(type: .removeFromCollection)) - } - - if item.rawType != ItemTypes.note && item.rawType != ItemTypes.attachment { - actions.append(ItemAction(type: .duplicate)) - } - actions.append(ItemAction(type: .trash)) - - return actions - } - - private func createTrailingCellActions(for item: RItem, state: ItemsState) -> [ItemAction] { - if state.collection.identifier.isTrash { + private func createTrailingCellActions(for item: RItem) -> [ItemAction] { + if delegate.isTrash { return [ItemAction(type: .delete), ItemAction(type: .restore)] } var trailingActions: [ItemAction] = [ItemAction(type: .trash), ItemAction(type: .addToCollection)] // Allow removing from collection only if item is in current collection. This can happen when "Show items from subcollection" is enabled. - if case .collection(let key) = state.collection.identifier, item.collections.filter(.key(key)).first != nil { + if let key = delegate.collectionKey, item.collections.filter(.key(key)).first != nil { trailingActions.insert(ItemAction(type: .removeFromCollection), at: 1) } return trailingActions } private func createContextMenu(for item: RItem) -> UIMenu { - let actions: [UIAction] = self.createContextMenuActions(for: item, state: self.viewModel.state).map({ action in + let actions: [UIAction] = self.delegate.createContextMenuActions(for: item).map({ action in return UIAction(title: action.title, image: action.image, attributes: (action.isDestructive ? .destructive : [])) { [weak self] _ in self?.delegate.process(action: action.type, for: item, completionAction: nil) } @@ -138,7 +96,7 @@ final class ItemsTableViewHandler: NSObject { } private func createSwipeConfiguration(from itemActions: [ItemAction], at indexPath: IndexPath) -> UISwipeActionsConfiguration? { - guard !self.tableView.isEditing && self.viewModel.state.library.metadataEditable else { return nil } + guard !self.tableView.isEditing && self.delegate.library.metadataEditable else { return nil } let actions = itemActions.map({ action -> UIContextualAction in let contextualAction = UIContextualAction(style: (action.isDestructive ? .destructive : .normal), title: action.title, handler: { [weak self] _, _, completion in guard let item = self?.snapshot?[indexPath.row] else { @@ -174,22 +132,6 @@ final class ItemsTableViewHandler: NSObject { return (self.tableView, cell?.frame) } - private func cellAccessory(from accessory: ItemAccessory?) -> ItemCellModel.Accessory? { - return accessory.flatMap({ accessory -> ItemCellModel.Accessory in - switch accessory { - case .attachment(let attachment, let parentKey): - let (progress, error) = self.fileDownloader?.data(for: attachment.key, parentKey: parentKey, libraryId: attachment.libraryId) ?? (nil, nil) - return .attachment(.stateFrom(type: attachment.type, progress: progress, error: error)) - - case .doi: - return .doi - - case .url: - return .url - } - }) - } - // MARK: - Actions /// Disables performing tableView batch reloads. Instead just uses `reloadData()`. @@ -206,9 +148,9 @@ final class ItemsTableViewHandler: NSObject { self.tableView.setEditing(editing, animated: animated) } - func updateCell(with accessory: ItemAccessory?, key: String) { - guard let cell = self.tableView.visibleCells.first(where: { ($0 as? ItemCell)?.key == key }) as? ItemCell else { return } - cell.set(accessory: self.cellAccessory(from: accessory)) + func updateCell(key: String, withAccessory accessory: ItemCellModel.Accessory?) { + guard let cell = tableView.visibleCells.first(where: { ($0 as? ItemCell)?.key == key }) as? ItemCell else { return } + cell.set(accessory: accessory) } func reloadAll(snapshot: Results? = nil) { @@ -219,7 +161,7 @@ final class ItemsTableViewHandler: NSObject { } func reloadAllAttachments() { - if viewModel.state.isEditing && !viewModel.state.selectedItems.isEmpty, let indexPathsForSelectedRows = tableView.indexPathsForSelectedRows { + if delegate.isEditing && !delegate.selectedItems.isEmpty, let indexPathsForSelectedRows = tableView.indexPathsForSelectedRows { tableView.reconfigureRows(at: indexPathsForSelectedRows) } else { tableView.reloadData() @@ -259,35 +201,6 @@ final class ItemsTableViewHandler: NSObject { }) } - private func tapAction(for indexPath: IndexPath) -> TapAction? { - guard let item = self.snapshot?[indexPath.row] else { return nil } - - if self.viewModel.state.isEditing { - return .selectItem(item.key) - } - - guard let accessory = self.viewModel.state.itemAccessories[item.key] else { - switch item.rawType { - case ItemTypes.note: - return .note(item) - - default: - return .metadata(item) - } - } - - switch accessory { - case .attachment(let attachment, let parentKey): - return .attachment(attachment: attachment, parentKey: parentKey) - - case .doi(let doi): - return .doi(doi) - - case .url(let url): - return .url(url) - } - } - // MARK: - Setups private func setupTableView() { @@ -362,23 +275,11 @@ extension ItemsTableViewHandler: UITableViewDataSource { } if let item = self.snapshot?[indexPath.row], let cell = cell as? ItemCell { - // Create and cache attachment if needed - self.viewModel.process(action: .cacheItemAccessory(item: item)) - - let title: NSAttributedString - if let _title = self.viewModel.state.itemTitles[item.key] { - title = _title - } else { - self.viewModel.process(action: .cacheItemTitle(key: item.key, title: item.displayTitle)) - title = self.viewModel.state.itemTitles[item.key, default: NSAttributedString()] - } - - let accessory = self.viewModel.state.itemAccessories[item.key] - let typeName = self.schemaController?.localized(itemType: item.rawType) ?? item.rawType - cell.set(item: ItemCellModel(item: item, typeName: typeName, title: title, accessory: self.cellAccessory(from: accessory))) + let model = delegate.model(for: item) + cell.set(item: model) let openInfoAction = UIAccessibilityCustomAction(name: L10n.Accessibility.Items.openItem, actionHandler: { [weak self, weak tableView] _ in - guard let self = self, let tableView = tableView else { return false } + guard let self, let tableView else { return false } self.tableView(tableView, didSelectRowAt: indexPath) return true }) @@ -391,36 +292,65 @@ extension ItemsTableViewHandler: UITableViewDataSource { extension ItemsTableViewHandler: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let action = self.tapAction(for: indexPath) else { return } - + guard let action = tapAction(for: indexPath) else { return } switch action { case .attachment, .doi, .metadata, .note, .url: tableView.deselectRow(at: indexPath, animated: true) case .selectItem: break + + case .deselectItem: // this should never happen + DDLogError("ItemsTableViewHandler: deselect item action called in didSelectRowAt") + return } - self.tapObserver.on(.next(action)) + delegate.process(tapAction: action) + + func tapAction(for indexPath: IndexPath) -> TapAction? { + guard let item = self.snapshot?[indexPath.row] else { return nil } + + if delegate.isEditing { + return .selectItem(item.key) + } + + guard let accessory = delegate.accessory(forKey: item.key) else { + switch item.rawType { + case ItemTypes.note: + return .note(item) + + default: + return .metadata(item) + } + } + + switch accessory { + case .attachment(let attachment, let parentKey): + return .attachment(attachment: attachment, parentKey: parentKey) + + case .doi(let doi): + return .doi(doi) + + case .url(let url): + return .url(url) + } + } } func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) { guard let item = self.snapshot?[indexPath.row] else { return } - switch item.rawType { case ItemTypes.note: - self.tapObserver.on(.next(.note(item))) + delegate.process(tapAction: .note(item)) default: - self.tapObserver.on(.next(.metadata(item))) + delegate.process(tapAction: .metadata(item)) } } func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { - if self.viewModel.state.isEditing, - let item = self.snapshot?[indexPath.row] { - self.viewModel.process(action: .deselectItem(item.key)) - } + guard delegate.isEditing, let item = self.snapshot?[indexPath.row] else { return } + delegate.process(tapAction: .deselectItem(item.key)) } func tableView(_ tableView: UITableView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool { @@ -437,7 +367,7 @@ extension ItemsTableViewHandler: UITableViewDelegate { func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { guard let item = self.snapshot?[indexPath.row] else { return nil } - return self.createSwipeConfiguration(from: self.createTrailingCellActions(for: item, state: self.viewModel.state), at: indexPath) + return self.createSwipeConfiguration(from: self.createTrailingCellActions(for: item), at: indexPath) } } @@ -458,32 +388,29 @@ extension ItemsTableViewHandler: UITableViewDropDelegate { let key = item.key let localObject = coordinator.items.first?.dragItem.localObject self.dragDropController.keys(from: coordinator.items.map({ $0.dragItem })) { [weak self] keys in + guard let self else { return } if localObject is RItem { - self?.viewModel.process(action: .moveItems(keys: keys, toItemKey: key)) + delegate.process(dragAndDropAction: .moveItems(keys: keys, toKey: key)) } else if localObject is RTag { - self?.viewModel.process(action: .tagItem(itemKey: key, libraryId: libraryId, tagNames: keys)) + delegate.process(dragAndDropAction: .tagItem(key: key, libraryId: libraryId, tags: keys)) } } default: break } } - func tableView(_ tableView: UITableView, - dropSessionDidUpdate session: UIDropSession, - withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal { - guard self.viewModel.state.library.metadataEditable, // allow only when library is editable - session.localDragSession != nil, // allow only local drag session - let destinationIndexPath = destinationIndexPath, - let results = self.snapshot, - destinationIndexPath.row < results.count else { + func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal { + guard + delegate.library.metadataEditable, // allow only when library is editable + session.localDragSession != nil, // allow only local drag session + let destinationIndexPath = destinationIndexPath, + let results = self.snapshot, + destinationIndexPath.row < results.count, + session.items.first?.localObject is RItem + else { return UITableViewDropProposal(operation: .forbidden) } - - if session.items.first?.localObject is RItem { - return self.itemDropSessionDidUpdate(session: session, withDestinationIndexPath: destinationIndexPath, results: results) - } - - return UITableViewDropProposal(operation: .forbidden) + return self.itemDropSessionDidUpdate(session: session, withDestinationIndexPath: destinationIndexPath, results: results) } private func itemDropSessionDidUpdate(session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath, results: Results) -> UITableViewDropProposal { @@ -493,7 +420,8 @@ extension ItemsTableViewHandler: UITableViewDropDelegate { if dragItemsLibraryId != item.libraryId || // allow dropping only to the same library item.rawType == ItemTypes.note || item.rawType == ItemTypes.attachment || // allow dropping only to non-standalone items session.items.compactMap({ self.dragDropController.item(from: $0) }) // allow drops of only standalone items - .contains(where: { $0.rawType != ItemTypes.attachment && $0.rawType != ItemTypes.note }) { + .contains(where: { $0.rawType != ItemTypes.attachment && $0.rawType != ItemTypes.note }) + { return UITableViewDropProposal(operation: .forbidden) } diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift index 644ba72d6..30d989259 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift @@ -58,14 +58,7 @@ final class ItemsViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - self.tableViewHandler = ItemsTableViewHandler( - tableView: self.tableView, - viewModel: self.viewModel, - delegate: self, - dragDropController: self.controllers.dragDropController, - fileDownloader: self.controllers.userControllers?.fileDownloader, - schemaController: self.controllers.schemaController - ) + tableViewHandler = ItemsTableViewHandler(tableView: tableView, delegate: self, dragDropController: controllers.dragDropController) self.toolbarController = ItemsToolbarController(viewController: self, initialState: self.viewModel.state, delegate: self) self.navigationController?.toolbar.barTintColor = UIColor(dynamicProvider: { traitCollection in return traitCollection.userInterfaceStyle == .dark ? .black : .white @@ -87,15 +80,6 @@ final class ItemsViewController: UIViewController { self.startObserving(results: results) } - self.tableViewHandler - .tapObserver - .observe(on: MainScheduler.instance) - .subscribe(with: self, onNext: { `self`, action in - self.resetActiveSearch() - self.handle(action: action) - }) - .disposed(by: self.disposeBag) - self.viewModel .stateObservable .observe(on: MainScheduler.instance) @@ -145,7 +129,8 @@ final class ItemsViewController: UIViewController { } else if state.changes.contains(.attachmentsRemoved) { self.tableViewHandler.reloadAllAttachments() } else if let key = state.updateItemKey { - self.tableViewHandler.updateCell(with: state.itemAccessories[key], key: key) + let accessory = state.itemAccessories[key].flatMap({ ItemCellModel.createAccessory(from: $0, fileDownloader: controllers.userControllers?.fileDownloader) }) + self.tableViewHandler.updateCell(key: key, withAccessory: accessory) } if state.changes.contains(.editing) { @@ -187,31 +172,6 @@ final class ItemsViewController: UIViewController { // MARK: - Actions - private func handle(action: ItemsTableViewHandler.TapAction) { - switch action { - case .metadata(let item): - self.coordinatorDelegate?.showItemDetail(for: .preview(key: item.key), libraryId: self.viewModel.state.library.identifier, scrolledToKey: nil, animated: true) - - case .attachment(let attachment, let parentKey): - self.viewModel.process(action: .openAttachment(attachment: attachment, parentKey: parentKey)) - - case .doi(let doi): - self.coordinatorDelegate?.show(doi: doi) - - case .url(let url): - self.coordinatorDelegate?.show(url: url) - - case .selectItem(let key): - self.viewModel.process(action: .selectItem(key)) - - case .note(let item): - guard let note = Note(item: item) else { return } - let tags = Array(item.tags.map({ Tag(tag: $0) })) - let library = self.viewModel.state.library - coordinatorDelegate?.showNote(library: library, kind: .edit(key: note.key), text: note.text, tags: tags, parentTitleData: nil, title: note.title, saveCallback: nil) - } - } - private func updateTagFilter(with state: ItemsState) { self.tagFilterDelegate?.itemsDidChange(filters: state.filters, collectionId: state.collection.identifier, libraryId: state.library.identifier) } @@ -313,11 +273,6 @@ final class ItemsViewController: UIViewController { } } - private func resetActiveSearch() { - guard let searchBar = navigationItem.searchController?.searchBar else { return } - searchBar.resignFirstResponder() - } - private func startObserving(results: Results) { self.resultsToken = results.observe(keyPaths: RItem.observableKeypathsForItemList, { [weak self] changes in guard let self else { return } @@ -573,12 +528,145 @@ final class ItemsViewController: UIViewController { } extension ItemsViewController: ItemsTableViewHandlerDelegate { - func process(action: ItemAction.Kind, for item: RItem, completionAction: ((Bool) -> Void)?) { - self.process(action: action, for: [item.key], button: nil, completionAction: completionAction) + var isTrash: Bool { + return false + } + + var collectionKey: String? { + return viewModel.state.collection.identifier.key } var isInViewHierarchy: Bool { - return self.view.window != nil + return view.window != nil + } + + var library: Library { + viewModel.state.library + } + + var selectedItems: Set { + viewModel.state.selectedItems + } + + func model(for item: RItem) -> ItemCellModel { + // Create and cache attachment if needed + viewModel.process(action: .cacheItemAccessory(item: item)) + + let title: NSAttributedString + if let _title = viewModel.state.itemTitles[item.key] { + title = _title + } else { + viewModel.process(action: .cacheItemTitle(key: item.key, title: item.displayTitle)) + title = viewModel.state.itemTitles[item.key, default: NSAttributedString()] + } + + let accessory = viewModel.state.itemAccessories[item.key] + let typeName = controllers.schemaController.localized(itemType: item.rawType) ?? item.rawType + return ItemCellModel(item: item, typeName: typeName, title: title, accessory: accessory, fileDownloader: controllers.userControllers?.fileDownloader) + } + + func accessory(forKey key: String) -> ItemAccessory? { + viewModel.state.itemAccessories[key] + } + + func createContextMenuActions(for item: RItem) -> [ItemAction] { + if viewModel.state.collection.identifier.isTrash { + return [ItemAction(type: .restore), ItemAction(type: .delete)] + } + + var actions: [ItemAction] = [] + + // Add citation for valid types + if !CitationController.invalidItemTypes.contains(item.rawType) { + actions.append(contentsOf: [ItemAction(type: .copyCitation), ItemAction(type: .copyBibliography), ItemAction(type: .share)]) + } + + // Add parent creation for standalone attachments + if item.rawType == ItemTypes.attachment, item.parent == nil { + actions.append(ItemAction(type: .createParent)) + } + + // Add download/remove downloaded option for attachments + if let accessory = viewModel.state.itemAccessories[item.key], let location = accessory.attachment?.location { + switch location { + case .local: + actions.append(ItemAction(type: .removeDownload)) + + case .remote: + actions.append(ItemAction(type: .download)) + + case .localAndChangedRemotely: + actions.append(ItemAction(type: .download)) + actions.append(ItemAction(type: .removeDownload)) + + case .remoteMissing: + break + } + } + + guard viewModel.state.library.metadataEditable else { return actions } + + actions.append(ItemAction(type: .addToCollection)) + + // Add removing from collection only if item is in current collection. + if case .collection(let key) = viewModel.state.collection.identifier, item.collections.filter(.key(key)).first != nil { + actions.append(ItemAction(type: .removeFromCollection)) + } + + if item.rawType != ItemTypes.note && item.rawType != ItemTypes.attachment { + actions.append(ItemAction(type: .duplicate)) + } + actions.append(ItemAction(type: .trash)) + + return actions + } + + func process(tapAction: ItemsTableViewHandler.TapAction) { + resetActiveSearch() + + switch tapAction { + case .metadata(let item): + coordinatorDelegate?.showItemDetail(for: .preview(key: item.key), libraryId: viewModel.state.library.identifier, scrolledToKey: nil, animated: true) + + case .attachment(let attachment, let parentKey): + viewModel.process(action: .openAttachment(attachment: attachment, parentKey: parentKey)) + + case .doi(let doi): + coordinatorDelegate?.show(doi: doi) + + case .url(let url): + coordinatorDelegate?.show(url: url) + + case .selectItem(let key): + viewModel.process(action: .selectItem(key)) + + case .deselectItem(let key): + viewModel.process(action: .deselectItem(key)) + + case .note(let item): + guard let note = Note(item: item) else { return } + let tags = Array(item.tags.map({ Tag(tag: $0) })) + coordinatorDelegate?.showNote(library: viewModel.state.library, kind: .edit(key: note.key), text: note.text, tags: tags, parentTitleData: nil, title: note.title, saveCallback: nil) + } + + func resetActiveSearch() { + guard let searchBar = navigationItem.searchController?.searchBar else { return } + searchBar.resignFirstResponder() + } + } + + func process(action: ItemAction.Kind, for item: RItem, completionAction: ((Bool) -> Void)?) { + process(action: action, for: [item.key], button: nil, completionAction: completionAction) + } + + func process(dragAndDropAction action: ItemsTableViewHandler.DragAndDropAction) { + switch action { + case .moveItems(let keys, let toKey): + viewModel.process(action: .moveItems(keys: keys, toItemKey: toKey)) + + case .tagItem(let key, let libraryId, let tags): + viewModel.process(action: .tagItem(itemKey: key, libraryId: libraryId, tagNames: tags)) + } } } @@ -602,21 +690,21 @@ extension ItemsViewController: ItemsToolbarControllerDelegate { extension ItemsViewController: TagFilterDelegate { var currentLibrary: Library { - return self.viewModel.state.library + return viewModel.state.library } func tagSelectionDidChange(selected: Set) { if selected.isEmpty { - if let tags = self.viewModel.state.tagsFilter { - self.viewModel.process(action: .disableFilter(.tags(tags))) + if let tags = viewModel.state.tagsFilter { + viewModel.process(action: .disableFilter(.tags(tags))) } } else { - self.viewModel.process(action: .enableFilter(.tags(selected))) + viewModel.process(action: .enableFilter(.tags(selected))) } } func tagOptionsDidChange() { - self.updateTagFilter(with: self.viewModel.state) + updateTagFilter(with: viewModel.state) } } diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift index ac811534a..bcf429016 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -8,13 +8,24 @@ import UIKit +import RxSwift + final class TrashViewController: UIViewController { private let viewModel: ViewModel + private unowned let controllers: Controllers + private let disposeBag: DisposeBag + + private weak var tableView: UITableView! + private var tableViewHandler: ItemsTableViewHandler! - init(viewModel: ViewModel) { + init(viewModel: ViewModel, controllers: Controllers) { self.viewModel = viewModel + self.controllers = controllers + disposeBag = DisposeBag() super.init(nibName: nil, bundle: nil) + + viewModel.process(action: .loadData) } required init?(coder: NSCoder) { @@ -24,6 +35,80 @@ final class TrashViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - viewModel.process(action: .loadData) + createTableView() + tableViewHandler = ItemsTableViewHandler(tableView: tableView, delegate: self, dragDropController: controllers.dragDropController) + + viewModel + .stateObservable + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] state in + self?.update(state: state) + }) + .disposed(by: self.disposeBag) + + func createTableView() { + let tableView = UITableView() + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + self.tableView = tableView + } + } + + private func update(state: TrashState) { + + } +} + +extension TrashViewController: ItemsTableViewHandlerDelegate { + var isInViewHierarchy: Bool { + return view.window != nil + } + + var library: Library { + return viewModel.state.library + } + + var selectedItems: Set { + return [] + } + + func model(for item: RItem) -> ItemCellModel { + // Create and cache attachment if needed +// viewModel.process(action: .cacheItemAccessory(item: item)) + +// let title: NSAttributedString +// if let _title = viewModel.state.itemTitles[item.key] { +// title = _title +// } else { +// viewModel.process(action: .cacheItemTitle(key: item.key, title: item.displayTitle)) +// title = viewModel.state.itemTitles[item.key, default: NSAttributedString()] +// } + +// let accessory = viewModel.state.itemAccessories[item.key] + let tmpTitle = NSAttributedString(string: item.displayTitle) + let typeName = controllers.schemaController.localized(itemType: item.rawType) ?? item.rawType + return ItemCellModel(item: item, typeName: typeName, title: tmpTitle, accessory: nil, fileDownloader: controllers.userControllers?.fileDownloader) + } + + func accessory(forKey key: String) -> ItemAccessory? { + return nil + } + + func process(tapAction: ItemsTableViewHandler.TapAction) { + } + + func process(action: ItemAction.Kind, for item: RItem, completionAction: ((Bool) -> Void)?) { + } + + func createContextMenuActions(for item: RItem) -> [ItemAction] { + return [] } } From a26b39900381f1f2366cb4f1568cb1645bf147c2 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Thu, 19 Sep 2024 16:56:26 +0200 Subject: [PATCH 05/23] WIP: refactoring items table view handler & data source --- Zotero.xcodeproj/project.pbxproj | 12 + Zotero/AppDelegate.swift | 61 ---- Zotero/Models/Notifications.swift | 1 - Zotero/Scenes/Detail/DetailCoordinator.swift | 2 +- .../Items/Models/ItemsTableViewObject.swift | 16 + .../Items/Views/ItemsTableViewHandler.swift | 297 +++++++----------- .../Items/Views/ItemsViewController.swift | 121 ++----- .../Views/RItemsTableViewDataSource.swift | 234 ++++++++++++++ .../Views/TrashTableViewDataSource.swift | 87 +++++ .../Trash/Views/TrashViewController.swift | 38 +-- 10 files changed, 491 insertions(+), 378 deletions(-) create mode 100644 Zotero/Scenes/Detail/Items/Models/ItemsTableViewObject.swift create mode 100644 Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift create mode 100644 Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index 153a967d9..f83f1c226 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -1148,6 +1148,9 @@ B3EBC9B1283E392900286A9E /* ReadBaseTagsToDeleteDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EBC9B0283E392900286A9E /* ReadBaseTagsToDeleteDbRequest.swift */; }; B3EBC9B2283E39AD00286A9E /* ReadBaseTagsToDeleteDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EBC9B0283E392900286A9E /* ReadBaseTagsToDeleteDbRequest.swift */; }; B3EC44612718490A001A9150 /* AnnotationPreviewBoundingBoxCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EC44602718490A001A9150 /* AnnotationPreviewBoundingBoxCalculator.swift */; }; + B3ED79352C9C665C004F5C9A /* RItemsTableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3ED79342C9C6652004F5C9A /* RItemsTableViewDataSource.swift */; }; + B3ED79372C9C6678004F5C9A /* ItemsTableViewObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3ED79362C9C6674004F5C9A /* ItemsTableViewObject.swift */; }; + B3ED79392C9C7060004F5C9A /* TrashTableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3ED79382C9C7058004F5C9A /* TrashTableViewDataSource.swift */; }; B3EFC60B2B0F503E00CB71A0 /* EmojiExtractorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFC60A2B0F503E00CB71A0 /* EmojiExtractorSpec.swift */; }; B3EFC60C2B0F633A00CB71A0 /* EmojiExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3CAE1172B0E38BA0000F8CA /* EmojiExtractor.swift */; }; B3F09AE629CAFF860084E4D8 /* TagFilterActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F09AE529CAFF860084E4D8 /* TagFilterActionHandler.swift */; }; @@ -2127,6 +2130,9 @@ B3EA5A1A2B7251EE00E283D7 /* CitationLocatorContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CitationLocatorContentView.swift; sourceTree = ""; }; B3EBC9B0283E392900286A9E /* ReadBaseTagsToDeleteDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadBaseTagsToDeleteDbRequest.swift; sourceTree = ""; }; B3EC44602718490A001A9150 /* AnnotationPreviewBoundingBoxCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationPreviewBoundingBoxCalculator.swift; sourceTree = ""; }; + B3ED79342C9C6652004F5C9A /* RItemsTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RItemsTableViewDataSource.swift; sourceTree = ""; }; + B3ED79362C9C6674004F5C9A /* ItemsTableViewObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsTableViewObject.swift; sourceTree = ""; }; + B3ED79382C9C7058004F5C9A /* TrashTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashTableViewDataSource.swift; sourceTree = ""; }; B3EFC60A2B0F503E00CB71A0 /* EmojiExtractorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiExtractorSpec.swift; sourceTree = ""; }; B3F09AE529CAFF860084E4D8 /* TagFilterActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagFilterActionHandler.swift; sourceTree = ""; }; B3F09AE729CAFF8D0084E4D8 /* TagFilterState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagFilterState.swift; sourceTree = ""; }; @@ -3341,6 +3347,7 @@ B3CD66BE29E800E3008180C7 /* ItemsFilter.swift */, B3593EED241A61C700760E20 /* ItemsSortType.swift */, B3593EEE241A61C700760E20 /* ItemsState.swift */, + B3ED79362C9C6674004F5C9A /* ItemsTableViewObject.swift */, ); path = Models; sourceTree = ""; @@ -3358,6 +3365,7 @@ B31D973B27F5E51100ED3DA2 /* ItemsToolbarDownloadProgressView.swift */, B3593EF6241A61C700760E20 /* ItemsViewController.swift */, B3593EF4241A61C700760E20 /* ItemsViewController.xib */, + B3ED79342C9C6652004F5C9A /* RItemsTableViewDataSource.swift */, B3593EF9241A61C700760E20 /* TagEmojiCirclesView.swift */, ); path = Views; @@ -3818,6 +3826,7 @@ B3C3EC272C492A570062705A /* Views */ = { isa = PBXGroup; children = ( + B3ED79382C9C7058004F5C9A /* TrashTableViewDataSource.swift */, B3C3EC292C492A870062705A /* TrashViewController.swift */, ); path = Views; @@ -5020,6 +5029,7 @@ B32B4F952475544700F78A05 /* SyncToolbarController.swift in Sources */, B30565DC23FC051E003304F2 /* AssignItemsToCollectionsDbRequest.swift in Sources */, B3B613F7260B844B00B92017 /* EmptyTrashDbRequest.swift in Sources */, + B3ED79392C9C7060004F5C9A /* TrashTableViewDataSource.swift in Sources */, B340ECA8290FDE3B00EE920D /* PDFReaderViewController.swift in Sources */, B3593F2F241A61C700760E20 /* ItemDetailTitleCell.swift in Sources */, B37AA2842899123C00A1C643 /* UICollectionViewListCell+Extensions.swift in Sources */, @@ -5453,6 +5463,7 @@ B3BC25CD247E6BA000AC27B5 /* DateParser.swift in Sources */, B305662523FC051F003304F2 /* RevertLibraryUpdatesSyncAction.swift in Sources */, B361820D24C9B9D900B30D56 /* NoRotationHostingController.swift in Sources */, + B3ED79372C9C6678004F5C9A /* ItemsTableViewObject.swift in Sources */, B3486B8D26CFAFEA0036A267 /* SingleCitationViewController.swift in Sources */, B3E8FE782714356900F51458 /* GeneralSettingsState.swift in Sources */, B3F4F2A12728015700685E1A /* MarkAttachmentsNotUploadedDbRequest.swift in Sources */, @@ -5490,6 +5501,7 @@ B3BC6B1D250F797B006A8B9C /* FieldKeys.swift in Sources */, B3830CEA255451DC00910FE0 /* TagPickerViewController.swift in Sources */, B3E8FE54271431DC00F51458 /* SavingSettingsView.swift in Sources */, + B3ED79352C9C665C004F5C9A /* RItemsTableViewDataSource.swift in Sources */, B3863FCA2AD830DE005082F0 /* DeleteCreatorItemDetailDbRequest.swift in Sources */, B36C5086257521DE00A370D3 /* AnnotationEditActionHandler.swift in Sources */, B3E2FF2229CAE3DF00F85AEB /* NoteEditorCoordinator.swift in Sources */, diff --git a/Zotero/AppDelegate.swift b/Zotero/AppDelegate.swift index 6e5657463..63d22002a 100644 --- a/Zotero/AppDelegate.swift +++ b/Zotero/AppDelegate.swift @@ -58,44 +58,6 @@ final class AppDelegate: UIResponder { UserDefaults.standard.removeObject(forKey: "ItemsSortType") } - /// This migration was created to move from "old" file structure (before build 120) to "new" one, where items are stored with their proper filenames. - /// In `DidMigrateFileStructure` all downloaded items were moved. Items which were up for upload were forgotten, so `DidMigrateFileStructure2` was added to migrate also these items. - /// TODO: - Remove after beta - private func migrateFileStructure(queue: DispatchQueue) { - let didMigrateFileStructure = UserDefaults.standard.bool(forKey: "DidMigrateFileStructure") - let didMigrateFileStructure2 = UserDefaults.standard.bool(forKey: "DidMigrateFileStructure2") - - guard !didMigrateFileStructure || !didMigrateFileStructure2 else { return } - - guard let dbStorage = self.controllers.userControllers?.dbStorage else { - // If user is logget out, no need to migrate, DB is empty and files should be gone. - UserDefaults.standard.setValue(true, forKey: "DidMigrateFileStructure") - UserDefaults.standard.setValue(true, forKey: "DidMigrateFileStructure2") - return - } - - // Migrate file structure - if !didMigrateFileStructure && !didMigrateFileStructure2 { - if let items = try? self.readAttachmentTypes(for: ReadAllDownloadedAndForUploadItemsDbRequest(), dbStorage: dbStorage, queue: queue) { - self.migrateFileStructure(for: items) - } - UserDefaults.standard.setValue(true, forKey: "DidMigrateFileStructure") - UserDefaults.standard.setValue(true, forKey: "DidMigrateFileStructure2") - } else if !didMigrateFileStructure { - if let items = try? self.readAttachmentTypes(for: ReadAllDownloadedItemsDbRequest(), dbStorage: dbStorage, queue: queue) { - self.migrateFileStructure(for: items) - } - UserDefaults.standard.setValue(true, forKey: "DidMigrateFileStructure") - } else if !didMigrateFileStructure2 { - if let items = try? self.readAttachmentTypes(for: ReadAllItemsForUploadDbRequest(), dbStorage: dbStorage, queue: queue) { - self.migrateFileStructure(for: items) - } - UserDefaults.standard.setValue(true, forKey: "DidMigrateFileStructure2") - } - - NotificationCenter.default.post(name: .forceReloadItems, object: nil) - } - private func readAttachmentTypes(for request: Request, dbStorage: DbStorage, queue: DispatchQueue) throws -> [(String, LibraryIdentifier, Attachment.Kind)] where Request.Response == Results { var types: [(String, LibraryIdentifier, Attachment.Kind)] = [] @@ -113,28 +75,6 @@ final class AppDelegate: UIResponder { return types } - private func migrateFileStructure(for items: [(String, LibraryIdentifier, Attachment.Kind)]) { - for (key, libraryId, type) in items { - switch type { - case .url: break - case .file(_, _, _, let linkType, _) where (linkType == .embeddedImage || linkType == .linkedFile): break // Embedded images and linked files don't need to be checked. - case .file(let filename, let contentType, _, let linkType, _): - // Snapshots were stored based on new structure, no need to do anything. - guard linkType != .importedUrl || contentType != "text/html" else { continue } - - let filenameParts = filename.split(separator: ".") - let oldFile: File - if filenameParts.count > 1, let ext = filenameParts.last.flatMap(String.init) { - oldFile = FileData(rootPath: Files.appGroupPath, relativeComponents: ["downloads", libraryId.folderName], name: key, ext: ext) - } else { - oldFile = FileData(rootPath: Files.appGroupPath, relativeComponents: ["downloads", libraryId.folderName], name: key, contentType: contentType) - } - let newFile = Files.attachmentFile(in: libraryId, key: key, filename: filename, contentType: contentType) - try? self.controllers.fileStorage.move(from: oldFile, to: newFile) - } - } - } - private func removeFinishedUploadFiles(queue: DispatchQueue) { let didDeleteFiles = UserDefaults.standard.bool(forKey: "DidDeleteFinishedUploadFiles") @@ -287,7 +227,6 @@ extension AppDelegate: UIApplicationDelegate { let queue = DispatchQueue(label: "org.zotero.AppDelegateMigration", qos: .userInitiated) queue.async { - self.migrateFileStructure(queue: queue) self.removeFinishedUploadFiles(queue: queue) self.updateCreatorSummaryFormat(queue: queue) } diff --git a/Zotero/Models/Notifications.swift b/Zotero/Models/Notifications.swift index ee00ae136..4a9392944 100644 --- a/Zotero/Models/Notifications.swift +++ b/Zotero/Models/Notifications.swift @@ -13,5 +13,4 @@ extension Notification.Name { static let attachmentFileDeleted = Notification.Name("org.zotero.AttachmentFileDeleted") // Sent when attachment (`RItem`) is completely removed from the app (not just trashed). Used to remove attachment files of deleted attachments. static let attachmentDeleted = Notification.Name(rawValue: "org.zotero.AttachmentsDeleted") - static let forceReloadItems = Notification.Name(rawValue: "org.zotero.ForceReloadItems") } diff --git a/Zotero/Scenes/Detail/DetailCoordinator.swift b/Zotero/Scenes/Detail/DetailCoordinator.swift index 5581dc297..7b0dd5712 100644 --- a/Zotero/Scenes/Detail/DetailCoordinator.swift +++ b/Zotero/Scenes/Detail/DetailCoordinator.swift @@ -163,7 +163,7 @@ final class DetailCoordinator: Coordinator { func createTrashViewController(libraryId: LibraryIdentifier, dbStorage: DbStorage, fileStorage: FileStorage, urlDetector: UrlDetector) -> TrashViewController { let state = TrashState(libraryId: libraryId) let handler = TrashActionHandler(dbStorage: dbStorage, fileStorage: fileStorage, urlDetector: urlDetector) - return TrashViewController(viewModel: ViewModel(initialState: state, handler: handler)) + return TrashViewController(viewModel: ViewModel(initialState: state, handler: handler), controllers: controllers) } func createItemsViewController( diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsTableViewObject.swift b/Zotero/Scenes/Detail/Items/Models/ItemsTableViewObject.swift new file mode 100644 index 000000000..ceda2d2d2 --- /dev/null +++ b/Zotero/Scenes/Detail/Items/Models/ItemsTableViewObject.swift @@ -0,0 +1,16 @@ +// +// ItemsTableViewObject.swift +// Zotero +// +// Created by Michal Rentka on 19.09.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +protocol ItemsTableViewObject { + var key: String { get } + var isNote: Bool { get } + var isAttachment: Bool { get } + var libraryId: LibraryIdentifier? { get } +} diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift b/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift index 2dda78e3d..56c3fcaad 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift @@ -14,24 +14,31 @@ import RxSwift protocol ItemsTableViewHandlerDelegate: AnyObject { var isInViewHierarchy: Bool { get } + var collectionKey: String? { get } var library: Library { get } var isEditing: Bool { get } - var selectedItems: Set { get } - var isTrash: Bool { get } - var collectionKey: String? { get } - func model(for item: RItem) -> ItemCellModel - func accessory(forKey key: String) -> ItemAccessory? - func process(action: ItemAction.Kind, for item: RItem, completionAction: ((Bool) -> Void)?) + func process(action: ItemAction.Kind, at index: Int, completionAction: ((Bool) -> Void)?) func process(tapAction action: ItemsTableViewHandler.TapAction) func process(dragAndDropAction action: ItemsTableViewHandler.DragAndDropAction) - func createContextMenuActions(for item: RItem) -> [ItemAction] +} + +protocol ItemsTableViewDataSource: UITableViewDataSource { + var count: Int { get } + var selectedItems: Set { get } + var handler: ItemsTableViewHandler? { get set } + + func object(at index: Int) -> ItemsTableViewObject? + func accessory(forKey key: String) -> ItemAccessory? + func tapAction(for indexPath: IndexPath) -> ItemsTableViewHandler.TapAction? + func createTrailingCellActions(at index: Int) -> [ItemAction]? + func createContextMenuActions(at index: Int) -> [ItemAction] } final class ItemsTableViewHandler: NSObject { enum TapAction { - case metadata(RItem) - case note(RItem) + case metadata(ItemsTableViewObject) + case note(ItemsTableViewObject) case attachment(attachment: Attachment, parentKey: String?) case doi(String) case url(URL) @@ -44,28 +51,31 @@ final class ItemsTableViewHandler: NSObject { case tagItem(key: String, libraryId: LibraryIdentifier, tags: Set) } - private static let cellId = "ItemCell" + static let cellId = "ItemCell" private unowned let tableView: UITableView private unowned let delegate: ItemsTableViewHandlerDelegate + private unowned let dataSource: ItemsTableViewDataSource private unowned let dragDropController: DragDropController private let disposeBag: DisposeBag - private var snapshot: Results? private var reloadAnimationsDisabled: Bool init( tableView: UITableView, delegate: ItemsTableViewHandlerDelegate, + dataSource: ItemsTableViewDataSource, dragDropController: DragDropController ) { self.tableView = tableView self.delegate = delegate + self.dataSource = dataSource self.dragDropController = dragDropController reloadAnimationsDisabled = false disposeBag = DisposeBag() super.init() + dataSource.handler = self setupTableView() setupKeyboardObserving() } @@ -74,36 +84,43 @@ final class ItemsTableViewHandler: NSObject { DDLogInfo("ItemsTableViewHandler deinitialized") } - private func createTrailingCellActions(for item: RItem) -> [ItemAction] { - if delegate.isTrash { - return [ItemAction(type: .delete), ItemAction(type: .restore)] + func attachmentAccessoriesChanged() { + if delegate.isEditing && !dataSource.selectedItems.isEmpty { + // Accessories changed by user, reload only selected items + reloadSelected() + } else { + // Otherwise just reload everything + tableView.reloadData() } - var trailingActions: [ItemAction] = [ItemAction(type: .trash), ItemAction(type: .addToCollection)] - // Allow removing from collection only if item is in current collection. This can happen when "Show items from subcollection" is enabled. - if let key = delegate.collectionKey, item.collections.filter(.key(key)).first != nil { - trailingActions.insert(ItemAction(type: .removeFromCollection), at: 1) + + func reloadSelected() { + guard let indexPathsForSelectedRows = tableView.indexPathsForSelectedRows else { return } + tableView.reconfigureRows(at: indexPathsForSelectedRows) } - return trailingActions } - private func createContextMenu(for item: RItem) -> UIMenu { - let actions: [UIAction] = self.delegate.createContextMenuActions(for: item).map({ action in + func reloadAll() { + tableView.reloadData() + } + + private func createContextMenu(at indexPath: IndexPath) -> UIMenu { + let actions: [UIAction] = dataSource.createContextMenuActions(at: indexPath.row).map({ action in return UIAction(title: action.title, image: action.image, attributes: (action.isDestructive ? .destructive : [])) { [weak self] _ in - self?.delegate.process(action: action.type, for: item, completionAction: nil) + self?.delegate.process(action: action.type, at: indexPath.row, completionAction: nil) } }) return UIMenu(title: "", children: actions) } private func createSwipeConfiguration(from itemActions: [ItemAction], at indexPath: IndexPath) -> UISwipeActionsConfiguration? { - guard !self.tableView.isEditing && self.delegate.library.metadataEditable else { return nil } + guard !tableView.isEditing && delegate.library.metadataEditable else { return nil } let actions = itemActions.map({ action -> UIContextualAction in let contextualAction = UIContextualAction(style: (action.isDestructive ? .destructive : .normal), title: action.title, handler: { [weak self] _, _, completion in - guard let item = self?.snapshot?[indexPath.row] else { + guard let self else { completion(false) return } - self?.delegate.process(action: action.type, for: item, completionAction: completion) + delegate.process(action: action.type, at: indexPath.row, completionAction: completion) }) contextualAction.image = action.image switch action.type { @@ -125,13 +142,31 @@ final class ItemsTableViewHandler: NSObject { return UISwipeActionsConfiguration(actions: actions) } - // MARK: - Data source - func sourceDataForCell(for key: String) -> (UIView, CGRect?) { let cell = self.tableView.visibleCells.first(where: { ($0 as? ItemCell)?.key == key }) return (self.tableView, cell?.frame) } + func reload(modifications: [IndexPath], insertions: [IndexPath], deletions: [IndexPath], updateSnapshot: () -> Void, completion: (() -> Void)? = nil) { + if !delegate.isInViewHierarchy || reloadAnimationsDisabled { + // If view controller is outside of view hierarchy, performing batch updates with animations will cause a crash (UITableViewAlertForLayoutOutsideViewHierarchy). + // Simple reload will suffice, animations will not be seen anyway. + updateSnapshot() + tableView.reloadData() + completion?() + return + } + + tableView.performBatchUpdates({ + updateSnapshot() + tableView.deleteRows(at: deletions, with: .automatic) + tableView.reloadRows(at: modifications, with: .none) + tableView.insertRows(at: insertions, with: .automatic) + }, completion: { _ in + completion?() + }) + } + // MARK: - Actions /// Disables performing tableView batch reloads. Instead just uses `reloadData()`. @@ -153,76 +188,57 @@ final class ItemsTableViewHandler: NSObject { cell.set(accessory: accessory) } - func reloadAll(snapshot: Results? = nil) { - if let snapshot { - self.snapshot = snapshot - } - self.tableView.reloadData() - } + func performTapAction(forIndexPath indexPath: IndexPath) { + guard let action = dataSource.tapAction(for: indexPath) else { return } + switch action { + case .attachment, .doi, .metadata, .note, .url: + tableView.deselectRow(at: indexPath, animated: true) - func reloadAllAttachments() { - if delegate.isEditing && !delegate.selectedItems.isEmpty, let indexPathsForSelectedRows = tableView.indexPathsForSelectedRows { - tableView.reconfigureRows(at: indexPathsForSelectedRows) - } else { - tableView.reloadData() - } - } + case .selectItem: + break - func reload(snapshot: Results, modifications: [Int], insertions: [Int], deletions: [Int], completion: (() -> Void)? = nil) { - if !self.delegate.isInViewHierarchy || self.reloadAnimationsDisabled { - // If view controller is outside of view hierarchy, performing batch updates with animations will cause a crash (UITableViewAlertForLayoutOutsideViewHierarchy). - // Simple reload will suffice, animations will not be seen anyway. - self.snapshot = snapshot - self.tableView.reloadData() - completion?() + case .deselectItem: // this should never happen + DDLogError("ItemsTableViewHandler: deselect item action called in didSelectRowAt") return } - - self.tableView.performBatchUpdates({ - self.snapshot = snapshot - self.tableView.deleteRows(at: deletions.map({ IndexPath(row: $0, section: 0) }), with: .automatic) - self.tableView.reloadRows(at: modifications.map({ IndexPath(row: $0, section: 0) }), with: .none) - self.tableView.insertRows(at: insertions.map({ IndexPath(row: $0, section: 0) }), with: .automatic) - }, completion: { _ in - completion?() - }) + delegate.process(tapAction: action) } func selectAll() { - let rows = self.tableView(self.tableView, numberOfRowsInSection: 0) + let rows = dataSource.tableView(tableView, numberOfRowsInSection: 0) (0.. Int { - return 1 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.snapshot?.count ?? 0 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: ItemsTableViewHandler.cellId, for: indexPath) - - let count = self.snapshot?.count ?? 0 - if indexPath.row >= count { - DDLogError("ItemsTableViewHandler: indexPath.row (\(indexPath.row)) out of bounds (\(count))") - return cell - } - - if let item = self.snapshot?[indexPath.row], let cell = cell as? ItemCell { - let model = delegate.model(for: item) - cell.set(item: model) - - let openInfoAction = UIAccessibilityCustomAction(name: L10n.Accessibility.Items.openItem, actionHandler: { [weak self, weak tableView] _ in - guard let self, let tableView else { return false } - self.tableView(tableView, didSelectRowAt: indexPath) - return true - }) - cell.accessibilityCustomActions = [openInfoAction] - } - - return cell } } extension ItemsTableViewHandler: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let action = tapAction(for: indexPath) else { return } - switch action { - case .attachment, .doi, .metadata, .note, .url: - tableView.deselectRow(at: indexPath, animated: true) - - case .selectItem: - break - - case .deselectItem: // this should never happen - DDLogError("ItemsTableViewHandler: deselect item action called in didSelectRowAt") - return - } - - delegate.process(tapAction: action) - - func tapAction(for indexPath: IndexPath) -> TapAction? { - guard let item = self.snapshot?[indexPath.row] else { return nil } - - if delegate.isEditing { - return .selectItem(item.key) - } - - guard let accessory = delegate.accessory(forKey: item.key) else { - switch item.rawType { - case ItemTypes.note: - return .note(item) - - default: - return .metadata(item) - } - } - - switch accessory { - case .attachment(let attachment, let parentKey): - return .attachment(attachment: attachment, parentKey: parentKey) - - case .doi(let doi): - return .doi(doi) - - case .url(let url): - return .url(url) - } - } + performTapAction(forIndexPath: indexPath) } func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) { - guard let item = self.snapshot?[indexPath.row] else { return } - switch item.rawType { - case ItemTypes.note: - delegate.process(tapAction: .note(item)) - - default: - delegate.process(tapAction: .metadata(item)) + guard let object = dataSource.object(at: indexPath.row) else { return } + if object.isNote { + delegate.process(tapAction: .note(object)) + } else { + delegate.process(tapAction: .metadata(object)) } } func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { - guard delegate.isEditing, let item = self.snapshot?[indexPath.row] else { return } - delegate.process(tapAction: .deselectItem(item.key)) + guard delegate.isEditing, let object = dataSource.object(at: indexPath.row) else { return } + delegate.process(tapAction: .deselectItem(object.key)) } func tableView(_ tableView: UITableView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool { @@ -358,34 +288,31 @@ extension ItemsTableViewHandler: UITableViewDelegate { } func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - guard !tableView.isEditing, let item = self.snapshot?[indexPath.row] else { return nil } - + guard !tableView.isEditing else { return nil } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ -> UIMenu? in - return self.createContextMenu(for: item) + return self.createContextMenu(at: indexPath) } } func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - guard let item = self.snapshot?[indexPath.row] else { return nil } - return self.createSwipeConfiguration(from: self.createTrailingCellActions(for: item), at: indexPath) + return dataSource.createTrailingCellActions(at: indexPath.row).flatMap({ createSwipeConfiguration(from: $0, at: indexPath) }) } } extension ItemsTableViewHandler: UITableViewDragDelegate { func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { - guard let item = self.snapshot?[indexPath.row] else { return [] } + guard let item = dataSource.object(at: indexPath.row)?.item else { return [] } return [self.dragDropController.dragItem(from: item)] } } extension ItemsTableViewHandler: UITableViewDropDelegate { func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) { - guard let item = coordinator.destinationIndexPath.flatMap({ self.snapshot?[$0.row] }), - let libraryId = item.libraryId else { return } + guard let object = coordinator.destinationIndexPath.flatMap({ dataSource.object(at: $0.row) }), let libraryId = object.libraryId else { return } switch coordinator.proposal.operation { case .copy: - let key = item.key + let key = object.key let localObject = coordinator.items.first?.dragItem.localObject self.dragDropController.keys(from: coordinator.items.map({ $0.dragItem })) { [weak self] keys in guard let self else { return } @@ -404,24 +331,24 @@ extension ItemsTableViewHandler: UITableViewDropDelegate { delegate.library.metadataEditable, // allow only when library is editable session.localDragSession != nil, // allow only local drag session let destinationIndexPath = destinationIndexPath, - let results = self.snapshot, - destinationIndexPath.row < results.count, + destinationIndexPath.row < dataSource.count, session.items.first?.localObject is RItem else { return UITableViewDropProposal(operation: .forbidden) } - return self.itemDropSessionDidUpdate(session: session, withDestinationIndexPath: destinationIndexPath, results: results) + return self.itemDropSessionDidUpdate(session: session, withDestinationIndexPath: destinationIndexPath) } - private func itemDropSessionDidUpdate(session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath, results: Results) -> UITableViewDropProposal { + private func itemDropSessionDidUpdate(session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath) -> UITableViewDropProposal { + guard let object = dataSource.object(at: destinationIndexPath.row) else { + return UITableViewDropProposal(operation: .forbidden) + } let dragItemsLibraryId = session.items.compactMap({ $0.localObject as? RItem }).compactMap({ $0.libraryId }).first - let item = results[destinationIndexPath.row] - if dragItemsLibraryId != item.libraryId || // allow dropping only to the same library - item.rawType == ItemTypes.note || item.rawType == ItemTypes.attachment || // allow dropping only to non-standalone items + if dragItemsLibraryId != object.libraryId || // allow dropping only to the same library + object.isNote || object.isAttachment || // allow dropping only to non-standalone items session.items.compactMap({ self.dragDropController.item(from: $0) }) // allow drops of only standalone items - .contains(where: { $0.rawType != ItemTypes.attachment && $0.rawType != ItemTypes.note }) - { + .contains(where: { $0.rawType != ItemTypes.attachment && $0.rawType != ItemTypes.note }) { return UITableViewDropProposal(operation: .forbidden) } diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift index 30d989259..c6366dcac 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift @@ -31,6 +31,7 @@ final class ItemsViewController: UIViewController { private unowned let controllers: Controllers private let disposeBag: DisposeBag + private var tableViewDataSource: RItemsTableViewDataSource! private var tableViewHandler: ItemsTableViewHandler! private var toolbarController: ItemsToolbarController! private var resultsToken: NotificationToken? @@ -58,7 +59,8 @@ final class ItemsViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - tableViewHandler = ItemsTableViewHandler(tableView: tableView, delegate: self, dragDropController: controllers.dragDropController) + tableViewDataSource = RItemsTableViewDataSource(viewModel: viewModel, fileDownloader: controllers.userControllers?.fileDownloader, schemaController: controllers.schemaController) + tableViewHandler = ItemsTableViewHandler(tableView: tableView, delegate: self, dataSource: tableViewDataSource, dragDropController: controllers.dragDropController) self.toolbarController = ItemsToolbarController(viewController: self, initialState: self.viewModel.state, delegate: self) self.navigationController?.toolbar.barTintColor = UIColor(dynamicProvider: { traitCollection in return traitCollection.userInterfaceStyle == .dark ? .black : .white @@ -127,7 +129,7 @@ final class ItemsViewController: UIViewController { if state.changes.contains(.results), let results = state.results { self.startObserving(results: results) } else if state.changes.contains(.attachmentsRemoved) { - self.tableViewHandler.reloadAllAttachments() + tableViewHandler.attachmentAccessoriesChanged() } else if let key = state.updateItemKey { let accessory = state.itemAccessories[key].flatMap({ ItemCellModel.createAccessory(from: $0, fileDownloader: controllers.userControllers?.fileDownloader) }) self.tableViewHandler.updateCell(key: key, withAccessory: accessory) @@ -181,9 +183,11 @@ final class ItemsViewController: UIViewController { switch error { case .itemMove, .deletion, .deletionFromCollection: if let snapshot = state.results { - self.tableViewHandler.reloadAll(snapshot: snapshot.freeze()) + tableViewDataSource.apply(snapshot: snapshot.freeze()) } - case .dataLoading, .collectionAssignment, .noteSaving, .attachmentAdding, .duplicationLoading: break + + case .dataLoading, .collectionAssignment, .noteSaving, .attachmentAdding, .duplicationLoading: + break } // Show appropriate message @@ -274,25 +278,24 @@ final class ItemsViewController: UIViewController { } private func startObserving(results: Results) { - self.resultsToken = results.observe(keyPaths: RItem.observableKeypathsForItemList, { [weak self] changes in + resultsToken = results.observe(keyPaths: RItem.observableKeypathsForItemList, { [weak self] changes in guard let self else { return } - switch changes { case .initial(let results): - self.tableViewHandler.reloadAll(snapshot: results.freeze()) - self.updateTagFilter(with: self.viewModel.state) + tableViewDataSource.apply(snapshot: results.freeze()) + updateTagFilter(with: self.viewModel.state) case .update(let results, let deletions, let insertions, let modifications): let correctedModifications = Database.correctedModifications(from: modifications, insertions: insertions, deletions: deletions) - self.viewModel.process(action: .updateKeys(items: results, deletions: deletions, insertions: insertions, modifications: correctedModifications)) - self.tableViewHandler.reload(snapshot: results.freeze(), modifications: modifications, insertions: insertions, deletions: deletions) { + viewModel.process(action: .updateKeys(items: results, deletions: deletions, insertions: insertions, modifications: correctedModifications)) + tableViewDataSource.apply(snapshot: results.freeze(), modifications: modifications, insertions: insertions, deletions: deletions) { self.updateTagFilter(with: self.viewModel.state) } - self.updateEmptyTrashButton(toEnabled: viewModel.state.library.metadataEditable && !results.isEmpty) + updateEmptyTrashButton(toEnabled: viewModel.state.library.metadataEditable && !results.isEmpty) case .error(let error): DDLogError("ItemsViewController: could not load results - \(error)") - self.viewModel.process(action: .observingFailed) + viewModel.process(action: .observingFailed) } }) } @@ -528,10 +531,6 @@ final class ItemsViewController: UIViewController { } extension ItemsViewController: ItemsTableViewHandlerDelegate { - var isTrash: Bool { - return false - } - var collectionKey: String? { return viewModel.state.collection.identifier.key } @@ -543,90 +542,13 @@ extension ItemsViewController: ItemsTableViewHandlerDelegate { var library: Library { viewModel.state.library } - - var selectedItems: Set { - viewModel.state.selectedItems - } - - func model(for item: RItem) -> ItemCellModel { - // Create and cache attachment if needed - viewModel.process(action: .cacheItemAccessory(item: item)) - - let title: NSAttributedString - if let _title = viewModel.state.itemTitles[item.key] { - title = _title - } else { - viewModel.process(action: .cacheItemTitle(key: item.key, title: item.displayTitle)) - title = viewModel.state.itemTitles[item.key, default: NSAttributedString()] - } - - let accessory = viewModel.state.itemAccessories[item.key] - let typeName = controllers.schemaController.localized(itemType: item.rawType) ?? item.rawType - return ItemCellModel(item: item, typeName: typeName, title: title, accessory: accessory, fileDownloader: controllers.userControllers?.fileDownloader) - } - - func accessory(forKey key: String) -> ItemAccessory? { - viewModel.state.itemAccessories[key] - } - - func createContextMenuActions(for item: RItem) -> [ItemAction] { - if viewModel.state.collection.identifier.isTrash { - return [ItemAction(type: .restore), ItemAction(type: .delete)] - } - - var actions: [ItemAction] = [] - - // Add citation for valid types - if !CitationController.invalidItemTypes.contains(item.rawType) { - actions.append(contentsOf: [ItemAction(type: .copyCitation), ItemAction(type: .copyBibliography), ItemAction(type: .share)]) - } - - // Add parent creation for standalone attachments - if item.rawType == ItemTypes.attachment, item.parent == nil { - actions.append(ItemAction(type: .createParent)) - } - - // Add download/remove downloaded option for attachments - if let accessory = viewModel.state.itemAccessories[item.key], let location = accessory.attachment?.location { - switch location { - case .local: - actions.append(ItemAction(type: .removeDownload)) - - case .remote: - actions.append(ItemAction(type: .download)) - - case .localAndChangedRemotely: - actions.append(ItemAction(type: .download)) - actions.append(ItemAction(type: .removeDownload)) - - case .remoteMissing: - break - } - } - - guard viewModel.state.library.metadataEditable else { return actions } - - actions.append(ItemAction(type: .addToCollection)) - - // Add removing from collection only if item is in current collection. - if case .collection(let key) = viewModel.state.collection.identifier, item.collections.filter(.key(key)).first != nil { - actions.append(ItemAction(type: .removeFromCollection)) - } - - if item.rawType != ItemTypes.note && item.rawType != ItemTypes.attachment { - actions.append(ItemAction(type: .duplicate)) - } - actions.append(ItemAction(type: .trash)) - - return actions - } func process(tapAction: ItemsTableViewHandler.TapAction) { resetActiveSearch() switch tapAction { - case .metadata(let item): - coordinatorDelegate?.showItemDetail(for: .preview(key: item.key), libraryId: viewModel.state.library.identifier, scrolledToKey: nil, animated: true) + case .metadata(let object): + coordinatorDelegate?.showItemDetail(for: .preview(key: object.key), libraryId: viewModel.state.library.identifier, scrolledToKey: nil, animated: true) case .attachment(let attachment, let parentKey): viewModel.process(action: .openAttachment(attachment: attachment, parentKey: parentKey)) @@ -643,8 +565,8 @@ extension ItemsViewController: ItemsTableViewHandlerDelegate { case .deselectItem(let key): viewModel.process(action: .deselectItem(key)) - case .note(let item): - guard let note = Note(item: item) else { return } + case .note(let object): + guard let item = object as? RItem, let note = Note(item: item) else { return } let tags = Array(item.tags.map({ Tag(tag: $0) })) coordinatorDelegate?.showNote(library: viewModel.state.library, kind: .edit(key: note.key), text: note.text, tags: tags, parentTitleData: nil, title: note.title, saveCallback: nil) } @@ -655,8 +577,9 @@ extension ItemsViewController: ItemsTableViewHandlerDelegate { } } - func process(action: ItemAction.Kind, for item: RItem, completionAction: ((Bool) -> Void)?) { - process(action: action, for: [item.key], button: nil, completionAction: completionAction) + func process(action: ItemAction.Kind, at index: Int, completionAction: ((Bool) -> Void)?) { + guard let object = tableViewDataSource.object(at: index) else { return } + process(action: action, for: [object.key], button: nil, completionAction: completionAction) } func process(dragAndDropAction action: ItemsTableViewHandler.DragAndDropAction) { diff --git a/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift b/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift new file mode 100644 index 000000000..4cf73e770 --- /dev/null +++ b/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift @@ -0,0 +1,234 @@ +// +// RItemsTableViewDataSource.swift +// Zotero +// +// Created by Michal Rentka on 19.09.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import CocoaLumberjackSwift +import RealmSwift + +extension RItem: ItemsTableViewObject { + var isNote: Bool { + switch rawType { + case ItemTypes.note: + return true + + default: + return false + } + } + + var isAttachment: Bool { + switch rawType { + case ItemTypes.attachment: + return true + + default: + return false + } + } + + var item: RItem? { + return self + } +} + +final class RItemsTableViewDataSource: NSObject { + private unowned let viewModel: ViewModel + private unowned let schemaController: SchemaController + private unowned let fileDownloader: AttachmentDownloader? + + private var snapshot: Results? + weak var handler: ItemsTableViewHandler? + + init(viewModel: ViewModel, fileDownloader: AttachmentDownloader?, schemaController: SchemaController) { + self.viewModel = viewModel + self.fileDownloader = fileDownloader + self.schemaController = schemaController + } + + func apply(snapshot: Results) { + self.snapshot = snapshot + handler?.reloadAll() + } + + func apply(snapshot: Results, modifications: [Int], insertions: [Int], deletions: [Int], completion: (() -> Void)? = nil) { + guard let handler else { return } + handler.reload( + modifications: modifications.map({ IndexPath(row: $0, section: 0) }), + insertions: insertions.map({ IndexPath(row: $0, section: 0) }), + deletions: deletions.map({ IndexPath(row: $0, section: 0) }), + updateSnapshot: { + self.snapshot = snapshot + }, + completion: completion + ) + } +} + +extension RItemsTableViewDataSource: ItemsTableViewDataSource { + var count: Int { + return snapshot?.count ?? 0 + } + + var selectedItems: Set { + return viewModel.state.selectedItems + } + + func object(at index: Int) -> ItemsTableViewObject? { + return item(at: index) + } + + private func item(at index: Int) -> RItem? { + guard index < count else { return nil } + return snapshot?[index] + } + + func accessory(forKey key: String) -> ItemAccessory? { + return viewModel.state.itemAccessories[key] + } + + func tapAction(for indexPath: IndexPath) -> ItemsTableViewHandler.TapAction? { + guard let item = item(at: indexPath.row) else { return nil } + + if viewModel.state.isEditing { + return .selectItem(item.key) + } + + guard let accessory = accessory(forKey: item.key) else { + switch item.rawType { + case ItemTypes.note: + return .note(item) + + default: + return .metadata(item) + } + } + + switch accessory { + case .attachment(let attachment, let parentKey): + return .attachment(attachment: attachment, parentKey: parentKey) + + case .doi(let doi): + return .doi(doi) + + case .url(let url): + return .url(url) + } + } + + func createTrailingCellActions(at index: Int) -> [ItemAction]? { + guard let item = item(at: index) else { return nil } + var trailingActions: [ItemAction] = [ItemAction(type: .trash), ItemAction(type: .addToCollection)] + // Allow removing from collection only if item is in current collection. This can happen when "Show items from subcollection" is enabled. + if let key = viewModel.state.collection.identifier.key, item.collections.filter(.key(key)).first != nil { + trailingActions.insert(ItemAction(type: .removeFromCollection), at: 1) + } + return trailingActions + } + + func createContextMenuActions(at index: Int) -> [ItemAction] { + guard let item = item(at: index) else { return [] } + + var actions: [ItemAction] = [] + + // Add citation for valid types + if !CitationController.invalidItemTypes.contains(item.rawType) { + actions.append(contentsOf: [ItemAction(type: .copyCitation), ItemAction(type: .copyBibliography), ItemAction(type: .share)]) + } + + // Add parent creation for standalone attachments + if item.rawType == ItemTypes.attachment && item.parent == nil { + actions.append(ItemAction(type: .createParent)) + } + + // Add download/remove downloaded option for attachments + if let accessory = accessory(forKey: item.key), let location = accessory.attachment?.location { + switch location { + case .local: + actions.append(ItemAction(type: .removeDownload)) + + case .remote: + actions.append(ItemAction(type: .download)) + + case .localAndChangedRemotely: + actions.append(ItemAction(type: .download)) + actions.append(ItemAction(type: .removeDownload)) + + case .remoteMissing: + break + } + } + + guard viewModel.state.library.metadataEditable else { return actions } + + actions.append(ItemAction(type: .addToCollection)) + + // Add removing from collection only if item is in current collection. + if let key = viewModel.state.collection.identifier.key, item.collections.filter(.key(key)).first != nil { + actions.append(ItemAction(type: .removeFromCollection)) + } + + if item.rawType != ItemTypes.note && item.rawType != ItemTypes.attachment { + actions.append(ItemAction(type: .duplicate)) + } + actions.append(ItemAction(type: .trash)) + + return actions + } +} + +extension RItemsTableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: ItemsTableViewHandler.cellId, for: indexPath) + + guard let item = item(at: indexPath.row) else { + DDLogError("ItemsTableViewHandler: indexPath.row (\(indexPath.row)) out of bounds (\(count))") + return cell + } + + if let model = model(for: item), let cell = cell as? ItemCell { + cell.set(item: model) + + let openInfoAction = UIAccessibilityCustomAction(name: L10n.Accessibility.Items.openItem, actionHandler: { [weak self] _ in + guard let self else { return false } + handler?.performTapAction(forIndexPath: indexPath) + return true + }) + cell.accessibilityCustomActions = [openInfoAction] + } + + return cell + + func model(for item: RItem) -> ItemCellModel? { + // Create and cache attachment if needed + viewModel.process(action: .cacheItemAccessory(item: item)) + + 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) + + func createTitleIfNeeded() -> NSAttributedString { + if let title = viewModel.state.itemTitles[item.key] { + return title + } else { + viewModel.process(action: .cacheItemTitle(key: item.key, title: item.displayTitle)) + return viewModel.state.itemTitles[item.key, default: NSAttributedString()] + } + } + } + } +} diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift new file mode 100644 index 000000000..8a255d9c5 --- /dev/null +++ b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift @@ -0,0 +1,87 @@ +// +// TrashTableViewDataSource.swift +// Zotero +// +// Created by Michal Rentka on 19.09.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import OrderedCollections + +final class TrashTableViewDataSource: NSObject, ItemsTableViewDataSource { + private let viewModel: ViewModel + + weak var handler: ItemsTableViewHandler? + private var snapshot: OrderedDictionary? + + init(viewModel: ViewModel) { + self.viewModel = viewModel + } +} + +extension TrashTableViewDataSource { + var count: Int { + return snapshot?.count ?? 0 + } + + var selectedItems: Set { + return [] + } + + func object(at index: Int) -> ItemsTableViewObject? { + guard let snapshot, index < snapshot.keys.count else { return nil } + return snapshot.values[index] + } + + func accessory(forKey key: String) -> ItemAccessory? { + <#code#> + } + + func tapAction(for indexPath: IndexPath) -> ItemsTableViewHandler.TapAction? { + <#code#> + } + + func createTrailingCellActions(at index: Int) -> [ItemAction]? { + <#code#> + } + + func createContextMenuActions(at index: Int) -> [ItemAction] { + <#code#> + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + <#code#> + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + <#code#> + } +} + +extension TrashObject: ItemsTableViewObject { + var isNote: Bool { + switch type { + case .item(let cellData, let sortData): + return sortData.type == ItemTypes.note + + case .collection: + return false + } + } + + var isAttachment: Bool { + switch type { + case .item(let cellData, let sortData): + return sortData.type == ItemTypes.attachment + + case .collection: + return false + } + } + + var item: RItem? { + return nil + } +} diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift index bcf429016..182034949 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -63,7 +63,6 @@ final class TrashViewController: UIViewController { } private func update(state: TrashState) { - } } @@ -72,43 +71,20 @@ extension TrashViewController: ItemsTableViewHandlerDelegate { return view.window != nil } - var library: Library { - return viewModel.state.library - } - - var selectedItems: Set { - return [] - } - - func model(for item: RItem) -> ItemCellModel { - // Create and cache attachment if needed -// viewModel.process(action: .cacheItemAccessory(item: item)) - -// let title: NSAttributedString -// if let _title = viewModel.state.itemTitles[item.key] { -// title = _title -// } else { -// viewModel.process(action: .cacheItemTitle(key: item.key, title: item.displayTitle)) -// title = viewModel.state.itemTitles[item.key, default: NSAttributedString()] -// } - -// let accessory = viewModel.state.itemAccessories[item.key] - let tmpTitle = NSAttributedString(string: item.displayTitle) - let typeName = controllers.schemaController.localized(itemType: item.rawType) ?? item.rawType - return ItemCellModel(item: item, typeName: typeName, title: tmpTitle, accessory: nil, fileDownloader: controllers.userControllers?.fileDownloader) + var collectionKey: String? { + return nil } - func accessory(forKey key: String) -> ItemAccessory? { - return nil + var library: Library { + return viewModel.state.library } - func process(tapAction: ItemsTableViewHandler.TapAction) { + func process(action: ItemAction.Kind, at index: Int, completionAction: ((Bool) -> Void)?) { } - func process(action: ItemAction.Kind, for item: RItem, completionAction: ((Bool) -> Void)?) { + func process(tapAction action: ItemsTableViewHandler.TapAction) { } - func createContextMenuActions(for item: RItem) -> [ItemAction] { - return [] + func process(dragAndDropAction action: ItemsTableViewHandler.DragAndDropAction) { } } From 0ced3894b7284794eae6f64151fe40baaf3cd0ca Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Fri, 20 Sep 2024 16:08:24 +0200 Subject: [PATCH 06/23] WIP: Items controller refactoring to simplify trash controller --- Zotero.xcodeproj/project.pbxproj | 16 +- Zotero/Scenes/Detail/DetailCoordinator.swift | 20 +- .../Detail/Items/Models/ItemCellModel.swift | 26 + .../Items/Models/ItemsTableViewObject.swift | 2 +- .../ViewModels/ItemsToolbarController.swift | 67 ++- .../Items/Views/BaseItemsViewController.swift | 261 +++++++++ .../Items/Views/ItemsTableViewHandler.swift | 8 +- .../Items/Views/ItemsViewController.swift | 538 ++++++------------ .../Items/Views/ItemsViewController.xib | 44 -- .../Views/RItemsTableViewDataSource.swift | 6 +- .../Detail/Trash/Models/TrashObject.swift | 2 + .../Trash/ViewModels/TrashActionHandler.swift | 7 +- .../Views/TrashTableViewDataSource.swift | 58 +- .../Trash/Views/TrashViewController.swift | 199 ++++++- 14 files changed, 761 insertions(+), 493 deletions(-) create mode 100644 Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift delete mode 100644 Zotero/Scenes/Detail/Items/Views/ItemsViewController.xib diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index f83f1c226..715b66c26 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -664,8 +664,7 @@ B3593F3D241A61C700760E20 /* ItemsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EEE241A61C700760E20 /* ItemsState.swift */; }; B3593F3E241A61C700760E20 /* ItemsTableViewHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EF0241A61C700760E20 /* ItemsTableViewHandler.swift */; }; B3593F40241A61C700760E20 /* ItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EF2241A61C700760E20 /* ItemCell.swift */; }; - B3593F42241A61C700760E20 /* ItemsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = B3593EF4241A61C700760E20 /* ItemsViewController.xib */; }; - B3593F44241A61C700760E20 /* ItemsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EF6241A61C700760E20 /* ItemsViewController.swift */; }; + B3593F44241A61C700760E20 /* BaseItemsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EF6241A61C700760E20 /* BaseItemsViewController.swift */; }; B3593F45241A61C700760E20 /* ItemSortTypePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EF7241A61C700760E20 /* ItemSortTypePickerView.swift */; }; B3593F47241A61C700760E20 /* TagEmojiCirclesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EF9241A61C700760E20 /* TagEmojiCirclesView.swift */; }; B3593F48241A61C700760E20 /* LibrariesActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EFD241A61C700760E20 /* LibrariesActionHandler.swift */; }; @@ -1151,6 +1150,7 @@ B3ED79352C9C665C004F5C9A /* RItemsTableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3ED79342C9C6652004F5C9A /* RItemsTableViewDataSource.swift */; }; B3ED79372C9C6678004F5C9A /* ItemsTableViewObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3ED79362C9C6674004F5C9A /* ItemsTableViewObject.swift */; }; B3ED79392C9C7060004F5C9A /* TrashTableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3ED79382C9C7058004F5C9A /* TrashTableViewDataSource.swift */; }; + B3ED793B2C9D74EF004F5C9A /* ItemsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3ED793A2C9D74DF004F5C9A /* ItemsViewController.swift */; }; B3EFC60B2B0F503E00CB71A0 /* EmojiExtractorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFC60A2B0F503E00CB71A0 /* EmojiExtractorSpec.swift */; }; B3EFC60C2B0F633A00CB71A0 /* EmojiExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3CAE1172B0E38BA0000F8CA /* EmojiExtractor.swift */; }; B3F09AE629CAFF860084E4D8 /* TagFilterActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F09AE529CAFF860084E4D8 /* TagFilterActionHandler.swift */; }; @@ -1728,8 +1728,7 @@ B3593EEE241A61C700760E20 /* ItemsState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemsState.swift; sourceTree = ""; }; B3593EF0241A61C700760E20 /* ItemsTableViewHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemsTableViewHandler.swift; sourceTree = ""; }; B3593EF2241A61C700760E20 /* ItemCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemCell.swift; sourceTree = ""; }; - B3593EF4241A61C700760E20 /* ItemsViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ItemsViewController.xib; sourceTree = ""; }; - B3593EF6241A61C700760E20 /* ItemsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemsViewController.swift; sourceTree = ""; }; + B3593EF6241A61C700760E20 /* BaseItemsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseItemsViewController.swift; sourceTree = ""; }; B3593EF7241A61C700760E20 /* ItemSortTypePickerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemSortTypePickerView.swift; sourceTree = ""; }; B3593EF9241A61C700760E20 /* TagEmojiCirclesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagEmojiCirclesView.swift; sourceTree = ""; }; B3593EFD241A61C700760E20 /* LibrariesActionHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibrariesActionHandler.swift; sourceTree = ""; }; @@ -2133,6 +2132,7 @@ B3ED79342C9C6652004F5C9A /* RItemsTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RItemsTableViewDataSource.swift; sourceTree = ""; }; B3ED79362C9C6674004F5C9A /* ItemsTableViewObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsTableViewObject.swift; sourceTree = ""; }; B3ED79382C9C7058004F5C9A /* TrashTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashTableViewDataSource.swift; sourceTree = ""; }; + B3ED793A2C9D74DF004F5C9A /* ItemsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsViewController.swift; sourceTree = ""; }; B3EFC60A2B0F503E00CB71A0 /* EmojiExtractorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiExtractorSpec.swift; sourceTree = ""; }; B3F09AE529CAFF860084E4D8 /* TagFilterActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagFilterActionHandler.swift; sourceTree = ""; }; B3F09AE729CAFF8D0084E4D8 /* TagFilterState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagFilterState.swift; sourceTree = ""; }; @@ -3355,6 +3355,7 @@ B3593EEF241A61C700760E20 /* Views */ = { isa = PBXGroup; children = ( + B3593EF6241A61C700760E20 /* BaseItemsViewController.swift */, B3593EF2241A61C700760E20 /* ItemCell.swift */, B30B405E2490CAFC00FAAF6D /* ItemCell.xib */, B31901922629C9CB00209E33 /* ItemsFilterViewController.swift */, @@ -3363,8 +3364,7 @@ B3593EF7241A61C700760E20 /* ItemSortTypePickerView.swift */, B3593EF0241A61C700760E20 /* ItemsTableViewHandler.swift */, B31D973B27F5E51100ED3DA2 /* ItemsToolbarDownloadProgressView.swift */, - B3593EF6241A61C700760E20 /* ItemsViewController.swift */, - B3593EF4241A61C700760E20 /* ItemsViewController.xib */, + B3ED793A2C9D74DF004F5C9A /* ItemsViewController.swift */, B3ED79342C9C6652004F5C9A /* RItemsTableViewDataSource.swift */, B3593EF9241A61C700760E20 /* TagEmojiCirclesView.swift */, ); @@ -4534,7 +4534,6 @@ B3F0C3FE250A1DB8002D557A /* LibraryCell.xib in Resources */, B37D8E6424DC21D300F526C5 /* CollectionCellContentView.xib in Resources */, B3593F23241A61C700760E20 /* ItemDetailSectionView.xib in Resources */, - B3593F42241A61C700760E20 /* ItemsViewController.xib in Resources */, B37C5B6F26454130009A37E5 /* NoteEditorViewController.xib in Resources */, B3593F27241A61C700760E20 /* ItemDetailFieldContentView.xib in Resources */, B34DF1BE2576956F0019CCD1 /* SwitchCell.xib in Resources */, @@ -4855,7 +4854,7 @@ B3593F48241A61C700760E20 /* LibrariesActionHandler.swift in Sources */, B30565D623FC051E003304F2 /* CreateAttachmentDbRequest.swift in Sources */, B3DCDF07240912060039ED0D /* NoteEditorViewController.swift in Sources */, - B3593F44241A61C700760E20 /* ItemsViewController.swift in Sources */, + B3593F44241A61C700760E20 /* BaseItemsViewController.swift in Sources */, B3E8FE732714344D00F51458 /* SettingsToggleRow.swift in Sources */, B3E8FE2C271429C300F51458 /* ExportLocalePickerActionHandler.swift in Sources */, B3E8FE1A2714297200F51458 /* CiteSearchAction.swift in Sources */, @@ -5402,6 +5401,7 @@ B305662723FC051F003304F2 /* SyncController.swift in Sources */, B30566A323FC051F003304F2 /* Collection.swift in Sources */, B340831128F95E700087D1A1 /* MigrateBaseKeysToPositionFieldDbAction.swift in Sources */, + B3ED793B2C9D74EF004F5C9A /* ItemsViewController.swift in Sources */, B36CBD5A25DD3BDD003C4613 /* ConflictAlertQueueHandler.swift in Sources */, B30566A423FC051F003304F2 /* UpdatableObject.swift in Sources */, B30566C723FC051F003304F2 /* AppGroup.swift in Sources */, diff --git a/Zotero/Scenes/Detail/DetailCoordinator.swift b/Zotero/Scenes/Detail/DetailCoordinator.swift index 7b0dd5712..2d06f11f4 100644 --- a/Zotero/Scenes/Detail/DetailCoordinator.swift +++ b/Zotero/Scenes/Detail/DetailCoordinator.swift @@ -124,7 +124,13 @@ final class DetailCoordinator: Coordinator { case .custom(let type): switch type { case .trash: - controller = createTrashViewController(libraryId: libraryId, dbStorage: userControllers.dbStorage, fileStorage: controllers.fileStorage, urlDetector: controllers.urlDetector) + controller = createTrashViewController( + libraryId: libraryId, + dbStorage: userControllers.dbStorage, + schemaController: controllers.schemaController, + fileStorage: controllers.fileStorage, + urlDetector: controllers.urlDetector + ) case .all, .publications, .unfiled: controller = createItemsViewController( @@ -160,10 +166,16 @@ final class DetailCoordinator: Coordinator { navigationController?.setViewControllers([controller], animated: animated) - func createTrashViewController(libraryId: LibraryIdentifier, dbStorage: DbStorage, fileStorage: FileStorage, urlDetector: UrlDetector) -> TrashViewController { + func createTrashViewController( + libraryId: LibraryIdentifier, + dbStorage: DbStorage, + schemaController: SchemaController, + fileStorage: FileStorage, + urlDetector: UrlDetector + ) -> TrashViewController { let state = TrashState(libraryId: libraryId) - let handler = TrashActionHandler(dbStorage: dbStorage, fileStorage: fileStorage, urlDetector: urlDetector) - return TrashViewController(viewModel: ViewModel(initialState: state, handler: handler), controllers: controllers) + let handler = TrashActionHandler(dbStorage: dbStorage, schemaController: schemaController, fileStorage: fileStorage, urlDetector: urlDetector) + return TrashViewController(viewModel: ViewModel(initialState: state, handler: handler), controllers: controllers, coordinatorDelegate: self) } func createItemsViewController( diff --git a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift index 423895bb4..0079cf36b 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift @@ -44,6 +44,32 @@ struct ItemCellModel { self.init(item: item, typeName: typeName, title: title, accessory: Self.createAccessory(from: accessory, fileDownloader: fileDownloader)) } + init(object: TrashObject) { + key = object.key + + switch object.type { + case .collection: + typeIconName = "collection" + subtitle = "" + hasNote = false + tagColors = [] + tagEmojis = [] + accessory = nil + typeName = "Collection" + title = NSAttributedString(string: object.title) + + case .item(let cellData, _): + typeIconName = cellData.typeIconName + subtitle = cellData.subtitle + hasNote = cellData.hasNote + tagColors = cellData.tagColors + tagEmojis = cellData.tagEmojis + accessory = cellData.accessory + typeName = cellData.localizedTypeName + title = cellData.attributedTitle + } + } + static func createAccessory(from accessory: ItemAccessory?, fileDownloader: AttachmentDownloader?) -> ItemCellModel.Accessory? { return accessory.flatMap({ accessory -> ItemCellModel.Accessory in switch accessory { diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsTableViewObject.swift b/Zotero/Scenes/Detail/Items/Models/ItemsTableViewObject.swift index ceda2d2d2..3951a5ac4 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemsTableViewObject.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemsTableViewObject.swift @@ -12,5 +12,5 @@ protocol ItemsTableViewObject { var key: String { get } var isNote: Bool { get } var isAttachment: Bool { get } - var libraryId: LibraryIdentifier? { get } + var libraryIdentifier: LibraryIdentifier { get } } diff --git a/Zotero/Scenes/Detail/Items/ViewModels/ItemsToolbarController.swift b/Zotero/Scenes/Detail/Items/ViewModels/ItemsToolbarController.swift index 194b4d204..f24ea89cb 100644 --- a/Zotero/Scenes/Detail/Items/ViewModels/ItemsToolbarController.swift +++ b/Zotero/Scenes/Detail/Items/ViewModels/ItemsToolbarController.swift @@ -17,6 +17,16 @@ protocol ItemsToolbarControllerDelegate: UITraitEnvironment { } final class ItemsToolbarController { + struct Data { + let isEditing: Bool + let selectedItems: Set + let filters: [ItemsFilter] + let downloadBatchData: ItemsState.DownloadBatchData? + let remoteDownloadBatchData: ItemsState.DownloadBatchData? + let identifierLookupBatchData: ItemsState.IdentifierLookupBatchData + let itemCount: Int + } + enum ToolbarItem: Int { case empty = 1 case single @@ -34,25 +44,25 @@ final class ItemsToolbarController { private weak var delegate: ItemsToolbarControllerDelegate? - init(viewController: UIViewController, initialState: ItemsState, delegate: ItemsToolbarControllerDelegate) { + init(viewController: UIViewController, data: Data, collection: Collection, library: Library, delegate: ItemsToolbarControllerDelegate) { self.viewController = viewController self.delegate = delegate - editingActions = createEditingActions(for: initialState) + editingActions = createEditingActions(collection: collection, library: library) disposeBag = DisposeBag() - createToolbarItems(for: initialState) + createToolbarItems(data: data) - func createEditingActions(for state: ItemsState) -> [ItemAction] { + func createEditingActions(collection: Collection, library: Library) -> [ItemAction] { var types: [ItemAction.Kind] = [] - if state.collection.identifier.isTrash && state.library.metadataEditable { + if collection.identifier.isTrash && library.metadataEditable { types.append(contentsOf: [.restore, .delete, .download, .removeDownload]) } else { - if state.library.metadataEditable { + if library.metadataEditable { types.append(contentsOf: [.addToCollection, .trash]) } - switch state.collection.identifier { + switch collection.identifier { case .collection: - if state.library.metadataEditable { + if library.metadataEditable { types.insert(.removeFromCollection, at: 1) } @@ -71,19 +81,19 @@ final class ItemsToolbarController { // MARK: - Actions - func createToolbarItems(for state: ItemsState) { - if state.isEditing { + func createToolbarItems(data: Data) { + if data.isEditing { viewController.toolbarItems = createEditingToolbarItems(from: editingActions) - updateEditingToolbarItems(for: state.selectedItems, results: state.results) + updateEditingToolbarItems(for: data.selectedItems) } else { - let filters = sizeClassSpecificFilters(from: state.filters) - viewController.toolbarItems = createNormalToolbarItems(for: filters) + let filters = sizeClassSpecificFilters(from: data.filters) + viewController.toolbarItems = createNormalToolbarItems(for: data.filters) updateNormalToolbarItems( - for: filters, - downloadBatchData: state.downloadBatchData, - remoteDownloadBatchData: state.remoteDownloadBatchData, - identifierLookupBatchData: state.identifierLookupBatchData, - results: state.results + for: data.filters, + downloadBatchData: data.downloadBatchData, + remoteDownloadBatchData: data.remoteDownloadBatchData, + identifierLookupBatchData: data.identifierLookupBatchData, + itemCount: data.itemCount ) } @@ -191,16 +201,16 @@ final class ItemsToolbarController { } } - func reloadToolbarItems(for state: ItemsState) { - if state.isEditing { - updateEditingToolbarItems(for: state.selectedItems, results: state.results) + func reloadToolbarItems(for data: Data) { + if data.isEditing { + updateEditingToolbarItems(for: data.selectedItems) } else { updateNormalToolbarItems( - for: sizeClassSpecificFilters(from: state.filters), - downloadBatchData: state.downloadBatchData, - remoteDownloadBatchData: state.remoteDownloadBatchData, - identifierLookupBatchData: state.identifierLookupBatchData, - results: state.results + for: sizeClassSpecificFilters(from: data.filters), + downloadBatchData: data.downloadBatchData, + remoteDownloadBatchData: data.remoteDownloadBatchData, + identifierLookupBatchData: data.identifierLookupBatchData, + itemCount: data.itemCount ) } } @@ -225,7 +235,7 @@ final class ItemsToolbarController { // MARK: - Helpers - private func updateEditingToolbarItems(for selectedItems: Set, results: Results?) { + private func updateEditingToolbarItems(for selectedItems: Set) { viewController.toolbarItems?.forEach({ item in switch ToolbarItem(rawValue: item.tag) { case .empty: @@ -245,7 +255,7 @@ final class ItemsToolbarController { downloadBatchData: ItemsState.DownloadBatchData?, remoteDownloadBatchData: ItemsState.DownloadBatchData?, identifierLookupBatchData: ItemsState.IdentifierLookupBatchData, - results: Results? + itemCount: Int ) { if let item = viewController.toolbarItems?.first(where: { $0.tag == ToolbarItem.filter.tag }) { let filterImageName = filters.isEmpty ? "line.horizontal.3.decrease.circle" : "line.horizontal.3.decrease.circle.fill" @@ -255,7 +265,6 @@ final class ItemsToolbarController { if let item = viewController.toolbarItems?.first(where: { $0.tag == ToolbarItem.title.tag }), let stackView = item.customView as? UIStackView { if let filterLabel = stackView.subviews.first as? UILabel { - let itemCount = results?.count ?? 0 filterLabel.isHidden = filters.isEmpty if !filterLabel.isHidden { diff --git a/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift b/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift new file mode 100644 index 000000000..24437b7f4 --- /dev/null +++ b/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift @@ -0,0 +1,261 @@ +// +// BaseItemsViewController.swift +// Zotero +// +// Created by Michal Rentka on 17/10/2019. +// Copyright © 2019 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import CocoaLumberjackSwift +import RealmSwift +import RxSwift +import WebKit + +class BaseItemsViewController: UIViewController { + enum RightBarButtonItem: Int { + case select + case done + case selectAll + case deselectAll + case add + case emptyTrash + } + + private static let itemBatchingLimit = 150 + unowned let controllers: Controllers + let disposeBag: DisposeBag + + weak var tableView: UITableView! + var toolbarController: ItemsToolbarController? + var refreshController: SyncRefreshController? + var handler: ItemsTableViewHandler? + weak var tagFilterDelegate: ItemsTagFilterDelegate? + var library: Library { + return Library(identifier: .custom(.myLibrary), name: "", metadataEditable: false, filesEditable: false) + } + var collection: Collection { + return .init(custom: .all) + } + var toolbarData: ItemsToolbarController.Data { + return .init( + isEditing: false, + selectedItems: [], + filters: [], + downloadBatchData: nil, + remoteDownloadBatchData: nil, + identifierLookupBatchData: ItemsState.IdentifierLookupBatchData(saved: 0, total: 0), + itemCount: 0 + ) + } + + weak var coordinatorDelegate: (DetailItemsCoordinatorDelegate & DetailNoteEditorCoordinatorDelegate)? + + init(controllers: Controllers, coordinatorDelegate: (DetailItemsCoordinatorDelegate & DetailNoteEditorCoordinatorDelegate)) { + self.controllers = controllers + self.coordinatorDelegate = coordinatorDelegate + self.disposeBag = DisposeBag() + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + createTableView() + navigationController?.toolbar.barTintColor = UIColor(dynamicProvider: { traitCollection in + return traitCollection.userInterfaceStyle == .dark ? .black : .white + }) + setupTitle() + setupSearchBar() + if let scheduler = controllers.userControllers?.syncScheduler { + refreshController = SyncRefreshController(libraryId: library.identifier, view: tableView, syncScheduler: scheduler) + } + startObservingSyncProgress() + + func createTableView() { + let tableView = UITableView() + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + self.tableView = tableView + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + toolbarController?.willAppear() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + // willTransition(to:with:) seems to not be not called for all transitions, so instead traitCollectionDidChange(_:) is used w/ a short animation block. + guard UIDevice.current.userInterfaceIdiom == .pad, traitCollection.horizontalSizeClass != previousTraitCollection?.horizontalSizeClass else { return } + setupTitle() + UIView.animate(withDuration: 0.1) { + self.toolbarController?.reloadToolbarItems(for: self.toolbarData) + } + } + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let key = presses.first?.key, key.characters == "f", key.modifierFlags.contains(.command) else { + super.pressesBegan(presses, with: event) + return + } + navigationItem.searchController?.searchBar.becomeFirstResponder() + } + + // MARK: - Actions + + func updateTagFilter(filters: [ItemsFilter], collectionId: CollectionIdentifier, libraryId: LibraryIdentifier) { + tagFilterDelegate?.itemsDidChange(filters: filters, collectionId: collection.identifier, libraryId: library.identifier) + } + + /// Starts observing progress of sync. The sync progress needs to be observed to optimize `UITableView` reloads for big syncs of items in current library. + private func startObservingSyncProgress() { + guard let syncController = controllers.userControllers?.syncScheduler.syncController else { return } + + syncController.progressObservable + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] progress in + guard let self else { return } + switch progress { + case .object(let object, let progress, _, let libraryId): + if library.identifier == libraryId && object == .item { + if let progress = progress, progress.total >= BaseItemsViewController.itemBatchingLimit { + // Disable batched reloads when there are a lot of upcoming updates. Batched updates kill tableView performance when many are performed in short period of time. + handler?.disableReloadAnimations() + } + } else { + // Re-enable batched reloads when items are synced. + handler?.enableReloadAnimations() + } + + default: + // Re-enable batched reloads when items are synced. + handler?.enableReloadAnimations() + } + }) + .disposed(by: disposeBag) + } + + // MARK: - To override + + func search(for term: String) {} + + func tagSelectionDidChange(selected: Set) {} + + func process(action: ItemAction.Kind, for selectedKeys: Set, button: UIBarButtonItem?, completionAction: ((Bool) -> Void)?) {} + + func process(barButtonItemAction: RightBarButtonItem, sender: UIBarButtonItem) {} + + // MARK: - Setups + + func setupTitle() { + title = traitCollection.horizontalSizeClass == .compact ? collection.name : nil + } + + private func setupSearchBar() { + let controller = UISearchController(searchResultsController: nil) + controller.searchBar.placeholder = L10n.Items.searchTitle + controller.searchBar.rx + .text.observe(on: MainScheduler.instance) + .skip(1) + .debounce(.milliseconds(150), scheduler: MainScheduler.instance) + .subscribe(onNext: { [weak self] text in + self?.search(for: text ?? "") + }) + .disposed(by: disposeBag) + controller.obscuresBackgroundDuringPresentation = false + controller.delegate = self + navigationItem.hidesSearchBarWhenScrolling = false + navigationItem.searchController = controller + } + + func setupRightBarButtonItems(expectedItems: [RightBarButtonItem]) { + let currentItems = (self.navigationItem.rightBarButtonItems ?? []).compactMap({ RightBarButtonItem(rawValue: $0.tag) }) + guard currentItems != expectedItems else { return } + self.navigationItem.rightBarButtonItems = expectedItems.compactMap({ createRightBarButtonItem($0) }).reversed() + + func createRightBarButtonItem(_ type: RightBarButtonItem) -> UIBarButtonItem? { + var image: UIImage? + var title: String? + let accessibilityLabel: String + + switch type { + case .deselectAll: + title = L10n.Items.deselectAll + accessibilityLabel = L10n.Accessibility.Items.deselectAllItems + + case .selectAll: + title = L10n.Items.selectAll + accessibilityLabel = L10n.Accessibility.Items.selectAllItems + + case .done: + title = L10n.done + accessibilityLabel = L10n.done + + case .select: + title = L10n.select + accessibilityLabel = L10n.Accessibility.Items.selectItems + + case .add: + image = UIImage(systemName: "plus") + accessibilityLabel = L10n.Items.new + title = L10n.Items.new + + case .emptyTrash: + title = L10n.Collections.emptyTrash + accessibilityLabel = L10n.Collections.emptyTrash + } + + let primaryAction = UIAction { [weak self] action in + guard let self, let sender = action.sender as? UIBarButtonItem else { return } + process(barButtonItemAction: type, sender: sender) + } + let item = UIBarButtonItem(title: title, image: image, primaryAction: primaryAction) + item.tag = type.rawValue + item.accessibilityLabel = accessibilityLabel + return item + } + } +} + +extension BaseItemsViewController: ItemsToolbarControllerDelegate { + func process(action: ItemAction.Kind, button: UIBarButtonItem) { + process(action: action, for: toolbarData.selectedItems, button: button, completionAction: nil) + } + + func showLookup() { + coordinatorDelegate?.showLookup() + } +} + +extension BaseItemsViewController: TagFilterDelegate { + var currentLibrary: Library { + return library + } + + func tagOptionsDidChange() { + updateTagFilter(filters: toolbarData.filters, collectionId: collection.identifier, libraryId: library.identifier) + } +} + +extension BaseItemsViewController: UISearchControllerDelegate { + func didDismissSearchController(_ searchController: UISearchController) { + search(for: "") + } +} diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift b/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift index 56c3fcaad..669912340 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift @@ -301,14 +301,14 @@ extension ItemsTableViewHandler: UITableViewDelegate { extension ItemsTableViewHandler: UITableViewDragDelegate { func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { - guard let item = dataSource.object(at: indexPath.row)?.item else { return [] } + guard let item = dataSource.object(at: indexPath.row) as? RItem else { return [] } return [self.dragDropController.dragItem(from: item)] } } extension ItemsTableViewHandler: UITableViewDropDelegate { func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) { - guard let object = coordinator.destinationIndexPath.flatMap({ dataSource.object(at: $0.row) }), let libraryId = object.libraryId else { return } + guard let object = coordinator.destinationIndexPath.flatMap({ dataSource.object(at: $0.row) }) else { return } switch coordinator.proposal.operation { case .copy: @@ -319,7 +319,7 @@ extension ItemsTableViewHandler: UITableViewDropDelegate { if localObject is RItem { delegate.process(dragAndDropAction: .moveItems(keys: keys, toKey: key)) } else if localObject is RTag { - delegate.process(dragAndDropAction: .tagItem(key: key, libraryId: libraryId, tags: keys)) + delegate.process(dragAndDropAction: .tagItem(key: key, libraryId: object.libraryIdentifier, tags: keys)) } } default: break @@ -345,7 +345,7 @@ extension ItemsTableViewHandler: UITableViewDropDelegate { } let dragItemsLibraryId = session.items.compactMap({ $0.localObject as? RItem }).compactMap({ $0.libraryId }).first - if dragItemsLibraryId != object.libraryId || // allow dropping only to the same library + if dragItemsLibraryId != object.libraryIdentifier || // allow dropping only to the same library object.isNote || object.isAttachment || // allow dropping only to non-standalone items session.items.compactMap({ self.dragDropController.item(from: $0) }) // allow drops of only standalone items .contains(where: { $0.rawType != ItemTypes.attachment && $0.rawType != ItemTypes.note }) { diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift index c6366dcac..bc249f8dc 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift @@ -1,9 +1,9 @@ // -// ItemsViewController.swift +// RItemsViewController.swift // Zotero // -// Created by Michal Rentka on 17/10/2019. -// Copyright © 2019 Corporation for Digital Scholarship. All rights reserved. +// Created by Michal Rentka on 20.09.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. // import UIKit @@ -13,42 +13,26 @@ import RealmSwift import RxSwift import WebKit -final class ItemsViewController: UIViewController { - private enum RightBarButtonItem: Int { - case select - case done - case selectAll - case deselectAll - case add - case emptyTrash - } - - @IBOutlet private weak var tableView: UITableView! - - private static let itemBatchingLimit = 150 - +final class ItemsViewController: BaseItemsViewController { private let viewModel: ViewModel - private unowned let controllers: Controllers - private let disposeBag: DisposeBag - private var tableViewDataSource: RItemsTableViewDataSource! - private var tableViewHandler: ItemsTableViewHandler! - private var toolbarController: ItemsToolbarController! + private var dataSource: RItemsTableViewDataSource! private var resultsToken: NotificationToken? private var libraryToken: NotificationToken? - private var refreshController: SyncRefreshController! - weak var tagFilterDelegate: ItemsTagFilterDelegate? - private weak var coordinatorDelegate: (DetailItemsCoordinatorDelegate & DetailNoteEditorCoordinatorDelegate)? + override var library: Library { + return viewModel.state.library + } + override var collection: Collection { + return viewModel.state.collection + } + override var toolbarData: ItemsToolbarController.Data { + return toolbarData(from: viewModel.state) + } init(viewModel: ViewModel, controllers: Controllers, coordinatorDelegate: (DetailItemsCoordinatorDelegate & DetailNoteEditorCoordinatorDelegate)) { self.viewModel = viewModel - self.controllers = controllers - self.coordinatorDelegate = coordinatorDelegate - self.disposeBag = DisposeBag() - - super.init(nibName: "ItemsViewController", bundle: nil) - + super.init(controllers: controllers, coordinatorDelegate: coordinatorDelegate) viewModel.process(action: .loadInitialState) } @@ -59,64 +43,27 @@ final class ItemsViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - tableViewDataSource = RItemsTableViewDataSource(viewModel: viewModel, fileDownloader: controllers.userControllers?.fileDownloader, schemaController: controllers.schemaController) - tableViewHandler = ItemsTableViewHandler(tableView: tableView, delegate: self, dataSource: tableViewDataSource, dragDropController: controllers.dragDropController) - self.toolbarController = ItemsToolbarController(viewController: self, initialState: self.viewModel.state, delegate: self) - self.navigationController?.toolbar.barTintColor = UIColor(dynamicProvider: { traitCollection in - return traitCollection.userInterfaceStyle == .dark ? .black : .white - }) - self.setupRightBarButtonItems(for: self.viewModel.state) - self.setupTitle() - self.setupSearchBar() - if let scheduler = controllers.userControllers?.syncScheduler { - refreshController = SyncRefreshController(libraryId: viewModel.state.library.identifier, view: tableView, syncScheduler: scheduler) - } - self.setupFileObservers() - self.startObservingSyncProgress() - self.setupAppStateObserver() + dataSource = RItemsTableViewDataSource(viewModel: viewModel, fileDownloader: controllers.userControllers?.fileDownloader, 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() + setupAppStateObserver() - if let term = self.viewModel.state.searchTerm, !term.isEmpty { + if let term = viewModel.state.searchTerm, !term.isEmpty { navigationItem.searchController?.searchBar.text = term } - if let results = self.viewModel.state.results { - self.startObserving(results: results) + if let results = viewModel.state.results { + startObserving(results: results) } - self.viewModel + viewModel .stateObservable .observe(on: MainScheduler.instance) - .subscribe(with: self, onNext: { `self`, state in - self.update(state: state) + .subscribe(onNext: { [weak self] state in + self?.update(state: state) }) - .disposed(by: self.disposeBag) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - self.toolbarController.willAppear() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - // willTransition(to:with:) seems to not be not called for all transitions, so instead traitCollectionDidChange(_:) is used w/ a short animation block. - guard UIDevice.current.userInterfaceIdiom == .pad, traitCollection.horizontalSizeClass != previousTraitCollection?.horizontalSizeClass else { return } - self.setupTitle() - UIView.animate(withDuration: 0.1) { - self.toolbarController.reloadToolbarItems(for: self.viewModel.state) - } - } - - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - guard let key = presses.first?.key, key.characters == "f", key.modifierFlags.contains(.command) else { - super.pressesBegan(presses, with: event) - return - } - navigationItem.searchController?.searchBar.becomeFirstResponder() + .disposed(by: disposeBag) } deinit { @@ -129,37 +76,37 @@ final class ItemsViewController: UIViewController { if state.changes.contains(.results), let results = state.results { self.startObserving(results: results) } else if state.changes.contains(.attachmentsRemoved) { - tableViewHandler.attachmentAccessoriesChanged() + handler?.attachmentAccessoriesChanged() } else if let key = state.updateItemKey { let accessory = state.itemAccessories[key].flatMap({ ItemCellModel.createAccessory(from: $0, fileDownloader: controllers.userControllers?.fileDownloader) }) - self.tableViewHandler.updateCell(key: key, withAccessory: accessory) + handler?.updateCell(key: key, withAccessory: accessory) } if state.changes.contains(.editing) { - self.tableViewHandler.set(editing: state.isEditing, animated: true) - self.setupRightBarButtonItems(for: state) - self.toolbarController.createToolbarItems(for: state) + handler?.set(editing: state.isEditing, animated: true) + setupRightBarButtonItems(expectedItems: rightBarButtonItemTypes(for: state)) + toolbarController?.createToolbarItems(data: toolbarData(from: state)) } if state.changes.contains(.selectAll) { if state.selectedItems.isEmpty { - self.tableViewHandler.deselectAll() + handler?.deselectAll() } else { - self.tableViewHandler.selectAll() + handler?.selectAll() } } if state.changes.contains(.selection) || state.changes.contains(.library) { - self.setupRightBarButtonItems(for: state) - self.toolbarController.reloadToolbarItems(for: state) + setupRightBarButtonItems(expectedItems: rightBarButtonItemTypes(for: state)) + toolbarController?.reloadToolbarItems(for: toolbarData(from: state)) } if state.changes.contains(.filters) || state.changes.contains(.batchData) { - self.toolbarController.reloadToolbarItems(for: state) + toolbarController?.reloadToolbarItems(for: toolbarData(from: state)) } if let key = state.itemKeyToDuplicate { - self.coordinatorDelegate?.showItemDetail( + coordinatorDelegate?.showItemDetail( for: .duplication(itemKey: key, collectionKey: self.viewModel.state.collection.identifier.key), libraryId: self.viewModel.state.library.identifier, scrolledToKey: nil, @@ -168,112 +115,131 @@ final class ItemsViewController: UIViewController { } if let error = state.error { - self.process(error: error, state: state) + process(error: error, state: state) } - } - - // MARK: - Actions - private func updateTagFilter(with state: ItemsState) { - self.tagFilterDelegate?.itemsDidChange(filters: state.filters, collectionId: state.collection.identifier, libraryId: state.library.identifier) - } + func process(error: ItemsError, state: ItemsState) { + // Perform additional actions for individual errors if needed + switch error { + case .itemMove, .deletion, .deletionFromCollection: + if let snapshot = state.results { + dataSource.apply(snapshot: snapshot.freeze()) + } - private func process(error: ItemsError, state: ItemsState) { - // Perform additional actions for individual errors if needed - switch error { - case .itemMove, .deletion, .deletionFromCollection: - if let snapshot = state.results { - tableViewDataSource.apply(snapshot: snapshot.freeze()) + case .dataLoading, .collectionAssignment, .noteSaving, .attachmentAdding, .duplicationLoading: + break } - case .dataLoading, .collectionAssignment, .noteSaving, .attachmentAdding, .duplicationLoading: - break + // Show appropriate message + coordinatorDelegate?.show(error: error) } + } + + // MARK: - Actions - // Show appropriate message - self.coordinatorDelegate?.show(error: error) + override func search(for term: String) { + self.viewModel.process(action: .search(term)) } - private func process(action: ItemAction.Kind, for selectedKeys: Set, button: UIBarButtonItem?, completionAction: ((Bool) -> Void)?) { + override func process(action: ItemAction.Kind, for selectedKeys: Set, button: UIBarButtonItem?, completionAction: ((Bool) -> Void)?) { switch action { case .addToCollection: guard !selectedKeys.isEmpty else { return } - self.coordinatorDelegate?.showCollectionsPicker(in: self.viewModel.state.library, completed: { [weak self] collections in + coordinatorDelegate?.showCollectionsPicker(in: library, completed: { [weak self] collections in self?.viewModel.process(action: .assignItemsToCollections(items: selectedKeys, collections: collections)) completionAction?(true) }) case .createParent: - guard let key = selectedKeys.first, case .attachment(let attachment, _) = self.viewModel.state.itemAccessories[key] else { return } - var collectionKey: String? - switch self.viewModel.state.collection.identifier { - case .collection(let _key): - collectionKey = _key - default: break - } - - self.coordinatorDelegate?.showItemDetail( + guard let key = selectedKeys.first, case .attachment(let attachment, _) = viewModel.state.itemAccessories[key] else { return } + let collectionKey = collection.identifier.key + coordinatorDelegate?.showItemDetail( for: .creation(type: ItemTypes.document, child: attachment, collectionKey: collectionKey), - libraryId: self.viewModel.state.library.identifier, + libraryId: library.identifier, scrolledToKey: nil, animated: true ) case .delete: guard !selectedKeys.isEmpty else { return } - self.coordinatorDelegate?.showDeletionQuestion(count: self.viewModel.state.selectedItems.count, confirmAction: { [weak self] in - self?.viewModel.process(action: .deleteItems(selectedKeys)) - }, cancelAction: { - completionAction?(false) - }) + coordinatorDelegate?.showDeletionQuestion( + count: viewModel.state.selectedItems.count, + confirmAction: { [weak self] in + self?.viewModel.process(action: .deleteItems(selectedKeys)) + }, + cancelAction: { + completionAction?(false) + } + ) case .duplicate: guard let key = selectedKeys.first else { return } - self.viewModel.process(action: .loadItemToDuplicate(key)) + viewModel.process(action: .loadItemToDuplicate(key)) case .removeFromCollection: guard !selectedKeys.isEmpty else { return } - self.coordinatorDelegate?.showRemoveFromCollectionQuestion(count: self.viewModel.state.selectedItems.count) { [weak self] in + coordinatorDelegate?.showRemoveFromCollectionQuestion( + count: viewModel.state.selectedItems.count + ) { [weak self] in self?.viewModel.process(action: .deleteItemsFromCollection(selectedKeys)) completionAction?(true) } case .restore: guard !selectedKeys.isEmpty else { return } - self.viewModel.process(action: .restoreItems(selectedKeys)) + viewModel.process(action: .restoreItems(selectedKeys)) completionAction?(true) case .trash: guard !selectedKeys.isEmpty else { return } - self.viewModel.process(action: .trashItems(selectedKeys)) + viewModel.process(action: .trashItems(selectedKeys)) case .filter: - guard let button = button else { return } - self.coordinatorDelegate?.showFilters(viewModel: self.viewModel, itemsController: self, button: button) + guard let button else { return } + coordinatorDelegate?.showFilters(viewModel: viewModel, itemsController: self, button: button) case .sort: - guard let button = button else { return } - self.coordinatorDelegate?.showSortActions(viewModel: self.viewModel, button: button) + guard let button else { return } + coordinatorDelegate?.showSortActions(viewModel: viewModel, button: button) case .share: guard !selectedKeys.isEmpty else { return } - self.coordinatorDelegate?.showCiteExport(for: selectedKeys, libraryId: self.viewModel.state.library.identifier) + coordinatorDelegate?.showCiteExport(for: selectedKeys, libraryId: library.identifier) case .copyBibliography: var presenter: UIViewController = self if let searchController = navigationItem.searchController, searchController.isActive { presenter = searchController } - coordinatorDelegate?.copyBibliography(using: presenter, for: selectedKeys, libraryId: viewModel.state.library.identifier, delegate: nil) + coordinatorDelegate?.copyBibliography(using: presenter, for: selectedKeys, libraryId: library.identifier, delegate: nil) case .copyCitation: - coordinatorDelegate?.showCitation(using: nil, for: selectedKeys, libraryId: viewModel.state.library.identifier, delegate: nil) + coordinatorDelegate?.showCitation(using: nil, for: selectedKeys, libraryId: library.identifier, delegate: nil) case .download: - self.viewModel.process(action: .download(selectedKeys)) + viewModel.process(action: .download(selectedKeys)) case .removeDownload: - self.viewModel.process(action: .removeDownloads(selectedKeys)) + viewModel.process(action: .removeDownloads(selectedKeys)) + } + } + + override func process(barButtonItemAction: BaseItemsViewController.RightBarButtonItem, sender: UIBarButtonItem) { + switch barButtonItemAction { + case .add: + coordinatorDelegate?.showAddActions(viewModel: viewModel, button: sender) + + case .deselectAll, .selectAll: + viewModel.process(action: .toggleSelectionState) + + case .done: + viewModel.process(action: .stopEditing) + + case .emptyTrash: + break + + case .select: + viewModel.process(action: .startEditing) } } @@ -282,16 +248,16 @@ final class ItemsViewController: UIViewController { guard let self else { return } switch changes { case .initial(let results): - tableViewDataSource.apply(snapshot: results.freeze()) - updateTagFilter(with: self.viewModel.state) + dataSource.apply(snapshot: results.freeze()) + updateTagFilter(filters: viewModel.state.filters, collectionId: collection.identifier, libraryId: library.identifier) case .update(let results, let deletions, let insertions, let modifications): let correctedModifications = Database.correctedModifications(from: modifications, insertions: insertions, deletions: deletions) viewModel.process(action: .updateKeys(items: results, deletions: deletions, insertions: insertions, modifications: correctedModifications)) - tableViewDataSource.apply(snapshot: results.freeze(), modifications: modifications, insertions: insertions, deletions: deletions) { - self.updateTagFilter(with: self.viewModel.state) + dataSource.apply(snapshot: results.freeze(), modifications: modifications, insertions: insertions, deletions: deletions) { [weak self] in + guard let self else { return } + updateTagFilter(filters: viewModel.state.filters, collectionId: collection.identifier, libraryId: library.identifier) } - updateEmptyTrashButton(toEnabled: viewModel.state.library.metadataEditable && !results.isEmpty) case .error(let error): DDLogError("ItemsViewController: could not load results - \(error)") @@ -300,39 +266,30 @@ final class ItemsViewController: UIViewController { }) } - /// Starts observing progress of sync. The sync progress needs to be observed to optimize `UITableView` reloads for big syncs of items in current library. - private func startObservingSyncProgress() { - guard let syncController = self.controllers.userControllers?.syncScheduler.syncController else { return } - - syncController.progressObservable - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] progress in - guard let self else { return } - switch progress { - case .object(let object, let progress, _, let libraryId): - if self.viewModel.state.library.identifier == libraryId && object == .item { - if let progress = progress, progress.total >= ItemsViewController.itemBatchingLimit { - // Disable batched reloads when there are a lot of upcoming updates. Batched updates kill tableView performance when many are performed in short period of time. - self.tableViewHandler.disableReloadAnimations() - } - } else { - // Re-enable batched reloads when items are synced. - self.tableViewHandler.enableReloadAnimations() - } + // MARK: - Tag filter delegate - default: - // Re-enable batched reloads when items are synced. - self.tableViewHandler.enableReloadAnimations() - } - }) - .disposed(by: self.disposeBag) + override func tagSelectionDidChange(selected: Set) { + if selected.isEmpty { + if let tags = viewModel.state.tagsFilter { + viewModel.process(action: .disableFilter(.tags(tags))) + } + } else { + viewModel.process(action: .enableFilter(.tags(selected))) + } } - private func emptyTrash() { - let count = self.viewModel.state.results?.count ?? 0 - self.coordinatorDelegate?.showDeletionQuestion(count: count, confirmAction: { [weak self] in - self?.viewModel.process(action: .emptyTrash) - }, cancelAction: {}) + // MARK: - Helpers + + private func toolbarData(from state: ItemsState) -> ItemsToolbarController.Data { + return .init( + isEditing: state.isEditing, + selectedItems: state.selectedItems, + filters: state.filters, + downloadBatchData: state.downloadBatchData, + remoteDownloadBatchData: state.remoteDownloadBatchData, + identifierLookupBatchData: state.identifierLookupBatchData, + itemCount: state.results?.count ?? 0 + ) } // MARK: - Setups @@ -342,11 +299,11 @@ final class ItemsViewController: UIViewController { .rx .notification(UIContentSizeCategory.didChangeNotification) .observe(on: MainScheduler.instance) - .subscribe(with: self, onNext: { `self`, _ in - self.viewModel.process(action: .clearTitleCache) - self.tableViewHandler.reloadAll() + .subscribe(onNext: { [weak self] _ in + self?.viewModel.process(action: .clearTitleCache) + self?.handler?.reloadAll() }) - .disposed(by: self.disposeBag) + .disposed(by: disposeBag) } private func setupFileObservers() { @@ -364,185 +321,79 @@ final class ItemsViewController: UIViewController { let downloader = controllers.userControllers?.fileDownloader downloader?.observable .observe(on: MainScheduler.asyncInstance) - .subscribe(with: self, onNext: { [weak downloader] `self`, update in + .subscribe(onNext: { [weak self, weak downloader] update in + guard let self else { return } + if let downloader { let batchData = ItemsState.DownloadBatchData(batchData: downloader.batchData) - self.viewModel.process(action: .updateDownload(update: update, batchData: batchData)) + viewModel.process(action: .updateDownload(update: update, batchData: batchData)) } - + if case .progress = update.kind { return } - - guard self.viewModel.state.attachmentToOpen == update.key else { return } - - self.viewModel.process(action: .attachmentOpened(update.key)) - + + guard viewModel.state.attachmentToOpen == update.key else { return } + + viewModel.process(action: .attachmentOpened(update.key)) + switch update.kind { case .ready: - self.coordinatorDelegate?.showAttachment(key: update.key, parentKey: update.parentKey, libraryId: update.libraryId) - + coordinatorDelegate?.showAttachment(key: update.key, parentKey: update.parentKey, libraryId: update.libraryId) + case .failed(let error): - self.coordinatorDelegate?.showAttachmentError(error) - - default: break + coordinatorDelegate?.showAttachmentError(error) + + default: + break } }) - .disposed(by: self.disposeBag) + .disposed(by: disposeBag) - let identifierLookupController = self.controllers.userControllers?.identifierLookupController + let identifierLookupController = controllers.userControllers?.identifierLookupController identifierLookupController?.observable .observe(on: MainScheduler.asyncInstance) - .subscribe(with: self, onNext: { [weak identifierLookupController] `self`, update in - guard let identifierLookupController else { return } + .subscribe(onNext: { [weak self, weak identifierLookupController] update in + guard let self, let identifierLookupController else { return } let batchData = ItemsState.IdentifierLookupBatchData(batchData: identifierLookupController.batchData) - self.viewModel.process(action: .updateIdentifierLookup(update: update, batchData: batchData)) + viewModel.process(action: .updateIdentifierLookup(update: update, batchData: batchData)) }) .disposed(by: self.disposeBag) - - let remoteDownloader = self.controllers.userControllers?.remoteFileDownloader + + let remoteDownloader = controllers.userControllers?.remoteFileDownloader remoteDownloader?.observable .observe(on: MainScheduler.asyncInstance) - .subscribe(with: self, onNext: { [weak remoteDownloader] `self`, update in - guard let remoteDownloader else { return } + .subscribe(onNext: { [weak self, weak remoteDownloader] update in + guard let self, let remoteDownloader else { return } let batchData = ItemsState.DownloadBatchData(batchData: remoteDownloader.batchData) - self.viewModel.process(action: .updateRemoteDownload(update: update, batchData: batchData)) + viewModel.process(action: .updateRemoteDownload(update: update, batchData: batchData)) }) - .disposed(by: self.disposeBag) - } - - private func setupTitle() { - self.title = self.traitCollection.horizontalSizeClass == .compact ? self.viewModel.state.collection.name : nil + .disposed(by: disposeBag) } - private func updateEmptyTrashButton(toEnabled enabled: Bool) { - guard self.viewModel.state.collection.identifier.isTrash, - let item = self.navigationItem.rightBarButtonItems?.first(where: { button in RightBarButtonItem(rawValue: button.tag) == .emptyTrash }) - else { return } - item.isEnabled = enabled - } + private func rightBarButtonItemTypes(for state: ItemsState) -> [RightBarButtonItem] { + let selectItems = rightBarButtonSelectItemTypes(for: state) + return state.library.metadataEditable ? [.add] + selectItems : selectItems - private func setupRightBarButtonItems(for state: ItemsState) { - let currentItems = (self.navigationItem.rightBarButtonItems ?? []).compactMap({ RightBarButtonItem(rawValue: $0.tag) }) - let expectedItems = rightBarButtonItemTypes(for: state) - guard currentItems != expectedItems else { return } - self.navigationItem.rightBarButtonItems = expectedItems.map({ createRightBarButtonItem($0) }).reversed() - self.updateEmptyTrashButton(toEnabled: state.library.metadataEditable && state.results?.isEmpty == false) - - func rightBarButtonItemTypes(for state: ItemsState) -> [RightBarButtonItem] { - var items: [RightBarButtonItem] - let selectItems = rightBarButtonSelectItemTypes(for: state) - if state.collection.identifier.isTrash { - items = selectItems + [.emptyTrash] - } else if state.library.metadataEditable { - items = [.add] + selectItems - } else { - items = selectItems + func rightBarButtonSelectItemTypes(for state: ItemsState) -> [RightBarButtonItem] { + if !state.isEditing { + return [.select] } - return items - - func rightBarButtonSelectItemTypes(for state: ItemsState) -> [RightBarButtonItem] { - if !state.isEditing { - return [.select] - } - - let allSelected = state.selectedItems.count == (state.results?.count ?? 0) - if allSelected { - return [.deselectAll, .done] - } - - return [.selectAll, .done] - } - } - - func createRightBarButtonItem(_ type: RightBarButtonItem) -> UIBarButtonItem { - var image: UIImage? - var title: String? - let primaryAction: UIAction? - let accessibilityLabel: String - - switch type { - case .deselectAll: - title = L10n.Items.deselectAll - accessibilityLabel = L10n.Accessibility.Items.deselectAllItems - primaryAction = UIAction { [weak self] _ in - self?.viewModel.process(action: .toggleSelectionState) - } - - case .selectAll: - title = L10n.Items.selectAll - accessibilityLabel = L10n.Accessibility.Items.selectAllItems - primaryAction = UIAction { [weak self] _ in - self?.viewModel.process(action: .toggleSelectionState) - } - - case .done: - title = L10n.done - accessibilityLabel = L10n.done - primaryAction = UIAction { [weak self] _ in - self?.viewModel.process(action: .stopEditing) - } - - case .select: - title = L10n.select - accessibilityLabel = L10n.Accessibility.Items.selectItems - primaryAction = UIAction { [weak self] _ in - self?.viewModel.process(action: .startEditing) - } - - case .add: - image = UIImage(systemName: "plus") - accessibilityLabel = L10n.Items.new - title = L10n.Items.new - primaryAction = UIAction { [weak self] action in - guard let self, let sender = action.sender as? UIBarButtonItem else { return } - coordinatorDelegate?.showAddActions(viewModel: viewModel, button: sender) - } - - case .emptyTrash: - title = L10n.Collections.emptyTrash - accessibilityLabel = L10n.Collections.emptyTrash - primaryAction = UIAction { [weak self] _ in - self?.emptyTrash() - } + if state.selectedItems.count == (state.results?.count ?? 0) { + return [.deselectAll, .done] } - - let item = UIBarButtonItem(title: title, image: image, primaryAction: primaryAction) - item.tag = type.rawValue - item.accessibilityLabel = accessibilityLabel - return item + return [.selectAll, .done] } } - - private func setupSearchBar() { - let controller = UISearchController(searchResultsController: nil) - controller.searchBar.placeholder = L10n.Items.searchTitle - controller.searchBar.rx - .text.observe(on: MainScheduler.instance) - .skip(1) - .debounce(.milliseconds(150), scheduler: MainScheduler.instance) - .subscribe(onNext: { [weak self] text in - self?.viewModel.process(action: .search(text ?? "")) - }) - .disposed(by: disposeBag) - controller.obscuresBackgroundDuringPresentation = false - controller.delegate = self - navigationItem.hidesSearchBarWhenScrolling = false - navigationItem.searchController = controller - } } extension ItemsViewController: ItemsTableViewHandlerDelegate { var collectionKey: String? { - return viewModel.state.collection.identifier.key + return collection.identifier.key } var isInViewHierarchy: Bool { return view.window != nil } - var library: Library { - viewModel.state.library - } - func process(tapAction: ItemsTableViewHandler.TapAction) { resetActiveSearch() @@ -578,7 +429,7 @@ extension ItemsViewController: ItemsTableViewHandlerDelegate { } func process(action: ItemAction.Kind, at index: Int, completionAction: ((Bool) -> Void)?) { - guard let object = tableViewDataSource.object(at: index) else { return } + guard let object = dataSource.object(at: index) else { return } process(action: action, for: [object.key], button: nil, completionAction: completionAction) } @@ -595,44 +446,11 @@ extension ItemsViewController: ItemsTableViewHandlerDelegate { extension ItemsViewController: DetailCoordinatorAttachmentProvider { func attachment(for key: String, parentKey: String?, libraryId: LibraryIdentifier) -> (Attachment, UIView, CGRect?)? { - guard let accessory = self.viewModel.state.itemAccessories[parentKey ?? key], let attachment = accessory.attachment else { return nil } - let (sourceView, sourceRect) = self.tableViewHandler.sourceDataForCell(for: (parentKey ?? key)) + guard + let accessory = self.viewModel.state.itemAccessories[parentKey ?? key], + let attachment = accessory.attachment, + let (sourceView, sourceRect) = handler?.sourceDataForCell(for: (parentKey ?? key)) + else { return nil } return (attachment, sourceView, sourceRect) } } - -extension ItemsViewController: ItemsToolbarControllerDelegate { - func process(action: ItemAction.Kind, button: UIBarButtonItem) { - self.process(action: action, for: self.viewModel.state.selectedItems, button: button, completionAction: nil) - } - - func showLookup() { - coordinatorDelegate?.showLookup() - } -} - -extension ItemsViewController: TagFilterDelegate { - var currentLibrary: Library { - return viewModel.state.library - } - - func tagSelectionDidChange(selected: Set) { - if selected.isEmpty { - if let tags = viewModel.state.tagsFilter { - viewModel.process(action: .disableFilter(.tags(tags))) - } - } else { - viewModel.process(action: .enableFilter(.tags(selected))) - } - } - - func tagOptionsDidChange() { - updateTagFilter(with: viewModel.state) - } -} - -extension ItemsViewController: UISearchControllerDelegate { - func didDismissSearchController(_ searchController: UISearchController) { - viewModel.process(action: .search("")) - } -} diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.xib b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.xib deleted file mode 100644 index 4a433cb03..000000000 --- a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.xib +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift b/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift index 4cf73e770..d10a2d5ff 100644 --- a/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift +++ b/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift @@ -12,6 +12,10 @@ import CocoaLumberjackSwift import RealmSwift extension RItem: ItemsTableViewObject { + var libraryIdentifier: LibraryIdentifier { + return libraryId ?? .custom(.myLibrary) + } + var isNote: Bool { switch rawType { case ItemTypes.note: @@ -195,7 +199,7 @@ extension RItemsTableViewDataSource { let cell = tableView.dequeueReusableCell(withIdentifier: ItemsTableViewHandler.cellId, for: indexPath) guard let item = item(at: indexPath.row) else { - DDLogError("ItemsTableViewHandler: indexPath.row (\(indexPath.row)) out of bounds (\(count))") + DDLogError("RItemsTableViewDataSource: indexPath.row (\(indexPath.row)) out of bounds (\(count))") return cell } diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift index aaea5ddcd..86c81f9e6 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift @@ -31,6 +31,8 @@ struct TrashObject { } struct ItemCellData { + let attributedTitle: NSAttributedString + let localizedTypeName: String let typeIconName: String let subtitle: String let accessory: ItemCellModel.Accessory? diff --git a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift index 6b1501c8e..bccc605e3 100644 --- a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift +++ b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift @@ -18,13 +18,15 @@ struct TrashActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH typealias Action = TrashAction unowned let dbStorage: DbStorage + private unowned let schemaController: SchemaController private unowned let fileStorage: FileStorage private unowned let urlDetector: UrlDetector var backgroundQueue: DispatchQueue - init(dbStorage: DbStorage, fileStorage: FileStorage, urlDetector: UrlDetector) { + init(dbStorage: DbStorage, schemaController: SchemaController, fileStorage: FileStorage, urlDetector: UrlDetector) { self.dbStorage = dbStorage + self.schemaController = schemaController self.fileStorage = fileStorage self.urlDetector = urlDetector backgroundQueue = DispatchQueue(label: "org.zotero.Zotero.TrashActionHandler.queue", qos: .userInteractive) @@ -172,7 +174,10 @@ struct TrashActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH let creatorSummary = ItemCellModel.creatorSummary(for: item) let (tagColors, tagEmojis) = ItemCellModel.tagData(item: item) let hasNote = ItemCellModel.hasNote(item: item) + let typeName = schemaController.localized(itemType: item.rawType) ?? item.rawType let cellData = TrashObject.ItemCellData( + attributedTitle: NSAttributedString(string: item.displayTitle), + localizedTypeName: typeName, typeIconName: ItemCellModel.typeIconName(for: item), subtitle: creatorSummary, accessory: accessory, diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift index 8a255d9c5..b4369754b 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift @@ -7,9 +7,10 @@ // import UIKit - import OrderedCollections +import CocoaLumberjackSwift + final class TrashTableViewDataSource: NSObject, ItemsTableViewDataSource { private let viewModel: ViewModel @@ -19,6 +20,11 @@ final class TrashTableViewDataSource: NSObject, ItemsTableViewDataSource { init(viewModel: ViewModel) { self.viewModel = viewModel } + + func apply(snapshot: OrderedDictionary) { + self.snapshot = snapshot + handler?.reloadAll() + } } extension TrashTableViewDataSource { @@ -31,39 +37,67 @@ extension TrashTableViewDataSource { } func object(at index: Int) -> ItemsTableViewObject? { + return trashObject(at: index) + } + + private func trashObject(at index: Int) -> TrashObject? { guard let snapshot, index < snapshot.keys.count else { return nil } return snapshot.values[index] } func accessory(forKey key: String) -> ItemAccessory? { - <#code#> + return nil } func tapAction(for indexPath: IndexPath) -> ItemsTableViewHandler.TapAction? { - <#code#> + return nil } func createTrailingCellActions(at index: Int) -> [ItemAction]? { - <#code#> + return nil } func createContextMenuActions(at index: Int) -> [ItemAction] { - <#code#> + return [] + } +} + +extension TrashTableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - <#code#> + return count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - <#code#> + let cell = tableView.dequeueReusableCell(withIdentifier: ItemsTableViewHandler.cellId, for: indexPath) + + guard let object = trashObject(at: indexPath.row) else { + DDLogError("TrashTableViewDataSource: indexPath.row (\(indexPath.row)) out of bounds (\(count))") + return cell + } + + if let cell = cell as? ItemCell { + cell.set(item: ItemCellModel(object: object)) + + let openInfoAction = UIAccessibilityCustomAction(name: L10n.Accessibility.Items.openItem, actionHandler: { [weak self] _ in + guard let self else { return false } + handler?.performTapAction(forIndexPath: indexPath) + return true + }) + cell.accessibilityCustomActions = [openInfoAction] + } + + return cell } } extension TrashObject: ItemsTableViewObject { var isNote: Bool { switch type { - case .item(let cellData, let sortData): + case .item(_, let sortData): return sortData.type == ItemTypes.note case .collection: @@ -73,15 +107,15 @@ extension TrashObject: ItemsTableViewObject { var isAttachment: Bool { switch type { - case .item(let cellData, let sortData): + case .item(_, let sortData): return sortData.type == ItemTypes.attachment case .collection: return false } } - - var item: RItem? { - return nil + + var libraryIdentifier: LibraryIdentifier { + return libraryId } } diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift index 182034949..d7465a3c9 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -10,21 +10,17 @@ import UIKit import RxSwift -final class TrashViewController: UIViewController { +final class TrashViewController: BaseItemsViewController { private let viewModel: ViewModel - private unowned let controllers: Controllers - private let disposeBag: DisposeBag - private weak var tableView: UITableView! - private var tableViewHandler: ItemsTableViewHandler! + private var dataSource: TrashTableViewDataSource! + override var toolbarData: ItemsToolbarController.Data { + return toolbarData(from: viewModel.state) + } - init(viewModel: ViewModel, controllers: Controllers) { + init(viewModel: ViewModel, controllers: Controllers, coordinatorDelegate: (DetailItemsCoordinatorDelegate & DetailNoteEditorCoordinatorDelegate)) { self.viewModel = viewModel - self.controllers = controllers - disposeBag = DisposeBag() - - super.init(nibName: nil, bundle: nil) - + super.init(controllers: controllers, coordinatorDelegate: coordinatorDelegate) viewModel.process(action: .loadData) } @@ -35,8 +31,11 @@ final class TrashViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - createTableView() - tableViewHandler = ItemsTableViewHandler(tableView: tableView, delegate: self, dragDropController: controllers.dragDropController) + dataSource = TrashTableViewDataSource(viewModel: viewModel) + 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)) + dataSource.apply(snapshot: viewModel.state.objects) viewModel .stateObservable @@ -45,24 +44,170 @@ final class TrashViewController: UIViewController { self?.update(state: state) }) .disposed(by: self.disposeBag) + } + + // MARK: - Actions + + private func update(state: TrashState) { + } + + override func search(for term: String) { +// self.viewModel.process(action: .search(term)) + } + + override func process(action: ItemAction.Kind, for selectedKeys: Set, button: UIBarButtonItem?, completionAction: ((Bool) -> Void)?) { + switch action { + case .addToCollection: + guard !selectedKeys.isEmpty else { return } + coordinatorDelegate?.showCollectionsPicker(in: library, completed: { [weak self] collections in +// self?.viewModel.process(action: .assignItemsToCollections(items: selectedKeys, collections: collections)) + completionAction?(true) + }) + + case .createParent: +// guard let key = selectedKeys.first, case .attachment(let attachment, _) = viewModel.state.itemAccessories[key] else { return } +// let collectionKey = collection.identifier.key +// coordinatorDelegate?.showItemDetail( +// for: .creation(type: ItemTypes.document, child: attachment, collectionKey: collectionKey), +// libraryId: library.identifier, +// scrolledToKey: nil, +// animated: true +// ) + break + + case .delete: + guard !selectedKeys.isEmpty else { return } + coordinatorDelegate?.showDeletionQuestion( + count: 0,//viewModel.state.selectedItems.count, + confirmAction: { [weak self] in +// self?.viewModel.process(action: .deleteItems(selectedKeys)) + }, + cancelAction: { + completionAction?(false) + } + ) + + case .duplicate: +// guard let key = selectedKeys.first else { return } +// viewModel.process(action: .loadItemToDuplicate(key)) + break + + case .removeFromCollection: + guard !selectedKeys.isEmpty else { return } + coordinatorDelegate?.showRemoveFromCollectionQuestion( + count: viewModel.state.objects.count + ) { [weak self] in +// self?.viewModel.process(action: .deleteItemsFromCollection(selectedKeys)) + completionAction?(true) + } + + case .restore: + guard !selectedKeys.isEmpty else { return } +// viewModel.process(action: .restoreItems(selectedKeys)) + completionAction?(true) + + case .trash: + guard !selectedKeys.isEmpty else { return } +// viewModel.process(action: .trashItems(selectedKeys)) + break + + case .filter: + guard let button else { return } +// coordinatorDelegate?.showFilters(viewModel: viewModel, itemsController: self, button: button) + break + + case .sort: + guard let button else { return } +// coordinatorDelegate?.showSortActions(viewModel: viewModel, button: button) + break - func createTableView() { - let tableView = UITableView() - tableView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(tableView) + case .share: + guard !selectedKeys.isEmpty else { return } + coordinatorDelegate?.showCiteExport(for: selectedKeys, libraryId: library.identifier) - NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor) - ]) + case .copyBibliography: + var presenter: UIViewController = self + if let searchController = navigationItem.searchController, searchController.isActive { + presenter = searchController + } + coordinatorDelegate?.copyBibliography(using: presenter, for: selectedKeys, libraryId: library.identifier, delegate: nil) - self.tableView = tableView + case .copyCitation: + coordinatorDelegate?.showCitation(using: nil, for: selectedKeys, libraryId: library.identifier, delegate: nil) + + case .download: +// viewModel.process(action: .download(selectedKeys)) + break + + case .removeDownload: +// viewModel.process(action: .removeDownloads(selectedKeys)) + break } } - private func update(state: TrashState) { + override func process(barButtonItemAction: BaseItemsViewController.RightBarButtonItem, sender: UIBarButtonItem) { + switch barButtonItemAction { + case .add: +// coordinatorDelegate?.showAddActions(viewModel: viewModel, button: sender) + break + + case .deselectAll, .selectAll: +// viewModel.process(action: .toggleSelectionState) + break + + case .done: +// viewModel.process(action: .stopEditing) + break + + case .emptyTrash: + break + + case .select: +// viewModel.process(action: .startEditing) + break + } + } + + // MARK: - Helpers + + private func toolbarData(from state: TrashState) -> ItemsToolbarController.Data { + return .init( + isEditing: false, + selectedItems: [], + filters: [], + downloadBatchData: nil, + remoteDownloadBatchData: nil, + identifierLookupBatchData: .init(saved: 0, total: 0), + itemCount: state.objects.count + ) + } + + private func rightBarButtonItemTypes(for state: TrashState) -> [RightBarButtonItem] { + let selectItems = rightBarButtonSelectItemTypes(for: state) + return selectItems + [.emptyTrash] + + func rightBarButtonSelectItemTypes(for state: TrashState) -> [RightBarButtonItem] { + return [.select] +// if !state.isEditing { +// return [.select] +// } +// if state.selectedItems.count == (state.results?.count ?? 0) { +// return [.deselectAll, .done] +// } +// return [.selectAll, .done] + } + } + + // MARK: - Tag filter delegate + + override func tagSelectionDidChange(selected: Set) { +// if selected.isEmpty { +// if let tags = viewModel.state.tagsFilter { +// viewModel.process(action: .disableFilter(.tags(tags))) +// } +// } else { +// viewModel.process(action: .enableFilter(.tags(selected))) +// } } } @@ -75,10 +220,6 @@ extension TrashViewController: ItemsTableViewHandlerDelegate { return nil } - var library: Library { - return viewModel.state.library - } - func process(action: ItemAction.Kind, at index: Int, completionAction: ((Bool) -> Void)?) { } From 182d058c704dd88392e4996869eda9fa41d68368 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Mon, 23 Sep 2024 16:01:14 +0200 Subject: [PATCH 07/23] Implementing items actions --- Zotero.xcodeproj/project.pbxproj | 4 + Zotero/Scenes/Detail/DetailCoordinator.swift | 25 ++- .../Detail/Items/ItemsFilterCoordinator.swift | 44 +++-- .../Detail/Items/Models/ItemCellModel.swift | 5 +- .../Detail/Items/Models/ItemsAction.swift | 3 - .../Detail/Items/Models/ItemsFilter.swift | 20 ++ .../Detail/Items/Models/ItemsState.swift | 12 -- .../ViewModels/BaseItemsActionHandler.swift | 105 +++++++++++ .../Items/ViewModels/ItemsActionHandler.swift | 163 +++------------- .../Items/Views/BaseItemsViewController.swift | 4 +- .../Views/ItemsFilterViewController.swift | 31 +-- .../Items/Views/ItemsTableViewHandler.swift | 9 +- .../Items/Views/ItemsViewController.swift | 40 ++-- .../Views/RItemsTableViewDataSource.swift | 12 +- .../Detail/Trash/Models/TrashAction.swift | 14 ++ .../Detail/Trash/Models/TrashObject.swift | 33 ++-- .../Detail/Trash/Models/TrashState.swift | 28 ++- .../Trash/ViewModels/TrashActionHandler.swift | 176 +++++++++++++++--- .../Views/TrashTableViewDataSource.swift | 44 +++-- .../Trash/Views/TrashViewController.swift | 137 +++++++------- .../Views/TagFilterViewController.swift | 7 - 21 files changed, 550 insertions(+), 366 deletions(-) create mode 100644 Zotero/Scenes/Detail/Items/ViewModels/BaseItemsActionHandler.swift diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index 715b66c26..66dbb06aa 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -526,6 +526,7 @@ B33E8A4D27B6AAE600CBC7DE /* CollectionCellContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34673CE27B14F0D00444C96 /* CollectionCellContentView.swift */; }; B33E8A4E27B6AAF000CBC7DE /* CollectionCellContentView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B37D8E6324DC21D300F526C5 /* CollectionCellContentView.xib */; }; B33EB2BA2B076657003255DA /* Localizable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37080512AA72135006F56B9 /* Localizable.swift */; }; + B33F47732CA1656E00278240 /* BaseItemsActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33F47722CA1656A00278240 /* BaseItemsActionHandler.swift */; }; B3401D572567D8F700BB8D6E /* AnnotationPopoverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3401D552567D8F700BB8D6E /* AnnotationPopoverViewController.swift */; }; B3401D5B2567DAAE00BB8D6E /* AnnotationPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3401D5A2567DAAE00BB8D6E /* AnnotationPopoverCoordinator.swift */; }; B3401D612568047D00BB8D6E /* AnnotationViewTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3401D602568047D00BB8D6E /* AnnotationViewTextView.swift */; }; @@ -1623,6 +1624,7 @@ B33E8A4527B69FFE00CBC7DE /* AllCollectionPickerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllCollectionPickerState.swift; sourceTree = ""; }; B33E8A4727B6A07100CBC7DE /* AllCollectionPickerAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllCollectionPickerAction.swift; sourceTree = ""; }; B33E8A4927B6A1D000CBC7DE /* AllCollectionPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AllCollectionPickerViewController.swift; sourceTree = ""; }; + B33F47722CA1656A00278240 /* BaseItemsActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemsActionHandler.swift; sourceTree = ""; }; B3401D552567D8F700BB8D6E /* AnnotationPopoverViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnnotationPopoverViewController.swift; sourceTree = ""; }; B3401D5A2567DAAE00BB8D6E /* AnnotationPopoverCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnnotationPopoverCoordinator.swift; sourceTree = ""; }; B3401D602568047D00BB8D6E /* AnnotationViewTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationViewTextView.swift; sourceTree = ""; }; @@ -3331,6 +3333,7 @@ B3593EE8241A61C700760E20 /* ViewModels */ = { isa = PBXGroup; children = ( + B33F47722CA1656A00278240 /* BaseItemsActionHandler.swift */, B3593EE9241A61C700760E20 /* ItemsActionHandler.swift */, B37673FA262DB25700C3BFAF /* ItemsToolbarController.swift */, ); @@ -4961,6 +4964,7 @@ B3830CF625545EE400910FE0 /* TagPickerCell.swift in Sources */, B30565C623FC051E003304F2 /* ReadDeletedObjectsDbRequest.swift in Sources */, B30566AF23FC051F003304F2 /* LibraryResponse.swift in Sources */, + B33F47732CA1656E00278240 /* BaseItemsActionHandler.swift in Sources */, B3868537270D90640068A022 /* RawDataEncoding.swift in Sources */, B37C5B6D26453C58009A37E5 /* NoteEditorAction.swift in Sources */, B30566C223FC051F003304F2 /* LibraryData.swift in Sources */, diff --git a/Zotero/Scenes/Detail/DetailCoordinator.swift b/Zotero/Scenes/Detail/DetailCoordinator.swift index 2d06f11f4..f9b35c495 100644 --- a/Zotero/Scenes/Detail/DetailCoordinator.swift +++ b/Zotero/Scenes/Detail/DetailCoordinator.swift @@ -41,7 +41,7 @@ protocol DetailItemsCoordinatorDelegate: AnyObject { func showSortActions(viewModel: ViewModel, button: UIBarButtonItem) func show(url: URL) func show(doi: String) - func showFilters(viewModel: ViewModel, itemsController: ItemsViewController, button: UIBarButtonItem) + func showFilters(filters: [ItemsFilter], filtersDelegate: BaseItemsViewController, button: UIBarButtonItem) func showDeletionQuestion(count: Int, confirmAction: @escaping () -> Void, cancelAction: @escaping () -> Void) func showRemoveFromCollectionQuestion(count: Int, confirmAction: @escaping () -> Void) func showCitation(using presenter: UIViewController?, for itemIds: Set, libraryId: LibraryIdentifier, delegate: DetailCitationCoordinatorDelegate?) @@ -72,7 +72,7 @@ protocol DetailNoteEditorCoordinatorDelegate: AnyObject { } protocol ItemsTagFilterDelegate: AnyObject { - var delegate: TagFilterDelegate? { get set } + var delegate: FiltersDelegate? { get set } func clearSelection() func itemsDidChange(filters: [ItemsFilter], collectionId: CollectionIdentifier, libraryId: LibraryIdentifier) @@ -129,7 +129,8 @@ final class DetailCoordinator: Coordinator { dbStorage: userControllers.dbStorage, schemaController: controllers.schemaController, fileStorage: controllers.fileStorage, - urlDetector: controllers.urlDetector + urlDetector: controllers.urlDetector, + htmlAttributedStringConverter: controllers.htmlAttributedStringConverter ) case .all, .publications, .unfiled: @@ -171,10 +172,18 @@ final class DetailCoordinator: Coordinator { dbStorage: DbStorage, schemaController: SchemaController, fileStorage: FileStorage, - urlDetector: UrlDetector + urlDetector: UrlDetector, + htmlAttributedStringConverter: HtmlAttributedStringConverter ) -> TrashViewController { let state = TrashState(libraryId: libraryId) - let handler = TrashActionHandler(dbStorage: dbStorage, schemaController: schemaController, fileStorage: fileStorage, urlDetector: urlDetector) + let handler = TrashActionHandler( + dbStorage: dbStorage, + schemaController: schemaController, + fileStorage: fileStorage, + fileDownloader: userControllers.fileDownloader, + urlDetector: urlDetector, + htmlAttributedStringConverter: htmlAttributedStringConverter + ) return TrashViewController(viewModel: ViewModel(initialState: state, handler: handler), controllers: controllers, coordinatorDelegate: self) } @@ -619,16 +628,16 @@ extension DetailCoordinator: DetailItemsCoordinatorDelegate { self.navigationController?.present(navigationController, animated: true, completion: nil) } - func showFilters(viewModel: ViewModel, itemsController: ItemsViewController, button: UIBarButtonItem) { + func showFilters(filters: [ItemsFilter], filtersDelegate: BaseItemsViewController, button: UIBarButtonItem) { DDLogInfo("DetailCoordinator: show item filters") let navigationController = NavigationViewController() navigationController.modalPresentationStyle = UIDevice.current.userInterfaceIdiom == .pad ? .popover : .formSheet navigationController.popoverPresentationController?.barButtonItem = button - let coordinator = ItemsFilterCoordinator(viewModel: viewModel, itemsController: itemsController, navigationController: navigationController, controllers: self.controllers) + let coordinator = ItemsFilterCoordinator(filters: filters, filtersDelegate: filtersDelegate, navigationController: navigationController, controllers: controllers) coordinator.parentCoordinator = self - self.childCoordinators.append(coordinator) + childCoordinators.append(coordinator) coordinator.start(animated: false) self.navigationController?.present(navigationController, animated: true, completion: nil) diff --git a/Zotero/Scenes/Detail/Items/ItemsFilterCoordinator.swift b/Zotero/Scenes/Detail/Items/ItemsFilterCoordinator.swift index 7e20e7e99..decd95551 100644 --- a/Zotero/Scenes/Detail/Items/ItemsFilterCoordinator.swift +++ b/Zotero/Scenes/Detail/Items/ItemsFilterCoordinator.swift @@ -12,21 +12,29 @@ protocol ItemsFilterCoordinatorDelegate: AnyObject { func showTagPicker(libraryId: LibraryIdentifier, selected: Set, picked: @escaping ([Tag]) -> Void) } +protocol FiltersDelegate: AnyObject { + var currentLibrary: Library { get } + + func downloadsFilterDidChange(enabled: Bool) + func tagSelectionDidChange(selected: Set) + func tagOptionsDidChange() +} + final class ItemsFilterCoordinator: NSObject, Coordinator { weak var parentCoordinator: Coordinator? var childCoordinators: [Coordinator] weak var navigationController: UINavigationController? - private unowned let viewModel: ViewModel + private let filters: [ItemsFilter] private unowned let controllers: Controllers - private weak var itemsController: ItemsViewController? + private weak var filtersDelegate: BaseItemsViewController? - init(viewModel: ViewModel, itemsController: ItemsViewController, navigationController: NavigationViewController, controllers: Controllers) { - self.viewModel = viewModel + init(filters: [ItemsFilter], filtersDelegate: BaseItemsViewController, navigationController: NavigationViewController, controllers: Controllers) { + self.filters = filters self.navigationController = navigationController self.controllers = controllers - self.itemsController = itemsController - self.childCoordinators = [] + self.filtersDelegate = filtersDelegate + childCoordinators = [] super.init() @@ -37,30 +45,30 @@ final class ItemsFilterCoordinator: NSObject, Coordinator { } func start(animated: Bool) { - guard let dbStorage = self.controllers.userControllers?.dbStorage else { return } - - let selected = self.viewModel.state.tagsFilter ?? [] - let state = TagFilterState(selectedTags: selected, showAutomatic: Defaults.shared.tagPickerShowAutomaticTags, displayAll: Defaults.shared.tagPickerDisplayAllTags) + guard let dbStorage = controllers.userControllers?.dbStorage else { return } + let tags = filters.compactMap({ $0.tags }).first + let state = TagFilterState(selectedTags: tags ?? [], showAutomatic: Defaults.shared.tagPickerShowAutomaticTags, displayAll: Defaults.shared.tagPickerDisplayAllTags) let handler = TagFilterActionHandler(dbStorage: dbStorage) - let tagController = TagFilterViewController(viewModel: ViewModel(initialState: state, handler: handler), dragDropController: self.controllers.dragDropController) + let tagController = TagFilterViewController(viewModel: ViewModel(initialState: state, handler: handler), dragDropController: controllers.dragDropController) tagController.view.translatesAutoresizingMaskIntoConstraints = false - tagController.delegate = self.itemsController - self.itemsController?.tagFilterDelegate = tagController + tagController.delegate = filtersDelegate + filtersDelegate?.tagFilterDelegate = tagController - let controller = ItemsFilterViewController(viewModel: self.viewModel, tagFilterController: tagController) + let downloadsFilterEnabled = filters.contains(where: { $0.isDownloadedFilesFilter }) + let controller = ItemsFilterViewController(downloadsFilterEnabled: downloadsFilterEnabled, tagFilterController: tagController) + controller.delegate = filtersDelegate controller.coordinatorDelegate = self - self.navigationController?.setViewControllers([controller], animated: animated) + navigationController?.setViewControllers([controller], animated: animated) } } extension ItemsFilterCoordinator: ItemsFilterCoordinatorDelegate { func showTagPicker(libraryId: LibraryIdentifier, selected: Set, picked: @escaping ([Tag]) -> Void) { - guard let dbStorage = self.controllers.userControllers?.dbStorage else { return } - + guard let dbStorage = controllers.userControllers?.dbStorage else { return } let state = TagPickerState(libraryId: libraryId, selectedTags: selected) let handler = TagPickerActionHandler(dbStorage: dbStorage) let viewModel = ViewModel(initialState: state, handler: handler) let controller = TagPickerViewController(viewModel: viewModel, saveAction: picked) - self.navigationController?.pushViewController(controller, animated: true) + navigationController?.pushViewController(controller, animated: true) } } diff --git a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift index 0079cf36b..5c9730dd3 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift @@ -46,6 +46,7 @@ struct ItemCellModel { init(object: TrashObject) { key = object.key + title = object.title switch object.type { case .collection: @@ -56,9 +57,8 @@ struct ItemCellModel { tagEmojis = [] accessory = nil typeName = "Collection" - title = NSAttributedString(string: object.title) - case .item(let cellData, _): + case .item(let cellData, _, _): typeIconName = cellData.typeIconName subtitle = cellData.subtitle hasNote = cellData.hasNote @@ -66,7 +66,6 @@ struct ItemCellModel { tagEmojis = cellData.tagEmojis accessory = cellData.accessory typeName = cellData.localizedTypeName - title = cellData.attributedTitle } } diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift b/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift index 756444e12..d0384d48e 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift @@ -17,7 +17,6 @@ enum ItemsAction { case cacheItemTitle(key: String, title: String) case clearTitleCache case deleteItemsFromCollection(Set) - case deleteItems(Set) case deselectItem(String) case download(Set) case enableFilter(ItemsFilter) @@ -27,7 +26,6 @@ enum ItemsAction { case moveItems(keys: Set, toItemKey: String) case observingFailed case removeDownloads(Set) - case restoreItems(Set) case search(String) case selectItem(String) case setSortField(ItemsSortType.Field) @@ -45,5 +43,4 @@ enum ItemsAction { case openAttachment(attachment: Attachment, parentKey: String?) case attachmentOpened(String) case updateKeys(items: Results, deletions: [Int], insertions: [Int], modifications: [Int]) - case emptyTrash } diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsFilter.swift b/Zotero/Scenes/Detail/Items/Models/ItemsFilter.swift index e772f137f..e43f93270 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemsFilter.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemsFilter.swift @@ -12,6 +12,26 @@ enum ItemsFilter: Equatable { case downloadedFiles case tags(Set) + var tags: Set? { + switch self { + case .tags(let tags): + return tags + + case .downloadedFiles: + return nil + } + } + + var isDownloadedFilesFilter: Bool { + switch self { + case .tags: + return false + + case .downloadedFiles: + return true + } + } + static func == (lhs: ItemsFilter, rhs: ItemsFilter) -> Bool { switch (lhs, rhs) { case (.downloadedFiles, .downloadedFiles): diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsState.swift b/Zotero/Scenes/Detail/Items/Models/ItemsState.swift index cbf3b3606..58c400eee 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemsState.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemsState.swift @@ -112,18 +112,6 @@ struct ItemsState: ViewModelState { return UIFont.preferredFont(for: .headline, weight: .regular) } - var tagsFilter: Set? { - let tagFilter = self.filters.first(where: { filter in - switch filter { - case .tags: return true - default: return false - } - }) - - guard let tagFilter = tagFilter, case .tags(let tags) = tagFilter else { return nil } - return tags - } - init( collection: Collection, libraryId: LibraryIdentifier, diff --git a/Zotero/Scenes/Detail/Items/ViewModels/BaseItemsActionHandler.swift b/Zotero/Scenes/Detail/Items/ViewModels/BaseItemsActionHandler.swift new file mode 100644 index 000000000..a19185f91 --- /dev/null +++ b/Zotero/Scenes/Detail/Items/ViewModels/BaseItemsActionHandler.swift @@ -0,0 +1,105 @@ +// +// BaseItemsActionHandler.swift +// Zotero +// +// Created by Michal Rentka on 23.09.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import CocoaLumberjackSwift +import RealmSwift +import RxSwift + +class BaseItemsActionHandler: BackgroundDbProcessingActionHandler { + unowned let dbStorage: DbStorage + let backgroundQueue: DispatchQueue + + init(dbStorage: DbStorage) { + self.dbStorage = dbStorage + self.backgroundQueue = DispatchQueue(label: "org.zotero.BaseItemsActionHandler.backgroundProcessing", qos: .userInitiated) + } + + // MARK: - Filtering + + func add(filter: ItemsFilter, to filters: [ItemsFilter]) -> [ItemsFilter] { + guard !filters.contains(filter) else { return filters } + + let modificationIndex = filters.firstIndex(where: { existing in + switch (existing, filter) { + // Update array inside existing `tags` filter + case (.tags, .tags): + return true + + default: + return false + } + }) + + var newFilters = filters + if let index = modificationIndex { + newFilters[index] = filter + } else { + newFilters.append(filter) + } + return newFilters + } + + func remove(filter: ItemsFilter, from filters: [ItemsFilter]) -> [ItemsFilter] { + guard let index = filters.firstIndex(of: filter) else { return filters } + var newFilters = filters + newFilters.remove(at: index) + return newFilters + } + + // MARK: - Drag & Drop + + func moveItems(from keys: Set, to key: String, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { + let request = MoveItemsToParentDbRequest(itemKeys: keys, parentKey: key, libraryId: libraryId) + self.perform(request: request) { error in + guard let error else { return } + DDLogError("BaseItemsActionHandler: can't move items to parent: \(error)") + completion(.failure(.itemMove)) + } + } + + func add(items itemKeys: Set, to collectionKeys: Set, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { + let request = AssignItemsToCollectionsDbRequest(collectionKeys: collectionKeys, itemKeys: itemKeys, libraryId: libraryId) + self.perform(request: request) { error in + guard let error else { return } + DDLogError("BaseItemsActionHandler: can't assign collections to items - \(error)") + completion(.failure(.collectionAssignment)) + } + } + + func tagItem(key: String, libraryId: LibraryIdentifier, with names: Set) { + let request = AddTagsToItemDbRequest(key: key, libraryId: libraryId, tagNames: names) + self.perform(request: request) { error in + guard let error = error else { return } + // TODO: - show error + DDLogError("BaseItemsActionHandler: can't add tags - \(error)") + } + } + + // MARK: - Toolbar Actions + + func deleteItemsFromCollection(keys: Set, collectionId: CollectionIdentifier, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { + guard let key = collectionId.key else { return } + let request = DeleteItemsFromCollectionDbRequest(collectionKey: key, itemKeys: keys, libraryId: libraryId) + self.perform(request: request) { error in + guard let error else { return } + DDLogError("BaseItemsActionHandler: can't delete items - \(error)") + completion(.failure(.deletionFromCollection)) + } + } + + func set(trashed: Bool, to keys: Set, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { + let request = MarkItemsAsTrashedDbRequest(keys: Array(keys), libraryId: libraryId, trashed: trashed) + self.perform(request: request) { error in + guard let error else { return } + DDLogError("BaseItemsActionHandler: can't trash items - \(error)") + completion(.failure(.deletion)) + } + } +} diff --git a/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift b/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift index 10f702929..c2eaa0215 100644 --- a/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift +++ b/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift @@ -14,11 +14,10 @@ import CocoaLumberjackSwift import RealmSwift import RxSwift -struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionHandler { +final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { typealias State = ItemsState typealias Action = ItemsAction - unowned let dbStorage: DbStorage private unowned let fileStorage: FileStorage private unowned let schemaController: SchemaController private unowned let urlDetector: UrlDetector @@ -27,7 +26,6 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH private unowned let fileCleanupController: AttachmentFileCleanupController private unowned let syncScheduler: SynchronizationScheduler private unowned let htmlAttributedStringConverter: HtmlAttributedStringConverter - let backgroundQueue: DispatchQueue private let disposeBag: DisposeBag private let quotationExpression: NSRegularExpression? @@ -42,8 +40,6 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH syncScheduler: SynchronizationScheduler, htmlAttributedStringConverter: HtmlAttributedStringConverter ) { - self.backgroundQueue = DispatchQueue(label: "org.zotero.ItemsActionHandler.backgroundProcessing", qos: .userInitiated) - self.dbStorage = dbStorage self.fileStorage = fileStorage self.schemaController = schemaController self.urlDetector = urlDetector @@ -60,18 +56,33 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH DDLogError("ItemsActionHandler: can't create quotation expression - \(error)") self.quotationExpression = nil } + + super.init(dbStorage: dbStorage) } func process(action: ItemsAction, in viewModel: ViewModel) { + let handleBaseActionResult: (Result) -> Void = { [weak self, weak viewModel] result in + guard let self, let viewModel else { return } + switch result { + case .failure(let error): + update(viewModel: viewModel) { state in + state.error = error + } + + case .success: + break + } + } + switch action { case .addAttachments(let urls): self.addAttachments(urls: urls, in: viewModel) case .assignItemsToCollections(let items, let collections): - self.add(items: items, to: collections, in: viewModel) + add(items: items, to: collections, libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) case .deleteItemsFromCollection(let keys): - self.deleteItemsFromCollection(keys: keys, in: viewModel) + deleteItemsFromCollection(keys: keys, collectionId: viewModel.state.collection.identifier, libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) case .deselectItem(let key): self.update(viewModel: viewModel) { state in @@ -89,7 +100,7 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH self.loadItemForDuplication(key: key, in: viewModel) case .moveItems(let fromKeys, let toKey): - self.moveItems(from: fromKeys, to: toKey, in: viewModel) + moveItems(from: fromKeys, to: toKey, libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) case .observingFailed: self.update(viewModel: viewModel) { state in @@ -119,13 +130,7 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH self.changeSortType(to: sortType, in: viewModel) case .trashItems(let keys): - self.set(trashed: true, to: keys, in: viewModel) - - case .restoreItems(let keys): - self.set(trashed: false, to: keys, in: viewModel) - - case .deleteItems(let keys): - self.delete(items: keys, in: viewModel) + set(trashed: true, to: keys, libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) case .loadInitialState: self.loadInitialState(in: viewModel) @@ -168,10 +173,10 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH self.updateDeletedAttachments(notification, in: viewModel) case .enableFilter(let filter): - self.enable(filter: filter, in: viewModel) + self.filter(with: add(filter: filter, to: viewModel.state.filters), in: viewModel) case .disableFilter(let filter): - self.disable(filter: filter, in: viewModel) + self.filter(with: remove(filter: filter, from: viewModel.state.filters), in: viewModel) case .download(let keys): self.downloadAttachments(for: keys, in: viewModel) @@ -179,11 +184,8 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH case .removeDownloads(let ids): self.fileCleanupController.delete(.allForItems(ids, viewModel.state.library.identifier)) - case .emptyTrash: - self.emptyTrash(in: viewModel) - case .tagItem(let itemKey, let libraryId, let tagNames): - self.tagItem(key: itemKey, libraryId: libraryId, with: tagNames, in: viewModel) + tagItem(key: itemKey, libraryId: libraryId, with: tagNames) case .cacheItemTitle(let key, let title): self.update(viewModel: viewModel) { state in @@ -197,28 +199,11 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH } } - private func tagItem(key: String, libraryId: LibraryIdentifier, with names: Set, in viewModel: ViewModel) { - let request = AddTagsToItemDbRequest(key: key, libraryId: libraryId, tagNames: names) - self.perform(request: request) { error in - guard let error = error else { return } - // TODO: - show error - DDLogError("ItemsActionHandler: can't add tags - \(error)") - } - } - - private func emptyTrash(in viewModel: ViewModel) { - self.perform(request: EmptyTrashDbRequest(libraryId: viewModel.state.library.identifier)) { error in - guard let error = error else { return } - // TODO: - show error - DDLogError("ItemsActionHandler: can't empty trash - \(error)") - } - } - private func loadInitialState(in viewModel: ViewModel) { do { let sortType = Defaults.shared.itemsSortType - let (library, libraryToken) = try viewModel.state.library.identifier.observe(in: dbStorage, changes: { [weak viewModel] library in - guard let viewModel else { return } + let (library, libraryToken) = try viewModel.state.library.identifier.observe(in: dbStorage, changes: { [weak self, weak viewModel] library in + guard let self, let viewModel else { return } update(viewModel: viewModel) { state in state.library = library state.changes = .library @@ -398,67 +383,8 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH } } - // MARK: - Drag & Drop - - private func moveItems(from keys: Set, to key: String, in viewModel: ViewModel) { - let request = MoveItemsToParentDbRequest(itemKeys: keys, parentKey: key, libraryId: viewModel.state.library.identifier) - self.perform(request: request) { [weak viewModel] error in - guard let viewModel = viewModel, let error = error else { return } - DDLogError("ItemsStore: can't move items to parent: \(error)") - self.update(viewModel: viewModel) { state in - state.error = .itemMove - } - } - } - - private func add(items itemKeys: Set, to collectionKeys: Set, in viewModel: ViewModel) { - let request = AssignItemsToCollectionsDbRequest(collectionKeys: collectionKeys, itemKeys: itemKeys, libraryId: viewModel.state.library.identifier) - self.perform(request: request) { [weak viewModel] error in - guard let viewModel = viewModel, let error = error else { return } - DDLogError("ItemsStore: can't assign collections to items - \(error)") - self.update(viewModel: viewModel) { state in - state.error = .collectionAssignment - } - } - } - // MARK: - Toolbar actions - private func deleteItemsFromCollection(keys: Set, in viewModel: ViewModel) { - guard case .collection(let key) = viewModel.state.collection.identifier else { return } - - let request = DeleteItemsFromCollectionDbRequest(collectionKey: key, itemKeys: keys, libraryId: viewModel.state.library.identifier) - self.perform(request: request) { [weak viewModel] error in - guard let viewModel = viewModel, let error = error else { return } - DDLogError("ItemsStore: can't delete items - \(error)") - self.update(viewModel: viewModel) { state in - state.error = .deletionFromCollection - } - } - } - - private func delete(items keys: Set, in viewModel: ViewModel) { - let request = MarkObjectsAsDeletedDbRequest(keys: Array(keys), libraryId: viewModel.state.library.identifier) - self.perform(request: request) { [weak viewModel] error in - guard let viewModel = viewModel, let error = error else { return } - DDLogError("ItemsStore: can't delete items - \(error)") - self.update(viewModel: viewModel) { state in - state.error = .deletion - } - } - } - - private func set(trashed: Bool, to keys: Set, in viewModel: ViewModel) { - let request = MarkItemsAsTrashedDbRequest(keys: Array(keys), libraryId: viewModel.state.library.identifier, trashed: trashed) - self.perform(request: request) { [weak viewModel] error in - guard let viewModel = viewModel, let error = error else { return } - DDLogError("ItemsStore: can't trash items - \(error)") - self.update(viewModel: viewModel) { state in - state.error = .deletion - } - } - } - /// Loads item which was selected for duplication from DB. When `itemDuplication` is set, appropriate screen with loaded item is opened. /// - parameter key: Key of item for duplication. private func loadItemForDuplication(key: String, in viewModel: ViewModel) { @@ -544,19 +470,19 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH let type = self.schemaController.localized(itemType: ItemTypes.attachment) ?? "" let request = CreateAttachmentsDbRequest(attachments: attachments, parentKey: nil, localizedType: type, collections: collections) - self.perform(request: request, invalidateRealm: true) { [weak viewModel] result in - guard let viewModel = viewModel else { return } + self.perform(request: request, invalidateRealm: true) { [weak self, weak viewModel] result in + guard let self, let viewModel else { return } switch result { case .success(let failed): guard !failed.isEmpty else { return } - self.update(viewModel: viewModel) { state in + update(viewModel: viewModel) { state in state.error = .attachmentAdding(.someFailed(failed.map({ $0.1 }))) } case .failure(let error): DDLogError("ItemsActionHandler: can't add attachment: \(error)") - self.update(viewModel: viewModel) { state in + update(viewModel: viewModel) { state in state.error = .attachmentAdding(.couldNotSave) } } @@ -565,37 +491,6 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH // MARK: - Searching & Filtering - private func enable(filter: ItemsFilter, in viewModel: ViewModel) { - var filters = viewModel.state.filters - - guard !filters.contains(filter) else { return } - - let modificationIndex = filters.firstIndex(where: { existing in - switch (existing, filter) { - // Update array inside existing `tags` filter - case (.tags, .tags): return true - default: return false - } - }) - - if let index = modificationIndex { - filters[index] = filter - } else { - filters.append(filter) - } - - self.filter(with: filters, in: viewModel) - } - - private func disable(filter: ItemsFilter, in viewModel: ViewModel) { - var filters = viewModel.state.filters - - guard let index = filters.firstIndex(of: filter) else { return } - - filters.remove(at: index) - self.filter(with: filters, in: viewModel) - } - private func filter(with filters: [ItemsFilter], in viewModel: ViewModel) { guard filters != viewModel.state.filters else { return } diff --git a/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift b/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift index 24437b7f4..bb94131ba 100644 --- a/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift @@ -162,6 +162,8 @@ class BaseItemsViewController: UIViewController { func process(barButtonItemAction: RightBarButtonItem, sender: UIBarButtonItem) {} + func downloadsFilterDidChange(enabled: Bool) {} + // MARK: - Setups func setupTitle() { @@ -244,7 +246,7 @@ extension BaseItemsViewController: ItemsToolbarControllerDelegate { } } -extension BaseItemsViewController: TagFilterDelegate { +extension BaseItemsViewController: FiltersDelegate { var currentLibrary: Library { return library } diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsFilterViewController.swift b/Zotero/Scenes/Detail/Items/Views/ItemsFilterViewController.swift index 52be2f2cd..4f3cd42d8 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsFilterViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsFilterViewController.swift @@ -19,22 +19,15 @@ class ItemsFilterViewController: UIViewController { @IBOutlet private weak var tagFilterControllerContainer: UIView! private static let width: CGFloat = 320 - private let viewModel: ViewModel private let tagFilterController: TagFilterViewController private let disposeBag: DisposeBag weak var coordinatorDelegate: ItemsFilterCoordinatorDelegate? - private var downloadsFilterEnabled: Bool { - return self.viewModel.state.filters.contains(where: { filter in - switch filter { - case .downloadedFiles: return true - default: return false - } - }) - } + private var downloadsFilterEnabled: Bool + weak var delegate: TagFilterDelegate? - init(viewModel: ViewModel, tagFilterController: TagFilterViewController) { - self.viewModel = viewModel + init(downloadsFilterEnabled: Bool, tagFilterController: TagFilterViewController) { + self.downloadsFilterEnabled = downloadsFilterEnabled self.tagFilterController = tagFilterController self.disposeBag = DisposeBag() super.init(nibName: "ItemsFilterViewController", bundle: nil) @@ -49,13 +42,6 @@ class ItemsFilterViewController: UIViewController { self.setupNavigationBar() self.setupUI() - - self.viewModel.stateObservable - .observe(on: MainScheduler.instance) - .subscribe(with: self, onNext: { `self`, state in - self.update(state: state) - }) - .disposed(by: self.disposeBag) parent?.presentationController?.delegate = self } @@ -71,15 +57,8 @@ class ItemsFilterViewController: UIViewController { // MARK: - Actions - private func update(state: ItemsState) { - } - @IBAction private func toggleDownloads(sender: UISwitch) { - if sender.isOn { - self.viewModel.process(action: .enableFilter(.downloadedFiles)) - } else { - self.viewModel.process(action: .disableFilter(.downloadedFiles)) - } + delegate?.downloadsFilterDidChange(enabled: sender.isOn) } @objc private func done() { diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift b/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift index 669912340..92aaa3dc5 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift @@ -25,11 +25,10 @@ protocol ItemsTableViewHandlerDelegate: AnyObject { protocol ItemsTableViewDataSource: UITableViewDataSource { var count: Int { get } - var selectedItems: Set { get } + var selectedItems: Set { get } var handler: ItemsTableViewHandler? { get set } func object(at index: Int) -> ItemsTableViewObject? - func accessory(forKey key: String) -> ItemAccessory? func tapAction(for indexPath: IndexPath) -> ItemsTableViewHandler.TapAction? func createTrailingCellActions(at index: Int) -> [ItemAction]? func createContextMenuActions(at index: Int) -> [ItemAction] @@ -42,8 +41,8 @@ final class ItemsTableViewHandler: NSObject { case attachment(attachment: Attachment, parentKey: String?) case doi(String) case url(URL) - case selectItem(String) - case deselectItem(String) + case selectItem(ItemsTableViewObject) + case deselectItem(ItemsTableViewObject) } enum DragAndDropAction { @@ -280,7 +279,7 @@ extension ItemsTableViewHandler: UITableViewDelegate { func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { guard delegate.isEditing, let object = dataSource.object(at: indexPath.row) else { return } - delegate.process(tapAction: .deselectItem(object.key)) + delegate.process(tapAction: .deselectItem(object)) } func tableView(_ tableView: UITableView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool { diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift index bc249f8dc..0f87cca3a 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift @@ -143,6 +143,9 @@ final class ItemsViewController: BaseItemsViewController { override func process(action: ItemAction.Kind, for selectedKeys: Set, button: UIBarButtonItem?, completionAction: ((Bool) -> Void)?) { switch action { + case .delete, .restore: + break + case .addToCollection: guard !selectedKeys.isEmpty else { return } coordinatorDelegate?.showCollectionsPicker(in: library, completed: { [weak self] collections in @@ -160,18 +163,6 @@ final class ItemsViewController: BaseItemsViewController { animated: true ) - case .delete: - guard !selectedKeys.isEmpty else { return } - coordinatorDelegate?.showDeletionQuestion( - count: viewModel.state.selectedItems.count, - confirmAction: { [weak self] in - self?.viewModel.process(action: .deleteItems(selectedKeys)) - }, - cancelAction: { - completionAction?(false) - } - ) - case .duplicate: guard let key = selectedKeys.first else { return } viewModel.process(action: .loadItemToDuplicate(key)) @@ -185,18 +176,13 @@ final class ItemsViewController: BaseItemsViewController { completionAction?(true) } - case .restore: - guard !selectedKeys.isEmpty else { return } - viewModel.process(action: .restoreItems(selectedKeys)) - completionAction?(true) - case .trash: guard !selectedKeys.isEmpty else { return } viewModel.process(action: .trashItems(selectedKeys)) case .filter: guard let button else { return } - coordinatorDelegate?.showFilters(viewModel: viewModel, itemsController: self, button: button) + coordinatorDelegate?.showFilters(filters: viewModel.state.filters, filtersDelegate: self, button: button) case .sort: guard let button else { return } @@ -270,7 +256,7 @@ final class ItemsViewController: BaseItemsViewController { override func tagSelectionDidChange(selected: Set) { if selected.isEmpty { - if let tags = viewModel.state.tagsFilter { + if let tags = viewModel.state.filters.compactMap({ $0.tags }).first { viewModel.process(action: .disableFilter(.tags(tags))) } } else { @@ -278,6 +264,14 @@ final class ItemsViewController: BaseItemsViewController { } } + override func downloadsFilterDidChange(enabled: Bool) { + if enabled { + viewModel.process(action: .enableFilter(.downloadedFiles)) + } else { + viewModel.process(action: .disableFilter(.downloadedFiles)) + } + } + // MARK: - Helpers private func toolbarData(from state: ItemsState) -> ItemsToolbarController.Data { @@ -410,11 +404,11 @@ extension ItemsViewController: ItemsTableViewHandlerDelegate { case .url(let url): coordinatorDelegate?.show(url: url) - case .selectItem(let key): - viewModel.process(action: .selectItem(key)) + case .selectItem(let object): + viewModel.process(action: .selectItem(object.key)) - case .deselectItem(let key): - viewModel.process(action: .deselectItem(key)) + case .deselectItem(let object): + viewModel.process(action: .deselectItem(object.key)) case .note(let object): guard let item = object as? RItem, let note = Note(item: item) else { return } diff --git a/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift b/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift index d10a2d5ff..c5e7c3b4f 100644 --- a/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift +++ b/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift @@ -72,6 +72,10 @@ final class RItemsTableViewDataSource: NSObject { completion: completion ) } + + private func accessory(forKey key: String) -> ItemAccessory? { + return viewModel.state.itemAccessories[key] + } } extension RItemsTableViewDataSource: ItemsTableViewDataSource { @@ -79,7 +83,7 @@ extension RItemsTableViewDataSource: ItemsTableViewDataSource { return snapshot?.count ?? 0 } - var selectedItems: Set { + var selectedItems: Set { return viewModel.state.selectedItems } @@ -92,15 +96,11 @@ extension RItemsTableViewDataSource: ItemsTableViewDataSource { return snapshot?[index] } - func accessory(forKey key: String) -> ItemAccessory? { - return viewModel.state.itemAccessories[key] - } - func tapAction(for indexPath: IndexPath) -> ItemsTableViewHandler.TapAction? { guard let item = item(at: indexPath.row) else { return nil } if viewModel.state.isEditing { - return .selectItem(item.key) + return .selectItem(item) } guard let accessory = accessory(forKey: item.key) else { diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift b/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift index 7b13f8454..edbbc53d4 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift @@ -9,5 +9,19 @@ import Foundation enum TrashAction { + case assignItemsToCollections(items: Set, collections: Set) + case deleteItems(Set) + case deleteItemsFromCollection(Set) + case deselectItem(TrashKey) + case disableFilter(ItemsFilter) + case emptyTrash + case enableFilter(ItemsFilter) case loadData + case moveItems(keys: Set, toItemKey: String) + case restoreItems(Set) + case selectItem(TrashKey) + case startEditing + case stopEditing + case tagItem(itemKey: String, libraryId: LibraryIdentifier, tagNames: Set) + case toggleSelectionState } diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift index 86c81f9e6..1174acfef 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift @@ -31,7 +31,6 @@ struct TrashObject { } struct ItemCellData { - let attributedTitle: NSAttributedString let localizedTypeName: String let typeIconName: String let subtitle: String @@ -43,13 +42,13 @@ struct TrashObject { enum Kind { case collection - case item(cellData: ItemCellData, sortData: ItemSortData) + case item(cellData: ItemCellData, sortData: ItemSortData, accessory: ItemAccessory?) } let type: Kind let key: String let libraryId: LibraryIdentifier - let title: String + let title: NSAttributedString let dateModified: Date var trashKey: TrashKey { @@ -67,16 +66,16 @@ struct TrashObject { var sortTitle: String { switch type { case .collection: - return title + return title.string - case .item(_, let sortData): + case .item(_, let sortData, _): return sortData.title } } var sortType: String? { switch type { - case .item(_, let sortData): + case .item(_, let sortData, _): return sortData.type case .collection: @@ -86,7 +85,7 @@ struct TrashObject { var creatorSummary: String? { switch type { - case .item(_, let sortData): + case .item(_, let sortData, _): return sortData.creatorSummary case .collection: @@ -96,7 +95,7 @@ struct TrashObject { var publisher: String? { switch type { - case .item(_, let sortData): + case .item(_, let sortData, _): return sortData.publisher case .collection: @@ -106,7 +105,7 @@ struct TrashObject { var publicationTitle: String? { switch type { - case .item(_, let sortData): + case .item(_, let sortData, _): return sortData.publicationTitle case .collection: @@ -116,7 +115,7 @@ struct TrashObject { var year: Int? { switch type { - case .item(_, let sortData): + case .item(_, let sortData, _): return sortData.year case .collection: @@ -126,7 +125,7 @@ struct TrashObject { var date: Date? { switch type { - case .item(_, let sortData): + case .item(_, let sortData, _): return sortData.date case .collection: @@ -136,11 +135,21 @@ struct TrashObject { var dateAdded: Date? { switch type { - case .item(_, let sortData): + case .item(_, let sortData, _): return sortData.dateAdded case .collection: return nil } } + + var itemAccessory: ItemAccessory? { + switch type { + case .item(_, _, let accessory): + return accessory + + case .collection: + return nil + } + } } diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift index aaada96b1..b4aeb59d8 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift @@ -6,12 +6,24 @@ // Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. // -import Foundation +import UIKit import OrderedCollections import RealmSwift struct TrashState: ViewModelState { + struct Changes: OptionSet { + typealias RawValue = UInt8 + + let rawValue: UInt8 + + static let objects = Changes(rawValue: 1 << 0) + static let editing = Changes(rawValue: 1 << 1) + static let selection = Changes(rawValue: 1 << 2) + static let selectAll = Changes(rawValue: 1 << 3) + static let filters = Changes(rawValue: 1 << 4) + } + enum Error: Swift.Error { case dataLoading } @@ -23,10 +35,21 @@ struct TrashState: ViewModelState { var collectionResults: Results? var collectionsToken: NotificationToken? var objects: OrderedDictionary - var error: Error? + var filters: [ItemsFilter] + var isEditing: Bool + var selectedItems: Set + var changes: Changes + var error: ItemsError? + var titleFont: UIFont { + return UIFont.preferredFont(for: .headline, weight: .regular) + } init(libraryId: LibraryIdentifier) { objects = [:] + filters = [] + isEditing = false + changes = [] + selectedItems = [] switch libraryId { case .custom: @@ -39,5 +62,6 @@ struct TrashState: ViewModelState { mutating func cleanup() { error = nil + changes = [] } } diff --git a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift index bccc605e3..7e0189138 100644 --- a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift +++ b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift @@ -13,29 +13,104 @@ import CocoaLumberjackSwift import RealmSwift import RxSwift -struct TrashActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionHandler { +final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { typealias State = TrashState typealias Action = TrashAction - unowned let dbStorage: DbStorage private unowned let schemaController: SchemaController private unowned let fileStorage: FileStorage + private unowned let fileDownloader: AttachmentDownloader private unowned let urlDetector: UrlDetector - - var backgroundQueue: DispatchQueue - - init(dbStorage: DbStorage, schemaController: SchemaController, fileStorage: FileStorage, urlDetector: UrlDetector) { - self.dbStorage = dbStorage + private unowned let htmlAttributedStringConverter: HtmlAttributedStringConverter + + init( + dbStorage: DbStorage, + schemaController: SchemaController, + fileStorage: FileStorage, + fileDownloader: AttachmentDownloader, + urlDetector: UrlDetector, + htmlAttributedStringConverter: HtmlAttributedStringConverter + ) { self.schemaController = schemaController self.fileStorage = fileStorage + self.fileDownloader = fileDownloader self.urlDetector = urlDetector - backgroundQueue = DispatchQueue(label: "org.zotero.Zotero.TrashActionHandler.queue", qos: .userInteractive) + self.htmlAttributedStringConverter = htmlAttributedStringConverter + super.init(dbStorage: dbStorage) } func process(action: TrashAction, in viewModel: ViewModel) { + let handleBaseActionResult: (Result) -> Void = { [weak self, weak viewModel] result in + guard let self, let viewModel else { return } + switch result { + case .failure(let error): + update(viewModel: viewModel) { state in + state.error = error + } + + case .success: + break + } + } + switch action { case .loadData: loadData(in: viewModel) + + case .deleteItems(let keys): + delete(items: keys, viewModel: viewModel) + + case .emptyTrash: + emptyTrash(in: viewModel) + + case .tagItem(let itemKey, let libraryId, let tagNames): + tagItem(key: itemKey, libraryId: libraryId, with: tagNames) + + case .assignItemsToCollections(let items, let collections): + add(items: items, to: collections, libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) + + case .deleteItemsFromCollection(let keys): + deleteItemsFromCollection(keys: keys, collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) + + case .moveItems(let keys, let toItemKey): + moveItems(from: keys, to: toItemKey, libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) + + case .restoreItems(let keys): + set(trashed: false, to: keys, libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) + + case .startEditing: + startEditing(in: viewModel) + + case .stopEditing: + stopEditing(in: viewModel) + + case .enableFilter(let filter): + self.filter(with: add(filter: filter, to: viewModel.state.filters), in: viewModel) + + case .disableFilter(let filter): + self.filter(with: remove(filter: filter, from: viewModel.state.filters), in: viewModel) + + case .toggleSelectionState: + update(viewModel: viewModel) { state in + if state.selectedItems.count != state.objects.count { + state.selectedItems = Set(state.objects.keys) + } else { + state.selectedItems = [] + } + state.changes = [.selection, .selectAll] + } + + case .deselectItem(let key): + self.update(viewModel: viewModel) { state in + state.selectedItems.remove(key) + state.changes.insert(.selection) + } + + case .selectItem(let key): + self.update(viewModel: viewModel) { state in + state.selectedItems.insert(key) + state.changes.insert(.selection) + } } } @@ -47,11 +122,11 @@ struct TrashActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH let collections = (try dbStorage.perform(request: collectionsRequest, on: .main)).sorted(by: collectionSortDescriptor(for: sortType)) var objects: OrderedDictionary = [:] - for object in items.compactMap({ trashObject(from: $0) }) { + for object in items.compactMap({ trashObject(from: $0, titleFont: viewModel.state.titleFont) }) { objects[object.trashKey] = object } for collection in collections { - guard let object = trashObject(from: collection) else { continue } + guard let object = trashObject(from: collection, titleFont: viewModel.state.titleFont) else { continue } let index = objects.index(of: object, sortedBy: { areInIncreasingOrder(lObject: $0, rObject: $1, sortType: sortType) }) objects.updateValue(object, forKey: object.trashKey, insertingAt: index) } @@ -120,7 +195,7 @@ struct TrashActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH if let lValue, let rValue { return lValue.compare(rValue, options: [.numeric], locale: Locale.autoupdatingCurrent) } - if let lValue { + if lValue != nil { return .orderedAscending } return .orderedDescending @@ -133,7 +208,7 @@ struct TrashActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH } return lValue < rValue ? .orderedAscending : .orderedDescending } - if let lValue { + if lValue != nil { return .orderedAscending } return .orderedDescending @@ -143,7 +218,7 @@ struct TrashActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH if let lValue, let rValue { return lValue.compare(rValue) } - if let lValue { + if lValue != nil { return .orderedAscending } return .orderedDescending @@ -163,24 +238,26 @@ struct TrashActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH } } - func trashObject(from collection: RCollection) -> TrashObject? { + func trashObject(from collection: RCollection, titleFont: UIFont) -> TrashObject? { guard let libraryId = collection.libraryId else { return nil } - return TrashObject(type: .collection, key: collection.key, libraryId: libraryId, title: collection.name, dateModified: collection.dateModified) + let attributedTitle = htmlAttributedStringConverter.convert(text: collection.name, baseAttributes: [.font: titleFont]) + return TrashObject(type: .collection, key: collection.key, libraryId: libraryId, title: attributedTitle, dateModified: collection.dateModified) } - func trashObject(from item: RItem) -> TrashObject? { + func trashObject(from item: RItem, titleFont: UIFont) -> TrashObject? { guard let libraryId = item.libraryId else { return nil } - let accessory = ItemAccessory.create(from: item, fileStorage: fileStorage, urlDetector: urlDetector).flatMap({ convertToItemCellModelAccessory(accessory: $0) }) + let itemAccessory = ItemAccessory.create(from: item, fileStorage: fileStorage, urlDetector: urlDetector) + let cellAccessory = itemAccessory.flatMap({ ItemCellModel.createAccessory(from: $0, fileDownloader: fileDownloader) }) let creatorSummary = ItemCellModel.creatorSummary(for: item) let (tagColors, tagEmojis) = ItemCellModel.tagData(item: item) let hasNote = ItemCellModel.hasNote(item: item) let typeName = schemaController.localized(itemType: item.rawType) ?? item.rawType + let attributedTitle = htmlAttributedStringConverter.convert(text: item.displayTitle, baseAttributes: [.font: titleFont]) let cellData = TrashObject.ItemCellData( - attributedTitle: NSAttributedString(string: item.displayTitle), localizedTypeName: typeName, typeIconName: ItemCellModel.typeIconName(for: item), subtitle: creatorSummary, - accessory: accessory, + accessory: cellAccessory, tagColors: tagColors, tagEmojis: tagEmojis, hasNote: hasNote @@ -195,21 +272,62 @@ struct TrashActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH date: item.parsedDate, dateAdded: item.dateAdded ) - return TrashObject(type: .item(cellData: cellData, sortData: sortData), key: item.key, libraryId: libraryId, title: item.displayTitle, dateModified: item.dateModified) + return TrashObject(type: .item(cellData: cellData, sortData: sortData, accessory: itemAccessory), key: item.key, libraryId: libraryId, title: attributedTitle, dateModified: item.dateModified) } + } - func convertToItemCellModelAccessory(accessory: ItemAccessory?) -> ItemCellModel.Accessory? { - guard let accessory else { return nil } - switch accessory { - case .attachment(let attachment, _): - return .attachment(.stateFrom(type: attachment.type, progress: nil, error: nil)) + // MARK: - Actions - case .doi: - return .doi + private func emptyTrash(in viewModel: ViewModel) { + self.perform(request: EmptyTrashDbRequest(libraryId: viewModel.state.library.identifier)) { error in + guard let error = error else { return } + // TODO: - show error + DDLogError("ItemsActionHandler: can't empty trash - \(error)") + } + } - case .url: - return .url + private func delete(items keys: Set, viewModel: ViewModel) { + let request = MarkObjectsAsDeletedDbRequest(keys: Array(keys), libraryId: viewModel.state.library.identifier) + self.perform(request: request) { [weak self, weak viewModel] error in + guard let self, let viewModel, let error else { return } + DDLogError("BaseItemsActionHandler: can't delete items - \(error)") + update(viewModel: viewModel) { state in + state.error = .deletion } } } + + private func startEditing(in viewModel: ViewModel) { + update(viewModel: viewModel) { state in + state.isEditing = true + state.changes.insert(.editing) + } + } + + private func stopEditing(in viewModel: ViewModel) { + update(viewModel: viewModel) { state in + state.isEditing = false + state.selectedItems.removeAll() + state.changes.insert(.editing) + } + } + + // MARK: - Searching & Filtering + + private func filter(with filters: [ItemsFilter], in viewModel: ViewModel) { + guard filters != viewModel.state.filters else { return } + +// let results = try? results( +// for: viewModel.state.searchTerm, +// filters: filters, +// collectionId: viewModel.state.collection.identifier, +// sortType: viewModel.state.sortType, +// libraryId: viewModel.state.library.identifier +// ) + update(viewModel: viewModel) { state in + state.filters = filters +// state.results = results + state.changes = [.objects, .filters] + } + } } diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift index b4369754b..22a08f8d6 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift @@ -32,8 +32,8 @@ extension TrashTableViewDataSource { return snapshot?.count ?? 0 } - var selectedItems: Set { - return [] + var selectedItems: Set { + return viewModel.state.selectedItems } func object(at index: Int) -> ItemsTableViewObject? { @@ -45,20 +45,42 @@ extension TrashTableViewDataSource { return snapshot.values[index] } - func accessory(forKey key: String) -> ItemAccessory? { - return nil - } - func tapAction(for indexPath: IndexPath) -> ItemsTableViewHandler.TapAction? { - return nil + guard let object = trashObject(at: indexPath.row) else { return nil } + + if viewModel.state.isEditing { + return .selectItem(object) + } + + guard let accessory = object.itemAccessory else { + guard case .item(_, let sortData, _) = object.type else { return nil } + switch sortData.type { + case ItemTypes.note: + return .note(object) + + default: + return .metadata(object) + } + } + + switch accessory { + case .attachment(let attachment, let parentKey): + return .attachment(attachment: attachment, parentKey: parentKey) + + case .doi(let doi): + return .doi(doi) + + case .url(let url): + return .url(url) + } } func createTrailingCellActions(at index: Int) -> [ItemAction]? { - return nil + return [ItemAction(type: .delete), ItemAction(type: .restore)] } func createContextMenuActions(at index: Int) -> [ItemAction] { - return [] + return [ItemAction(type: .restore), ItemAction(type: .delete)] } } @@ -97,7 +119,7 @@ extension TrashTableViewDataSource { extension TrashObject: ItemsTableViewObject { var isNote: Bool { switch type { - case .item(_, let sortData): + case .item(_, let sortData, _): return sortData.type == ItemTypes.note case .collection: @@ -107,7 +129,7 @@ extension TrashObject: ItemsTableViewObject { var isAttachment: Bool { switch type { - case .item(_, let sortData): + case .item(_, let sortData, _): return sortData.type == ItemTypes.attachment case .collection: diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift index d7465a3c9..a32a5f543 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -57,83 +57,50 @@ final class TrashViewController: BaseItemsViewController { override func process(action: ItemAction.Kind, for selectedKeys: Set, button: UIBarButtonItem?, completionAction: ((Bool) -> Void)?) { switch action { + case .createParent, .duplicate, .trash, .copyBibliography, .copyCitation, .share: + // These actions are not available in trash collection + break + case .addToCollection: guard !selectedKeys.isEmpty else { return } coordinatorDelegate?.showCollectionsPicker(in: library, completed: { [weak self] collections in -// self?.viewModel.process(action: .assignItemsToCollections(items: selectedKeys, collections: collections)) + self?.viewModel.process(action: .assignItemsToCollections(items: selectedKeys, collections: collections)) completionAction?(true) }) - case .createParent: -// guard let key = selectedKeys.first, case .attachment(let attachment, _) = viewModel.state.itemAccessories[key] else { return } -// let collectionKey = collection.identifier.key -// coordinatorDelegate?.showItemDetail( -// for: .creation(type: ItemTypes.document, child: attachment, collectionKey: collectionKey), -// libraryId: library.identifier, -// scrolledToKey: nil, -// animated: true -// ) - break - case .delete: guard !selectedKeys.isEmpty else { return } coordinatorDelegate?.showDeletionQuestion( - count: 0,//viewModel.state.selectedItems.count, + count: selectedKeys.count, confirmAction: { [weak self] in -// self?.viewModel.process(action: .deleteItems(selectedKeys)) + self?.viewModel.process(action: .deleteItems(selectedKeys)) }, cancelAction: { completionAction?(false) } ) - case .duplicate: -// guard let key = selectedKeys.first else { return } -// viewModel.process(action: .loadItemToDuplicate(key)) - break - case .removeFromCollection: guard !selectedKeys.isEmpty else { return } coordinatorDelegate?.showRemoveFromCollectionQuestion( count: viewModel.state.objects.count ) { [weak self] in -// self?.viewModel.process(action: .deleteItemsFromCollection(selectedKeys)) + self?.viewModel.process(action: .deleteItemsFromCollection(selectedKeys)) completionAction?(true) } case .restore: guard !selectedKeys.isEmpty else { return } -// viewModel.process(action: .restoreItems(selectedKeys)) + viewModel.process(action: .restoreItems(selectedKeys)) completionAction?(true) - case .trash: - guard !selectedKeys.isEmpty else { return } -// viewModel.process(action: .trashItems(selectedKeys)) - break - case .filter: guard let button else { return } -// coordinatorDelegate?.showFilters(viewModel: viewModel, itemsController: self, button: button) - break + coordinatorDelegate?.showFilters(filters: viewModel.state.filters, filtersDelegate: self, button: button) case .sort: guard let button else { return } // coordinatorDelegate?.showSortActions(viewModel: viewModel, button: button) - break - - case .share: - guard !selectedKeys.isEmpty else { return } - coordinatorDelegate?.showCiteExport(for: selectedKeys, libraryId: library.identifier) - - case .copyBibliography: - var presenter: UIViewController = self - if let searchController = navigationItem.searchController, searchController.isActive { - presenter = searchController - } - coordinatorDelegate?.copyBibliography(using: presenter, for: selectedKeys, libraryId: library.identifier, delegate: nil) - - case .copyCitation: - coordinatorDelegate?.showCitation(using: nil, for: selectedKeys, libraryId: library.identifier, delegate: nil) case .download: // viewModel.process(action: .download(selectedKeys)) @@ -148,23 +115,19 @@ final class TrashViewController: BaseItemsViewController { override func process(barButtonItemAction: BaseItemsViewController.RightBarButtonItem, sender: UIBarButtonItem) { switch barButtonItemAction { case .add: -// coordinatorDelegate?.showAddActions(viewModel: viewModel, button: sender) break case .deselectAll, .selectAll: -// viewModel.process(action: .toggleSelectionState) - break + viewModel.process(action: .toggleSelectionState) case .done: -// viewModel.process(action: .stopEditing) - break + viewModel.process(action: .stopEditing) case .emptyTrash: - break + viewModel.process(action: .emptyTrash) case .select: -// viewModel.process(action: .startEditing) - break + viewModel.process(action: .startEditing) } } @@ -187,27 +150,26 @@ final class TrashViewController: BaseItemsViewController { return selectItems + [.emptyTrash] func rightBarButtonSelectItemTypes(for state: TrashState) -> [RightBarButtonItem] { - return [.select] -// if !state.isEditing { -// return [.select] -// } -// if state.selectedItems.count == (state.results?.count ?? 0) { -// return [.deselectAll, .done] -// } -// return [.selectAll, .done] + if !state.isEditing { + return [.select] + } + if state.selectedItems.count == state.objects.count { + return [.deselectAll, .done] + } + return [.selectAll, .done] } } // MARK: - Tag filter delegate override func tagSelectionDidChange(selected: Set) { -// if selected.isEmpty { -// if let tags = viewModel.state.tagsFilter { -// viewModel.process(action: .disableFilter(.tags(tags))) -// } -// } else { -// viewModel.process(action: .enableFilter(.tags(selected))) -// } + if selected.isEmpty { + if let tags = viewModel.state.filters.compactMap({ $0.tags }).first { + viewModel.process(action: .disableFilter(.tags(tags))) + } + } else { + viewModel.process(action: .enableFilter(.tags(selected))) + } } } @@ -221,11 +183,54 @@ extension TrashViewController: ItemsTableViewHandlerDelegate { } func process(action: ItemAction.Kind, at index: Int, completionAction: ((Bool) -> Void)?) { + guard let object = dataSource.object(at: index) else { return } + process(action: action, for: [object.key], button: nil, completionAction: completionAction) } func process(tapAction action: ItemsTableViewHandler.TapAction) { + resetActiveSearch() + + switch action { + case .metadata(let object): + coordinatorDelegate?.showItemDetail(for: .preview(key: object.key), libraryId: viewModel.state.library.identifier, scrolledToKey: nil, animated: true) + + case .attachment(let attachment, let parentKey): +// viewModel.process(action: .openAttachment(attachment: attachment, parentKey: parentKey)) + break + + case .doi(let doi): + coordinatorDelegate?.show(doi: doi) + + case .url(let url): + coordinatorDelegate?.show(url: url) + + case .selectItem(let object): + guard let trashObject = object as? TrashObject else { return } + viewModel.process(action: .selectItem(trashObject.trashKey)) + + case .deselectItem(let object): + guard let trashObject = object as? TrashObject else { return } + viewModel.process(action: .deselectItem(trashObject.trashKey)) + + case .note(let object): + guard let item = object as? RItem, let note = Note(item: item) else { return } + let tags = Array(item.tags.map({ Tag(tag: $0) })) + coordinatorDelegate?.showNote(library: viewModel.state.library, kind: .edit(key: note.key), text: note.text, tags: tags, parentTitleData: nil, title: note.title, saveCallback: nil) + } + + func resetActiveSearch() { + guard let searchBar = navigationItem.searchController?.searchBar else { return } + searchBar.resignFirstResponder() + } } func process(dragAndDropAction action: ItemsTableViewHandler.DragAndDropAction) { + switch action { + case .moveItems(let keys, let toKey): + viewModel.process(action: .moveItems(keys: keys, toItemKey: toKey)) + + case .tagItem(let key, let libraryId, let tags): + viewModel.process(action: .tagItem(itemKey: key, libraryId: libraryId, tagNames: tags)) + } } } diff --git a/Zotero/Scenes/Master/TagFiltering/Views/TagFilterViewController.swift b/Zotero/Scenes/Master/TagFiltering/Views/TagFilterViewController.swift index 165bcb367..479953979 100644 --- a/Zotero/Scenes/Master/TagFiltering/Views/TagFilterViewController.swift +++ b/Zotero/Scenes/Master/TagFiltering/Views/TagFilterViewController.swift @@ -12,13 +12,6 @@ import CocoaLumberjackSwift import RealmSwift import RxSwift -protocol TagFilterDelegate: AnyObject { - var currentLibrary: Library { get } - - func tagSelectionDidChange(selected: Set) - func tagOptionsDidChange() -} - class TagFilterViewController: UIViewController { private(set) weak var searchBar: UISearchBar! private weak var collectionView: UICollectionView! From b94ebe487d1dcbae2eb65ae85a352467b5e0aca8 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Tue, 24 Sep 2024 15:43:04 +0200 Subject: [PATCH 08/23] WIP: Implementing sorting / filtering --- .../ViewModels/BaseItemsActionHandler.swift | 49 +++ .../Items/ViewModels/ItemsActionHandler.swift | 57 +-- .../Items/Views/BaseItemsViewController.swift | 1 - .../Views/ItemsFilterViewController.swift | 2 +- .../Detail/Trash/Models/TrashAction.swift | 3 + .../Detail/Trash/Models/TrashState.swift | 9 +- .../Trash/ViewModels/TrashActionHandler.swift | 369 ++++++++++++------ .../Views/TagFilterViewController.swift | 2 +- 8 files changed, 324 insertions(+), 168 deletions(-) diff --git a/Zotero/Scenes/Detail/Items/ViewModels/BaseItemsActionHandler.swift b/Zotero/Scenes/Detail/Items/ViewModels/BaseItemsActionHandler.swift index a19185f91..b0c7099b5 100644 --- a/Zotero/Scenes/Detail/Items/ViewModels/BaseItemsActionHandler.swift +++ b/Zotero/Scenes/Detail/Items/ViewModels/BaseItemsActionHandler.swift @@ -15,10 +15,18 @@ import RxSwift class BaseItemsActionHandler: BackgroundDbProcessingActionHandler { unowned let dbStorage: DbStorage let backgroundQueue: DispatchQueue + private let quotationExpression: NSRegularExpression? init(dbStorage: DbStorage) { self.dbStorage = dbStorage self.backgroundQueue = DispatchQueue(label: "org.zotero.BaseItemsActionHandler.backgroundProcessing", qos: .userInitiated) + + do { + quotationExpression = try NSRegularExpression(pattern: #"("[^"]+"?)"#) + } catch let error { + DDLogError("BaseItemsActionHandler: can't create quotation expression - \(error)") + quotationExpression = nil + } } // MARK: - Filtering @@ -53,6 +61,47 @@ class BaseItemsActionHandler: BackgroundDbProcessingActionHandler { return newFilters } + // MARK: - Search + + func createComponents(from searchTerm: String) -> [String] { + guard let expression = quotationExpression else { return [searchTerm] } + + let normalizedSearchTerm = searchTerm.replacingOccurrences(of: #"“"#, with: "\"") + .replacingOccurrences(of: #"”"#, with: "\"") + + let matches = expression.matches(in: normalizedSearchTerm, options: [], range: NSRange(normalizedSearchTerm.startIndex..., in: normalizedSearchTerm)) + + guard !matches.isEmpty else { + return separateComponents(from: normalizedSearchTerm) + } + + var components: [String] = [] + for (idx, match) in matches.enumerated() { + if match.range.lowerBound > 0 { + let lowerBound = idx == 0 ? 0 : matches[idx - 1].range.upperBound + let precedingRange = normalizedSearchTerm.index(normalizedSearchTerm.startIndex, offsetBy: lowerBound).. [String] { + return string.components(separatedBy: " ").filter({ !$0.isEmpty }) + } + } + // MARK: - Drag & Drop func moveItems(from keys: Set, to key: String, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { diff --git a/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift b/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift index c2eaa0215..5c3b3cfeb 100644 --- a/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift +++ b/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift @@ -27,7 +27,6 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { private unowned let syncScheduler: SynchronizationScheduler private unowned let htmlAttributedStringConverter: HtmlAttributedStringConverter private let disposeBag: DisposeBag - private let quotationExpression: NSRegularExpression? init( dbStorage: DbStorage, @@ -49,14 +48,6 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { self.syncScheduler = syncScheduler self.htmlAttributedStringConverter = htmlAttributedStringConverter self.disposeBag = DisposeBag() - - do { - self.quotationExpression = try NSRegularExpression(pattern: #"("[^"]+"?)"#) - } catch let error { - DDLogError("ItemsActionHandler: can't create quotation expression - \(error)") - self.quotationExpression = nil - } - super.init(dbStorage: dbStorage) } @@ -111,10 +102,7 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { self.search(for: (text.isEmpty ? nil : text), ignoreOriginal: false, in: viewModel) case .setSortField(let field): - var sortType = viewModel.state.sortType - sortType.field = field - sortType.ascending = field.defaultOrderAscending - self.changeSortType(to: sortType, in: viewModel) + changeSortType(to: ItemsSortType(field: field, ascending: field.defaultOrderAscending), in: viewModel) case .startEditing: self.startEditing(in: viewModel) @@ -127,7 +115,7 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { case .setSortOrder(let ascending): var sortType = viewModel.state.sortType sortType.ascending = ascending - self.changeSortType(to: sortType, in: viewModel) + changeSortType(to: sortType, in: viewModel) case .trashItems(let keys): set(trashed: true, to: keys, libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) @@ -528,51 +516,12 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { private func results(for searchText: String?, filters: [ItemsFilter], collectionId: CollectionIdentifier, sortType: ItemsSortType, libraryId: LibraryIdentifier) throws -> Results { var searchComponents: [String] = [] if let text = searchText, !text.isEmpty { - searchComponents = self.createComponents(from: text) + searchComponents = createComponents(from: text) } let request = ReadItemsDbRequest(collectionId: collectionId, libraryId: libraryId, filters: filters, sortType: sortType, searchTextComponents: searchComponents) return try self.dbStorage.perform(request: request, on: .main) } - private func createComponents(from searchTerm: String) -> [String] { - guard let expression = self.quotationExpression else { return [searchTerm] } - - let normalizedSearchTerm = searchTerm.replacingOccurrences(of: #"“"#, with: "\"") - .replacingOccurrences(of: #"”"#, with: "\"") - - let matches = expression.matches(in: normalizedSearchTerm, options: [], range: NSRange(normalizedSearchTerm.startIndex..., in: normalizedSearchTerm)) - - guard !matches.isEmpty else { - return self.separateComponents(from: normalizedSearchTerm) - } - - var components: [String] = [] - for (idx, match) in matches.enumerated() { - if match.range.lowerBound > 0 { - let lowerBound = idx == 0 ? 0 : matches[idx - 1].range.upperBound - let precedingRange = normalizedSearchTerm.index(normalizedSearchTerm.startIndex, offsetBy: lowerBound).. [String] { - return string.components(separatedBy: " ").filter({ !$0.isEmpty }) - } - // MARK: - Helpers /// Updates the `keys` array which mirrors `Results` identifiers. Updates `selectedItems` if needed. Updates `attachments` if needed. diff --git a/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift b/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift index bb94131ba..06bd8f1a9 100644 --- a/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift @@ -56,7 +56,6 @@ class BaseItemsViewController: UIViewController { self.controllers = controllers self.coordinatorDelegate = coordinatorDelegate self.disposeBag = DisposeBag() - super.init(nibName: nil, bundle: nil) } diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsFilterViewController.swift b/Zotero/Scenes/Detail/Items/Views/ItemsFilterViewController.swift index 4f3cd42d8..db16e816a 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsFilterViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsFilterViewController.swift @@ -24,7 +24,7 @@ class ItemsFilterViewController: UIViewController { weak var coordinatorDelegate: ItemsFilterCoordinatorDelegate? private var downloadsFilterEnabled: Bool - weak var delegate: TagFilterDelegate? + weak var delegate: FiltersDelegate? init(downloadsFilterEnabled: Bool, tagFilterController: TagFilterViewController) { self.downloadsFilterEnabled = downloadsFilterEnabled diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift b/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift index edbbc53d4..08fbb72ac 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift @@ -19,6 +19,9 @@ enum TrashAction { case loadData case moveItems(keys: Set, toItemKey: String) case restoreItems(Set) + case search(String) + case setSortField(ItemsSortType.Field) + case setSortOrder(Bool) case selectItem(TrashKey) case startEditing case stopEditing diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift index b4aeb59d8..474069459 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift @@ -35,6 +35,9 @@ struct TrashState: ViewModelState { var collectionResults: Results? var collectionsToken: NotificationToken? var objects: OrderedDictionary + var snapshot: OrderedDictionary? + var sortType: ItemsSortType + var searchTerm: String? var filters: [ItemsFilter] var isEditing: Bool var selectedItems: Set @@ -44,9 +47,11 @@ struct TrashState: ViewModelState { return UIFont.preferredFont(for: .headline, weight: .regular) } - init(libraryId: LibraryIdentifier) { + init(libraryId: LibraryIdentifier, sortType: ItemsSortType, searchTerm: String?, filters: [ItemsFilter]) { objects = [:] - filters = [] + self.sortType = sortType + self.filters = filters + self.searchTerm = searchTerm isEditing = false changes = [] selectedItems = [] diff --git a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift index 7e0189138..89fea4cb8 100644 --- a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift +++ b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift @@ -90,6 +90,17 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { case .disableFilter(let filter): self.filter(with: remove(filter: filter, from: viewModel.state.filters), in: viewModel) + case .search(let term): + search(with: term, in: viewModel) + + case .setSortField(let field): + changeSortType(to: ItemsSortType(field: field, ascending: field.defaultOrderAscending), in: viewModel) + + case .setSortOrder(let ascending): + var type = viewModel.state.sortType + type.ascending = ascending + changeSortType(to: type, in: viewModel) + case .toggleSelectionState: update(viewModel: viewModel) { state in if state.selectedItems.count != state.objects.count { @@ -116,23 +127,19 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { private func loadData(in viewModel: ViewModel) { do { - let sortType = Defaults.shared.itemsSortType - let items = try dbStorage.perform(request: ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier, sortType: sortType), on: .main) + let items = try dbStorage.perform(request: ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier, sortType: viewModel.state.sortType), on: .main) let collectionsRequest = ReadCollectionsDbRequest(libraryId: viewModel.state.library.identifier, trash: true) - let collections = (try dbStorage.perform(request: collectionsRequest, on: .main)).sorted(by: collectionSortDescriptor(for: sortType)) - - var objects: OrderedDictionary = [:] - for object in items.compactMap({ trashObject(from: $0, titleFont: viewModel.state.titleFont) }) { - objects[object.trashKey] = object - } - for collection in collections { - guard let object = trashObject(from: collection, titleFont: viewModel.state.titleFont) else { continue } - let index = objects.index(of: object, sortedBy: { areInIncreasingOrder(lObject: $0, rObject: $1, sortType: sortType) }) - objects.updateValue(object, forKey: object.trashKey, insertingAt: index) - } - + let collections = (try dbStorage.perform(request: collectionsRequest, on: .main)).sorted(by: collectionSortDescriptor(for: viewModel.state.sortType)) + let results = results( + fromItems: items, + collections: collections, + sortType: viewModel.state.sortType, + filters: viewModel.state.filters, + searchTerm: viewModel.state.searchTerm, + titleFont: viewModel.state.titleFont + ) update(viewModel: viewModel) { state in - state.objects = objects + state.objects = results } } catch let error { DDLogInfo("TrashActionHandler: can't load initial data - \(error)") @@ -141,90 +148,6 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } - func areInIncreasingOrder(lObject: TrashObject, rObject: TrashObject, sortType: ItemsSortType) -> Bool { - let initialResult: ComparisonResult - - switch sortType.field { - case .creator: - initialResult = compare(lValue: lObject.creatorSummary, rValue: rObject.creatorSummary) - - case .date: - initialResult = compare(lValue: lObject.date, rValue: rObject.date) - - case .dateAdded: - initialResult = compare(lValue: lObject.dateAdded, rValue: rObject.dateAdded) - - case .dateModified: - initialResult = compare(lValue: lObject.dateModified, rValue: rObject.dateModified) - - case .itemType: - initialResult = compare(lValue: lObject.sortType, rValue: rObject.sortType) - - case .publicationTitle: - initialResult = compare(lValue: lObject.publicationTitle, rValue: rObject.publicationTitle) - - case .publisher: - initialResult = compare(lValue: lObject.publisher, rValue: rObject.publisher) - - case .year: - initialResult = compare(lValue: lObject.year, rValue: rObject.year) - - case .title: - return isInIncreasingOrder(result: compare(lValue: lObject.sortTitle, rValue: rObject.sortTitle), ascending: sortType.ascending, comparedSame: nil) - } - - return isInIncreasingOrder(result: initialResult, ascending: sortType.ascending, comparedSame: { compare(lValue: lObject.sortTitle, rValue: rObject.sortTitle) }) - - func isInIncreasingOrder(result: ComparisonResult, ascending: Bool, comparedSame: (() -> ComparisonResult)?) -> Bool { - switch result { - case .orderedSame: - if let result = comparedSame?() { - return ascending ? result == .orderedAscending : result == .orderedDescending - } - return true - - case .orderedAscending: - return ascending - - case .orderedDescending: - return !ascending - } - } - - func compare(lValue: String?, rValue: String?) -> ComparisonResult { - if let lValue, let rValue { - return lValue.compare(rValue, options: [.numeric], locale: Locale.autoupdatingCurrent) - } - if lValue != nil { - return .orderedAscending - } - return .orderedDescending - } - - func compare(lValue: Int?, rValue: Int?) -> ComparisonResult { - if let lValue, let rValue { - if lValue == rValue { - return .orderedSame - } - return lValue < rValue ? .orderedAscending : .orderedDescending - } - if lValue != nil { - return .orderedAscending - } - return .orderedDescending - } - - func compare(lValue: Date?, rValue: Date?) -> ComparisonResult { - if let lValue, let rValue { - return lValue.compare(rValue) - } - if lValue != nil { - return .orderedAscending - } - return .orderedDescending - } - } - func collectionSortDescriptor(for sortType: ItemsSortType) -> [RealmSwift.SortDescriptor] { switch sortType.field { case .dateModified: @@ -238,6 +161,26 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } + func results( + fromItems items: Results, + collections: Results, + sortType: ItemsSortType, + filters: [ItemsFilter], + searchTerm: String?, + titleFont: UIFont + ) -> OrderedDictionary { + var objects: OrderedDictionary = [:] + for object in items.compactMap({ trashObject(from: $0, titleFont: titleFont) }) { + objects[object.trashKey] = object + } + for collection in collections { + guard let object = trashObject(from: collection, titleFont: titleFont) else { continue } + let index = objects.index(of: object, sortedBy: { areInIncreasingOrder(lObject: $0, rObject: $1, sortType: sortType) }) + objects.updateValue(object, forKey: object.trashKey, insertingAt: index) + } + return objects + } + func trashObject(from collection: RCollection, titleFont: UIFont) -> TrashObject? { guard let libraryId = collection.libraryId else { return nil } let attributedTitle = htmlAttributedStringConverter.convert(text: collection.name, baseAttributes: [.font: titleFont]) @@ -276,6 +219,181 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } + private func results( + fromOriginal original: OrderedDictionary, + sortType: ItemsSortType, + filters: [ItemsFilter], + searchTerm: String? + ) -> OrderedDictionary { + var results: OrderedDictionary = [:] + for (key, value) in original { + guard object(value, containsTerm: searchTerm) && object(value, satisfiesFilters: filters) else { continue } + let index = results.index(of: value, sortedBy: { areInIncreasingOrder(lObject: $0, rObject: $1, sortType: sortType) }) + results.updateValue(value, forKey: key, insertingAt: index) + } + return original + + func object(_ object: TrashObject, satisfiesFilters filters: [ItemsFilter]) -> Bool { + guard !filters.isEmpty else { return true } + + for filter in filters { + switch object.type { + case .collection: + // Collections don't have tags or can be "downloaded", so they fail automatically + return false + + case .item(let cellData, let sortData, let accessory): + switch filter { + case .downloadedFiles: + continue + + case .tags(let tagNames): + continue + } + } + } + + return true + } + + func object(_ object: TrashObject, containsTerm term: String?) -> Bool { + guard let term else { return true } + let components = createComponents(from: term) + guard !components.isEmpty else { return true } + for component in components { + // If object contains component somewhere, return true, otherwise continue +// let keyPredicate = NSPredicate(format: "key == %@", text) +// let childrenKeyPredicate = NSPredicate(format: "any children.key == %@", text) +// // TODO: - ideally change back to "==" if Realm issue is fixed +// let childrenChildrenKeyPredicate = NSPredicate(format: "any children.children.key contains %@", text) +// let contentPredicate = NSPredicate(format: "htmlFreeContent contains[c] %@", text) +// let childrenContentPredicate = NSPredicate(format: "any children.htmlFreeContent contains[c] %@", text) +// let childrenChildrenContentPredicate = NSPredicate(format: "any children.children.htmlFreeContent contains[c] %@", text) +// let titlePredicate = NSPredicate(format: "sortTitle contains[c] %@", text) +// let childrenTitlePredicate = NSPredicate(format: "any children.sortTitle contains[c] %@", text) +// let creatorFullNamePredicate = NSPredicate(format: "any creators.name contains[c] %@", text) +// let creatorFirstNamePredicate = NSPredicate(format: "any creators.firstName contains[c] %@", text) +// let creatorLastNamePredicate = NSPredicate(format: "any creators.lastName contains[c] %@", text) +// let tagPredicate = NSPredicate(format: "any tags.tag.name contains[c] %@", text) +// let childrenTagPredicate = NSPredicate(format: "any children.tags.tag.name contains[c] %@", text) +// let childrenChildrenTagPredicate = NSPredicate(format: "any children.children.tags.tag.name contains[c] %@", text) +// let fieldsPredicate = NSPredicate(format: "any fields.value contains[c] %@", text) +// let childrenFieldsPredicate = NSPredicate(format: "any children.fields.value contains[c] %@", text) +// let childrenChildrenFieldsPredicate = NSPredicate(format: "any children.children.fields.value contains[c] %@", text) +// +// var predicates = [ +// keyPredicate, +// titlePredicate, +// contentPredicate, +// creatorFullNamePredicate, +// creatorFirstNamePredicate, +// creatorLastNamePredicate, +// tagPredicate, +// childrenKeyPredicate, +// childrenTitlePredicate, +// childrenContentPredicate, +// childrenTagPredicate, +// childrenChildrenKeyPredicate, +// childrenChildrenContentPredicate, +// childrenChildrenTagPredicate, +// fieldsPredicate, +// childrenFieldsPredicate, +// childrenChildrenFieldsPredicate +// ] +// +// if let int = Int(text) { +// let yearPredicate = NSPredicate(format: "parsedYear == %d", int) +// predicates.insert(yearPredicate, at: 3) +// } + } + return false + } + } + + private func areInIncreasingOrder(lObject: TrashObject, rObject: TrashObject, sortType: ItemsSortType) -> Bool { + let initialResult: ComparisonResult + + switch sortType.field { + case .creator: + initialResult = compare(lValue: lObject.creatorSummary, rValue: rObject.creatorSummary) + + case .date: + initialResult = compare(lValue: lObject.date, rValue: rObject.date) + + case .dateAdded: + initialResult = compare(lValue: lObject.dateAdded, rValue: rObject.dateAdded) + + case .dateModified: + initialResult = compare(lValue: lObject.dateModified, rValue: rObject.dateModified) + + case .itemType: + initialResult = compare(lValue: lObject.sortType, rValue: rObject.sortType) + + case .publicationTitle: + initialResult = compare(lValue: lObject.publicationTitle, rValue: rObject.publicationTitle) + + case .publisher: + initialResult = compare(lValue: lObject.publisher, rValue: rObject.publisher) + + case .year: + initialResult = compare(lValue: lObject.year, rValue: rObject.year) + + case .title: + return isInIncreasingOrder(result: compare(lValue: lObject.sortTitle, rValue: rObject.sortTitle), ascending: sortType.ascending, comparedSame: nil) + } + + return isInIncreasingOrder(result: initialResult, ascending: sortType.ascending, comparedSame: { compare(lValue: lObject.sortTitle, rValue: rObject.sortTitle) }) + + func isInIncreasingOrder(result: ComparisonResult, ascending: Bool, comparedSame: (() -> ComparisonResult)?) -> Bool { + switch result { + case .orderedSame: + if let result = comparedSame?() { + return ascending ? result == .orderedAscending : result == .orderedDescending + } + return true + + case .orderedAscending: + return ascending + + case .orderedDescending: + return !ascending + } + } + + func compare(lValue: String?, rValue: String?) -> ComparisonResult { + if let lValue, let rValue { + return lValue.compare(rValue, options: [.numeric], locale: Locale.autoupdatingCurrent) + } + if lValue != nil { + return .orderedAscending + } + return .orderedDescending + } + + func compare(lValue: Int?, rValue: Int?) -> ComparisonResult { + if let lValue, let rValue { + if lValue == rValue { + return .orderedSame + } + return lValue < rValue ? .orderedAscending : .orderedDescending + } + if lValue != nil { + return .orderedAscending + } + return .orderedDescending + } + + func compare(lValue: Date?, rValue: Date?) -> ComparisonResult { + if let lValue, let rValue { + return lValue.compare(rValue) + } + if lValue != nil { + return .orderedAscending + } + return .orderedDescending + } + } + // MARK: - Actions private func emptyTrash(in viewModel: ViewModel) { @@ -314,20 +432,53 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { // MARK: - Searching & Filtering + private func search(with term: String?, in viewModel: ViewModel) { + guard term != viewModel.state.searchTerm else { return } + let results = results( + fromOriginal: viewModel.state.snapshot ?? viewModel.state.objects, + sortType: viewModel.state.sortType, + filters: viewModel.state.filters, + searchTerm: term + ) + updateState(withResults: results, in: viewModel) { state in + state.searchTerm = term + } + } + private func filter(with filters: [ItemsFilter], in viewModel: ViewModel) { guard filters != viewModel.state.filters else { return } + let results = results( + fromOriginal: viewModel.state.snapshot ?? viewModel.state.objects, + sortType: viewModel.state.sortType, + filters: filters, + searchTerm: viewModel.state.searchTerm + ) + updateState(withResults: results, in: viewModel) { state in + state.filters = filters + state.changes.insert(.filters) + } + } -// let results = try? results( -// for: viewModel.state.searchTerm, -// filters: filters, -// collectionId: viewModel.state.collection.identifier, -// sortType: viewModel.state.sortType, -// libraryId: viewModel.state.library.identifier -// ) + private func changeSortType(to sortType: ItemsSortType, in viewModel: ViewModel) { + var ordered: OrderedDictionary = [:] + for object in viewModel.state.objects { + let index = ordered.index(of: object.value, sortedBy: { areInIncreasingOrder(lObject: $0, rObject: $1, sortType: sortType) }) + ordered.updateValue(object.value, forKey: object.key, insertingAt: index) + } + updateState(withResults: ordered, in: viewModel) { state in + state.sortType = sortType + } + Defaults.shared.itemsSortType = sortType + } + + private func updateState(withResults results: OrderedDictionary, in viewModel: ViewModel, additionalStateUpdate: (inout TrashState) -> Void) { update(viewModel: viewModel) { state in - state.filters = filters -// state.results = results - state.changes = [.objects, .filters] + if state.snapshot == nil { + state.snapshot = state.objects + } + state.objects = results + state.changes = [.objects] + additionalStateUpdate(&state) } } } diff --git a/Zotero/Scenes/Master/TagFiltering/Views/TagFilterViewController.swift b/Zotero/Scenes/Master/TagFiltering/Views/TagFilterViewController.swift index 479953979..004c556ce 100644 --- a/Zotero/Scenes/Master/TagFiltering/Views/TagFilterViewController.swift +++ b/Zotero/Scenes/Master/TagFiltering/Views/TagFilterViewController.swift @@ -17,7 +17,7 @@ class TagFilterViewController: UIViewController { private weak var collectionView: UICollectionView! private weak var searchBarTopConstraint: NSLayoutConstraint! private weak var optionsButton: UIButton! - weak var delegate: TagFilterDelegate? + weak var delegate: FiltersDelegate? private var searchBarScrollEnabled: Bool private var didAppear: Bool From 52c0825c1bf62becf54ca913d9891ceb6e04074e Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Thu, 26 Sep 2024 15:43:58 +0200 Subject: [PATCH 09/23] WIP: added remaining actions, added download attachment --- .../AttachmentDownloader.swift | 10 + .../Requests/EmptyTrashDbRequest.swift | 6 +- Zotero/Scenes/Detail/DetailCoordinator.swift | 106 +++--- .../Detail/Items/Models/ItemCellModel.swift | 16 +- .../Detail/Items/Models/ItemsAction.swift | 3 +- .../Detail/Items/Models/ItemsSortType.swift | 2 +- .../Detail/Items/Models/ItemsState.swift | 8 +- .../ViewModels/BaseItemsActionHandler.swift | 41 +-- .../Items/ViewModels/ItemsActionHandler.swift | 54 ++- .../ViewModels/ItemsToolbarController.swift | 8 +- .../Items/Views/BaseItemsViewController.swift | 72 ++-- .../Items/Views/ItemSortTypePickerView.swift | 14 +- .../Detail/Items/Views/ItemSortingView.swift | 15 +- .../Items/Views/ItemsViewController.swift | 56 ++-- .../Detail/Trash/Models/TrashAction.swift | 14 +- .../Detail/Trash/Models/TrashObject.swift | 58 ++-- .../Detail/Trash/Models/TrashState.swift | 9 +- .../Trash/ViewModels/TrashActionHandler.swift | 308 ++++++++++++------ .../Views/TrashTableViewDataSource.swift | 17 +- .../Trash/Views/TrashViewController.swift | 136 ++++++-- 20 files changed, 606 insertions(+), 347 deletions(-) diff --git a/Zotero/Controllers/Attachment Downloader/AttachmentDownloader.swift b/Zotero/Controllers/Attachment Downloader/AttachmentDownloader.swift index 225ff47e7..a26f17a1f 100644 --- a/Zotero/Controllers/Attachment Downloader/AttachmentDownloader.swift +++ b/Zotero/Controllers/Attachment Downloader/AttachmentDownloader.swift @@ -28,6 +28,16 @@ final class AttachmentDownloader: NSObject { case ready(compressed: Bool?) case failed(Swift.Error) case cancelled + + var isProgress: Bool { + switch self { + case .progress: + return true + + case .ready, .failed, .cancelled: + return false + } + } } let key: String diff --git a/Zotero/Controllers/Database/Requests/EmptyTrashDbRequest.swift b/Zotero/Controllers/Database/Requests/EmptyTrashDbRequest.swift index dfe9cd0ec..405f58988 100644 --- a/Zotero/Controllers/Database/Requests/EmptyTrashDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/EmptyTrashDbRequest.swift @@ -16,7 +16,11 @@ struct EmptyTrashDbRequest: DbRequest { var needsWrite: Bool { return true } func process(in database: Realm) throws { - database.objects(RItem.self).filter(.items(for: .custom(.trash), libraryId: self.libraryId)).forEach { + database.objects(RItem.self).filter(.items(for: .custom(.trash), libraryId: libraryId)).forEach { + $0.deleted = true + $0.changeType = .user + } + database.objects(RCollection.self).filter(.trashedCollections(in: libraryId)).forEach { $0.deleted = true $0.changeType = .user } diff --git a/Zotero/Scenes/Detail/DetailCoordinator.swift b/Zotero/Scenes/Detail/DetailCoordinator.swift index f9b35c495..3ee1264c0 100644 --- a/Zotero/Scenes/Detail/DetailCoordinator.swift +++ b/Zotero/Scenes/Detail/DetailCoordinator.swift @@ -38,7 +38,7 @@ protocol DetailItemsCoordinatorDelegate: AnyObject { func showItemDetail(for type: ItemDetailState.DetailType, libraryId: LibraryIdentifier, scrolledToKey childKey: String?, animated: Bool) func showAttachmentError(_ error: Error) func showAddActions(viewModel: ViewModel, button: UIBarButtonItem) - func showSortActions(viewModel: ViewModel, button: UIBarButtonItem) + func showSortActions(sortType: ItemsSortType, button: UIBarButtonItem, changed: @escaping (ItemsSortType) -> Void) func show(url: URL) func show(doi: String) func showFilters(filters: [ItemsFilter], filtersDelegate: BaseItemsViewController, button: UIBarButtonItem) @@ -130,6 +130,7 @@ final class DetailCoordinator: Coordinator { schemaController: controllers.schemaController, fileStorage: controllers.fileStorage, urlDetector: controllers.urlDetector, + itemsTagFilterDelegate: itemsTagFilterDelegate, htmlAttributedStringConverter: controllers.htmlAttributedStringConverter ) @@ -173,9 +174,14 @@ final class DetailCoordinator: Coordinator { schemaController: SchemaController, fileStorage: FileStorage, urlDetector: UrlDetector, + itemsTagFilterDelegate: ItemsTagFilterDelegate?, htmlAttributedStringConverter: HtmlAttributedStringConverter ) -> TrashViewController { - let state = TrashState(libraryId: libraryId) + itemsTagFilterDelegate?.clearSelection() + + let searchTerm = searchItemKeys?.joined(separator: " ") + let sortType = Defaults.shared.itemsSortType + let state = TrashState(libraryId: libraryId, sortType: sortType, searchTerm: searchTerm, filters: []) let handler = TrashActionHandler( dbStorage: dbStorage, schemaController: schemaController, @@ -202,14 +208,15 @@ final class DetailCoordinator: Coordinator { ) -> ItemsViewController { itemsTagFilterDelegate?.clearSelection() - let searchTerm = self.searchItemKeys?.joined(separator: " ") + let searchTerm = searchItemKeys?.joined(separator: " ") let downloadBatchData = ItemsState.DownloadBatchData(batchData: fileDownloader.batchData) let remoteDownloadBatchData = ItemsState.DownloadBatchData(batchData: remoteFileDownloader.batchData) let identifierLookupBatchData = ItemsState.IdentifierLookupBatchData(batchData: identifierLookupController.batchData) + let sortType = Defaults.shared.itemsSortType let state = ItemsState( collection: collection, libraryId: libraryId, - sortType: .default, + sortType: sortType, searchTerm: searchTerm, filters: [], downloadBatchData: downloadBatchData, @@ -479,56 +486,59 @@ extension DetailCoordinator: DetailItemsCoordinatorDelegate { self.navigationController?.present(controller, animated: true, completion: nil) } - func showSortActions(viewModel: ViewModel, button: UIBarButtonItem) { + func showSortActions(sortType: ItemsSortType, button: UIBarButtonItem, changed: @escaping (ItemsSortType) -> Void) { DDLogInfo("DetailCoordinator: show item sort popup") - let navigationController = UINavigationController() - navigationController.modalPresentationStyle = UIDevice.current.userInterfaceIdiom == .pad ? .popover : .formSheet - navigationController.popoverPresentationController?.barButtonItem = button - - let sortByBinding = viewModel.binding(keyPath: \.sortType.field, action: { .setSortField($0) }) - - let view = ItemSortingView(viewModel: viewModel, showPickerAction: { [weak self, weak navigationController] in - guard let self = self, let navigationController = navigationController else { return } - self.showSortTypePicker(sortBy: sortByBinding, in: navigationController) - }) - - let controller = DisappearActionHostingController(rootView: view) - - var size: CGSize? - controller.willAppear = { [weak controller, weak navigationController] in - guard let `controller` = controller else { return } - let _size = size ?? controller.view.systemLayoutSizeFitting(CGSize(width: 400.0, height: .greatestFiniteMagnitude)) - size = _size - controller.preferredContentSize = _size - navigationController?.preferredContentSize = _size - } + let sortNavigationController = UINavigationController() + sortNavigationController.modalPresentationStyle = UIDevice.current.userInterfaceIdiom == .pad ? .popover : .formSheet + sortNavigationController.popoverPresentationController?.barButtonItem = button + let view = ItemSortingView( + sortType: sortType, + changed: changed, + showPicker: { [weak sortNavigationController] view in + guard let sortNavigationController else { return } + showPicker(view: view, navigationController: sortNavigationController) + }, + closePicker: { [weak sortNavigationController] in + sortNavigationController?.popViewController(animated: true) + } + ) + let controller = createItemSortingController(for: view, navigationController: sortNavigationController) + sortNavigationController.setViewControllers([controller], animated: false) + navigationController?.present(sortNavigationController, animated: true, completion: nil) + + func createItemSortingController(for view: ItemSortingView, navigationController: UINavigationController) -> UIHostingController { + let controller = DisappearActionHostingController(rootView: view) + var size: CGSize? + controller.willAppear = { [weak controller, weak navigationController] in + guard let controller else { return } + let _size = size ?? controller.view.systemLayoutSizeFitting(CGSize(width: 400.0, height: .greatestFiniteMagnitude)) + size = _size + controller.preferredContentSize = _size + navigationController?.preferredContentSize = _size + } - if UIDevice.current.userInterfaceIdiom == .phone { - controller.didLoad = { [weak self] viewController in - guard let self = self else { return } - let doneButton = UIBarButtonItem(title: L10n.done, style: .done, target: nil, action: nil) - doneButton.rx.tap.subscribe({ [weak self] _ in - self?.navigationController?.dismiss(animated: true) - }).disposed(by: self.disposeBag) - viewController.navigationItem.rightBarButtonItem = doneButton + if UIDevice.current.userInterfaceIdiom == .phone { + controller.didLoad = { [weak self] viewController in + guard let self else { return } + let doneButton = UIBarButtonItem(title: L10n.done, style: .done, target: nil, action: nil) + doneButton.rx.tap + .subscribe({ [weak self] _ in + self?.navigationController?.dismiss(animated: true) + }) + .disposed(by: disposeBag) + viewController.navigationItem.rightBarButtonItem = doneButton + } } + return controller } - navigationController.setViewControllers([controller], animated: false) - - self.navigationController?.present(navigationController, animated: true, completion: nil) - } - - func showSortTypePicker(sortBy: Binding, in navigationController: UINavigationController) { - let view = ItemSortTypePickerView(sortBy: sortBy, - closeAction: { [weak navigationController] in - navigationController?.popViewController(animated: true) - }) - let controller = UIHostingController(rootView: view) - controller.preferredContentSize = CGSize(width: 400, height: 600) - navigationController.preferredContentSize = controller.preferredContentSize - navigationController.pushViewController(controller, animated: true) + func showPicker(view: ItemSortTypePickerView, navigationController: UINavigationController) { + let controller = UIHostingController(rootView: view) + controller.preferredContentSize = CGSize(width: 400, height: 600) + navigationController.preferredContentSize = controller.preferredContentSize + navigationController.pushViewController(controller, animated: true) + } } private func sortButtonTitles(for sortType: ItemsSortType) -> (field: String, order: String) { diff --git a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift index 5c9730dd3..8525c0020 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift @@ -58,14 +58,14 @@ struct ItemCellModel { accessory = nil typeName = "Collection" - case .item(let cellData, _, _): - typeIconName = cellData.typeIconName - subtitle = cellData.subtitle - hasNote = cellData.hasNote - tagColors = cellData.tagColors - tagEmojis = cellData.tagEmojis - accessory = cellData.accessory - typeName = cellData.localizedTypeName + case .item(let item): + typeIconName = item.typeIconName + subtitle = item.creatorSummary + hasNote = item.hasNote + tagColors = item.tagColors + tagEmojis = item.tagEmojis + accessory = item.cellAccessory + typeName = item.localizedTypeName } } diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift b/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift index d0384d48e..461b20b79 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemsAction.swift @@ -28,8 +28,7 @@ enum ItemsAction { case removeDownloads(Set) case search(String) case selectItem(String) - case setSortField(ItemsSortType.Field) - case setSortOrder(Bool) + case setSortType(ItemsSortType) case startEditing case stopEditing case tagItem(itemKey: String, libraryId: LibraryIdentifier, tagNames: Set) diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsSortType.swift b/Zotero/Scenes/Detail/Items/Models/ItemsSortType.swift index 92091b6b0..81fb65857 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemsSortType.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemsSortType.swift @@ -10,7 +10,7 @@ import Foundation import RealmSwift -struct ItemsSortType: Codable { +struct ItemsSortType: Codable, Equatable { enum Field: Int, CaseIterable, Identifiable, Codable { case creator, date, dateAdded, dateModified, itemType, publicationTitle, publisher, title, year diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsState.swift b/Zotero/Scenes/Detail/Items/Models/ItemsState.swift index 58c400eee..5c45b1656 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemsState.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemsState.swift @@ -149,9 +149,9 @@ struct ItemsState: ViewModelState { } mutating func cleanup() { - self.error = nil - self.changes = [] - self.itemKeyToDuplicate = nil - self.updateItemKey = nil + error = nil + changes = [] + itemKeyToDuplicate = nil + updateItemKey = nil } } diff --git a/Zotero/Scenes/Detail/Items/ViewModels/BaseItemsActionHandler.swift b/Zotero/Scenes/Detail/Items/ViewModels/BaseItemsActionHandler.swift index b0c7099b5..e4f274018 100644 --- a/Zotero/Scenes/Detail/Items/ViewModels/BaseItemsActionHandler.swift +++ b/Zotero/Scenes/Detail/Items/ViewModels/BaseItemsActionHandler.swift @@ -104,51 +104,12 @@ class BaseItemsActionHandler: BackgroundDbProcessingActionHandler { // MARK: - Drag & Drop - func moveItems(from keys: Set, to key: String, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { - let request = MoveItemsToParentDbRequest(itemKeys: keys, parentKey: key, libraryId: libraryId) - self.perform(request: request) { error in - guard let error else { return } - DDLogError("BaseItemsActionHandler: can't move items to parent: \(error)") - completion(.failure(.itemMove)) - } - } - - func add(items itemKeys: Set, to collectionKeys: Set, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { - let request = AssignItemsToCollectionsDbRequest(collectionKeys: collectionKeys, itemKeys: itemKeys, libraryId: libraryId) - self.perform(request: request) { error in - guard let error else { return } - DDLogError("BaseItemsActionHandler: can't assign collections to items - \(error)") - completion(.failure(.collectionAssignment)) - } - } - func tagItem(key: String, libraryId: LibraryIdentifier, with names: Set) { let request = AddTagsToItemDbRequest(key: key, libraryId: libraryId, tagNames: names) - self.perform(request: request) { error in + perform(request: request) { error in guard let error = error else { return } // TODO: - show error DDLogError("BaseItemsActionHandler: can't add tags - \(error)") } } - - // MARK: - Toolbar Actions - - func deleteItemsFromCollection(keys: Set, collectionId: CollectionIdentifier, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { - guard let key = collectionId.key else { return } - let request = DeleteItemsFromCollectionDbRequest(collectionKey: key, itemKeys: keys, libraryId: libraryId) - self.perform(request: request) { error in - guard let error else { return } - DDLogError("BaseItemsActionHandler: can't delete items - \(error)") - completion(.failure(.deletionFromCollection)) - } - } - - func set(trashed: Bool, to keys: Set, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { - let request = MarkItemsAsTrashedDbRequest(keys: Array(keys), libraryId: libraryId, trashed: trashed) - self.perform(request: request) { error in - guard let error else { return } - DDLogError("BaseItemsActionHandler: can't trash items - \(error)") - completion(.failure(.deletion)) - } - } } diff --git a/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift b/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift index 5c3b3cfeb..956284f62 100644 --- a/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift +++ b/Zotero/Scenes/Detail/Items/ViewModels/ItemsActionHandler.swift @@ -101,8 +101,8 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { case .search(let text): self.search(for: (text.isEmpty ? nil : text), ignoreOriginal: false, in: viewModel) - case .setSortField(let field): - changeSortType(to: ItemsSortType(field: field, ascending: field.defaultOrderAscending), in: viewModel) + case .setSortType(let type): + changeSortType(to: type, in: viewModel) case .startEditing: self.startEditing(in: viewModel) @@ -112,11 +112,6 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { self.stopEditing(in: &state) } - case .setSortOrder(let ascending): - var sortType = viewModel.state.sortType - sortType.ascending = ascending - changeSortType(to: sortType, in: viewModel) - case .trashItems(let keys): set(trashed: true, to: keys, libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) @@ -189,7 +184,6 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { private func loadInitialState(in viewModel: ViewModel) { do { - let sortType = Defaults.shared.itemsSortType let (library, libraryToken) = try viewModel.state.library.identifier.observe(in: dbStorage, changes: { [weak self, weak viewModel] library in guard let self, let viewModel else { return } update(viewModel: viewModel) { state in @@ -201,7 +195,7 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { for: viewModel.state.searchTerm, filters: viewModel.state.filters, collectionId: viewModel.state.collection.identifier, - sortType: sortType, + sortType: viewModel.state.sortType, libraryId: viewModel.state.library.identifier ) @@ -209,7 +203,6 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { state.results = results state.library = library state.libraryToken = libraryToken - state.sortType = sortType } } catch let error { update(viewModel: viewModel) { state in @@ -371,6 +364,26 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } + // MARK: - Drag & Drop + + private func moveItems(from keys: Set, to key: String, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { + let request = MoveItemsToParentDbRequest(itemKeys: keys, parentKey: key, libraryId: libraryId) + perform(request: request) { error in + guard let error else { return } + DDLogError("BaseItemsActionHandler: can't move items to parent: \(error)") + completion(.failure(.itemMove)) + } + } + + private func add(items itemKeys: Set, to collectionKeys: Set, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { + let request = AssignItemsToCollectionsDbRequest(collectionKeys: collectionKeys, itemKeys: itemKeys, libraryId: libraryId) + perform(request: request) { error in + guard let error else { return } + DDLogError("BaseItemsActionHandler: can't assign collections to items - \(error)") + completion(.failure(.collectionAssignment)) + } + } + // MARK: - Toolbar actions /// Loads item which was selected for duplication from DB. When `itemDuplication` is set, appropriate screen with loaded item is opened. @@ -392,9 +405,30 @@ final class ItemsActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } + private func deleteItemsFromCollection(keys: Set, collectionId: CollectionIdentifier, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { + guard let key = collectionId.key else { return } + let request = DeleteItemsFromCollectionDbRequest(collectionKey: key, itemKeys: keys, libraryId: libraryId) + perform(request: request) { error in + guard let error else { return } + DDLogError("BaseItemsActionHandler: can't delete items - \(error)") + completion(.failure(.deletionFromCollection)) + } + } + + private func set(trashed: Bool, to keys: Set, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { + let request = MarkItemsAsTrashedDbRequest(keys: Array(keys), libraryId: libraryId, trashed: trashed) + perform(request: request) { error in + guard let error else { return } + DDLogError("BaseItemsActionHandler: can't trash items - \(error)") + completion(.failure(.deletion)) + } + } + // MARK: - Overlay actions private func changeSortType(to sortType: ItemsSortType, in viewModel: ViewModel) { + guard sortType != viewModel.state.sortType else { return } + let results = try? results( for: viewModel.state.searchTerm, filters: viewModel.state.filters, diff --git a/Zotero/Scenes/Detail/Items/ViewModels/ItemsToolbarController.swift b/Zotero/Scenes/Detail/Items/ViewModels/ItemsToolbarController.swift index f24ea89cb..822ea42be 100644 --- a/Zotero/Scenes/Detail/Items/ViewModels/ItemsToolbarController.swift +++ b/Zotero/Scenes/Detail/Items/ViewModels/ItemsToolbarController.swift @@ -19,7 +19,7 @@ protocol ItemsToolbarControllerDelegate: UITraitEnvironment { final class ItemsToolbarController { struct Data { let isEditing: Bool - let selectedItems: Set + let selectedItems: Set let filters: [ItemsFilter] let downloadBatchData: ItemsState.DownloadBatchData? let remoteDownloadBatchData: ItemsState.DownloadBatchData? @@ -87,9 +87,9 @@ final class ItemsToolbarController { updateEditingToolbarItems(for: data.selectedItems) } else { let filters = sizeClassSpecificFilters(from: data.filters) - viewController.toolbarItems = createNormalToolbarItems(for: data.filters) + viewController.toolbarItems = createNormalToolbarItems(for: filters) updateNormalToolbarItems( - for: data.filters, + for: filters, downloadBatchData: data.downloadBatchData, remoteDownloadBatchData: data.remoteDownloadBatchData, identifierLookupBatchData: data.identifierLookupBatchData, @@ -235,7 +235,7 @@ final class ItemsToolbarController { // MARK: - Helpers - private func updateEditingToolbarItems(for selectedItems: Set) { + private func updateEditingToolbarItems(for selectedItems: Set) { viewController.toolbarItems?.forEach({ item in switch ToolbarItem(rawValue: item.tag) { case .empty: diff --git a/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift b/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift index 06bd8f1a9..0da786466 100644 --- a/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/BaseItemsViewController.swift @@ -32,24 +32,6 @@ class BaseItemsViewController: UIViewController { var refreshController: SyncRefreshController? var handler: ItemsTableViewHandler? weak var tagFilterDelegate: ItemsTagFilterDelegate? - var library: Library { - return Library(identifier: .custom(.myLibrary), name: "", metadataEditable: false, filesEditable: false) - } - var collection: Collection { - return .init(custom: .all) - } - var toolbarData: ItemsToolbarController.Data { - return .init( - isEditing: false, - selectedItems: [], - filters: [], - downloadBatchData: nil, - remoteDownloadBatchData: nil, - identifierLookupBatchData: ItemsState.IdentifierLookupBatchData(saved: 0, total: 0), - itemCount: 0 - ) - } - weak var coordinatorDelegate: (DetailItemsCoordinatorDelegate & DetailNoteEditorCoordinatorDelegate)? init(controllers: Controllers, coordinatorDelegate: (DetailItemsCoordinatorDelegate & DetailNoteEditorCoordinatorDelegate)) { @@ -151,14 +133,54 @@ class BaseItemsViewController: UIViewController { .disposed(by: disposeBag) } + func process(downloadUpdate update: AttachmentDownloader.Update, toOpen: String?, downloader: AttachmentDownloader?, dataUpdate: (ItemsState.DownloadBatchData?) -> Void, attachmentWillOpen: (AttachmentDownloader.Update) -> Void) { + if let downloader { + let batchData = ItemsState.DownloadBatchData(batchData: downloader.batchData) + dataUpdate(batchData) + } + + guard !update.kind.isProgress && toOpen == update.key else { return } + + attachmentWillOpen(update) + + switch update.kind { + case .ready: + coordinatorDelegate?.showAttachment(key: update.key, parentKey: update.parentKey, libraryId: update.libraryId) + + case .failed(let error): + coordinatorDelegate?.showAttachmentError(error) + + case .progress, .cancelled: + break + } + } + // MARK: - To override + var library: Library { + return Library(identifier: .custom(.myLibrary), name: "", metadataEditable: false, filesEditable: false) + } + + var collection: Collection { + return .init(custom: .all) + } + + var toolbarData: ItemsToolbarController.Data { + return .init( + isEditing: false, + selectedItems: [], + filters: [], + downloadBatchData: nil, + remoteDownloadBatchData: nil, + identifierLookupBatchData: ItemsState.IdentifierLookupBatchData(saved: 0, total: 0), + itemCount: 0 + ) + } + func search(for term: String) {} func tagSelectionDidChange(selected: Set) {} - func process(action: ItemAction.Kind, for selectedKeys: Set, button: UIBarButtonItem?, completionAction: ((Bool) -> Void)?) {} - func process(barButtonItemAction: RightBarButtonItem, sender: UIBarButtonItem) {} func downloadsFilterDidChange(enabled: Bool) {} @@ -235,16 +257,6 @@ class BaseItemsViewController: UIViewController { } } -extension BaseItemsViewController: ItemsToolbarControllerDelegate { - func process(action: ItemAction.Kind, button: UIBarButtonItem) { - process(action: action, for: toolbarData.selectedItems, button: button, completionAction: nil) - } - - func showLookup() { - coordinatorDelegate?.showLookup() - } -} - extension BaseItemsViewController: FiltersDelegate { var currentLibrary: Library { return library diff --git a/Zotero/Scenes/Detail/Items/Views/ItemSortTypePickerView.swift b/Zotero/Scenes/Detail/Items/Views/ItemSortTypePickerView.swift index 8b61e204c..f5b750536 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemSortTypePickerView.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemSortTypePickerView.swift @@ -9,19 +9,21 @@ import SwiftUI struct ItemSortTypePickerView: View { - @Binding var sortBy: ItemsSortType.Field + @Binding var sortType: ItemsSortType let closeAction: () -> Void var body: some View { List { - ForEach(ItemsSortType.Field.allCases) { sortType in + ForEach(ItemsSortType.Field.allCases) { field in Button { - self.sortBy = sortType + var new = sortType + new.field = field + new.ascending = field.defaultOrderAscending + sortType = new self.closeAction() } label: { - SortTypeRow(title: sortType.title, - isSelected: (self.sortBy == sortType)) + SortTypeRow(title: field.title, isSelected: (sortType.field == field)) } .foregroundColor(Color(.label)) } @@ -50,6 +52,6 @@ private struct SortTypeRow: View { struct ItemSortTypePickerView_Previews: PreviewProvider { static var previews: some View { - ItemSortTypePickerView(sortBy: .constant(.title), closeAction: {}) + ItemSortTypePickerView(sortType: .constant(ItemsSortType(field: .creator, ascending: false)), closeAction: {}) } } diff --git a/Zotero/Scenes/Detail/Items/Views/ItemSortingView.swift b/Zotero/Scenes/Detail/Items/Views/ItemSortingView.swift index 5e27fe9f7..35bde9bdb 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemSortingView.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemSortingView.swift @@ -9,17 +9,19 @@ import SwiftUI struct ItemSortingView: View { - @ObservedObject var viewModel: ViewModel + @State var sortType: ItemsSortType - var showPickerAction: () -> Void + let changed: (ItemsSortType) -> Void + let showPicker: (ItemSortTypePickerView) -> Void + let closePicker: () -> Void var body: some View { VStack(spacing: 20) { Button { - self.showPickerAction() + showPicker(ItemSortTypePickerView(sortType: $sortType, closeAction: closePicker)) } label: { HStack { - Text("\(L10n.Items.sortBy): \(self.viewModel.state.sortType.field.title)") + Text("\(L10n.Items.sortBy): \(sortType.field.title)") .foregroundColor(Color(UIColor.label)) Spacer() @@ -34,7 +36,7 @@ struct ItemSortingView: View { Divider() - Picker(L10n.Items.sortOrder, selection: self.viewModel.binding(get: \.sortType.ascending, action: { .setSortOrder($0) })) { + Picker(L10n.Items.sortOrder, selection: $sortType.ascending) { Text("Ascending").tag(true) Text("Descending").tag(false) } @@ -46,5 +48,8 @@ struct ItemSortingView: View { } } .padding(EdgeInsets(top: 20, leading: 0, bottom: 20, trailing: 0)) + .onChange(of: sortType) { newValue in + self.changed(newValue) + } } } diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift index 0f87cca3a..798273bb3 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift @@ -6,7 +6,9 @@ // Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. // +import Combine import UIKit +import SwiftUI import CocoaLumberjackSwift import RealmSwift @@ -19,7 +21,6 @@ final class ItemsViewController: BaseItemsViewController { private var dataSource: RItemsTableViewDataSource! private var resultsToken: NotificationToken? private var libraryToken: NotificationToken? - override var library: Library { return viewModel.state.library } @@ -141,7 +142,7 @@ final class ItemsViewController: BaseItemsViewController { self.viewModel.process(action: .search(term)) } - override func process(action: ItemAction.Kind, for selectedKeys: Set, button: UIBarButtonItem?, completionAction: ((Bool) -> Void)?) { + private func process(action: ItemAction.Kind, for selectedKeys: Set, button: UIBarButtonItem?, completionAction: ((Bool) -> Void)?) { switch action { case .delete, .restore: break @@ -186,7 +187,13 @@ final class ItemsViewController: BaseItemsViewController { case .sort: guard let button else { return } - coordinatorDelegate?.showSortActions(viewModel: viewModel, button: button) + coordinatorDelegate?.showSortActions( + sortType: viewModel.state.sortType, + button: button, + changed: { [weak self] newValue in + self?.viewModel.process(action: .setSortType(newValue)) + } + ) case .share: guard !selectedKeys.isEmpty else { return } @@ -317,28 +324,17 @@ final class ItemsViewController: BaseItemsViewController { .observe(on: MainScheduler.asyncInstance) .subscribe(onNext: { [weak self, weak downloader] update in guard let self else { return } - - if let downloader { - let batchData = ItemsState.DownloadBatchData(batchData: downloader.batchData) - viewModel.process(action: .updateDownload(update: update, batchData: batchData)) - } - - if case .progress = update.kind { return } - - guard viewModel.state.attachmentToOpen == update.key else { return } - - viewModel.process(action: .attachmentOpened(update.key)) - - switch update.kind { - case .ready: - coordinatorDelegate?.showAttachment(key: update.key, parentKey: update.parentKey, libraryId: update.libraryId) - - case .failed(let error): - coordinatorDelegate?.showAttachmentError(error) - - default: - break - } + 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) @@ -438,6 +434,16 @@ extension ItemsViewController: ItemsTableViewHandlerDelegate { } } +extension ItemsViewController: ItemsToolbarControllerDelegate { + func process(action: ItemAction.Kind, button: UIBarButtonItem) { + process(action: action, for: viewModel.state.selectedItems, button: button, completionAction: nil) + } + + func showLookup() { + coordinatorDelegate?.showLookup() + } +} + extension ItemsViewController: DetailCoordinatorAttachmentProvider { func attachment(for key: String, parentKey: String?, libraryId: LibraryIdentifier) -> (Attachment, UIView, CGRect?)? { guard diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift b/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift index 08fbb72ac..d55b1d962 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift @@ -9,22 +9,22 @@ import Foundation enum TrashAction { - case assignItemsToCollections(items: Set, collections: Set) - case deleteItems(Set) - case deleteItemsFromCollection(Set) + case attachmentOpened(String) + case deleteObjects(Set) case deselectItem(TrashKey) case disableFilter(ItemsFilter) + case download(Set) case emptyTrash case enableFilter(ItemsFilter) case loadData - case moveItems(keys: Set, toItemKey: String) - case restoreItems(Set) + case openAttachment(attachment: Attachment, parentKey: String?) + case restoreItems(Set) case search(String) - case setSortField(ItemsSortType.Field) - case setSortOrder(Bool) + case setSortType(ItemsSortType) case selectItem(TrashKey) case startEditing case stopEditing case tagItem(itemKey: String, libraryId: LibraryIdentifier, tagNames: Set) case toggleSelectionState + case updateDownload(update: AttachmentDownloader.Update, batchData: ItemsState.DownloadBatchData?) } diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift index 1174acfef..bb6238639 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift @@ -19,30 +19,30 @@ struct TrashKey: Hashable { } struct TrashObject { - struct ItemSortData { - let title: String + struct Item { + let sortTitle: String let type: String + let localizedTypeName: String + let typeIconName: String let creatorSummary: String let publisher: String? let publicationTitle: String? let year: Int? let date: Date? let dateAdded: Date - } - - struct ItemCellData { - let localizedTypeName: String - let typeIconName: String - let subtitle: String - let accessory: ItemCellModel.Accessory? + let tagNames: Set let tagColors: [UIColor] let tagEmojis: [String] let hasNote: Bool + let itemAccessory: ItemAccessory? + let cellAccessory: ItemCellModel.Accessory? + let isMainAttachmentDownloaded: Bool + let searchStrings: Set } enum Kind { case collection - case item(cellData: ItemCellData, sortData: ItemSortData, accessory: ItemAccessory?) + case item(item: Item) } let type: Kind @@ -68,15 +68,15 @@ struct TrashObject { case .collection: return title.string - case .item(_, let sortData, _): - return sortData.title + case .item(let item): + return item.sortTitle } } var sortType: String? { switch type { - case .item(_, let sortData, _): - return sortData.type + case .item(let item): + return item.type case .collection: return nil @@ -85,8 +85,8 @@ struct TrashObject { var creatorSummary: String? { switch type { - case .item(_, let sortData, _): - return sortData.creatorSummary + case .item(let item): + return item.creatorSummary case .collection: return nil @@ -95,8 +95,8 @@ struct TrashObject { var publisher: String? { switch type { - case .item(_, let sortData, _): - return sortData.publisher + case .item(let item): + return item.publisher case .collection: return nil @@ -105,8 +105,8 @@ struct TrashObject { var publicationTitle: String? { switch type { - case .item(_, let sortData, _): - return sortData.publicationTitle + case .item(let item): + return item.publicationTitle case .collection: return nil @@ -115,8 +115,8 @@ struct TrashObject { var year: Int? { switch type { - case .item(_, let sortData, _): - return sortData.year + case .item(let item): + return item.year case .collection: return nil @@ -125,8 +125,8 @@ struct TrashObject { var date: Date? { switch type { - case .item(_, let sortData, _): - return sortData.date + case .item(let item): + return item.date case .collection: return nil @@ -135,18 +135,18 @@ struct TrashObject { var dateAdded: Date? { switch type { - case .item(_, let sortData, _): - return sortData.dateAdded + case .item(let item): + return item.dateAdded case .collection: - return nil + return dateModified } } var itemAccessory: ItemAccessory? { switch type { - case .item(_, _, let accessory): - return accessory + case .item(let item): + return item.itemAccessory case .collection: return nil diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift index 474069459..3ed0ad649 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift @@ -22,6 +22,7 @@ struct TrashState: ViewModelState { static let selection = Changes(rawValue: 1 << 2) static let selectAll = Changes(rawValue: 1 << 3) static let filters = Changes(rawValue: 1 << 4) + static let batchData = Changes(rawValue: 1 << 5) } enum Error: Swift.Error { @@ -41,13 +42,17 @@ struct TrashState: ViewModelState { var filters: [ItemsFilter] var isEditing: Bool var selectedItems: Set + var attachmentToOpen: String? + var downloadBatchData: ItemsState.DownloadBatchData? + // Used to indicate which row should update it's attachment view. The update is done directly to cell instead of tableView reload. + var updateItemKey: TrashKey? var changes: Changes var error: ItemsError? var titleFont: UIFont { return UIFont.preferredFont(for: .headline, weight: .regular) } - init(libraryId: LibraryIdentifier, sortType: ItemsSortType, searchTerm: String?, filters: [ItemsFilter]) { + init(libraryId: LibraryIdentifier, sortType: ItemsSortType, searchTerm: String?, filters: [ItemsFilter], downloadBatchData: ItemsState.DownloadBatchData?) { objects = [:] self.sortType = sortType self.filters = filters @@ -55,6 +60,7 @@ struct TrashState: ViewModelState { isEditing = false changes = [] selectedItems = [] + self.downloadBatchData = downloadBatchData switch libraryId { case .custom: @@ -68,5 +74,6 @@ struct TrashState: ViewModelState { mutating func cleanup() { error = nil changes = [] + updateItemKey = nil } } diff --git a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift index 89fea4cb8..dcc5cec0f 100644 --- a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift +++ b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift @@ -57,8 +57,11 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { case .loadData: loadData(in: viewModel) - case .deleteItems(let keys): - delete(items: keys, viewModel: viewModel) + case .deleteObjects(let keys): + delete(objects: keys, viewModel: viewModel) + + case .download(let keys): + downloadAttachments(for: keys, in: viewModel) case .emptyTrash: emptyTrash(in: viewModel) @@ -66,15 +69,6 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { case .tagItem(let itemKey, let libraryId, let tagNames): tagItem(key: itemKey, libraryId: libraryId, with: tagNames) - case .assignItemsToCollections(let items, let collections): - add(items: items, to: collections, libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) - - case .deleteItemsFromCollection(let keys): - deleteItemsFromCollection(keys: keys, collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) - - case .moveItems(let keys, let toItemKey): - moveItems(from: keys, to: toItemKey, libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) - case .restoreItems(let keys): set(trashed: false, to: keys, libraryId: viewModel.state.library.identifier, completion: handleBaseActionResult) @@ -93,12 +87,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { case .search(let term): search(with: term, in: viewModel) - case .setSortField(let field): - changeSortType(to: ItemsSortType(field: field, ascending: field.defaultOrderAscending), in: viewModel) - - case .setSortOrder(let ascending): - var type = viewModel.state.sortType - type.ascending = ascending + case .setSortType(let type): changeSortType(to: type, in: viewModel) case .toggleSelectionState: @@ -122,6 +111,18 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { state.selectedItems.insert(key) state.changes.insert(.selection) } + + case .updateDownload(let update, let batchData): + process(downloadUpdate: update, batchData: batchData, in: viewModel) + + case .openAttachment(let attachment, let parentKey): + open(attachment: attachment, parentKey: parentKey, in: viewModel) + + case .attachmentOpened(let key): + guard viewModel.state.attachmentToOpen == key else { return } + self.update(viewModel: viewModel) { state in + state.attachmentToOpen = nil + } } } @@ -187,35 +188,65 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { return TrashObject(type: .collection, key: collection.key, libraryId: libraryId, title: attributedTitle, dateModified: collection.dateModified) } - func trashObject(from item: RItem, titleFont: UIFont) -> TrashObject? { - guard let libraryId = item.libraryId else { return nil } - let itemAccessory = ItemAccessory.create(from: item, fileStorage: fileStorage, urlDetector: urlDetector) + func trashObject(from rItem: RItem, titleFont: UIFont) -> TrashObject? { + guard let libraryId = rItem.libraryId else { return nil } + let itemAccessory = ItemAccessory.create(from: rItem, fileStorage: fileStorage, urlDetector: urlDetector) let cellAccessory = itemAccessory.flatMap({ ItemCellModel.createAccessory(from: $0, fileDownloader: fileDownloader) }) - let creatorSummary = ItemCellModel.creatorSummary(for: item) - let (tagColors, tagEmojis) = ItemCellModel.tagData(item: item) - let hasNote = ItemCellModel.hasNote(item: item) - let typeName = schemaController.localized(itemType: item.rawType) ?? item.rawType - let attributedTitle = htmlAttributedStringConverter.convert(text: item.displayTitle, baseAttributes: [.font: titleFont]) - let cellData = TrashObject.ItemCellData( + let creatorSummary = ItemCellModel.creatorSummary(for: rItem) + let (tagColors, tagEmojis) = ItemCellModel.tagData(item: rItem) + let hasNote = ItemCellModel.hasNote(item: rItem) + let typeName = schemaController.localized(itemType: rItem.rawType) ?? rItem.rawType + let attributedTitle = htmlAttributedStringConverter.convert(text: rItem.displayTitle, baseAttributes: [.font: titleFont]) + let item = TrashObject.Item( + sortTitle: rItem.sortTitle, + type: rItem.rawType, localizedTypeName: typeName, - typeIconName: ItemCellModel.typeIconName(for: item), - subtitle: creatorSummary, - accessory: cellAccessory, + typeIconName: ItemCellModel.typeIconName(for: rItem), + creatorSummary: creatorSummary, + publisher: rItem.publisher, + publicationTitle: rItem.publicationTitle, + year: rItem.hasParsedYear ? rItem.parsedYear : nil, + date: rItem.parsedDate, + dateAdded: rItem.dateAdded, + tagNames: Set(rItem.tags.compactMap({ $0.tag?.name })), tagColors: tagColors, tagEmojis: tagEmojis, - hasNote: hasNote - ) - let sortData = TrashObject.ItemSortData( - title: item.sortTitle, - type: item.localizedType, - creatorSummary: creatorSummary, - publisher: item.publisher, - publicationTitle: item.publicationTitle, - year: item.hasParsedYear ? item.parsedYear : nil, - date: item.parsedDate, - dateAdded: item.dateAdded + hasNote: hasNote, + itemAccessory: itemAccessory, + cellAccessory: cellAccessory, + isMainAttachmentDownloaded: rItem.fileDownloaded, + searchStrings: searchStrings(from: rItem) ) - return TrashObject(type: .item(cellData: cellData, sortData: sortData, accessory: itemAccessory), key: item.key, libraryId: libraryId, title: attributedTitle, dateModified: item.dateModified) + return TrashObject(type: .item(item: item), key: rItem.key, libraryId: libraryId, title: attributedTitle, dateModified: rItem.dateModified) + + func searchStrings(from item: RItem) -> Set { + var strings: Set = [item.key, item.sortTitle] + if let value = item.htmlFreeContent { + strings.insert(value) + } + for creator in item.creators { + if !creator.name.isEmpty { + strings.insert(creator.name) + } + if !creator.firstName.isEmpty { + strings.insert(creator.firstName) + } + if !creator.lastName.isEmpty { + strings.insert(creator.lastName) + } + } + for tag in item.tags { + guard let name = tag.tag?.name else { continue } + strings.insert(name) + } + for field in item.fields { + strings.insert(field.value) + } + for child in item.children { + strings.formUnion(searchStrings(from: child)) + } + return strings + } } } @@ -242,13 +273,17 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { // Collections don't have tags or can be "downloaded", so they fail automatically return false - case .item(let cellData, let sortData, let accessory): + case .item(let item): switch filter { case .downloadedFiles: - continue + if !item.isMainAttachmentDownloaded { + return false + } case .tags(let tagNames): - continue + if item.tagNames.intersection(tagNames).isEmpty { + return false + } } } } @@ -261,50 +296,25 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { let components = createComponents(from: term) guard !components.isEmpty else { return true } for component in components { - // If object contains component somewhere, return true, otherwise continue -// let keyPredicate = NSPredicate(format: "key == %@", text) -// let childrenKeyPredicate = NSPredicate(format: "any children.key == %@", text) -// // TODO: - ideally change back to "==" if Realm issue is fixed -// let childrenChildrenKeyPredicate = NSPredicate(format: "any children.children.key contains %@", text) -// let contentPredicate = NSPredicate(format: "htmlFreeContent contains[c] %@", text) -// let childrenContentPredicate = NSPredicate(format: "any children.htmlFreeContent contains[c] %@", text) -// let childrenChildrenContentPredicate = NSPredicate(format: "any children.children.htmlFreeContent contains[c] %@", text) -// let titlePredicate = NSPredicate(format: "sortTitle contains[c] %@", text) -// let childrenTitlePredicate = NSPredicate(format: "any children.sortTitle contains[c] %@", text) -// let creatorFullNamePredicate = NSPredicate(format: "any creators.name contains[c] %@", text) -// let creatorFirstNamePredicate = NSPredicate(format: "any creators.firstName contains[c] %@", text) -// let creatorLastNamePredicate = NSPredicate(format: "any creators.lastName contains[c] %@", text) -// let tagPredicate = NSPredicate(format: "any tags.tag.name contains[c] %@", text) -// let childrenTagPredicate = NSPredicate(format: "any children.tags.tag.name contains[c] %@", text) -// let childrenChildrenTagPredicate = NSPredicate(format: "any children.children.tags.tag.name contains[c] %@", text) -// let fieldsPredicate = NSPredicate(format: "any fields.value contains[c] %@", text) -// let childrenFieldsPredicate = NSPredicate(format: "any children.fields.value contains[c] %@", text) -// let childrenChildrenFieldsPredicate = NSPredicate(format: "any children.children.fields.value contains[c] %@", text) -// -// var predicates = [ -// keyPredicate, -// titlePredicate, -// contentPredicate, -// creatorFullNamePredicate, -// creatorFirstNamePredicate, -// creatorLastNamePredicate, -// tagPredicate, -// childrenKeyPredicate, -// childrenTitlePredicate, -// childrenContentPredicate, -// childrenTagPredicate, -// childrenChildrenKeyPredicate, -// childrenChildrenContentPredicate, -// childrenChildrenTagPredicate, -// fieldsPredicate, -// childrenFieldsPredicate, -// childrenChildrenFieldsPredicate -// ] -// -// if let int = Int(text) { -// let yearPredicate = NSPredicate(format: "parsedYear == %d", int) -// predicates.insert(yearPredicate, at: 3) -// } + switch object.type { + case .item(let item): + for string in item.searchStrings { + if string == component || string.localizedCaseInsensitiveContains(component) { + return true + } + } + + case .collection: + if component.lowercased() == "collection" { + return true + } + if object.key == component { + return true + } + if object.title.string.localizedCaseInsensitiveContains(component) { + return true + } + } } return false } @@ -404,17 +414,56 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } - private func delete(items keys: Set, viewModel: ViewModel) { - let request = MarkObjectsAsDeletedDbRequest(keys: Array(keys), libraryId: viewModel.state.library.identifier) - self.perform(request: request) { [weak self, weak viewModel] error in + private func delete(objects keys: Set, viewModel: ViewModel) { + let (items, collections) = split(keys: keys) + var requests: [DbRequest] = [] + if !items.isEmpty { + requests.append(MarkObjectsAsDeletedDbRequest(keys: items, libraryId: viewModel.state.library.identifier)) + } + if !collections.isEmpty { + requests.append(MarkObjectsAsDeletedDbRequest(keys: collections, libraryId: viewModel.state.library.identifier)) + } + + perform(writeRequests: requests) { [weak self, weak viewModel] error in guard let self, let viewModel, let error else { return } - DDLogError("BaseItemsActionHandler: can't delete items - \(error)") + DDLogError("TrashActionHandler: can't delete objects - \(error)") update(viewModel: viewModel) { state in state.error = .deletion } } } + private func set(trashed: Bool, to keys: Set, libraryId: LibraryIdentifier, completion: @escaping (Result) -> Void) { + let (items, collections) = split(keys: keys) + var requests: [DbRequest] = [] + if !items.isEmpty { + requests.append(MarkItemsAsTrashedDbRequest(keys: items, libraryId: libraryId, trashed: trashed)) + } + if !collections.isEmpty { + requests.append(MarkCollectionsAsTrashedDbRequest(keys: collections, libraryId: libraryId, trashed: trashed)) + } + perform(writeRequests: requests) { error in + guard let error else { return } + DDLogError("TrashActionHandler: can't trash objects - \(error)") + completion(.failure(.deletion)) + } + } + + private func split(keys: Set) -> (items: [String], collections: [String]) { + var items: [String] = [] + var collections: [String] = [] + for key in keys { + switch key.type { + case .collection: + collections.append(key.key) + + case .item: + items.append(key.key) + } + } + return (items, collections) + } + private func startEditing(in viewModel: ViewModel) { update(viewModel: viewModel) { state in state.isEditing = true @@ -430,6 +479,80 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } + // MARK: - Downloads + + private func downloadAttachments(for keys: Set, in viewModel: ViewModel) { + var attachments: [(Attachment, String?)] = [] + for key in keys { + guard let attachment = viewModel.state.objects[TrashKey(type: .item, key: key)]?.itemAccessory?.attachment else { continue } + let parentKey = attachment.key == key ? nil : key + attachments.append((attachment, parentKey)) + } + fileDownloader.batchDownload(attachments: attachments) + } + + private func process(downloadUpdate: AttachmentDownloader.Update, batchData: ItemsState.DownloadBatchData?, in viewModel: ViewModel) { + let updateKey = TrashKey(type: .item, key: downloadUpdate.parentKey ?? downloadUpdate.key) + guard let accessory = viewModel.state.objects[updateKey]?.itemAccessory, let attachment = accessory.attachment else { + updateViewModel() + return + } + + switch downloadUpdate.kind { + case .ready(let compressed): + DDLogInfo("TrashActionHandler: download update \(attachment.key); \(attachment.libraryId); kind \(downloadUpdate.kind)") + 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 + } + + case .progress: + // If file is being extracted, the extraction is usually very quick and sends multiple quick progress updates, due to switching between queues and small delays those updates are then + // received here, but the file downloader is already done and we're unnecessarily reloading the table view with the same progress. So we're filtering out those unnecessary updates + 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 + } + + case .cancelled, .failed: + DDLogInfo("TrashActionHandler: download update \(attachment.key); \(attachment.libraryId); kind \(downloadUpdate.kind)") + updateViewModel { state in + state.updateItemKey = updateKey + } + } + + func updateViewModel(additional: ((inout TrashState) -> Void)? = nil) { + update(viewModel: viewModel) { state in + if state.downloadBatchData != batchData { + state.downloadBatchData = batchData + state.changes = .batchData + } + + additional?(&state) + } + } + } + + private func open(attachment: Attachment, parentKey: String?, in viewModel: ViewModel) { + let (progress, _) = fileDownloader.data(for: attachment.key, parentKey: parentKey, libraryId: attachment.libraryId) + if progress != nil { + if viewModel.state.attachmentToOpen == attachment.key { + self.update(viewModel: viewModel) { state in + state.attachmentToOpen = nil + } + } + + fileDownloader.cancel(key: attachment.key, parentKey: parentKey, libraryId: attachment.libraryId) + } else { + update(viewModel: viewModel) { state in + state.attachmentToOpen = attachment.key + } + fileDownloader.downloadIfNeeded(attachment: attachment, parentKey: parentKey) + } + } + // MARK: - Searching & Filtering private func search(with term: String?, in viewModel: ViewModel) { @@ -460,6 +583,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } private func changeSortType(to sortType: ItemsSortType, in viewModel: ViewModel) { + guard sortType != viewModel.state.sortType else { return } var ordered: OrderedDictionary = [:] for object in viewModel.state.objects { let index = ordered.index(of: object.value, sortedBy: { areInIncreasingOrder(lObject: $0, rObject: $1, sortType: sortType) }) diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift index 22a08f8d6..1f8bdecac 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift @@ -36,6 +36,11 @@ extension TrashTableViewDataSource { return viewModel.state.selectedItems } + func key(at index: Int) -> TrashKey? { + guard let snapshot, index < snapshot.keys.count else { return nil } + return snapshot.keys[index] + } + func object(at index: Int) -> ItemsTableViewObject? { return trashObject(at: index) } @@ -53,8 +58,8 @@ extension TrashTableViewDataSource { } guard let accessory = object.itemAccessory else { - guard case .item(_, let sortData, _) = object.type else { return nil } - switch sortData.type { + guard case .item(let item) = object.type else { return nil } + switch item.type { case ItemTypes.note: return .note(object) @@ -119,8 +124,8 @@ extension TrashTableViewDataSource { extension TrashObject: ItemsTableViewObject { var isNote: Bool { switch type { - case .item(_, let sortData, _): - return sortData.type == ItemTypes.note + case .item(let item): + return item.type == ItemTypes.note case .collection: return false @@ -129,8 +134,8 @@ extension TrashObject: ItemsTableViewObject { var isAttachment: Bool { switch type { - case .item(_, let sortData, _): - return sortData.type == ItemTypes.attachment + case .item(let item): + return item.type == ItemTypes.attachment case .collection: return false diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift index a32a5f543..0d08ae8f6 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -14,6 +14,12 @@ final class TrashViewController: BaseItemsViewController { private let viewModel: ViewModel private var dataSource: TrashTableViewDataSource! + override var library: Library { + return viewModel.state.library + } + override var collection: Collection { + return .init(custom: .trash) + } override var toolbarData: ItemsToolbarController.Data { return toolbarData(from: viewModel.state) } @@ -35,6 +41,7 @@ final class TrashViewController: BaseItemsViewController { 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)) + setupDownloadObserver() dataSource.apply(snapshot: viewModel.state.objects) viewModel @@ -44,51 +51,107 @@ final class TrashViewController: BaseItemsViewController { self?.update(state: state) }) .disposed(by: self.disposeBag) + + func setupDownloadObserver() { + 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) + } } // MARK: - Actions private func update(state: TrashState) { + if state.changes.contains(.objects) { + dataSource.apply(snapshot: state.objects) + updateTagFilter(filters: state.filters, collectionId: .custom(.trash), libraryId: state.library.identifier) + } +// 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) +// } + + if state.changes.contains(.editing) { + handler?.set(editing: state.isEditing, animated: true) + setupRightBarButtonItems(expectedItems: rightBarButtonItemTypes(for: state)) + toolbarController?.createToolbarItems(data: toolbarData(from: state)) + } + + if state.changes.contains(.selectAll) { + if state.selectedItems.isEmpty { + handler?.deselectAll() + } else { + handler?.selectAll() + } + } + + if state.changes.contains(.selection) {// || state.changes.contains(.library) { + setupRightBarButtonItems(expectedItems: rightBarButtonItemTypes(for: state)) + toolbarController?.reloadToolbarItems(for: toolbarData(from: state)) + } + + if state.changes.contains(.filters) {// || state.changes.contains(.batchData) { + toolbarController?.reloadToolbarItems(for: toolbarData(from: state)) + } + + if let error = state.error { + process(error: error, state: state) + } + + func process(error: ItemsError, state: TrashState) { + // Perform additional actions for individual errors if needed + switch error { + case .itemMove, .deletion, .deletionFromCollection: + dataSource.apply(snapshot: state.objects) + + case .dataLoading, .collectionAssignment, .noteSaving, .attachmentAdding, .duplicationLoading: + break + } + + // Show appropriate message + coordinatorDelegate?.show(error: error) + } } override func search(for term: String) { -// self.viewModel.process(action: .search(term)) + viewModel.process(action: .search(term)) } - override func process(action: ItemAction.Kind, for selectedKeys: Set, button: UIBarButtonItem?, completionAction: ((Bool) -> Void)?) { + private func process(action: ItemAction.Kind, for selectedKeys: Set, button: UIBarButtonItem?, completionAction: ((Bool) -> Void)?) { switch action { - case .createParent, .duplicate, .trash, .copyBibliography, .copyCitation, .share: + case .createParent, .duplicate, .trash, .copyBibliography, .copyCitation, .share, .addToCollection, .removeFromCollection: // These actions are not available in trash collection break - case .addToCollection: - guard !selectedKeys.isEmpty else { return } - coordinatorDelegate?.showCollectionsPicker(in: library, completed: { [weak self] collections in - self?.viewModel.process(action: .assignItemsToCollections(items: selectedKeys, collections: collections)) - completionAction?(true) - }) - case .delete: guard !selectedKeys.isEmpty else { return } coordinatorDelegate?.showDeletionQuestion( count: selectedKeys.count, confirmAction: { [weak self] in - self?.viewModel.process(action: .deleteItems(selectedKeys)) + self?.viewModel.process(action: .deleteObjects(selectedKeys)) }, cancelAction: { completionAction?(false) } ) - case .removeFromCollection: - guard !selectedKeys.isEmpty else { return } - coordinatorDelegate?.showRemoveFromCollectionQuestion( - count: viewModel.state.objects.count - ) { [weak self] in - self?.viewModel.process(action: .deleteItemsFromCollection(selectedKeys)) - completionAction?(true) - } - case .restore: guard !selectedKeys.isEmpty else { return } viewModel.process(action: .restoreItems(selectedKeys)) @@ -100,7 +163,13 @@ final class TrashViewController: BaseItemsViewController { case .sort: guard let button else { return } -// coordinatorDelegate?.showSortActions(viewModel: viewModel, button: button) + coordinatorDelegate?.showSortActions( + sortType: viewModel.state.sortType, + button: button, + changed: { [weak self] newValue in + self?.viewModel.process(action: .setSortType(newValue)) + } + ) case .download: // viewModel.process(action: .download(selectedKeys)) @@ -135,9 +204,9 @@ final class TrashViewController: BaseItemsViewController { private func toolbarData(from state: TrashState) -> ItemsToolbarController.Data { return .init( - isEditing: false, - selectedItems: [], - filters: [], + isEditing: state.isEditing, + selectedItems: state.selectedItems, + filters: state.filters, downloadBatchData: nil, remoteDownloadBatchData: nil, identifierLookupBatchData: .init(saved: 0, total: 0), @@ -183,8 +252,8 @@ extension TrashViewController: ItemsTableViewHandlerDelegate { } func process(action: ItemAction.Kind, at index: Int, completionAction: ((Bool) -> Void)?) { - guard let object = dataSource.object(at: index) else { return } - process(action: action, for: [object.key], button: nil, completionAction: completionAction) + guard let key = dataSource.key(at: index) else { return } + process(action: action, for: [key], button: nil, completionAction: completionAction) } func process(tapAction action: ItemsTableViewHandler.TapAction) { @@ -226,11 +295,22 @@ extension TrashViewController: ItemsTableViewHandlerDelegate { func process(dragAndDropAction action: ItemsTableViewHandler.DragAndDropAction) { switch action { - case .moveItems(let keys, let toKey): - viewModel.process(action: .moveItems(keys: keys, toItemKey: toKey)) + case .moveItems: + // Action not supported in trash + break case .tagItem(let key, let libraryId, let tags): viewModel.process(action: .tagItem(itemKey: key, libraryId: libraryId, tagNames: tags)) } } } + +extension TrashViewController: ItemsToolbarControllerDelegate { + func process(action: ItemAction.Kind, button: UIBarButtonItem) { + process(action: action, for: viewModel.state.selectedItems, button: button, completionAction: nil) + } + + func showLookup() { + coordinatorDelegate?.showLookup() + } +} From d397c7650167b91183b9780a83d35eedd57775ab Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Fri, 27 Sep 2024 16:08:19 +0200 Subject: [PATCH 10/23] WIP: added download/remove download compatibility --- Zotero/Assets/en.lproj/Localizable.strings | 1 + Zotero/Extensions/Localizable.swift | 2 + Zotero/Scenes/Detail/DetailCoordinator.swift | 8 +- .../Detail/Items/Models/ItemCellModel.swift | 28 ++++-- .../Scenes/Detail/Items/Views/ItemCell.swift | 6 +- .../Items/Views/ItemsTableViewHandler.swift | 5 +- .../Detail/Trash/Models/TrashAction.swift | 4 +- .../Detail/Trash/Models/TrashObject.swift | 39 +++++++- .../Detail/Trash/Models/TrashState.swift | 1 + .../Trash/ViewModels/TrashActionHandler.swift | 91 +++++++++++++++++-- .../Views/TrashTableViewDataSource.swift | 28 +++++- .../Trash/Views/TrashViewController.swift | 33 ++++--- 12 files changed, 203 insertions(+), 43 deletions(-) diff --git a/Zotero/Assets/en.lproj/Localizable.strings b/Zotero/Assets/en.lproj/Localizable.strings index 76fb859f6..162a9eaf8 100644 --- a/Zotero/Assets/en.lproj/Localizable.strings +++ b/Zotero/Assets/en.lproj/Localizable.strings @@ -539,6 +539,7 @@ "accessibility.items.share" = "Share selected items"; "accessibility.items.download_attachments" = "Download attachments for selected items"; "accessibility.items.remove_downloads" = "Remove downloads for selected items"; +"accessibility.items.collection" = "Collection"; "accessibility.item_detail.download_and_open" = "Double tap to download and open"; "accessibility.item_detail.open" = "Double tap to open"; "accessibility.pdf.sidebar_open" = "Open sidebar"; diff --git a/Zotero/Extensions/Localizable.swift b/Zotero/Extensions/Localizable.swift index 594e448e2..15155f3ce 100644 --- a/Zotero/Extensions/Localizable.swift +++ b/Zotero/Extensions/Localizable.swift @@ -168,6 +168,8 @@ internal enum L10n { internal enum Items { /// Add selected items to collection internal static let addToCollection = L10n.tr("Localizable", "accessibility.items.add_to_collection", fallback: "Add selected items to collection") + /// Collection + internal static let collection = L10n.tr("Localizable", "accessibility.items.collection", fallback: "Collection") /// Delete selected items internal static let delete = L10n.tr("Localizable", "accessibility.items.delete", fallback: "Delete selected items") /// Deselect All Items diff --git a/Zotero/Scenes/Detail/DetailCoordinator.swift b/Zotero/Scenes/Detail/DetailCoordinator.swift index 3ee1264c0..63b3a921a 100644 --- a/Zotero/Scenes/Detail/DetailCoordinator.swift +++ b/Zotero/Scenes/Detail/DetailCoordinator.swift @@ -127,6 +127,7 @@ final class DetailCoordinator: Coordinator { controller = createTrashViewController( libraryId: libraryId, dbStorage: userControllers.dbStorage, + fileDownloader: userControllers.fileDownloader, schemaController: controllers.schemaController, fileStorage: controllers.fileStorage, urlDetector: controllers.urlDetector, @@ -171,6 +172,7 @@ final class DetailCoordinator: Coordinator { func createTrashViewController( libraryId: LibraryIdentifier, dbStorage: DbStorage, + fileDownloader: AttachmentDownloader, schemaController: SchemaController, fileStorage: FileStorage, urlDetector: UrlDetector, @@ -181,14 +183,16 @@ final class DetailCoordinator: Coordinator { let searchTerm = searchItemKeys?.joined(separator: " ") let sortType = Defaults.shared.itemsSortType - let state = TrashState(libraryId: libraryId, sortType: sortType, searchTerm: searchTerm, filters: []) + let downloadBatchData = ItemsState.DownloadBatchData(batchData: fileDownloader.batchData) + let state = TrashState(libraryId: libraryId, sortType: sortType, searchTerm: searchTerm, filters: [], downloadBatchData: downloadBatchData) let handler = TrashActionHandler( dbStorage: dbStorage, schemaController: schemaController, fileStorage: fileStorage, fileDownloader: userControllers.fileDownloader, urlDetector: urlDetector, - htmlAttributedStringConverter: htmlAttributedStringConverter + htmlAttributedStringConverter: htmlAttributedStringConverter, + fileCleanupController: userControllers.fileCleanupController ) return TrashViewController(viewModel: ViewModel(initialState: state, handler: handler), controllers: controllers, coordinatorDelegate: self) } diff --git a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift index 8525c0020..4689cb7f7 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift @@ -19,6 +19,7 @@ struct ItemCellModel { let key: String let typeIconName: String + let iconRenderingMode: UIImage.RenderingMode let typeName: String let title: NSAttributedString let subtitle: String @@ -26,46 +27,53 @@ struct ItemCellModel { let tagColors: [UIColor] let tagEmojis: [String] let accessory: Accessory? + let hasDetailButton: Bool init(item: RItem, typeName: String, title: NSAttributedString, accessory: Accessory?) { - self.key = item.key - self.typeIconName = Self.typeIconName(for: item) + key = item.key + typeIconName = Self.typeIconName(for: item) + iconRenderingMode = .alwaysOriginal self.typeName = typeName self.title = title - self.subtitle = Self.creatorSummary(for: item) - self.hasNote = Self.hasNote(item: item) + subtitle = Self.creatorSummary(for: item) + hasNote = Self.hasNote(item: item) self.accessory = accessory let (colors, emojis) = Self.tagData(item: item) - self.tagColors = colors - self.tagEmojis = emojis + tagColors = colors + tagEmojis = emojis + 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(object: TrashObject) { + init(object: TrashObject, fileDownloader: AttachmentDownloader?) { key = object.key title = object.title switch object.type { case .collection: - typeIconName = "collection" + typeIconName = Asset.Images.Cells.collection.name + iconRenderingMode = .alwaysTemplate subtitle = "" hasNote = false tagColors = [] tagEmojis = [] accessory = nil - typeName = "Collection" + typeName = L10n.Accessibility.Items.collection + hasDetailButton = false case .item(let item): typeIconName = item.typeIconName + iconRenderingMode = .alwaysOriginal subtitle = item.creatorSummary hasNote = item.hasNote tagColors = item.tagColors tagEmojis = item.tagEmojis - accessory = item.cellAccessory + accessory = Self.createAccessory(from: item.itemAccessory, fileDownloader: fileDownloader) typeName = item.localizedTypeName + hasDetailButton = true } } diff --git a/Zotero/Scenes/Detail/Items/Views/ItemCell.swift b/Zotero/Scenes/Detail/Items/Views/ItemCell.swift index 9fac7cbf4..105917907 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemCell.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemCell.swift @@ -41,8 +41,6 @@ final class ItemCell: UITableViewCell { override func awakeFromNib() { super.awakeFromNib() - self.accessoryType = .detailButton - self.titleLabelsToContainerBottom.constant = 12 + ItemDetailLayout.separatorHeight // + bottom separator self.fileView.contentInsets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) self.tagCircles.borderColor = self.tagBorderColor @@ -96,7 +94,9 @@ final class ItemCell: UITableViewCell { func set(item: ItemCellModel) { self.key = item.key - self.typeImageView.image = UIImage(named: item.typeIconName) + self.accessoryType = item.hasDetailButton ? .detailButton : .none + self.typeImageView.image = UIImage(named: item.typeIconName)?.withRenderingMode(item.iconRenderingMode) + self.typeImageView.tintColor = Asset.Colors.zoteroBlueWithDarkMode.color if item.title.string.isEmpty { self.titleLabel.text = " " } else { diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift b/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift index 92aaa3dc5..38b58ba81 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift @@ -188,7 +188,10 @@ final class ItemsTableViewHandler: NSObject { } func performTapAction(forIndexPath indexPath: IndexPath) { - guard let action = dataSource.tapAction(for: indexPath) else { return } + guard let action = dataSource.tapAction(for: indexPath) else { + tableView.deselectRow(at: indexPath, animated: true) + return + } switch action { case .attachment, .doi, .metadata, .note, .url: tableView.deselectRow(at: indexPath, animated: true) diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift b/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift index d55b1d962..fd443dc13 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift @@ -13,11 +13,12 @@ enum TrashAction { case deleteObjects(Set) case deselectItem(TrashKey) case disableFilter(ItemsFilter) - case download(Set) + case download(Set) case emptyTrash case enableFilter(ItemsFilter) case loadData case openAttachment(attachment: Attachment, parentKey: String?) + case removeDownloads(Set) case restoreItems(Set) case search(String) case setSortType(ItemsSortType) @@ -26,5 +27,6 @@ enum TrashAction { case stopEditing case tagItem(itemKey: String, libraryId: LibraryIdentifier, tagNames: Set) case toggleSelectionState + case updateAttachments(AttachmentFileDeletedNotification) case updateDownload(update: AttachmentDownloader.Update, batchData: ItemsState.DownloadBatchData?) } diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift index bb6238639..ec8b5f5ba 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift @@ -35,9 +35,30 @@ struct TrashObject { let tagEmojis: [String] let hasNote: Bool let itemAccessory: ItemAccessory? - let cellAccessory: ItemCellModel.Accessory? let isMainAttachmentDownloaded: Bool let searchStrings: Set + + func copy(itemAccessory: ItemAccessory?) -> Item { + return .init( + sortTitle: sortTitle, + type: type, + localizedTypeName: localizedTypeName, + typeIconName: typeIconName, + creatorSummary: creatorSummary, + publisher: publisher, + publicationTitle: publicationTitle, + year: year, + date: date, + dateAdded: dateAdded, + tagNames: tagNames, + tagColors: tagColors, + tagEmojis: tagEmojis, + hasNote: hasNote, + itemAccessory: itemAccessory, + isMainAttachmentDownloaded: isMainAttachmentDownloaded, + searchStrings: searchStrings + ) + } } enum Kind { @@ -152,4 +173,20 @@ struct TrashObject { return nil } } + + func updated(itemAccessory: ItemAccessory) -> TrashObject? { + switch type { + case .collection: + return nil + + case .item(let item): + return TrashObject( + type: .item(item: item.copy(itemAccessory: itemAccessory)), + key: key, + libraryId: libraryId, + title: title, + dateModified: dateModified + ) + } + } } diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift index 3ed0ad649..811bde83b 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift @@ -23,6 +23,7 @@ struct TrashState: ViewModelState { static let selectAll = Changes(rawValue: 1 << 3) static let filters = Changes(rawValue: 1 << 4) static let batchData = Changes(rawValue: 1 << 5) + static let attachmentsRemoved = Changes(rawValue: 1 << 6) } enum Error: Swift.Error { diff --git a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift index dcc5cec0f..bc0b2e80b 100644 --- a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift +++ b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift @@ -22,6 +22,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { private unowned let fileDownloader: AttachmentDownloader private unowned let urlDetector: UrlDetector private unowned let htmlAttributedStringConverter: HtmlAttributedStringConverter + private unowned let fileCleanupController: AttachmentFileCleanupController init( dbStorage: DbStorage, @@ -29,13 +30,15 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { fileStorage: FileStorage, fileDownloader: AttachmentDownloader, urlDetector: UrlDetector, - htmlAttributedStringConverter: HtmlAttributedStringConverter + htmlAttributedStringConverter: HtmlAttributedStringConverter, + fileCleanupController: AttachmentFileCleanupController ) { self.schemaController = schemaController self.fileStorage = fileStorage self.fileDownloader = fileDownloader self.urlDetector = urlDetector self.htmlAttributedStringConverter = htmlAttributedStringConverter + self.fileCleanupController = fileCleanupController super.init(dbStorage: dbStorage) } @@ -63,6 +66,14 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { case .download(let keys): downloadAttachments(for: keys, in: viewModel) + case .removeDownloads(let keys): + var items: Set = [] + for key in keys { + guard key.type == .item else { continue } + items.insert(key.key) + } + fileCleanupController.delete(.allForItems(items, viewModel.state.library.identifier)) + case .emptyTrash: emptyTrash(in: viewModel) @@ -115,6 +126,9 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { case .updateDownload(let update, let batchData): process(downloadUpdate: update, batchData: batchData, in: viewModel) + case .updateAttachments(let notification): + processAttachmentDeletion(notification: notification, in: viewModel) + case .openAttachment(let attachment, let parentKey): open(attachment: attachment, parentKey: parentKey, in: viewModel) @@ -191,7 +205,6 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { func trashObject(from rItem: RItem, titleFont: UIFont) -> TrashObject? { guard let libraryId = rItem.libraryId else { return nil } let itemAccessory = ItemAccessory.create(from: rItem, fileStorage: fileStorage, urlDetector: urlDetector) - let cellAccessory = itemAccessory.flatMap({ ItemCellModel.createAccessory(from: $0, fileDownloader: fileDownloader) }) let creatorSummary = ItemCellModel.creatorSummary(for: rItem) let (tagColors, tagEmojis) = ItemCellModel.tagData(item: rItem) let hasNote = ItemCellModel.hasNote(item: rItem) @@ -213,7 +226,6 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { tagEmojis: tagEmojis, hasNote: hasNote, itemAccessory: itemAccessory, - cellAccessory: cellAccessory, isMainAttachmentDownloaded: rItem.fileDownloaded, searchStrings: searchStrings(from: rItem) ) @@ -481,11 +493,11 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { // MARK: - Downloads - private func downloadAttachments(for keys: Set, in viewModel: ViewModel) { + private func downloadAttachments(for keys: Set, in viewModel: ViewModel) { var attachments: [(Attachment, String?)] = [] for key in keys { - guard let attachment = viewModel.state.objects[TrashKey(type: .item, key: key)]?.itemAccessory?.attachment else { continue } - let parentKey = attachment.key == key ? nil : key + guard let attachment = viewModel.state.objects[key]?.itemAccessory?.attachment else { continue } + let parentKey = attachment.key == key.key ? nil : key.key attachments.append((attachment, parentKey)) } fileDownloader.batchDownload(attachments: attachments) @@ -503,14 +515,18 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { DDLogInfo("TrashActionHandler: download update \(attachment.key); \(attachment.libraryId); kind \(downloadUpdate.kind)") guard let updatedAttachment = attachment.changed(location: .local, compressed: compressed) else { return } updateViewModel { state in - state.itemAccessories[updateKey] = .attachment(attachment: updatedAttachment, parentKey: downloadUpdate.parentKey) + if let object = state.objects[updateKey] { + state.objects[updateKey] = object.updated(itemAccessory: .attachment(attachment: updatedAttachment, parentKey: downloadUpdate.parentKey)) + } state.updateItemKey = updateKey } case .progress: // If file is being extracted, the extraction is usually very quick and sends multiple quick progress updates, due to switching between queues and small delays those updates are then // received here, but the file downloader is already done and we're unnecessarily reloading the table view with the same progress. So we're filtering out those unnecessary updates - guard let currentProgress = fileDownloader.data(for: downloadUpdate.key, parentKey: downloadUpdate.parentKey, libraryId: downloadUpdate.libraryId).progress, currentProgress < 1 + 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 @@ -529,12 +545,69 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { state.downloadBatchData = batchData state.changes = .batchData } - additional?(&state) } } } + private func processAttachmentDeletion(notification: AttachmentFileDeletedNotification, in viewModel: ViewModel) { + switch notification { + case .all: + // Update all attachment locations to `.remote`. + self.update(viewModel: viewModel) { state in + changeAttachmentsToRemoteLocation(in: &state) + state.changes = .attachmentsRemoved + } + + case .library(let libraryId): + // Check whether files in this library have been deleted. + guard viewModel.state.library.identifier == libraryId else { return } + // Update all attachment locations to `.remote`. + self.update(viewModel: viewModel) { state in + changeAttachmentsToRemoteLocation(in: &state) + state.changes = .attachmentsRemoved + } + + case .allForItems(let keys, let libraryId): + // Check whether files in this library have been deleted. + guard viewModel.state.library.identifier == libraryId else { return } + update(viewModel: viewModel) { state in + changeAttachmentsToRemoteLocation(for: keys, in: &state) + state.changes = .attachmentsRemoved + } + + case .individual(let key, let parentKey, let libraryId): + let updateKey = parentKey ?? key + let trashKey = TrashKey(type: .item, key: updateKey) + // Check whether the deleted file was in this library and there is a cached object for it. + guard viewModel.state.library.identifier == libraryId && viewModel.state.objects[trashKey] != nil else { return } + update(viewModel: viewModel) { state in + changeAttachmentsToRemoteLocation(for: [updateKey], in: &state) + state.updateItemKey = trashKey + } + } + + func changeAttachmentsToRemoteLocation(for keys: Set? = nil, in state: inout TrashState) { + if let keys { + for key in keys { + let trashKey = TrashKey(type: .item, key: key) + guard let object = state.objects[trashKey] else { continue } + update(key: trashKey, object: object, in: &state) + } + } else { + for (key, object) in state.objects { + guard key.type == .item else { continue } + update(key: key, object: object, in: &state) + } + } + + func update(key: TrashKey, object: TrashObject, in state: inout TrashState) { + guard let acccessory = object.itemAccessory?.updatedAttachment(update: { attachment in attachment.changed(location: .remote, condition: { $0 == .local }) }) else { return } + state.objects[key] = object.updated(itemAccessory: acccessory) + } + } + } + private func open(attachment: Attachment, parentKey: String?, in viewModel: ViewModel) { let (progress, _) = fileDownloader.data(for: attachment.key, parentKey: parentKey, libraryId: attachment.libraryId) if progress != nil { diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift index 1f8bdecac..650ea1b0b 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift @@ -13,12 +13,14 @@ import CocoaLumberjackSwift final class TrashTableViewDataSource: NSObject, ItemsTableViewDataSource { private let viewModel: ViewModel + private unowned let fileDownloader: AttachmentDownloader? weak var handler: ItemsTableViewHandler? private var snapshot: OrderedDictionary? - init(viewModel: ViewModel) { + init(viewModel: ViewModel, fileDownloader: AttachmentDownloader?) { self.viewModel = viewModel + self.fileDownloader = fileDownloader } func apply(snapshot: OrderedDictionary) { @@ -85,7 +87,27 @@ extension TrashTableViewDataSource { } func createContextMenuActions(at index: Int) -> [ItemAction] { - return [ItemAction(type: .restore), ItemAction(type: .delete)] + var actions = [ItemAction(type: .restore), ItemAction(type: .delete)] + + // Add download/remove downloaded option for attachments + if let accessory = trashObject(at: index)?.itemAccessory, let location = accessory.attachment?.location { + switch location { + case .local: + actions.append(ItemAction(type: .removeDownload)) + + case .remote: + actions.append(ItemAction(type: .download)) + + case .localAndChangedRemotely: + actions.append(ItemAction(type: .download)) + actions.append(ItemAction(type: .removeDownload)) + + case .remoteMissing: + break + } + } + + return actions } } @@ -107,7 +129,7 @@ extension TrashTableViewDataSource { } if let cell = cell as? ItemCell { - cell.set(item: ItemCellModel(object: object)) + cell.set(item: ItemCellModel(object: object, fileDownloader: fileDownloader)) let openInfoAction = UIAccessibilityCustomAction(name: L10n.Accessibility.Items.openItem, actionHandler: { [weak self] _ in guard let self else { return false } diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift index 0d08ae8f6..e5b1d0b0a 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -37,7 +37,7 @@ final class TrashViewController: BaseItemsViewController { override func viewDidLoad() { super.viewDidLoad() - dataSource = TrashTableViewDataSource(viewModel: viewModel) + dataSource = TrashTableViewDataSource(viewModel: viewModel, fileDownloader: controllers.userControllers?.fileDownloader) 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)) @@ -71,6 +71,17 @@ final class TrashViewController: BaseItemsViewController { ) }) .disposed(by: disposeBag) + + 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) } } @@ -80,13 +91,12 @@ final class TrashViewController: BaseItemsViewController { if state.changes.contains(.objects) { dataSource.apply(snapshot: state.objects) updateTagFilter(filters: state.filters, collectionId: .custom(.trash), libraryId: state.library.identifier) + } else if let key = state.updateItemKey, let object = state.objects[key] { + let accessory = ItemCellModel.createAccessory(from: object.itemAccessory, fileDownloader: controllers.userControllers?.fileDownloader) + handler?.updateCell(key: key.key, withAccessory: accessory) + } else if state.changes.contains(.attachmentsRemoved) { + handler?.attachmentAccessoriesChanged() } -// 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) -// } if state.changes.contains(.editing) { handler?.set(editing: state.isEditing, animated: true) @@ -172,12 +182,10 @@ final class TrashViewController: BaseItemsViewController { ) case .download: -// viewModel.process(action: .download(selectedKeys)) - break + viewModel.process(action: .download(selectedKeys)) case .removeDownload: -// viewModel.process(action: .removeDownloads(selectedKeys)) - break + viewModel.process(action: .removeDownloads(selectedKeys)) } } @@ -264,8 +272,7 @@ extension TrashViewController: ItemsTableViewHandlerDelegate { coordinatorDelegate?.showItemDetail(for: .preview(key: object.key), libraryId: viewModel.state.library.identifier, scrolledToKey: nil, animated: true) case .attachment(let attachment, let parentKey): -// viewModel.process(action: .openAttachment(attachment: attachment, parentKey: parentKey)) - break + viewModel.process(action: .openAttachment(attachment: attachment, parentKey: parentKey)) case .doi(let doi): coordinatorDelegate?.show(doi: doi) From 487c06f03f20b33991987bcebe94e18626d47ec9 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Mon, 30 Sep 2024 15:15:01 +0200 Subject: [PATCH 11/23] WIP: Added observing to trash controller --- .../Detail/Trash/Models/TrashState.swift | 7 + .../Trash/ViewModels/TrashActionHandler.swift | 257 ++++++++++++++++-- .../Views/TrashTableViewDataSource.swift | 12 + .../Trash/Views/TrashViewController.swift | 11 +- .../Collections/Models/CollectionsState.swift | 3 +- .../ViewModels/CollectionsActionHandler.swift | 134 ++++++--- 6 files changed, 355 insertions(+), 69 deletions(-) diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift index 811bde83b..ab50b7780 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift @@ -24,6 +24,7 @@ struct TrashState: ViewModelState { static let filters = Changes(rawValue: 1 << 4) static let batchData = Changes(rawValue: 1 << 5) static let attachmentsRemoved = Changes(rawValue: 1 << 6) + static let library = Changes(rawValue: 1 << 7) } enum Error: Swift.Error { @@ -34,8 +35,12 @@ struct TrashState: ViewModelState { var libraryToken: NotificationToken? var itemResults: Results? var itemsToken: NotificationToken? + // Keys for all items are stored so that when a deletion comes in it can be determined which keys were deleted + var itemKeys: [String] var collectionResults: Results? var collectionsToken: NotificationToken? + // Keys for all collecitons are stored so that when a deletion comes in it can be determined which keys were deleted + var collectionKeys: [String] var objects: OrderedDictionary var snapshot: OrderedDictionary? var sortType: ItemsSortType @@ -61,6 +66,8 @@ struct TrashState: ViewModelState { isEditing = false changes = [] selectedItems = [] + itemKeys = [] + collectionKeys = [] self.downloadBatchData = downloadBatchData switch libraryId { diff --git a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift index bc0b2e80b..17993f08f 100644 --- a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift +++ b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift @@ -23,6 +23,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { private unowned let urlDetector: UrlDetector private unowned let htmlAttributedStringConverter: HtmlAttributedStringConverter private unowned let fileCleanupController: AttachmentFileCleanupController + private let disposeBag: DisposeBag init( dbStorage: DbStorage, @@ -39,6 +40,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { self.urlDetector = urlDetector self.htmlAttributedStringConverter = htmlAttributedStringConverter self.fileCleanupController = fileCleanupController + disposeBag = DisposeBag() super.init(dbStorage: dbStorage) } @@ -142,19 +144,70 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { private func loadData(in viewModel: ViewModel) { do { - let items = try dbStorage.perform(request: ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier, sortType: viewModel.state.sortType), on: .main) - let collectionsRequest = ReadCollectionsDbRequest(libraryId: viewModel.state.library.identifier, trash: true) - let collections = (try dbStorage.perform(request: collectionsRequest, on: .main)).sorted(by: collectionSortDescriptor(for: viewModel.state.sortType)) - let results = results( - fromItems: items, - collections: collections, - sortType: viewModel.state.sortType, - filters: viewModel.state.filters, - searchTerm: viewModel.state.searchTerm, - titleFont: viewModel.state.titleFont - ) - update(viewModel: viewModel) { state in - state.objects = results + try dbStorage.perform(on: .main) { coordinator in + let (library, libraryToken) = try viewModel.state.library.identifier.observe(in: coordinator, changes: { [weak self, weak viewModel] library in + guard let self, let viewModel else { return } + update(viewModel: viewModel) { state in + state.library = library + state.changes = .library + } + }) + let items = try coordinator.perform(request: ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier, sortType: viewModel.state.sortType)) + let itemKeys = Array(items.map({ $0.key })) + let collectionsRequest = ReadCollectionsDbRequest(libraryId: viewModel.state.library.identifier, trash: true) + let collections = (try coordinator.perform(request: collectionsRequest)).sorted(by: collectionSortDescriptor(for: viewModel.state.sortType)) + let collectionKeys = Array(collections.map({ $0.key })) + let results = results( + fromItems: items, + collections: collections, + sortType: viewModel.state.sortType, + filters: viewModel.state.filters, + searchTerm: viewModel.state.searchTerm, + titleFont: viewModel.state.titleFont + ) + + let itemsToken = items.observe(keyPaths: RItem.observableKeypathsForItemList, { [weak self, weak viewModel] changes in + guard let self, let viewModel else { return } + switch changes { + case .update(let items, let deletions, let insertions, let modifications): + updateItems(items, deletions: deletions, insertions: insertions, modifications: modifications, viewModel: viewModel, handler: self) + + case .error(let error): + DDLogError("TrashActionHandler: could not load items - \(error)") + update(viewModel: viewModel) { state in + state.error = .dataLoading + } + + case .initial: + break + } + }) + + let collectionsToken = collections.observe(keyPaths: RCollection.observableKeypathsForList, { [weak self, weak viewModel] changes in + guard let self, let viewModel else { return } + switch changes { + case .update(let items, let deletions, let insertions, let modifications): + updateCollections(collections, deletions: deletions, insertions: insertions, modifications: modifications, viewModel: viewModel, handler: self) + + case .error(let error): + DDLogError("TrashActionHandler: could not load collections - \(error)") + update(viewModel: viewModel) { state in + state.error = .dataLoading + } + + case .initial: + break + } + }) + + update(viewModel: viewModel) { state in + state.library = library + state.objects = results + state.itemKeys = itemKeys + state.itemsToken = itemsToken + state.collectionKeys = collectionKeys + state.collectionsToken = collectionsToken + } } } catch let error { DDLogInfo("TrashActionHandler: can't load initial data - \(error)") @@ -177,25 +230,187 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } func results( - fromItems items: Results, - collections: Results, + fromItems items: Results?, + collections: Results?, sortType: ItemsSortType, filters: [ItemsFilter], searchTerm: String?, titleFont: UIFont ) -> OrderedDictionary { var objects: OrderedDictionary = [:] - for object in items.compactMap({ trashObject(from: $0, titleFont: titleFont) }) { - objects[object.trashKey] = object + if let items { + for object in items.compactMap({ trashObject(from: $0, titleFont: titleFont) }) { + objects[object.trashKey] = object + } } - for collection in collections { - guard let object = trashObject(from: collection, titleFont: titleFont) else { continue } - let index = objects.index(of: object, sortedBy: { areInIncreasingOrder(lObject: $0, rObject: $1, sortType: sortType) }) - objects.updateValue(object, forKey: object.trashKey, insertingAt: index) + if let collections { + for collection in collections { + guard let object = trashObject(from: collection, titleFont: titleFont) else { continue } + let index = objects.index(of: object, sortedBy: { areInIncreasingOrder(lObject: $0, rObject: $1, sortType: sortType) }) + objects.updateValue(object, forKey: object.trashKey, insertingAt: index) + } } return objects } + func updateObjects( + _ results: Results, + deletions: [Int], + insertions: [Int], + modifications: [Int], + trashKeyType: TrashKey.Kind, + viewModel: ViewModel, + handler: TrashActionHandler, + createObject: (RObject) -> TrashObject?, + rebuildData: () -> Void + ) { + var keys = trashKeyType == .item ? viewModel.state.itemKeys : viewModel.state.collectionKeys + var objects = viewModel.state.objects + var selectedItems = viewModel.state.selectedItems + var changes: TrashState.Changes = [] + var rebuildObjects = false + + for index in deletions { + guard index < keys.count else { + rebuildObjects = true + break + } + let key = keys.remove(at: index) + let trashKey = TrashKey(type: trashKeyType, key: key) + objects[trashKey] = nil + if selectedItems.remove(trashKey) != nil { + changes.insert(.selection) + } + } + + if rebuildObjects { + rebuildData() + return + } + + for index in modifications { + let result = results[index] + let trashKey = TrashKey(type: trashKeyType, key: result.key) + objects[trashKey] = createObject(result) + } + + for index in insertions { + let result = results[index] + let trashKey = TrashKey(type: trashKeyType, key: result.key) + guard let object = createObject(result), index < keys.count else { + rebuildObjects = true + break + } + keys.insert(result.key, at: index) + objects.updateValue(object, forKey: trashKey, insertingAt: index) + } + + if rebuildObjects { + rebuildData() + return + } + + changes.insert(.objects) + + handler.update(viewModel: viewModel) { state in + switch trashKeyType { + case .collection: + state.collectionKeys = keys + + case .item: + state.itemKeys = keys + } + state.objects = objects + state.selectedItems = selectedItems + state.changes = changes + } + } + + func updateItems(_ items: Results, deletions: [Int], insertions: [Int], modifications: [Int], viewModel: ViewModel, handler: TrashActionHandler) { + updateObjects( + items, + deletions: deletions, + insertions: insertions, + modifications: modifications, + trashKeyType: .item, + viewModel: viewModel, + handler: handler, + createObject: { item in + return trashObject(from: item, titleFont: viewModel.state.titleFont) + }, + rebuildData: { + rebuild(items: items, viewModel: viewModel, handler: handler) + } + ) + + func rebuild(items: Results, viewModel: ViewModel, handler: TrashActionHandler) { + let itemKeys = Array(items.map({ $0.key })) + let collectionsRequest = ReadCollectionsDbRequest(libraryId: viewModel.state.library.identifier, trash: true) + let collections = (try? handler.dbStorage.perform(request: collectionsRequest, on: .main))?.sorted(by: collectionSortDescriptor(for: viewModel.state.sortType)) + var collectionKeys: [String] = [] + if let collections { + collectionKeys = collections.map({ $0.key }) + } + let results = results( + fromItems: items, + collections: collections, + sortType: viewModel.state.sortType, + filters: viewModel.state.filters, + searchTerm: viewModel.state.searchTerm, + titleFont: viewModel.state.titleFont + ) + handler.update(viewModel: viewModel) { state in + state.objects = results + state.itemKeys = itemKeys + state.collectionKeys = collectionKeys + state.selectedItems = [] + state.changes = .objects + } + } + } + + func updateCollections(_ collections: Results, deletions: [Int], insertions: [Int], modifications: [Int], viewModel: ViewModel, handler: TrashActionHandler) { + updateObjects( + collections, + deletions: deletions, + insertions: insertions, + modifications: modifications, + trashKeyType: .collection, + viewModel: viewModel, + handler: handler, + createObject: { item in + return trashObject(from: item, titleFont: viewModel.state.titleFont) + }, + rebuildData: { + rebuild(collections: collections, viewModel: viewModel, handler: handler) + } + ) + + func rebuild(collections: Results, viewModel: ViewModel, handler: TrashActionHandler) { + let collectionKeys = Array(collections.map({ $0.key })) + let items = try? dbStorage.perform(request: ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier, sortType: viewModel.state.sortType), on: .main) + var itemKeys: [String] = [] + if let items { + itemKeys = collections.map({ $0.key }) + } + let results = results( + fromItems: items, + collections: collections, + sortType: viewModel.state.sortType, + filters: viewModel.state.filters, + searchTerm: viewModel.state.searchTerm, + titleFont: viewModel.state.titleFont + ) + handler.update(viewModel: viewModel) { state in + state.objects = results + state.itemKeys = itemKeys + state.collectionKeys = collectionKeys + state.selectedItems = [] + state.changes = .objects + } + } + } + func trashObject(from collection: RCollection, titleFont: UIFont) -> TrashObject? { guard let libraryId = collection.libraryId else { return nil } let attributedTitle = htmlAttributedStringConverter.convert(text: collection.name, baseAttributes: [.font: titleFont]) diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift index 650ea1b0b..5df2c9786 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift @@ -27,6 +27,18 @@ final class TrashTableViewDataSource: NSObject, ItemsTableViewDataSource { self.snapshot = snapshot handler?.reloadAll() } + + func updateCellAccessory(key: TrashKey, snapshot: OrderedDictionary) { + self.snapshot = snapshot + guard let itemAccessory = snapshot[key]?.itemAccessory else { return } + let accessory = ItemCellModel.createAccessory(from: itemAccessory, fileDownloader: fileDownloader) + handler?.updateCell(key: key.key, withAccessory: accessory) + } + + func updateAttachmentAccessories(snapshot: OrderedDictionary) { + self.snapshot = snapshot + handler?.attachmentAccessoriesChanged() + } } extension TrashTableViewDataSource { diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift index e5b1d0b0a..a7a0b9951 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -91,11 +91,10 @@ final class TrashViewController: BaseItemsViewController { if state.changes.contains(.objects) { dataSource.apply(snapshot: state.objects) updateTagFilter(filters: state.filters, collectionId: .custom(.trash), libraryId: state.library.identifier) - } else if let key = state.updateItemKey, let object = state.objects[key] { - let accessory = ItemCellModel.createAccessory(from: object.itemAccessory, fileDownloader: controllers.userControllers?.fileDownloader) - handler?.updateCell(key: key.key, withAccessory: accessory) + } else if let key = state.updateItemKey { + dataSource.updateCellAccessory(key: key, snapshot: state.objects) } else if state.changes.contains(.attachmentsRemoved) { - handler?.attachmentAccessoriesChanged() + dataSource.updateAttachmentAccessories(snapshot: state.objects) } if state.changes.contains(.editing) { @@ -112,12 +111,12 @@ final class TrashViewController: BaseItemsViewController { } } - if state.changes.contains(.selection) {// || state.changes.contains(.library) { + if state.changes.contains(.selection) || state.changes.contains(.library) { setupRightBarButtonItems(expectedItems: rightBarButtonItemTypes(for: state)) toolbarController?.reloadToolbarItems(for: toolbarData(from: state)) } - if state.changes.contains(.filters) {// || state.changes.contains(.batchData) { + if state.changes.contains(.filters) || state.changes.contains(.batchData) { toolbarController?.reloadToolbarItems(for: toolbarData(from: state)) } diff --git a/Zotero/Scenes/Master/Collections/Models/CollectionsState.swift b/Zotero/Scenes/Master/Collections/Models/CollectionsState.swift index bdee5203f..1895b0327 100644 --- a/Zotero/Scenes/Master/Collections/Models/CollectionsState.swift +++ b/Zotero/Scenes/Master/Collections/Models/CollectionsState.swift @@ -43,7 +43,8 @@ struct CollectionsState: ViewModelState { var searchesToken: NotificationToken? var itemsToken: NotificationToken? var unfiledToken: NotificationToken? - var trashToken: NotificationToken? + var trashItemsToken: NotificationToken? + var trashCollectionsToken: NotificationToken? var error: CollectionsError? // Used when user wants to create bibliography from whole collection. var itemKeysForBibliography: Swift.Result, Error>? diff --git a/Zotero/Scenes/Master/Collections/ViewModels/CollectionsActionHandler.swift b/Zotero/Scenes/Master/Collections/ViewModels/CollectionsActionHandler.swift index f9b053046..35927ddf3 100644 --- a/Zotero/Scenes/Master/Collections/ViewModels/CollectionsActionHandler.swift +++ b/Zotero/Scenes/Master/Collections/ViewModels/CollectionsActionHandler.swift @@ -231,7 +231,8 @@ struct CollectionsActionHandler: ViewModelActionHandler, BackgroundDbProcessingA var trashItemCount = 0 var itemsToken: NotificationToken? var unfiledToken: NotificationToken? - var trashToken: NotificationToken? + var trashItemsToken: NotificationToken? + var trashCollectionsToken: NotificationToken? if includeItemCounts { let allItems = try coordinator.perform(request: ReadItemsDbRequest(collectionId: .custom(.all), libraryId: libraryId)) @@ -241,11 +242,13 @@ struct CollectionsActionHandler: ViewModelActionHandler, BackgroundDbProcessingA unfiledItemCount = unfiledItems.count let trashItems = try coordinator.perform(request: ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: libraryId)) - trashItemCount = trashItems.count + let trashCollections = try coordinator.perform(request: ReadCollectionsDbRequest(libraryId: libraryId, trash: true)) + trashItemCount = trashItems.count + trashCollections.count - itemsToken = self.observeItemCount(in: allItems, for: .all, in: viewModel) - unfiledToken = self.observeItemCount(in: unfiledItems, for: .unfiled, in: viewModel) - trashToken = self.observeItemCount(in: trashItems, for: .trash, in: viewModel) + itemsToken = observeItemCount(in: allItems, for: .all, in: viewModel) + unfiledToken = observeItemCount(in: unfiledItems, for: .unfiled, in: viewModel) + trashItemsToken = observeItemCount(in: trashItems, for: .trash, in: viewModel) + trashCollectionsToken = observeTrashedCollectionCount(in: trashCollections, in: viewModel) } let collectionTree = CollectionTreeBuilder.collections(from: collections, libraryId: libraryId, includeItemCounts: includeItemCounts) @@ -254,11 +257,13 @@ struct CollectionsActionHandler: ViewModelActionHandler, BackgroundDbProcessingA collectionTree.append(collection: Collection(custom: .trash, itemCount: trashItemCount)) let collectionsToken = collections.observe(keyPaths: RCollection.observableKeypathsForList, { [weak viewModel] changes in - guard let viewModel = viewModel else { return } + guard let viewModel else { return } switch changes { - case .update(let objects, _, _, _): self.update(collections: objects, includeItemCounts: includeItemCounts, viewModel: viewModel) - case .initial: break - case .error: break + case .update(let objects, _, _, _): + update(collections: objects, includeItemCounts: includeItemCounts, viewModel: viewModel) + + case .initial, .error: + break } }) @@ -269,15 +274,92 @@ struct CollectionsActionHandler: ViewModelActionHandler, BackgroundDbProcessingA state.collectionsToken = collectionsToken state.itemsToken = itemsToken state.unfiledToken = unfiledToken - state.trashToken = trashToken + state.trashItemsToken = trashItemsToken + state.trashCollectionsToken = trashCollectionsToken } }) } catch let error { DDLogError("CollectionsActionHandlers: can't load data - \(error)") - self.update(viewModel: viewModel) { state in + update(viewModel: viewModel) { state in state.error = .dataLoading } } + + func observeItemCount(in results: Results, for customType: CollectionIdentifier.CustomType, in viewModel: ViewModel) -> NotificationToken { + return results.observe({ [weak viewModel] changes in + guard let viewModel else { return } + switch changes { + case .update(let objects, _, _, _): + switch customType { + case .trash: + updateTrashCount(itemsCount: objects.count, collectionsCount: nil, in: viewModel) + + case .all, .publications, .unfiled: + updateItemsCount(objects.count, for: customType, in: viewModel) + } + + case .initial: + break + + case .error: + break + } + }) + } + + func observeTrashedCollectionCount(in results: Results, in viewModel: ViewModel) -> NotificationToken { + return results.observe({ [weak viewModel] changes in + guard let viewModel else { return } + switch changes { + case .update(let objects, _, _, _): + updateTrashCount(itemsCount: nil, collectionsCount: objects.count, in: viewModel) + + case .initial: + break + + case .error: + break + } + }) + } + + func updateItemsCount(_ count: Int, for customType: CollectionIdentifier.CustomType, in viewModel: ViewModel) { + self.update(viewModel: viewModel) { state in + state.collectionTree.update(collection: Collection(custom: customType, itemCount: count)) + + switch customType { + case .all: + state.changes = .allItemCount + + case .unfiled: + state.changes = .unfiledItemCount + + case .trash: + state.changes = .trashItemCount + + case .publications: + break + } + } + } + + func updateTrashCount(itemsCount: Int?, collectionsCount: Int?, in viewModel: ViewModel) { + var count = 0 + if let itemsCount { + count += itemsCount + } else { + count += (try? dbStorage.perform(request: ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: libraryId), on: .main))?.count ?? 0 + } + if let collectionsCount { + count += collectionsCount + } else { + count += (try? dbStorage.perform(request: ReadCollectionsDbRequest(libraryId: libraryId, trash: true), on: .main))?.count ?? 0 + } + update(viewModel: viewModel) { state in + state.collectionTree.update(collection: Collection(custom: .trash, itemCount: count)) + state.changes = .trashItemCount + } + } } private func assignItems(keys: Set, to collectionKey: String, in viewModel: ViewModel) { @@ -343,36 +425,6 @@ struct CollectionsActionHandler: ViewModelActionHandler, BackgroundDbProcessingA } } - private func observeItemCount(in results: Results, for customType: CollectionIdentifier.CustomType, in viewModel: ViewModel) -> NotificationToken { - return results.observe({ [weak viewModel] changes in - guard let viewModel = viewModel else { return } - switch changes { - case .update(let objects, _, _, _): - self.update(itemsCount: objects.count, for: customType, in: viewModel) - case .initial: break - case .error: break - } - }) - } - - private func update(itemsCount: Int, for customType: CollectionIdentifier.CustomType, in viewModel: ViewModel) { - self.update(viewModel: viewModel) { state in - state.collectionTree.update(collection: Collection(custom: customType, itemCount: itemsCount)) - - switch customType { - case .all: - state.changes = .allItemCount - - case .unfiled: - state.changes = .unfiledItemCount - - case .trash: - state.changes = .trashItemCount - case .publications: break - } - } - } - private func update(collections: Results, includeItemCounts: Bool, viewModel: ViewModel) { let tree = CollectionTreeBuilder.collections(from: collections, libraryId: viewModel.state.library.identifier, includeItemCounts: includeItemCounts) From 2ec3aa2822521def2b5895c8f8934ca509b17128 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Tue, 1 Oct 2024 13:38:24 +0200 Subject: [PATCH 12/23] last fixes --- Zotero/Scenes/Detail/DetailCoordinator.swift | 5 ++++- Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift | 1 + .../Collections/ViewModels/CollectionsActionHandler.swift | 2 -- .../Views/ExpandableCollectionsCollectionViewHandler.swift | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Zotero/Scenes/Detail/DetailCoordinator.swift b/Zotero/Scenes/Detail/DetailCoordinator.swift index 63b3a921a..0cdf69f99 100644 --- a/Zotero/Scenes/Detail/DetailCoordinator.swift +++ b/Zotero/Scenes/Detail/DetailCoordinator.swift @@ -194,7 +194,10 @@ final class DetailCoordinator: Coordinator { htmlAttributedStringConverter: htmlAttributedStringConverter, fileCleanupController: userControllers.fileCleanupController ) - return TrashViewController(viewModel: ViewModel(initialState: state, handler: handler), controllers: controllers, coordinatorDelegate: self) + let controller = TrashViewController(viewModel: ViewModel(initialState: state, handler: handler), controllers: controllers, coordinatorDelegate: self) + controller.tagFilterDelegate = itemsTagFilterDelegate + itemsTagFilterDelegate?.delegate = controller + return controller } func createItemsViewController( diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift index a7a0b9951..1062070aa 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -43,6 +43,7 @@ final class TrashViewController: BaseItemsViewController { setupRightBarButtonItems(expectedItems: rightBarButtonItemTypes(for: viewModel.state)) setupDownloadObserver() dataSource.apply(snapshot: viewModel.state.objects) + updateTagFilter(filters: viewModel.state.filters, collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier) viewModel .stateObservable diff --git a/Zotero/Scenes/Master/Collections/ViewModels/CollectionsActionHandler.swift b/Zotero/Scenes/Master/Collections/ViewModels/CollectionsActionHandler.swift index 35927ddf3..33bb69e3c 100644 --- a/Zotero/Scenes/Master/Collections/ViewModels/CollectionsActionHandler.swift +++ b/Zotero/Scenes/Master/Collections/ViewModels/CollectionsActionHandler.swift @@ -380,9 +380,7 @@ struct CollectionsActionHandler: ViewModelActionHandler, BackgroundDbProcessingA let request = MarkCollectionsAsTrashedDbRequest(keys: keys, libraryId: viewModel.state.library.identifier, trashed: true) self.perform(request: request) { [weak viewModel] error in guard let error = error, let viewModel = viewModel else { return } - DDLogError("CollectionsActionHandler: can't delete object - \(error)") - self.update(viewModel: viewModel) { state in state.error = .deletion } diff --git a/Zotero/Scenes/Master/Collections/Views/ExpandableCollectionsCollectionViewHandler.swift b/Zotero/Scenes/Master/Collections/Views/ExpandableCollectionsCollectionViewHandler.swift index b0c4e9ea0..7073134a5 100644 --- a/Zotero/Scenes/Master/Collections/Views/ExpandableCollectionsCollectionViewHandler.swift +++ b/Zotero/Scenes/Master/Collections/Views/ExpandableCollectionsCollectionViewHandler.swift @@ -122,7 +122,7 @@ final class ExpandableCollectionsCollectionViewHandler: NSObject { actions.append(createBibliography) if viewModel.state.library.metadataEditable { - let delete = UIAction(title: L10n.delete, image: UIImage(systemName: "trash"), attributes: .destructive) { [weak viewModel] _ in + let delete = UIAction(title: L10n.moveToTrash, image: UIImage(systemName: "trash"), attributes: .destructive) { [weak viewModel] _ in viewModel?.process(action: .deleteCollection(key)) } actions.append(delete) From d46abeae873ee93dc5b9a2672bc882d89b1af7b5 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Wed, 16 Oct 2024 14:38:04 +0200 Subject: [PATCH 13/23] Fixed changed comment --- Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift index 798273bb3..9063204ae 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift @@ -1,5 +1,5 @@ // -// RItemsViewController.swift +// ItemsViewController.swift // Zotero // // Created by Michal Rentka on 20.09.2024. From 6e4560c5d5a6e0769342158d6961a3de35aa9dec Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Wed, 16 Oct 2024 14:38:23 +0200 Subject: [PATCH 14/23] Simplified switch --- Zotero/Scenes/Detail/DetailCoordinator.swift | 42 ++++++-------------- 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/Zotero/Scenes/Detail/DetailCoordinator.swift b/Zotero/Scenes/Detail/DetailCoordinator.swift index 0cdf69f99..2bf5d14fa 100644 --- a/Zotero/Scenes/Detail/DetailCoordinator.swift +++ b/Zotero/Scenes/Detail/DetailCoordinator.swift @@ -121,37 +121,19 @@ final class DetailCoordinator: Coordinator { let controller: UIViewController switch collection.identifier { - case .custom(let type): - switch type { - case .trash: - controller = createTrashViewController( - libraryId: libraryId, - dbStorage: userControllers.dbStorage, - fileDownloader: userControllers.fileDownloader, - schemaController: controllers.schemaController, - fileStorage: controllers.fileStorage, - urlDetector: controllers.urlDetector, - itemsTagFilterDelegate: itemsTagFilterDelegate, - htmlAttributedStringConverter: controllers.htmlAttributedStringConverter - ) - - case .all, .publications, .unfiled: - controller = createItemsViewController( - collection: collection, - libraryId: libraryId, - dbStorage: userControllers.dbStorage, - fileDownloader: userControllers.fileDownloader, - remoteFileDownloader: userControllers.remoteFileDownloader, - identifierLookupController: userControllers.identifierLookupController, - syncScheduler: userControllers.syncScheduler, - citationController: userControllers.citationController, - fileCleanupController: userControllers.fileCleanupController, - itemsTagFilterDelegate: itemsTagFilterDelegate, - htmlAttributedStringConverter: controllers.htmlAttributedStringConverter - ) - } + case .custom(let type) where type == .trash: + controller = createTrashViewController( + libraryId: libraryId, + dbStorage: userControllers.dbStorage, + fileDownloader: userControllers.fileDownloader, + schemaController: controllers.schemaController, + fileStorage: controllers.fileStorage, + urlDetector: controllers.urlDetector, + itemsTagFilterDelegate: itemsTagFilterDelegate, + htmlAttributedStringConverter: controllers.htmlAttributedStringConverter + ) - case .collection, .search: + case .collection, .search, .custom: controller = createItemsViewController( collection: collection, libraryId: libraryId, From c352b0fb99f24983e10920a2924d2c2241077226 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Wed, 16 Oct 2024 14:41:41 +0200 Subject: [PATCH 15/23] Update Zotero/Scenes/Detail/DetailCoordinator.swift Co-authored-by: Miltiadis Vasilakis --- Zotero/Scenes/Detail/DetailCoordinator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Zotero/Scenes/Detail/DetailCoordinator.swift b/Zotero/Scenes/Detail/DetailCoordinator.swift index 2bf5d14fa..8d6a9a148 100644 --- a/Zotero/Scenes/Detail/DetailCoordinator.swift +++ b/Zotero/Scenes/Detail/DetailCoordinator.swift @@ -171,7 +171,7 @@ final class DetailCoordinator: Coordinator { dbStorage: dbStorage, schemaController: schemaController, fileStorage: fileStorage, - fileDownloader: userControllers.fileDownloader, + fileDownloader: fileDownloader, urlDetector: urlDetector, htmlAttributedStringConverter: htmlAttributedStringConverter, fileCleanupController: userControllers.fileCleanupController From 692a451f2cfd833488980b20eade90f37163cf41 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Wed, 16 Oct 2024 14:51:26 +0200 Subject: [PATCH 16/23] Update Zotero/Scenes/Detail/Trash/Models/TrashObject.swift Co-authored-by: Miltiadis Vasilakis --- Zotero/Scenes/Detail/Trash/Models/TrashObject.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift index ec8b5f5ba..cdab738e1 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift @@ -1,5 +1,5 @@ // -// File.swift +// TrashObject.swift // Zotero // // Created by Michal Rentka on 18.07.2024. From 7afe693ef2a96904b9ca2cf008ff08f97a4d4251 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Wed, 16 Oct 2024 14:52:40 +0200 Subject: [PATCH 17/23] Retain library token --- Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift index 17993f08f..39d03a240 100644 --- a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift +++ b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift @@ -202,6 +202,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { update(viewModel: viewModel) { state in state.library = library + state.libraryToken = libraryToken state.objects = results state.itemKeys = itemKeys state.itemsToken = itemsToken From a44d18cfd1d4e9f3229283dcbaae1a756cf42953 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Wed, 16 Oct 2024 15:19:11 +0200 Subject: [PATCH 18/23] Fixed typos --- .../Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift index 39d03a240..f2d4e1015 100644 --- a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift +++ b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift @@ -186,7 +186,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { let collectionsToken = collections.observe(keyPaths: RCollection.observableKeypathsForList, { [weak self, weak viewModel] changes in guard let self, let viewModel else { return } switch changes { - case .update(let items, let deletions, let insertions, let modifications): + case .update(let collections, let deletions, let insertions, let modifications): updateCollections(collections, deletions: deletions, insertions: insertions, modifications: modifications, viewModel: viewModel, handler: self) case .error(let error): @@ -392,7 +392,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { let items = try? dbStorage.perform(request: ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier, sortType: viewModel.state.sortType), on: .main) var itemKeys: [String] = [] if let items { - itemKeys = collections.map({ $0.key }) + itemKeys = items.map({ $0.key }) } let results = results( fromItems: items, From 97b82f0a992fa1afd75d0452fb01d38a2f51a6ad Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Wed, 16 Oct 2024 15:28:05 +0200 Subject: [PATCH 19/23] Simplified items controller creation --- Zotero/Scenes/Detail/DetailCoordinator.swift | 82 +++++++------------- 1 file changed, 26 insertions(+), 56 deletions(-) diff --git a/Zotero/Scenes/Detail/DetailCoordinator.swift b/Zotero/Scenes/Detail/DetailCoordinator.swift index 8d6a9a148..b82a8068e 100644 --- a/Zotero/Scenes/Detail/DetailCoordinator.swift +++ b/Zotero/Scenes/Detail/DetailCoordinator.swift @@ -122,58 +122,34 @@ final class DetailCoordinator: Coordinator { let controller: UIViewController switch collection.identifier { case .custom(let type) where type == .trash: - controller = createTrashViewController( - libraryId: libraryId, - dbStorage: userControllers.dbStorage, - fileDownloader: userControllers.fileDownloader, - schemaController: controllers.schemaController, - fileStorage: controllers.fileStorage, - urlDetector: controllers.urlDetector, - itemsTagFilterDelegate: itemsTagFilterDelegate, - htmlAttributedStringConverter: controllers.htmlAttributedStringConverter - ) + controller = createTrashViewController(libraryId: libraryId, itemsTagFilterDelegate: itemsTagFilterDelegate, controllers: controllers) case .collection, .search, .custom: controller = createItemsViewController( collection: collection, libraryId: libraryId, - dbStorage: userControllers.dbStorage, - fileDownloader: userControllers.fileDownloader, - remoteFileDownloader: userControllers.remoteFileDownloader, - identifierLookupController: userControllers.identifierLookupController, - syncScheduler: userControllers.syncScheduler, - citationController: userControllers.citationController, - fileCleanupController: userControllers.fileCleanupController, + searchItemKeys: searchItemKeys, itemsTagFilterDelegate: itemsTagFilterDelegate, - htmlAttributedStringConverter: controllers.htmlAttributedStringConverter + controllers: controllers ) } navigationController?.setViewControllers([controller], animated: animated) - func createTrashViewController( - libraryId: LibraryIdentifier, - dbStorage: DbStorage, - fileDownloader: AttachmentDownloader, - schemaController: SchemaController, - fileStorage: FileStorage, - urlDetector: UrlDetector, - itemsTagFilterDelegate: ItemsTagFilterDelegate?, - htmlAttributedStringConverter: HtmlAttributedStringConverter - ) -> TrashViewController { + func createTrashViewController(libraryId: LibraryIdentifier, itemsTagFilterDelegate: ItemsTagFilterDelegate?, controllers: Controllers) -> TrashViewController { itemsTagFilterDelegate?.clearSelection() let searchTerm = searchItemKeys?.joined(separator: " ") let sortType = Defaults.shared.itemsSortType - let downloadBatchData = ItemsState.DownloadBatchData(batchData: fileDownloader.batchData) + let downloadBatchData = ItemsState.DownloadBatchData(batchData: userControllers.fileDownloader.batchData) let state = TrashState(libraryId: libraryId, sortType: sortType, searchTerm: searchTerm, filters: [], downloadBatchData: downloadBatchData) let handler = TrashActionHandler( - dbStorage: dbStorage, - schemaController: schemaController, - fileStorage: fileStorage, - fileDownloader: fileDownloader, - urlDetector: urlDetector, - htmlAttributedStringConverter: htmlAttributedStringConverter, + dbStorage: userControllers.dbStorage, + schemaController: controllers.schemaController, + fileStorage: controllers.fileStorage, + fileDownloader: userControllers.fileDownloader, + urlDetector: controllers.urlDetector, + htmlAttributedStringConverter: controllers.htmlAttributedStringConverter, fileCleanupController: userControllers.fileCleanupController ) let controller = TrashViewController(viewModel: ViewModel(initialState: state, handler: handler), controllers: controllers, coordinatorDelegate: self) @@ -185,22 +161,16 @@ final class DetailCoordinator: Coordinator { func createItemsViewController( collection: Collection, libraryId: LibraryIdentifier, - dbStorage: DbStorage, - fileDownloader: AttachmentDownloader, - remoteFileDownloader: RemoteAttachmentDownloader, - identifierLookupController: IdentifierLookupController, - syncScheduler: SynchronizationScheduler, - citationController: CitationController, - fileCleanupController: AttachmentFileCleanupController, + searchItemKeys: [String]?, itemsTagFilterDelegate: ItemsTagFilterDelegate?, - htmlAttributedStringConverter: HtmlAttributedStringConverter + controllers: Controllers ) -> ItemsViewController { itemsTagFilterDelegate?.clearSelection() let searchTerm = searchItemKeys?.joined(separator: " ") - let downloadBatchData = ItemsState.DownloadBatchData(batchData: fileDownloader.batchData) - let remoteDownloadBatchData = ItemsState.DownloadBatchData(batchData: remoteFileDownloader.batchData) - let identifierLookupBatchData = ItemsState.IdentifierLookupBatchData(batchData: identifierLookupController.batchData) + let downloadBatchData = ItemsState.DownloadBatchData(batchData: userControllers.fileDownloader.batchData) + let remoteDownloadBatchData = ItemsState.DownloadBatchData(batchData: userControllers.remoteFileDownloader.batchData) + let identifierLookupBatchData = ItemsState.IdentifierLookupBatchData(batchData: userControllers.identifierLookupController.batchData) let sortType = Defaults.shared.itemsSortType let state = ItemsState( collection: collection, @@ -214,17 +184,17 @@ final class DetailCoordinator: Coordinator { error: nil ) let handler = ItemsActionHandler( - dbStorage: dbStorage, - fileStorage: self.controllers.fileStorage, - schemaController: self.controllers.schemaController, - urlDetector: self.controllers.urlDetector, - fileDownloader: fileDownloader, - citationController: citationController, - fileCleanupController: fileCleanupController, - syncScheduler: syncScheduler, - htmlAttributedStringConverter: htmlAttributedStringConverter + dbStorage: userControllers.dbStorage, + fileStorage: controllers.fileStorage, + schemaController: controllers.schemaController, + urlDetector: controllers.urlDetector, + fileDownloader: userControllers.fileDownloader, + citationController: userControllers.citationController, + fileCleanupController: userControllers.fileCleanupController, + syncScheduler: userControllers.syncScheduler, + htmlAttributedStringConverter: controllers.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: controllers, coordinatorDelegate: self) controller.tagFilterDelegate = itemsTagFilterDelegate itemsTagFilterDelegate?.delegate = controller return controller From a32a8ce0b56011ec8c130a722081f8dfb8fb1259 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Tue, 22 Oct 2024 16:19:27 +0200 Subject: [PATCH 20/23] WIP: Refactoring trash state & handler to optimise object loading --- Zotero.xcodeproj/project.pbxproj | 12 +- .../Requests/ReadCollectionsDbRequest.swift | 21 +- .../Scenes/Detail/Trash/Models/TrashKey.swift | 17 + .../Detail/Trash/Models/TrashObject.swift | 220 +++------ .../Detail/Trash/Models/TrashState.swift | 30 +- .../Trash/ViewModels/TrashActionHandler.swift | 447 ++++-------------- 6 files changed, 217 insertions(+), 530 deletions(-) create mode 100644 Zotero/Scenes/Detail/Trash/Models/TrashKey.swift diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index 66dbb06aa..326a40f65 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -857,6 +857,8 @@ B398A915270C6A4300968EE8 /* WebDavController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B398A914270C6A4300968EE8 /* WebDavController.swift */; }; B398A917270C6A5B00968EE8 /* WebDavSessionStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B398A916270C6A5B00968EE8 /* WebDavSessionStorage.swift */; }; B398D6C02A77F9C60049A296 /* FontSizeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B398D6BF2A77F9C60049A296 /* FontSizeView.swift */; }; + B399A9172CC67F1600731B21 /* TrashKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399A9162CC67F1400731B21 /* TrashKey.swift */; }; + B399A9192CC6803000731B21 /* TrashObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399A9182CC6802E00731B21 /* TrashObject.swift */; }; B39ADE712C4AAB090006FA79 /* OrderedDictionary+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39ADE702C4AAB030006FA79 /* OrderedDictionary+Utils.swift */; }; B39AF554290033CD001F400F /* TableOfContentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39AF553290033CD001F400F /* TableOfContentsViewController.swift */; }; B39B18E8223947050019F467 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39B18E7223947050019F467 /* main.swift */; }; @@ -984,7 +986,6 @@ B3C3EC2C2C492A970062705A /* TrashActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC2B2C492A970062705A /* TrashActionHandler.swift */; }; B3C3EC2E2C492A9F0062705A /* TrashState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC2D2C492A9F0062705A /* TrashState.swift */; }; B3C3EC302C492AAC0062705A /* TrashAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC2F2C492AAC0062705A /* TrashAction.swift */; }; - B3C3EC322C492CEA0062705A /* TrashObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C3EC312C492CEA0062705A /* TrashObject.swift */; }; B3C43C2028589F300007076D /* NotePreviewGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C43C1F28589F300007076D /* NotePreviewGenerator.swift */; }; B3C43C212858A84A0007076D /* NotePreviewGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C43C1F28589F300007076D /* NotePreviewGenerator.swift */; }; B3C6AB28248E1B720009AC96 /* SyncBatchProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C6AB27248E1B720009AC96 /* SyncBatchProcessor.swift */; }; @@ -1883,6 +1884,8 @@ B398A914270C6A4300968EE8 /* WebDavController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebDavController.swift; sourceTree = ""; }; B398A916270C6A5B00968EE8 /* WebDavSessionStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDavSessionStorage.swift; sourceTree = ""; }; B398D6BF2A77F9C60049A296 /* FontSizeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontSizeView.swift; sourceTree = ""; }; + B399A9162CC67F1400731B21 /* TrashKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashKey.swift; sourceTree = ""; }; + B399A9182CC6802E00731B21 /* TrashObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashObject.swift; sourceTree = ""; }; B39ADE702C4AAB030006FA79 /* OrderedDictionary+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrderedDictionary+Utils.swift"; sourceTree = ""; }; B39AF553290033CD001F400F /* TableOfContentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsViewController.swift; sourceTree = ""; }; B39B18E7223947050019F467 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; @@ -1996,7 +1999,6 @@ B3C3EC2B2C492A970062705A /* TrashActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashActionHandler.swift; sourceTree = ""; }; B3C3EC2D2C492A9F0062705A /* TrashState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashState.swift; sourceTree = ""; }; B3C3EC2F2C492AAC0062705A /* TrashAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashAction.swift; sourceTree = ""; }; - B3C3EC312C492CEA0062705A /* TrashObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashObject.swift; sourceTree = ""; }; B3C43C1F28589F300007076D /* NotePreviewGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotePreviewGenerator.swift; sourceTree = ""; }; B3C6AB27248E1B720009AC96 /* SyncBatchProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncBatchProcessor.swift; sourceTree = ""; }; B3C6AB2A248E3EB90009AC96 /* ApiOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiOperation.swift; sourceTree = ""; }; @@ -3812,8 +3814,9 @@ isa = PBXGroup; children = ( B3C3EC2F2C492AAC0062705A /* TrashAction.swift */, + B399A9162CC67F1400731B21 /* TrashKey.swift */, + B399A9182CC6802E00731B21 /* TrashObject.swift */, B3C3EC2D2C492A9F0062705A /* TrashState.swift */, - B3C3EC312C492CEA0062705A /* TrashObject.swift */, ); path = Models; sourceTree = ""; @@ -4889,7 +4892,6 @@ B3593F28241A61C700760E20 /* ItemDetailFieldCell.swift in Sources */, B361A3012511F98700271173 /* LinkMode.swift in Sources */, B3429B8124BDE73A008359FC /* UIDevice+Extensions.swift in Sources */, - B3C3EC322C492CEA0062705A /* TrashObject.swift in Sources */, B3229FD128C0A07500DAF3B7 /* EditAnnotationRectsDbRequest.swift in Sources */, B3DF9AD22747AAD2007933CB /* ApiRequest.swift in Sources */, B3DCDF0E240912500039ED0D /* SinglePickerActionHandler.swift in Sources */, @@ -5106,6 +5108,7 @@ 61BD13952A5831EF008A0704 /* TextKit1TextView.swift in Sources */, B3B8800525FB546300904235 /* DeviceInfoProvider.swift in Sources */, B3B41F192848E5A90017CA4B /* AnnotationsFilterState.swift in Sources */, + B399A9192CC6803000731B21 /* TrashObject.swift in Sources */, B36E9D4925E51B0E00CD1109 /* AnnotationPosition.swift in Sources */, B3F9A4C42B04D0D900684030 /* ReaderSettingsState.swift in Sources */, B3C9D60824DA9D40003EA1EE /* CollectionsSearchState.swift in Sources */, @@ -5320,6 +5323,7 @@ B3B41F1A2848E5A90017CA4B /* AnnotationsFilterAction.swift in Sources */, B305667623FC051F003304F2 /* UIColor+Custom.swift in Sources */, B30565B823FC051E003304F2 /* ReadUserChangedObjectsDbRequest.swift in Sources */, + B399A9172CC67F1600731B21 /* TrashKey.swift in Sources */, B3593F54241A61C700760E20 /* CollectionsError.swift in Sources */, B30565B523FC051E003304F2 /* MarkObjectsAsSyncedDbRequest.swift in Sources */, B30565DE23FC051E003304F2 /* SyncVersionsDbRequest.swift in Sources */, diff --git a/Zotero/Controllers/Database/Requests/ReadCollectionsDbRequest.swift b/Zotero/Controllers/Database/Requests/ReadCollectionsDbRequest.swift index 770e0c401..3b59d9491 100644 --- a/Zotero/Controllers/Database/Requests/ReadCollectionsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/ReadCollectionsDbRequest.swift @@ -16,20 +16,29 @@ struct ReadCollectionsDbRequest: DbResponseRequest { let libraryId: LibraryIdentifier let excludedKeys: Set let trash: Bool + let searchTextComponents: [String] var needsWrite: Bool { return false } - init(libraryId: LibraryIdentifier, trash: Bool = false, excludedKeys: Set = []) { + init(libraryId: LibraryIdentifier, trash: Bool = false, searchTextComponents: [String] = [], excludedKeys: Set = []) { self.libraryId = libraryId self.trash = trash self.excludedKeys = excludedKeys + self.searchTextComponents = searchTextComponents } func process(in database: Realm) throws -> Results { - let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [.notSyncState(.dirty, in: self.libraryId), - .deleted(false), - .isTrash(trash), - .key(notIn: self.excludedKeys)]) - return database.objects(RCollection.self).filter(predicate) + var predicates: [NSPredicate] = [ + .notSyncState(.dirty, in: libraryId), + .deleted(false), + .isTrash(trash), + .key(notIn: excludedKeys) + ] + if !searchTextComponents.isEmpty { + for component in searchTextComponents { + predicates.append(NSPredicate(format: "name contains[c] %@", component)) + } + } + return database.objects(RCollection.self).filter(NSCompoundPredicate(andPredicateWithSubpredicates: predicates)) } } diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashKey.swift b/Zotero/Scenes/Detail/Trash/Models/TrashKey.swift new file mode 100644 index 000000000..620157c6d --- /dev/null +++ b/Zotero/Scenes/Detail/Trash/Models/TrashKey.swift @@ -0,0 +1,17 @@ +// +// TrashKey.swift +// Zotero +// +// Created by Michal Rentka on 21.10.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +struct TrashKey: Hashable { + enum Kind: Hashable { + case collection + case item + } + + let type: Kind + let key: String +} diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift index cdab738e1..85ad7a86c 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift @@ -2,191 +2,81 @@ // TrashObject.swift // Zotero // -// Created by Michal Rentka on 18.07.2024. +// Created by Michal Rentka on 21.10.2024. // Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. // -import UIKit - -struct TrashKey: Hashable { - enum Kind: Hashable { - case collection - case item - } - - let type: Kind - let key: String +import Foundation + +import RealmSwift + +protocol TrashObject: AnyObject { + var key: String { get } + var libraryId: LibraryIdentifier? { get } + var dateAdded: Date { get } + var dateModified: Date { get } + var date: Date? { get } + var sortTitle: String { get } + var sortType: String? { get } + var creatorSummary: String? { get } + var publisher: String? { get } + var publicationTitle: String? { get } + var year: Int? { get } + var isMainAttachmentDownloaded: Bool { get } } -struct TrashObject { - struct Item { - let sortTitle: String - let type: String - let localizedTypeName: String - let typeIconName: String - let creatorSummary: String - let publisher: String? - let publicationTitle: String? - let year: Int? - let date: Date? - let dateAdded: Date - let tagNames: Set - let tagColors: [UIColor] - let tagEmojis: [String] - let hasNote: Bool - let itemAccessory: ItemAccessory? - let isMainAttachmentDownloaded: Bool - let searchStrings: Set - - func copy(itemAccessory: ItemAccessory?) -> Item { - return .init( - sortTitle: sortTitle, - type: type, - localizedTypeName: localizedTypeName, - typeIconName: typeIconName, - creatorSummary: creatorSummary, - publisher: publisher, - publicationTitle: publicationTitle, - year: year, - date: date, - dateAdded: dateAdded, - tagNames: tagNames, - tagColors: tagColors, - tagEmojis: tagEmojis, - hasNote: hasNote, - itemAccessory: itemAccessory, - isMainAttachmentDownloaded: isMainAttachmentDownloaded, - searchStrings: searchStrings - ) - } +extension RItem: TrashObject { + var date: Date? { + return parsedDate } - - enum Kind { - case collection - case item(item: Item) + + var sortType: String? { + return localizedType } - - let type: Kind - let key: String - let libraryId: LibraryIdentifier - let title: NSAttributedString - let dateModified: Date - - var trashKey: TrashKey { - let keyType: TrashKey.Kind - switch type { - case .collection: - keyType = .collection - - case .item: - keyType = .item - } - return TrashKey(type: keyType, key: key) + + var year: Int? { + return parsedYear + } + + var isMainAttachmentDownloaded: Bool { + return fileDownloaded } +} +extension RCollection: TrashObject { + var dateAdded: Date { + return .distantPast + } + + var date: Date? { + return nil + } + var sortTitle: String { - switch type { - case .collection: - return title.string - - case .item(let item): - return item.sortTitle - } + return name } - + var sortType: String? { - switch type { - case .item(let item): - return item.type - - case .collection: - return nil - } + return nil } - + var creatorSummary: String? { - switch type { - case .item(let item): - return item.creatorSummary - - case .collection: - return nil - } + return nil } - + var publisher: String? { - switch type { - case .item(let item): - return item.publisher - - case .collection: - return nil - } + return nil } - + var publicationTitle: String? { - switch type { - case .item(let item): - return item.publicationTitle - - case .collection: - return nil - } + return nil } - + var year: Int? { - switch type { - case .item(let item): - return item.year - - case .collection: - return nil - } + return nil } - - var date: Date? { - switch type { - case .item(let item): - return item.date - - case .collection: - return nil - } - } - - var dateAdded: Date? { - switch type { - case .item(let item): - return item.dateAdded - - case .collection: - return dateModified - } - } - - var itemAccessory: ItemAccessory? { - switch type { - case .item(let item): - return item.itemAccessory - - case .collection: - return nil - } - } - - func updated(itemAccessory: ItemAccessory) -> TrashObject? { - switch type { - case .collection: - return nil - - case .item(let item): - return TrashObject( - type: .item(item: item.copy(itemAccessory: itemAccessory)), - key: key, - libraryId: libraryId, - title: title, - dateModified: dateModified - ) - } + + var isMainAttachmentDownloaded: Bool { + return false } } diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift index ab50b7780..f5e97e954 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift @@ -12,6 +12,19 @@ import OrderedCollections import RealmSwift struct TrashState: ViewModelState { + struct Snapshot { + let sortedKeys: [TrashKey] + let keyToIdx: [TrashKey: Int] + + static var empty: Snapshot { + return Snapshot(sortedKeys: [], keyToIdx: [:]) + } + + var count: Int { + return sortedKeys.count + } + } + struct Changes: OptionSet { typealias RawValue = UInt8 @@ -35,14 +48,11 @@ struct TrashState: ViewModelState { var libraryToken: NotificationToken? var itemResults: Results? var itemsToken: NotificationToken? - // Keys for all items are stored so that when a deletion comes in it can be determined which keys were deleted - var itemKeys: [String] var collectionResults: Results? var collectionsToken: NotificationToken? - // Keys for all collecitons are stored so that when a deletion comes in it can be determined which keys were deleted - var collectionKeys: [String] - var objects: OrderedDictionary - var snapshot: OrderedDictionary? + var snapshot: Snapshot + // Cache of item accessories (attachment, doi, url) so that they don't need to be re-fetched in tableView. The key is key of parent item, or item if it's a standalone attachment. + var itemAccessories: [TrashKey: ItemAccessory] var sortType: ItemsSortType var searchTerm: String? var filters: [ItemsFilter] @@ -50,8 +60,6 @@ struct TrashState: ViewModelState { var selectedItems: Set var attachmentToOpen: String? var downloadBatchData: ItemsState.DownloadBatchData? - // Used to indicate which row should update it's attachment view. The update is done directly to cell instead of tableView reload. - var updateItemKey: TrashKey? var changes: Changes var error: ItemsError? var titleFont: UIFont { @@ -59,15 +67,14 @@ struct TrashState: ViewModelState { } init(libraryId: LibraryIdentifier, sortType: ItemsSortType, searchTerm: String?, filters: [ItemsFilter], downloadBatchData: ItemsState.DownloadBatchData?) { - objects = [:] + snapshot = .empty + itemAccessories = [:] self.sortType = sortType self.filters = filters self.searchTerm = searchTerm isEditing = false changes = [] selectedItems = [] - itemKeys = [] - collectionKeys = [] self.downloadBatchData = downloadBatchData switch libraryId { @@ -82,6 +89,5 @@ struct TrashState: ViewModelState { mutating func cleanup() { error = nil changes = [] - updateItemKey = nil } } diff --git a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift index f2d4e1015..c7f8726c4 100644 --- a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift +++ b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift @@ -105,8 +105,8 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { case .toggleSelectionState: update(viewModel: viewModel) { state in - if state.selectedItems.count != state.objects.count { - state.selectedItems = Set(state.objects.keys) + if state.selectedItems.count != state.snapshot.count { + state.selectedItems = Set(state.snapshot.sortedKeys) } else { state.selectedItems = [] } @@ -144,7 +144,9 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { private func loadData(in viewModel: ViewModel) { do { - try dbStorage.perform(on: .main) { coordinator in + try dbStorage.perform(on: .main) { [weak self, weak viewModel] coordinator in + guard let self, let viewModel else { return } + let (library, libraryToken) = try viewModel.state.library.identifier.observe(in: coordinator, changes: { [weak self, weak viewModel] library in guard let self, let viewModel else { return } update(viewModel: viewModel) { state in @@ -152,25 +154,20 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { state.changes = .library } }) - let items = try coordinator.perform(request: ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier, sortType: viewModel.state.sortType)) - let itemKeys = Array(items.map({ $0.key })) - let collectionsRequest = ReadCollectionsDbRequest(libraryId: viewModel.state.library.identifier, trash: true) - let collections = (try coordinator.perform(request: collectionsRequest)).sorted(by: collectionSortDescriptor(for: viewModel.state.sortType)) - let collectionKeys = Array(collections.map({ $0.key })) - let results = results( - fromItems: items, - collections: collections, + let (snapshot, items, collections) = try createSnapshot( + libraryId: viewModel.state.library.identifier, sortType: viewModel.state.sortType, filters: viewModel.state.filters, searchTerm: viewModel.state.searchTerm, - titleFont: viewModel.state.titleFont + titleFont: viewModel.state.titleFont, + coordinator: coordinator ) let itemsToken = items.observe(keyPaths: RItem.observableKeypathsForItemList, { [weak self, weak viewModel] changes in guard let self, let viewModel else { return } switch changes { - case .update(let items, let deletions, let insertions, let modifications): - updateItems(items, deletions: deletions, insertions: insertions, modifications: modifications, viewModel: viewModel, handler: self) + case .update(let items, _, _, _): + updateItems(items, viewModel: viewModel, handler: self) case .error(let error): DDLogError("TrashActionHandler: could not load items - \(error)") @@ -183,11 +180,11 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } }) - let collectionsToken = collections.observe(keyPaths: RCollection.observableKeypathsForList, { [weak self, weak viewModel] changes in + let collectionsToken = collections?.observe(keyPaths: RCollection.observableKeypathsForList, { [weak self, weak viewModel] changes in guard let self, let viewModel else { return } switch changes { - case .update(let collections, let deletions, let insertions, let modifications): - updateCollections(collections, deletions: deletions, insertions: insertions, modifications: modifications, viewModel: viewModel, handler: self) + case .update(let collections, _, _, _): + updateCollections(collections, viewModel: viewModel, handler: self) case .error(let error): DDLogError("TrashActionHandler: could not load collections - \(error)") @@ -203,11 +200,12 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { update(viewModel: viewModel) { state in state.library = library state.libraryToken = libraryToken - state.objects = results - state.itemKeys = itemKeys + state.itemResults = items.freeze() state.itemsToken = itemsToken - state.collectionKeys = collectionKeys + state.collectionResults = collections?.freeze() state.collectionsToken = collectionsToken + state.snapshot = snapshot + state.changes = .objects } } } catch let error { @@ -217,6 +215,45 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } + func updateItems(_ items: Results, viewModel: ViewModel, handler: TrashActionHandler) { + let snapshot = createSnapshot(fromItems: items, collections: viewModel.state.collectionResults, sortType: viewModel.state.sortType) + handler.update(viewModel: viewModel) { state in + state.itemResults = items.freeze() + state.snapshot = snapshot + state.changes = .objects + } + } + + func updateCollections(_ collections: Results, viewModel: ViewModel, handler: TrashActionHandler) { + let snapshot = createSnapshot(fromItems: viewModel.state.itemResults, collections: collections, sortType: viewModel.state.sortType) + handler.update(viewModel: viewModel) { state in + state.collectionResults = collections.freeze() + state.snapshot = snapshot + state.changes = .objects + } + } + } + + private func createSnapshot( + libraryId: LibraryIdentifier, + sortType: ItemsSortType, + filters: [ItemsFilter], + searchTerm: String?, + titleFont: UIFont, + coordinator: DbCoordinator + ) throws -> (TrashState.Snapshot, Results, Results?) { + let searchComponents = searchTerm.flatMap({ createComponents(from: $0) }) ?? [] + let itemsRequest = ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: libraryId, filters: filters, sortType: sortType, searchTextComponents: searchComponents) + let items = try coordinator.perform(request: itemsRequest) + if filters.isEmpty { + let snapshot = createSnapshot(fromItems: items, collections: nil, sortType: sortType) + return (snapshot, items, nil) + } + let collectionsRequest = ReadCollectionsDbRequest(libraryId: libraryId, trash: true, searchTextComponents: searchComponents) + let collections = (try coordinator.perform(request: collectionsRequest)).sorted(by: collectionSortDescriptor(for: sortType)) + let snapshot = createSnapshot(fromItems: items, collections: collections, sortType: sortType) + return (snapshot, items, collections) + func collectionSortDescriptor(for sortType: ItemsSortType) -> [RealmSwift.SortDescriptor] { switch sortType.field { case .dateModified: @@ -229,323 +266,47 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { return [SortDescriptor(keyPath: "name", ascending: sortType.ascending)] } } + } - func results( - fromItems items: Results?, - collections: Results?, - sortType: ItemsSortType, - filters: [ItemsFilter], - searchTerm: String?, - titleFont: UIFont - ) -> OrderedDictionary { - var objects: OrderedDictionary = [:] - if let items { - for object in items.compactMap({ trashObject(from: $0, titleFont: titleFont) }) { - objects[object.trashKey] = object - } - } - if let collections { - for collection in collections { - guard let object = trashObject(from: collection, titleFont: titleFont) else { continue } - let index = objects.index(of: object, sortedBy: { areInIncreasingOrder(lObject: $0, rObject: $1, sortType: sortType) }) - objects.updateValue(object, forKey: object.trashKey, insertingAt: index) - } - } - return objects - } - - func updateObjects( - _ results: Results, - deletions: [Int], - insertions: [Int], - modifications: [Int], - trashKeyType: TrashKey.Kind, - viewModel: ViewModel, - handler: TrashActionHandler, - createObject: (RObject) -> TrashObject?, - rebuildData: () -> Void - ) { - var keys = trashKeyType == .item ? viewModel.state.itemKeys : viewModel.state.collectionKeys - var objects = viewModel.state.objects - var selectedItems = viewModel.state.selectedItems - var changes: TrashState.Changes = [] - var rebuildObjects = false - - for index in deletions { - guard index < keys.count else { - rebuildObjects = true - break - } - let key = keys.remove(at: index) - let trashKey = TrashKey(type: trashKeyType, key: key) - objects[trashKey] = nil - if selectedItems.remove(trashKey) != nil { - changes.insert(.selection) - } - } - - if rebuildObjects { - rebuildData() - return - } - - for index in modifications { - let result = results[index] - let trashKey = TrashKey(type: trashKeyType, key: result.key) - objects[trashKey] = createObject(result) - } - - for index in insertions { - let result = results[index] - let trashKey = TrashKey(type: trashKeyType, key: result.key) - guard let object = createObject(result), index < keys.count else { - rebuildObjects = true - break - } - keys.insert(result.key, at: index) - objects.updateValue(object, forKey: trashKey, insertingAt: index) - } - - if rebuildObjects { - rebuildData() - return - } - - changes.insert(.objects) - - handler.update(viewModel: viewModel) { state in - switch trashKeyType { - case .collection: - state.collectionKeys = keys - - case .item: - state.itemKeys = keys - } - state.objects = objects - state.selectedItems = selectedItems - state.changes = changes - } - } - - func updateItems(_ items: Results, deletions: [Int], insertions: [Int], modifications: [Int], viewModel: ViewModel, handler: TrashActionHandler) { - updateObjects( - items, - deletions: deletions, - insertions: insertions, - modifications: modifications, - trashKeyType: .item, - viewModel: viewModel, - handler: handler, - createObject: { item in - return trashObject(from: item, titleFont: viewModel.state.titleFont) - }, - rebuildData: { - rebuild(items: items, viewModel: viewModel, handler: handler) - } - ) - - func rebuild(items: Results, viewModel: ViewModel, handler: TrashActionHandler) { - let itemKeys = Array(items.map({ $0.key })) - let collectionsRequest = ReadCollectionsDbRequest(libraryId: viewModel.state.library.identifier, trash: true) - let collections = (try? handler.dbStorage.perform(request: collectionsRequest, on: .main))?.sorted(by: collectionSortDescriptor(for: viewModel.state.sortType)) - var collectionKeys: [String] = [] - if let collections { - collectionKeys = collections.map({ $0.key }) - } - let results = results( - fromItems: items, - collections: collections, - sortType: viewModel.state.sortType, - filters: viewModel.state.filters, - searchTerm: viewModel.state.searchTerm, - titleFont: viewModel.state.titleFont - ) - handler.update(viewModel: viewModel) { state in - state.objects = results - state.itemKeys = itemKeys - state.collectionKeys = collectionKeys - state.selectedItems = [] - state.changes = .objects - } - } - } - - func updateCollections(_ collections: Results, deletions: [Int], insertions: [Int], modifications: [Int], viewModel: ViewModel, handler: TrashActionHandler) { - updateObjects( - collections, - deletions: deletions, - insertions: insertions, - modifications: modifications, - trashKeyType: .collection, - viewModel: viewModel, - handler: handler, - createObject: { item in - return trashObject(from: item, titleFont: viewModel.state.titleFont) - }, - rebuildData: { - rebuild(collections: collections, viewModel: viewModel, handler: handler) - } - ) - - func rebuild(collections: Results, viewModel: ViewModel, handler: TrashActionHandler) { - let collectionKeys = Array(collections.map({ $0.key })) - let items = try? dbStorage.perform(request: ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier, sortType: viewModel.state.sortType), on: .main) - var itemKeys: [String] = [] - if let items { - itemKeys = items.map({ $0.key }) - } - let results = results( - fromItems: items, - collections: collections, - sortType: viewModel.state.sortType, - filters: viewModel.state.filters, - searchTerm: viewModel.state.searchTerm, - titleFont: viewModel.state.titleFont - ) - handler.update(viewModel: viewModel) { state in - state.objects = results - state.itemKeys = itemKeys - state.collectionKeys = collectionKeys - state.selectedItems = [] - state.changes = .objects - } - } - } - - func trashObject(from collection: RCollection, titleFont: UIFont) -> TrashObject? { - guard let libraryId = collection.libraryId else { return nil } - let attributedTitle = htmlAttributedStringConverter.convert(text: collection.name, baseAttributes: [.font: titleFont]) - return TrashObject(type: .collection, key: collection.key, libraryId: libraryId, title: attributedTitle, dateModified: collection.dateModified) - } - - func trashObject(from rItem: RItem, titleFont: UIFont) -> TrashObject? { - guard let libraryId = rItem.libraryId else { return nil } - let itemAccessory = ItemAccessory.create(from: rItem, fileStorage: fileStorage, urlDetector: urlDetector) - let creatorSummary = ItemCellModel.creatorSummary(for: rItem) - let (tagColors, tagEmojis) = ItemCellModel.tagData(item: rItem) - let hasNote = ItemCellModel.hasNote(item: rItem) - let typeName = schemaController.localized(itemType: rItem.rawType) ?? rItem.rawType - let attributedTitle = htmlAttributedStringConverter.convert(text: rItem.displayTitle, baseAttributes: [.font: titleFont]) - let item = TrashObject.Item( - sortTitle: rItem.sortTitle, - type: rItem.rawType, - localizedTypeName: typeName, - typeIconName: ItemCellModel.typeIconName(for: rItem), - creatorSummary: creatorSummary, - publisher: rItem.publisher, - publicationTitle: rItem.publicationTitle, - year: rItem.hasParsedYear ? rItem.parsedYear : nil, - date: rItem.parsedDate, - dateAdded: rItem.dateAdded, - tagNames: Set(rItem.tags.compactMap({ $0.tag?.name })), - tagColors: tagColors, - tagEmojis: tagEmojis, - hasNote: hasNote, - itemAccessory: itemAccessory, - isMainAttachmentDownloaded: rItem.fileDownloaded, - searchStrings: searchStrings(from: rItem) - ) - return TrashObject(type: .item(item: item), key: rItem.key, libraryId: libraryId, title: attributedTitle, dateModified: rItem.dateModified) - - func searchStrings(from item: RItem) -> Set { - var strings: Set = [item.key, item.sortTitle] - if let value = item.htmlFreeContent { - strings.insert(value) - } - for creator in item.creators { - if !creator.name.isEmpty { - strings.insert(creator.name) - } - if !creator.firstName.isEmpty { - strings.insert(creator.firstName) - } - if !creator.lastName.isEmpty { - strings.insert(creator.lastName) - } - } - for tag in item.tags { - guard let name = tag.tag?.name else { continue } - strings.insert(name) - } - for field in item.fields { - strings.insert(field.value) - } - for child in item.children { - strings.formUnion(searchStrings(from: child)) + private func createSnapshot(fromItems items: Results?, collections: Results?, sortType: ItemsSortType) -> TrashState.Snapshot { + var itemsIdx = 0 + var collectionsIdx = 0 + var keys: [TrashKey] = [] + var keyToIdx: [TrashKey: Int] = [:] + if let collections, let items { + while itemsIdx < items.count && collectionsIdx < collections.count { + let item = items[itemsIdx] + let collection = collections[collectionsIdx] + if areInIncreasingOrder(lObject: item, rObject: collection, sortType: sortType) { + keys.append(TrashKey(type: .item, key: item.key)) + keyToIdx[keys.last!] = itemsIdx + itemsIdx += 1 + } else { + keys.append(TrashKey(type: .item, key: collection.key)) + keyToIdx[keys.last!] = collectionsIdx + collectionsIdx += 1 } - return strings } } - } - - private func results( - fromOriginal original: OrderedDictionary, - sortType: ItemsSortType, - filters: [ItemsFilter], - searchTerm: String? - ) -> OrderedDictionary { - var results: OrderedDictionary = [:] - for (key, value) in original { - guard object(value, containsTerm: searchTerm) && object(value, satisfiesFilters: filters) else { continue } - let index = results.index(of: value, sortedBy: { areInIncreasingOrder(lObject: $0, rObject: $1, sortType: sortType) }) - results.updateValue(value, forKey: key, insertingAt: index) - } - return original - - func object(_ object: TrashObject, satisfiesFilters filters: [ItemsFilter]) -> Bool { - guard !filters.isEmpty else { return true } - - for filter in filters { - switch object.type { - case .collection: - // Collections don't have tags or can be "downloaded", so they fail automatically - return false - - case .item(let item): - switch filter { - case .downloadedFiles: - if !item.isMainAttachmentDownloaded { - return false - } - - case .tags(let tagNames): - if item.tagNames.intersection(tagNames).isEmpty { - return false - } - } + if let collections { + if collectionsIdx < collections.count { + while collectionsIdx < collections.count { + keys.append(TrashKey(type: .collection, key: collections[collectionsIdx].key)) + keyToIdx[keys.last!] = collectionsIdx + collectionsIdx += 1 } } - - return true } - - func object(_ object: TrashObject, containsTerm term: String?) -> Bool { - guard let term else { return true } - let components = createComponents(from: term) - guard !components.isEmpty else { return true } - for component in components { - switch object.type { - case .item(let item): - for string in item.searchStrings { - if string == component || string.localizedCaseInsensitiveContains(component) { - return true - } - } - - case .collection: - if component.lowercased() == "collection" { - return true - } - if object.key == component { - return true - } - if object.title.string.localizedCaseInsensitiveContains(component) { - return true - } + if let items { + if itemsIdx < items.count { + while itemsIdx < items.count { + keys.append(TrashKey(type: .item, key: items[itemsIdx].key)) + keyToIdx[keys.last!] = itemsIdx + itemsIdx += 1 } } - return false } + return TrashState.Snapshot(sortedKeys: keys, keyToIdx: keyToIdx) } private func areInIncreasingOrder(lObject: TrashObject, rObject: TrashObject, sortType: ItemsSortType) -> Bool { @@ -634,6 +395,21 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { // MARK: - Actions + private func split(keys: Set) -> (items: [String], collections: [String]) { + var items: [String] = [] + var collections: [String] = [] + for key in keys { + switch key.type { + case .collection: + collections.append(key.key) + + case .item: + items.append(key.key) + } + } + return (items, collections) + } + private func emptyTrash(in viewModel: ViewModel) { self.perform(request: EmptyTrashDbRequest(libraryId: viewModel.state.library.identifier)) { error in guard let error = error else { return } @@ -677,21 +453,6 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } - private func split(keys: Set) -> (items: [String], collections: [String]) { - var items: [String] = [] - var collections: [String] = [] - for key in keys { - switch key.type { - case .collection: - collections.append(key.key) - - case .item: - items.append(key.key) - } - } - return (items, collections) - } - private func startEditing(in viewModel: ViewModel) { update(viewModel: viewModel) { state in state.isEditing = true @@ -712,7 +473,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { private func downloadAttachments(for keys: Set, in viewModel: ViewModel) { var attachments: [(Attachment, String?)] = [] for key in keys { - guard let attachment = viewModel.state.objects[key]?.itemAccessory?.attachment else { continue } + guard let attachment = viewModel.state.itemAccessories[key]?.attachment else { continue } let parentKey = attachment.key == key.key ? nil : key.key attachments.append((attachment, parentKey)) } From c4b5855e4d77e7c8d16fe78aecac4c2887e13bf4 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Wed, 23 Oct 2024 14:48:19 +0200 Subject: [PATCH 21/23] WIP: finishing trash state data refactoring --- .../Detail/Items/Models/ItemCellModel.swift | 39 +-- .../Items/Models/ItemsTableViewObject.swift | 2 +- .../Views/RItemsTableViewDataSource.swift | 4 - .../Detail/Trash/Models/TrashAction.swift | 1 + .../Detail/Trash/Models/TrashObject.swift | 9 +- .../Detail/Trash/Models/TrashState.swift | 55 +++- .../Trash/ViewModels/TrashActionHandler.swift | 249 +++++++++--------- .../Views/TrashTableViewDataSource.swift | 73 +++-- .../Trash/Views/TrashViewController.swift | 24 +- 9 files changed, 244 insertions(+), 212 deletions(-) diff --git a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift index 4689cb7f7..ce4d68fc8 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift @@ -48,33 +48,18 @@ struct ItemCellModel { self.init(item: item, typeName: typeName, title: title, accessory: Self.createAccessory(from: accessory, fileDownloader: fileDownloader)) } - init(object: TrashObject, fileDownloader: AttachmentDownloader?) { - key = object.key - title = object.title - - switch object.type { - case .collection: - typeIconName = Asset.Images.Cells.collection.name - iconRenderingMode = .alwaysTemplate - subtitle = "" - hasNote = false - tagColors = [] - tagEmojis = [] - accessory = nil - typeName = L10n.Accessibility.Items.collection - hasDetailButton = false - - case .item(let item): - typeIconName = item.typeIconName - iconRenderingMode = .alwaysOriginal - subtitle = item.creatorSummary - hasNote = item.hasNote - tagColors = item.tagColors - tagEmojis = item.tagEmojis - accessory = Self.createAccessory(from: item.itemAccessory, fileDownloader: fileDownloader) - typeName = item.localizedTypeName - hasDetailButton = true - } + init(collectionWithKey key: String, title: NSAttributedString) { + self.key = key + self.title = title + accessory = nil + typeIconName = Asset.Images.Cells.collection.name + iconRenderingMode = .alwaysTemplate + subtitle = "" + hasNote = false + tagColors = [] + tagEmojis = [] + typeName = L10n.Accessibility.Items.collection + hasDetailButton = false } static func createAccessory(from accessory: ItemAccessory?, fileDownloader: AttachmentDownloader?) -> ItemCellModel.Accessory? { diff --git a/Zotero/Scenes/Detail/Items/Models/ItemsTableViewObject.swift b/Zotero/Scenes/Detail/Items/Models/ItemsTableViewObject.swift index 3951a5ac4..b3635ec69 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemsTableViewObject.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemsTableViewObject.swift @@ -8,7 +8,7 @@ import Foundation -protocol ItemsTableViewObject { +protocol ItemsTableViewObject: AnyObject { var key: String { get } var isNote: Bool { get } var isAttachment: Bool { get } diff --git a/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift b/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift index c5e7c3b4f..5558613bd 100644 --- a/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift +++ b/Zotero/Scenes/Detail/Items/Views/RItemsTableViewDataSource.swift @@ -35,10 +35,6 @@ extension RItem: ItemsTableViewObject { return false } } - - var item: RItem? { - return self - } } final class RItemsTableViewDataSource: NSObject { diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift b/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift index fd443dc13..1a0aa3cb4 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashAction.swift @@ -10,6 +10,7 @@ import Foundation enum TrashAction { case attachmentOpened(String) + case cacheItemDataIfNeeded(TrashKey) case deleteObjects(Set) case deselectItem(TrashKey) case disableFilter(ItemsFilter) diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift index 85ad7a86c..dd6c1f61a 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashObject.swift @@ -10,12 +10,13 @@ import Foundation import RealmSwift -protocol TrashObject: AnyObject { +protocol TrashObject: ItemsTableViewObject { var key: String { get } var libraryId: LibraryIdentifier? { get } var dateAdded: Date { get } var dateModified: Date { get } var date: Date? { get } + var displayTitle: String { get } var sortTitle: String { get } var sortType: String? { get } var creatorSummary: String? { get } @@ -51,7 +52,11 @@ extension RCollection: TrashObject { var date: Date? { return nil } - + + var displayTitle: String { + return name + } + var sortTitle: String { return name } diff --git a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift index f5e97e954..f3279ce6e 100644 --- a/Zotero/Scenes/Detail/Trash/Models/TrashState.swift +++ b/Zotero/Scenes/Detail/Trash/Models/TrashState.swift @@ -15,6 +15,10 @@ struct TrashState: ViewModelState { struct Snapshot { let sortedKeys: [TrashKey] let keyToIdx: [TrashKey: Int] + var itemResults: Results? + var itemsToken: NotificationToken? + var collectionResults: Results? + var collectionsToken: NotificationToken? static var empty: Snapshot { return Snapshot(sortedKeys: [], keyToIdx: [:]) @@ -23,6 +27,45 @@ struct TrashState: ViewModelState { var count: Int { return sortedKeys.count } + + func key(for index: Int) -> TrashKey? { + guard index < sortedKeys.count else { return nil } + return sortedKeys[index] + } + + func object(for key: TrashKey) -> TrashObject? { + guard let idx = keyToIdx[key] else { return nil } + switch key.type { + case .item: + guard idx < (itemResults?.count ?? 0) else { return nil } + return itemResults?[idx] + + case .collection: + guard idx < (collectionResults?.count ?? 0) else { return nil } + return collectionResults?[idx] + } + } + + func updated(sortedKeys: [TrashKey], keyToIdx: [TrashKey: Int], items: Results) -> Snapshot { + return Snapshot(sortedKeys: sortedKeys, keyToIdx: keyToIdx, itemResults: items, itemsToken: itemsToken, collectionResults: collectionResults, collectionsToken: collectionsToken) + } + + func updated(sortedKeys: [TrashKey], keyToIdx: [TrashKey: Int], collections: Results) -> Snapshot { + return Snapshot(sortedKeys: sortedKeys, keyToIdx: keyToIdx, itemResults: itemResults, itemsToken: itemsToken, collectionResults: collections, collectionsToken: collectionsToken) + } + } + + struct ItemData { + let title: NSAttributedString? + let accessory: ItemAccessory? + + func copy(with title: NSAttributedString?) -> ItemData { + return ItemData(title: title, accessory: accessory) + } + + func copy(with accessory: ItemAccessory?) -> ItemData { + return ItemData(title: title, accessory: accessory) + } } struct Changes: OptionSet { @@ -46,13 +89,10 @@ struct TrashState: ViewModelState { var library: Library var libraryToken: NotificationToken? - var itemResults: Results? - var itemsToken: NotificationToken? - var collectionResults: Results? - var collectionsToken: NotificationToken? var snapshot: Snapshot - // Cache of item accessories (attachment, doi, url) so that they don't need to be re-fetched in tableView. The key is key of parent item, or item if it's a standalone attachment. - var itemAccessories: [TrashKey: ItemAccessory] + // Cache of item data (accessory, title) so that they don't need to be re-fetched in tableView. + var itemDataCache: [TrashKey: ItemData] + var updateItemKey: TrashKey? var sortType: ItemsSortType var searchTerm: String? var filters: [ItemsFilter] @@ -68,7 +108,7 @@ struct TrashState: ViewModelState { init(libraryId: LibraryIdentifier, sortType: ItemsSortType, searchTerm: String?, filters: [ItemsFilter], downloadBatchData: ItemsState.DownloadBatchData?) { snapshot = .empty - itemAccessories = [:] + itemDataCache = [:] self.sortType = sortType self.filters = filters self.searchTerm = searchTerm @@ -89,5 +129,6 @@ struct TrashState: ViewModelState { mutating func cleanup() { error = nil changes = [] + updateItemKey = nil } } diff --git a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift index c7f8726c4..3fc9e20fa 100644 --- a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift +++ b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift @@ -139,6 +139,9 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { self.update(viewModel: viewModel) { state in state.attachmentToOpen = nil } + + case .cacheItemDataIfNeeded(let key): + cacheItemData(key: key, viewModel: viewModel) } } @@ -154,56 +157,19 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { state.changes = .library } }) - let (snapshot, items, collections) = try createSnapshot( + let snapshot = try createSnapshotAndObserve( libraryId: viewModel.state.library.identifier, sortType: viewModel.state.sortType, filters: viewModel.state.filters, searchTerm: viewModel.state.searchTerm, titleFont: viewModel.state.titleFont, - coordinator: coordinator + coordinator: coordinator, + viewModel: viewModel ) - let itemsToken = items.observe(keyPaths: RItem.observableKeypathsForItemList, { [weak self, weak viewModel] changes in - guard let self, let viewModel else { return } - switch changes { - case .update(let items, _, _, _): - updateItems(items, viewModel: viewModel, handler: self) - - case .error(let error): - DDLogError("TrashActionHandler: could not load items - \(error)") - update(viewModel: viewModel) { state in - state.error = .dataLoading - } - - case .initial: - break - } - }) - - let collectionsToken = collections?.observe(keyPaths: RCollection.observableKeypathsForList, { [weak self, weak viewModel] changes in - guard let self, let viewModel else { return } - switch changes { - case .update(let collections, _, _, _): - updateCollections(collections, viewModel: viewModel, handler: self) - - case .error(let error): - DDLogError("TrashActionHandler: could not load collections - \(error)") - update(viewModel: viewModel) { state in - state.error = .dataLoading - } - - case .initial: - break - } - }) - update(viewModel: viewModel) { state in state.library = library state.libraryToken = libraryToken - state.itemResults = items.freeze() - state.itemsToken = itemsToken - state.collectionResults = collections?.freeze() - state.collectionsToken = collectionsToken state.snapshot = snapshot state.changes = .objects } @@ -214,45 +180,28 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { state.error = .dataLoading } } - - func updateItems(_ items: Results, viewModel: ViewModel, handler: TrashActionHandler) { - let snapshot = createSnapshot(fromItems: items, collections: viewModel.state.collectionResults, sortType: viewModel.state.sortType) - handler.update(viewModel: viewModel) { state in - state.itemResults = items.freeze() - state.snapshot = snapshot - state.changes = .objects - } - } - - func updateCollections(_ collections: Results, viewModel: ViewModel, handler: TrashActionHandler) { - let snapshot = createSnapshot(fromItems: viewModel.state.itemResults, collections: collections, sortType: viewModel.state.sortType) - handler.update(viewModel: viewModel) { state in - state.collectionResults = collections.freeze() - state.snapshot = snapshot - state.changes = .objects - } - } } - private func createSnapshot( + private func createSnapshotAndObserve( libraryId: LibraryIdentifier, sortType: ItemsSortType, filters: [ItemsFilter], searchTerm: String?, titleFont: UIFont, - coordinator: DbCoordinator - ) throws -> (TrashState.Snapshot, Results, Results?) { + coordinator: DbCoordinator, + viewModel: ViewModel + ) throws -> TrashState.Snapshot { let searchComponents = searchTerm.flatMap({ createComponents(from: $0) }) ?? [] let itemsRequest = ReadItemsDbRequest(collectionId: .custom(.trash), libraryId: libraryId, filters: filters, sortType: sortType, searchTextComponents: searchComponents) let items = try coordinator.perform(request: itemsRequest) + var collections: Results? if filters.isEmpty { - let snapshot = createSnapshot(fromItems: items, collections: nil, sortType: sortType) - return (snapshot, items, nil) + let collectionsRequest = ReadCollectionsDbRequest(libraryId: libraryId, trash: true, searchTextComponents: searchComponents) + collections = (try coordinator.perform(request: collectionsRequest)).sorted(by: collectionSortDescriptor(for: sortType)) } - let collectionsRequest = ReadCollectionsDbRequest(libraryId: libraryId, trash: true, searchTextComponents: searchComponents) - let collections = (try coordinator.perform(request: collectionsRequest)).sorted(by: collectionSortDescriptor(for: sortType)) - let snapshot = createSnapshot(fromItems: items, collections: collections, sortType: sortType) - return (snapshot, items, collections) + let (keys, keyToIdx) = createSnapshotData(fromItems: items, collections: collections, sortType: sortType) + let (itemsToken, collectionsToken) = observe(items: items, collections: collections, viewModel: viewModel) + return TrashState.Snapshot(sortedKeys: keys, keyToIdx: keyToIdx, itemResults: items, itemsToken: itemsToken, collectionResults: collections, collectionsToken: collectionsToken) func collectionSortDescriptor(for sortType: ItemsSortType) -> [RealmSwift.SortDescriptor] { switch sortType.field { @@ -268,7 +217,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } - private func createSnapshot(fromItems items: Results?, collections: Results?, sortType: ItemsSortType) -> TrashState.Snapshot { + private func createSnapshotData(fromItems items: Results?, collections: Results?, sortType: ItemsSortType) -> ([TrashKey], [TrashKey: Int]) { var itemsIdx = 0 var collectionsIdx = 0 var keys: [TrashKey] = [] @@ -306,7 +255,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } } - return TrashState.Snapshot(sortedKeys: keys, keyToIdx: keyToIdx) + return (keys, keyToIdx) } private func areInIncreasingOrder(lObject: TrashObject, rObject: TrashObject, sortType: ItemsSortType) -> Bool { @@ -393,8 +342,76 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } + private func observe(items: Results?, collections: Results?, viewModel: ViewModel) -> (NotificationToken?, NotificationToken?) { + let itemsToken = items?.observe(keyPaths: RItem.observableKeypathsForItemList, { [weak self, weak viewModel] changes in + guard let self, let viewModel else { return } + switch changes { + case .update(let items, _, _, _): + updateItems(items, viewModel: viewModel, handler: self) + + case .error(let error): + DDLogError("TrashActionHandler: could not load items - \(error)") + update(viewModel: viewModel) { state in + state.error = .dataLoading + } + + case .initial: + break + } + }) + + let collectionsToken = collections?.observe(keyPaths: RCollection.observableKeypathsForList, { [weak self, weak viewModel] changes in + guard let self, let viewModel else { return } + switch changes { + case .update(let collections, _, _, _): + updateCollections(collections, viewModel: viewModel, handler: self) + + case .error(let error): + DDLogError("TrashActionHandler: could not load collections - \(error)") + update(viewModel: viewModel) { state in + state.error = .dataLoading + } + + case .initial: + break + } + }) + + return (itemsToken, collectionsToken) + + func updateItems(_ items: Results, viewModel: ViewModel, handler: TrashActionHandler) { + let (keys, keyToIdx) = createSnapshotData(fromItems: items, collections: viewModel.state.snapshot.collectionResults, sortType: viewModel.state.sortType) + handler.update(viewModel: viewModel) { state in + state.snapshot = state.snapshot.updated(sortedKeys: keys, keyToIdx: keyToIdx, items: items.freeze()) + state.changes = .objects + } + } + + func updateCollections(_ collections: Results, viewModel: ViewModel, handler: TrashActionHandler) { + let (keys, keyToIdx) = createSnapshotData(fromItems: viewModel.state.snapshot.itemResults, collections: collections, sortType: viewModel.state.sortType) + handler.update(viewModel: viewModel) { state in + state.snapshot = state.snapshot.updated(sortedKeys: keys, keyToIdx: keyToIdx, collections: collections.freeze()) + state.changes = .objects + } + } + } + // MARK: - Actions + private func cacheItemData(key: TrashKey, viewModel: ViewModel) { + guard let object = viewModel.state.snapshot.object(for: key) else { return } + var data = viewModel.state.itemDataCache[key] ?? TrashState.ItemData(title: nil, accessory: nil) + if data.title == nil { + data = data.copy(with: htmlAttributedStringConverter.convert(text: object.displayTitle, baseAttributes: [.font: viewModel.state.titleFont])) + } + if data.accessory == nil, let item = object as? RItem, let accessory = ItemAccessory.create(from: item, fileStorage: fileStorage, urlDetector: urlDetector) { + data = data.copy(with: accessory) + } + update(viewModel: viewModel) { state in + state.itemDataCache[key] = data + } + } + private func split(keys: Set) -> (items: [String], collections: [String]) { var items: [String] = [] var collections: [String] = [] @@ -473,7 +490,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { private func downloadAttachments(for keys: Set, in viewModel: ViewModel) { var attachments: [(Attachment, String?)] = [] for key in keys { - guard let attachment = viewModel.state.itemAccessories[key]?.attachment else { continue } + guard let attachment = viewModel.state.itemDataCache[key]?.accessory?.attachment else { continue } let parentKey = attachment.key == key.key ? nil : key.key attachments.append((attachment, parentKey)) } @@ -482,7 +499,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { private func process(downloadUpdate: AttachmentDownloader.Update, batchData: ItemsState.DownloadBatchData?, in viewModel: ViewModel) { let updateKey = TrashKey(type: .item, key: downloadUpdate.parentKey ?? downloadUpdate.key) - guard let accessory = viewModel.state.objects[updateKey]?.itemAccessory, let attachment = accessory.attachment else { + guard let itemData = viewModel.state.itemDataCache[updateKey], let attachment = itemData.accessory?.attachment else { updateViewModel() return } @@ -492,9 +509,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { DDLogInfo("TrashActionHandler: download update \(attachment.key); \(attachment.libraryId); kind \(downloadUpdate.kind)") guard let updatedAttachment = attachment.changed(location: .local, compressed: compressed) else { return } updateViewModel { state in - if let object = state.objects[updateKey] { - state.objects[updateKey] = object.updated(itemAccessory: .attachment(attachment: updatedAttachment, parentKey: downloadUpdate.parentKey)) - } + state.itemDataCache[updateKey] = itemData.copy(with: .attachment(attachment: updatedAttachment, parentKey: downloadUpdate.parentKey)) state.updateItemKey = updateKey } @@ -557,7 +572,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { let updateKey = parentKey ?? key let trashKey = TrashKey(type: .item, key: updateKey) // Check whether the deleted file was in this library and there is a cached object for it. - guard viewModel.state.library.identifier == libraryId && viewModel.state.objects[trashKey] != nil else { return } + guard viewModel.state.library.identifier == libraryId && viewModel.state.snapshot.keyToIdx[trashKey] != nil else { return } update(viewModel: viewModel) { state in changeAttachmentsToRemoteLocation(for: [updateKey], in: &state) state.updateItemKey = trashKey @@ -568,20 +583,21 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { if let keys { for key in keys { let trashKey = TrashKey(type: .item, key: key) - guard let object = state.objects[trashKey] else { continue } - update(key: trashKey, object: object, in: &state) + guard + let data = state.itemDataCache[trashKey], + let newAccessory = data.accessory?.updatedAttachment(update: { attachment in attachment.changed(location: .remote, condition: { $0 == .local }) }) + else { continue } + state.itemDataCache[trashKey] = data.copy(with: newAccessory) } } else { - for (key, object) in state.objects { - guard key.type == .item else { continue } - update(key: key, object: object, in: &state) + for (key, data) in state.itemDataCache { + guard + key.type == .item, + let newAccessory = data.accessory?.updatedAttachment(update: { attachment in attachment.changed(location: .remote, condition: { $0 == .local }) }) + else { continue } + state.itemDataCache[key] = data.copy(with: newAccessory) } } - - func update(key: TrashKey, object: TrashObject, in state: inout TrashState) { - guard let acccessory = object.itemAccessory?.updatedAttachment(update: { attachment in attachment.changed(location: .remote, condition: { $0 == .local }) }) else { return } - state.objects[key] = object.updated(itemAccessory: acccessory) - } } } @@ -607,52 +623,47 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { private func search(with term: String?, in viewModel: ViewModel) { guard term != viewModel.state.searchTerm else { return } - let results = results( - fromOriginal: viewModel.state.snapshot ?? viewModel.state.objects, - sortType: viewModel.state.sortType, - filters: viewModel.state.filters, - searchTerm: term - ) - updateState(withResults: results, in: viewModel) { state in - state.searchTerm = term - } + updateState(searchTerm: term, filters: viewModel.state.filters, sortType: viewModel.state.sortType, in: viewModel) } private func filter(with filters: [ItemsFilter], in viewModel: ViewModel) { guard filters != viewModel.state.filters else { return } - let results = results( - fromOriginal: viewModel.state.snapshot ?? viewModel.state.objects, - sortType: viewModel.state.sortType, - filters: filters, - searchTerm: viewModel.state.searchTerm - ) - updateState(withResults: results, in: viewModel) { state in - state.filters = filters - state.changes.insert(.filters) - } + updateState(searchTerm: viewModel.state.searchTerm, filters: filters, sortType: viewModel.state.sortType, in: viewModel) } private func changeSortType(to sortType: ItemsSortType, in viewModel: ViewModel) { guard sortType != viewModel.state.sortType else { return } - var ordered: OrderedDictionary = [:] - for object in viewModel.state.objects { - let index = ordered.index(of: object.value, sortedBy: { areInIncreasingOrder(lObject: $0, rObject: $1, sortType: sortType) }) - ordered.updateValue(object.value, forKey: object.key, insertingAt: index) - } - updateState(withResults: ordered, in: viewModel) { state in - state.sortType = sortType - } + updateState(searchTerm: viewModel.state.searchTerm, filters: viewModel.state.filters, sortType: sortType, in: viewModel) Defaults.shared.itemsSortType = sortType } - private func updateState(withResults results: OrderedDictionary, in viewModel: ViewModel, additionalStateUpdate: (inout TrashState) -> Void) { - update(viewModel: viewModel) { state in - if state.snapshot == nil { - state.snapshot = state.objects + private func updateState( + searchTerm: String?, + filters: [ItemsFilter], + sortType: ItemsSortType, + in viewModel: ViewModel + ) { + try? dbStorage.perform(on: .main) { [weak self, weak viewModel] coordinator in + guard let self, let viewModel else { return } + let snapshot = try createSnapshotAndObserve( + libraryId: viewModel.state.library.identifier, + sortType: sortType, + filters: filters, + searchTerm: searchTerm, + titleFont: viewModel.state.titleFont, + coordinator: coordinator, + viewModel: viewModel + ) + update(viewModel: viewModel) { state in + state.snapshot = snapshot + state.changes = .objects + state.searchTerm = searchTerm + state.sortType = sortType + if state.filters != filters { + state.filters = filters + state.changes.insert(.filters) + } } - state.objects = results - state.changes = [.objects] - additionalStateUpdate(&state) } } } diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift index 5df2c9786..bb2f229a1 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift @@ -13,30 +13,29 @@ import CocoaLumberjackSwift final class TrashTableViewDataSource: NSObject, ItemsTableViewDataSource { private let viewModel: ViewModel + private unowned let schemaController: SchemaController private unowned let fileDownloader: AttachmentDownloader? weak var handler: ItemsTableViewHandler? - private var snapshot: OrderedDictionary? + private var snapshot: TrashState.Snapshot? - init(viewModel: ViewModel, fileDownloader: AttachmentDownloader?) { + init(viewModel: ViewModel, schemaController: SchemaController, fileDownloader: AttachmentDownloader?) { self.viewModel = viewModel + self.schemaController = schemaController self.fileDownloader = fileDownloader } - func apply(snapshot: OrderedDictionary) { + func apply(snapshot: TrashState.Snapshot) { self.snapshot = snapshot handler?.reloadAll() } - func updateCellAccessory(key: TrashKey, snapshot: OrderedDictionary) { - self.snapshot = snapshot - guard let itemAccessory = snapshot[key]?.itemAccessory else { return } + func updateCellAccessory(key: TrashKey, itemAccessory: ItemAccessory) { let accessory = ItemCellModel.createAccessory(from: itemAccessory, fileDownloader: fileDownloader) handler?.updateCell(key: key.key, withAccessory: accessory) } - func updateAttachmentAccessories(snapshot: OrderedDictionary) { - self.snapshot = snapshot + func updateAttachmentAccessories() { handler?.attachmentAccessoriesChanged() } } @@ -51,8 +50,7 @@ extension TrashTableViewDataSource { } func key(at index: Int) -> TrashKey? { - guard let snapshot, index < snapshot.keys.count else { return nil } - return snapshot.keys[index] + return snapshot?.key(for: index) } func object(at index: Int) -> ItemsTableViewObject? { @@ -60,25 +58,23 @@ extension TrashTableViewDataSource { } private func trashObject(at index: Int) -> TrashObject? { - guard let snapshot, index < snapshot.keys.count else { return nil } - return snapshot.values[index] + return snapshot?.key(for: index).flatMap({ snapshot?.object(for: $0) }) } func tapAction(for indexPath: IndexPath) -> ItemsTableViewHandler.TapAction? { - guard let object = trashObject(at: indexPath.row) else { return nil } + guard let item = trashObject(at: indexPath.row) as? RItem else { return nil } if viewModel.state.isEditing { - return .selectItem(object) + return .selectItem(item) } - guard let accessory = object.itemAccessory else { - guard case .item(let item) = object.type else { return nil } - switch item.type { + guard let accessory = viewModel.state.itemDataCache[TrashKey(type: .item, key: item.key)]?.accessory else { + switch item.rawType { case ItemTypes.note: - return .note(object) + return .note(item) default: - return .metadata(object) + return .metadata(item) } } @@ -102,7 +98,7 @@ extension TrashTableViewDataSource { var actions = [ItemAction(type: .restore), ItemAction(type: .delete)] // Add download/remove downloaded option for attachments - if let accessory = trashObject(at: index)?.itemAccessory, let location = accessory.attachment?.location { + if let key = snapshot?.key(for: index), let accessory = viewModel.state.itemDataCache[key]?.accessory, let location = accessory.attachment?.location { switch location { case .local: actions.append(ItemAction(type: .removeDownload)) @@ -135,13 +131,13 @@ extension TrashTableViewDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: ItemsTableViewHandler.cellId, for: indexPath) - guard let object = trashObject(at: indexPath.row) else { + guard let key = key(at: indexPath.row), let object = trashObject(at: indexPath.row) else { DDLogError("TrashTableViewDataSource: indexPath.row (\(indexPath.row)) out of bounds (\(count))") return cell } - if let cell = cell as? ItemCell { - cell.set(item: ItemCellModel(object: object, fileDownloader: fileDownloader)) + if let cell = cell as? ItemCell, let model = model(for: object, key: key) { + cell.set(item: model) let openInfoAction = UIAccessibilityCustomAction(name: L10n.Accessibility.Items.openItem, actionHandler: { [weak self] _ in guard let self else { return false } @@ -152,31 +148,30 @@ extension TrashTableViewDataSource { } return cell + + func model(for object: TrashObject, key: TrashKey) -> ItemCellModel? { + viewModel.process(action: .cacheItemDataIfNeeded(key)) + 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) + } else { + return ItemCellModel(collectionWithKey: object.key, title: data?.title ?? NSAttributedString()) + } + } } } -extension TrashObject: ItemsTableViewObject { +extension RCollection: ItemsTableViewObject { var isNote: Bool { - switch type { - case .item(let item): - return item.type == ItemTypes.note - - case .collection: - return false - } + return false } var isAttachment: Bool { - switch type { - case .item(let item): - return item.type == ItemTypes.attachment - - case .collection: - return false - } + return false } var libraryIdentifier: LibraryIdentifier { - return libraryId + return libraryId ?? .custom(.myLibrary) } } diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift index 1062070aa..ad2a3a009 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -37,12 +37,12 @@ final class TrashViewController: BaseItemsViewController { override func viewDidLoad() { super.viewDidLoad() - dataSource = TrashTableViewDataSource(viewModel: viewModel, fileDownloader: controllers.userControllers?.fileDownloader) + dataSource = TrashTableViewDataSource(viewModel: viewModel, schemaController: controllers.schemaController, fileDownloader: controllers.userControllers?.fileDownloader) 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)) setupDownloadObserver() - dataSource.apply(snapshot: viewModel.state.objects) + dataSource.apply(snapshot: viewModel.state.snapshot) updateTagFilter(filters: viewModel.state.filters, collectionId: .custom(.trash), libraryId: viewModel.state.library.identifier) viewModel @@ -90,12 +90,12 @@ final class TrashViewController: BaseItemsViewController { private func update(state: TrashState) { if state.changes.contains(.objects) { - dataSource.apply(snapshot: state.objects) + dataSource.apply(snapshot: state.snapshot) updateTagFilter(filters: state.filters, collectionId: .custom(.trash), libraryId: state.library.identifier) - } else if let key = state.updateItemKey { - dataSource.updateCellAccessory(key: key, snapshot: state.objects) + } else if let key = state.updateItemKey, let accessory = state.itemDataCache[key]?.accessory { + dataSource.updateCellAccessory(key: key, itemAccessory: accessory) } else if state.changes.contains(.attachmentsRemoved) { - dataSource.updateAttachmentAccessories(snapshot: state.objects) + dataSource.updateAttachmentAccessories() } if state.changes.contains(.editing) { @@ -129,7 +129,7 @@ final class TrashViewController: BaseItemsViewController { // Perform additional actions for individual errors if needed switch error { case .itemMove, .deletion, .deletionFromCollection: - dataSource.apply(snapshot: state.objects) + dataSource.apply(snapshot: state.snapshot) case .dataLoading, .collectionAssignment, .noteSaving, .attachmentAdding, .duplicationLoading: break @@ -218,7 +218,7 @@ final class TrashViewController: BaseItemsViewController { downloadBatchData: nil, remoteDownloadBatchData: nil, identifierLookupBatchData: .init(saved: 0, total: 0), - itemCount: state.objects.count + itemCount: state.snapshot.count ) } @@ -230,7 +230,7 @@ final class TrashViewController: BaseItemsViewController { if !state.isEditing { return [.select] } - if state.selectedItems.count == state.objects.count { + if state.selectedItems.count == state.snapshot.count { return [.deselectAll, .done] } return [.selectAll, .done] @@ -281,12 +281,10 @@ extension TrashViewController: ItemsTableViewHandlerDelegate { coordinatorDelegate?.show(url: url) case .selectItem(let object): - guard let trashObject = object as? TrashObject else { return } - viewModel.process(action: .selectItem(trashObject.trashKey)) + viewModel.process(action: .selectItem(TrashKey(type: .item, key: object.key))) case .deselectItem(let object): - guard let trashObject = object as? TrashObject else { return } - viewModel.process(action: .deselectItem(trashObject.trashKey)) + viewModel.process(action: .deselectItem(TrashKey(type: .item, key: object.key))) case .note(let object): guard let item = object as? RItem, let note = Note(item: item) else { return } From 0f375228f7c9461c2f72b09be1c61923dd6270da Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Thu, 24 Oct 2024 15:08:14 +0200 Subject: [PATCH 22/23] Bug fixes --- .../Requests/CreateCollectionDbRequest.swift | 1 + .../Requests/EditCollectionDbRequest.swift | 1 + .../Requests/StoreCollectionsDbRequest.swift | 1 + Zotero/Models/Database/Database.swift | 23 +++++++- Zotero/Models/Database/RCollection.swift | 12 ++++ Zotero/Models/Database/RItem.swift | 10 +++- Zotero/Models/Database/RTag.swift | 2 +- .../Trash/ViewModels/TrashActionHandler.swift | 56 +++++++++---------- .../Views/TrashTableViewDataSource.swift | 13 +++-- .../Trash/Views/TrashViewController.swift | 22 ++++++-- 10 files changed, 94 insertions(+), 47 deletions(-) diff --git a/Zotero/Controllers/Database/Requests/CreateCollectionDbRequest.swift b/Zotero/Controllers/Database/Requests/CreateCollectionDbRequest.swift index f26c32b73..2e58dc69b 100644 --- a/Zotero/Controllers/Database/Requests/CreateCollectionDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/CreateCollectionDbRequest.swift @@ -24,6 +24,7 @@ struct CreateCollectionDbRequest: DbRequest { collection.name = self.name collection.syncState = .synced collection.libraryId = self.libraryId + collection.updateSortName() var changes: RCollectionChanges = .name diff --git a/Zotero/Controllers/Database/Requests/EditCollectionDbRequest.swift b/Zotero/Controllers/Database/Requests/EditCollectionDbRequest.swift index 97c6fc94d..dfbfd4e30 100644 --- a/Zotero/Controllers/Database/Requests/EditCollectionDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/EditCollectionDbRequest.swift @@ -25,6 +25,7 @@ struct EditCollectionDbRequest: DbRequest { if collection.name != self.name { collection.name = self.name + collection.updateSortName() changes.insert(.name) } diff --git a/Zotero/Controllers/Database/Requests/StoreCollectionsDbRequest.swift b/Zotero/Controllers/Database/Requests/StoreCollectionsDbRequest.swift index 54040ac36..2d0e9b432 100644 --- a/Zotero/Controllers/Database/Requests/StoreCollectionsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/StoreCollectionsDbRequest.swift @@ -61,6 +61,7 @@ struct StoreCollectionsDbRequest: DbRequest { collection.trash = response.data.isTrash collection.trashDate = collection.trash ? Date.now : nil } + collection.updateSortName() self.sync(parentCollection: response.data.parentCollection, libraryId: libraryId, collection: collection, database: database) } diff --git a/Zotero/Models/Database/Database.swift b/Zotero/Models/Database/Database.swift index 008c77fb4..37671699d 100644 --- a/Zotero/Models/Database/Database.swift +++ b/Zotero/Models/Database/Database.swift @@ -13,7 +13,7 @@ import RealmSwift import Network struct Database { - private static let schemaVersion: UInt64 = 47 + private static let schemaVersion: UInt64 = 49 static func mainConfiguration(url: URL, fileStorage: FileStorage) -> Realm.Configuration { var config = Realm.Configuration( @@ -92,6 +92,27 @@ struct Database { if schemaVersion < 47 { setTrashDates(migration: migration) } + if schemaVersion < 49 { + migrateCollectionsSortName(migration: migration) + migrateTagNames(migration: migration) + migrateItemSortTitle(migration: migration) + } + } + } + + private static func migrateItemSortTitle(migration: Migration) { + migration.enumerateObjects(ofType: RItem.className()) { oldObject, newObject in + if let title = oldObject?["displayTitle"] as? String, !title.isEmpty { + newObject?["sortTitle"] = RItem.sortTitle(from: title) + } + } + } + + private static func migrateCollectionsSortName(migration: Migration) { + migration.enumerateObjects(ofType: RCollection.className()) { oldObject, newObject in + if let name = oldObject?["name"] as? String, !name.isEmpty { + newObject?["sortName"] = RCollection.sortName(from: name) + } } } diff --git a/Zotero/Models/Database/RCollection.swift b/Zotero/Models/Database/RCollection.swift index 72bdfaa1d..9b4a169e0 100644 --- a/Zotero/Models/Database/RCollection.swift +++ b/Zotero/Models/Database/RCollection.swift @@ -32,6 +32,7 @@ final class RCollection: Object { @Persisted(indexed: true) var key: String @Persisted var name: String + @Persisted var sortName: String @Persisted var dateModified: Date @Persisted var parentKey: String? @Persisted var collapsed: Bool = true @@ -60,6 +61,17 @@ final class RCollection: Object { /// Indicates whether the object is trashed locally and needs to be synced with backend @Persisted var trash: Bool + static func sortName(from name: String) -> String { + return name.folding(options: .diacriticInsensitive, locale: .current).trimmingCharacters(in: CharacterSet(charactersIn: "[]'\"")).lowercased() + } + + func updateSortName() { + let newName = RCollection.sortName(from: name) + if newName != sortName { + sortName = newName + } + } + // MARK: - Sync properties var changedFields: RCollectionChanges { diff --git a/Zotero/Models/Database/RItem.swift b/Zotero/Models/Database/RItem.swift index f5ade57e1..f31cbc840 100644 --- a/Zotero/Models/Database/RItem.swift +++ b/Zotero/Models/Database/RItem.swift @@ -191,10 +191,14 @@ final class RItem: Object { self.updateSortTitle() } + static func sortTitle(from title: String) -> String { + return title.strippedRichTextTags.folding(options: .diacriticInsensitive, locale: .current).trimmingCharacters(in: CharacterSet(charactersIn: "[]'\"")).lowercased() + } + private func updateSortTitle() { - let newTitle = self.displayTitle.strippedRichTextTags.trimmingCharacters(in: CharacterSet(charactersIn: "[]'\"")).lowercased() - if newTitle != self.sortTitle { - self.sortTitle = newTitle + let newTitle = RItem.sortTitle(from: displayTitle) + if newTitle != sortTitle { + sortTitle = newTitle } } diff --git a/Zotero/Models/Database/RTag.swift b/Zotero/Models/Database/RTag.swift index c5f637974..81fccff5c 100644 --- a/Zotero/Models/Database/RTag.swift +++ b/Zotero/Models/Database/RTag.swift @@ -39,7 +39,7 @@ final class RTag: Object { } static func sortName(from name: String) -> String { - return name.trimmingCharacters(in: CharacterSet(charactersIn: "[]'\"")).lowercased() + return name.folding(options: .diacriticInsensitive, locale: .current).trimmingCharacters(in: CharacterSet(charactersIn: "[]'\"")).lowercased() } static func create(name: String, color: String? = nil, libraryId: LibraryIdentifier, order: Int? = nil) -> RTag { diff --git a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift index 3fc9e20fa..fd9307351 100644 --- a/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift +++ b/Zotero/Scenes/Detail/Trash/ViewModels/TrashActionHandler.swift @@ -208,11 +208,11 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { case .dateModified: return [ SortDescriptor(keyPath: "dateModified", ascending: sortType.ascending), - SortDescriptor(keyPath: "name", ascending: sortType.ascending) + SortDescriptor(keyPath: "sortName", ascending: sortType.ascending) ] case .title, .creator, .date, .dateAdded, .itemType, .publisher, .publicationTitle, .year: - return [SortDescriptor(keyPath: "name", ascending: sortType.ascending)] + return [SortDescriptor(keyPath: "sortName", ascending: sortType.ascending)] } } } @@ -231,28 +231,24 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { keyToIdx[keys.last!] = itemsIdx itemsIdx += 1 } else { - keys.append(TrashKey(type: .item, key: collection.key)) + keys.append(TrashKey(type: .collection, key: collection.key)) keyToIdx[keys.last!] = collectionsIdx collectionsIdx += 1 } } } - if let collections { - if collectionsIdx < collections.count { - while collectionsIdx < collections.count { - keys.append(TrashKey(type: .collection, key: collections[collectionsIdx].key)) - keyToIdx[keys.last!] = collectionsIdx - collectionsIdx += 1 - } + if let collections, collectionsIdx < collections.count { + while collectionsIdx < collections.count { + keys.append(TrashKey(type: .collection, key: collections[collectionsIdx].key)) + keyToIdx[keys.last!] = collectionsIdx + collectionsIdx += 1 } } - if let items { - if itemsIdx < items.count { - while itemsIdx < items.count { - keys.append(TrashKey(type: .item, key: items[itemsIdx].key)) - keyToIdx[keys.last!] = itemsIdx - itemsIdx += 1 - } + if let items, itemsIdx < items.count { + while itemsIdx < items.count { + keys.append(TrashKey(type: .item, key: items[itemsIdx].key)) + keyToIdx[keys.last!] = itemsIdx + itemsIdx += 1 } } return (keys, keyToIdx) @@ -281,6 +277,7 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { initialResult = compare(lValue: lObject.publicationTitle, rValue: rObject.publicationTitle) case .publisher: + DDLogInfo("LPublisher: \(lObject.key); '\(lObject.publisher ?? "nil")' - \(rObject.key); '\(rObject.publisher ?? "nil")'") initialResult = compare(lValue: lObject.publisher, rValue: rObject.publisher) case .year: @@ -308,9 +305,12 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { } } - func compare(lValue: String?, rValue: String?) -> ComparisonResult { + func compare(lValue: Val?, rValue: Val?, nonNilCompare: (Val, Val) -> ComparisonResult) -> ComparisonResult { if let lValue, let rValue { - return lValue.compare(rValue, options: [.numeric], locale: Locale.autoupdatingCurrent) + return nonNilCompare(lValue, rValue) + } + if lValue == nil && rValue == nil { + return .orderedSame } if lValue != nil { return .orderedAscending @@ -318,27 +318,21 @@ final class TrashActionHandler: BaseItemsActionHandler, ViewModelActionHandler { return .orderedDescending } + func compare(lValue: String?, rValue: String?) -> ComparisonResult { + return compare(lValue: lValue, rValue: rValue, nonNilCompare: { $0.compare($1, options: [.numeric], locale: Locale.autoupdatingCurrent) }) + } + func compare(lValue: Int?, rValue: Int?) -> ComparisonResult { - if let lValue, let rValue { + compare(lValue: lValue, rValue: rValue) { lValue, rValue in if lValue == rValue { return .orderedSame } return lValue < rValue ? .orderedAscending : .orderedDescending } - if lValue != nil { - return .orderedAscending - } - return .orderedDescending } func compare(lValue: Date?, rValue: Date?) -> ComparisonResult { - if let lValue, let rValue { - return lValue.compare(rValue) - } - if lValue != nil { - return .orderedAscending - } - return .orderedDescending + return compare(lValue: lValue, rValue: rValue, nonNilCompare: { $0.compare($1) }) } } diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift index bb2f229a1..3840d19d6 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashTableViewDataSource.swift @@ -62,19 +62,20 @@ extension TrashTableViewDataSource { } func tapAction(for indexPath: IndexPath) -> ItemsTableViewHandler.TapAction? { - guard let item = trashObject(at: indexPath.row) as? RItem else { return nil } + guard let object = trashObject(at: indexPath.row) else { return nil } if viewModel.state.isEditing { - return .selectItem(item) + return .selectItem(object) } - guard let accessory = viewModel.state.itemDataCache[TrashKey(type: .item, key: item.key)]?.accessory else { - switch item.rawType { + guard let accessory = viewModel.state.itemDataCache[TrashKey(type: .item, key: object.key)]?.accessory else { + let itemType = (object as? RItem)?.rawType ?? "" + switch itemType { case ItemTypes.note: - return .note(item) + return .note(object) default: - return .metadata(item) + return .metadata(object) } } diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift index ad2a3a009..237322ea1 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -29,7 +29,7 @@ final class TrashViewController: BaseItemsViewController { super.init(controllers: controllers, coordinatorDelegate: coordinatorDelegate) viewModel.process(action: .loadData) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -254,21 +254,22 @@ extension TrashViewController: ItemsTableViewHandlerDelegate { var isInViewHierarchy: Bool { return view.window != nil } - + var collectionKey: String? { return nil } - + func process(action: ItemAction.Kind, at index: Int, completionAction: ((Bool) -> Void)?) { guard let key = dataSource.key(at: index) else { return } process(action: action, for: [key], button: nil, completionAction: completionAction) } - + func process(tapAction action: ItemsTableViewHandler.TapAction) { resetActiveSearch() switch action { case .metadata(let object): + guard object is RItem else { return } coordinatorDelegate?.showItemDetail(for: .preview(key: object.key), libraryId: viewModel.state.library.identifier, scrolledToKey: nil, animated: true) case .attachment(let attachment, let parentKey): @@ -297,7 +298,7 @@ extension TrashViewController: ItemsTableViewHandlerDelegate { searchBar.resignFirstResponder() } } - + func process(dragAndDropAction action: ItemsTableViewHandler.DragAndDropAction) { switch action { case .moveItems: @@ -319,3 +320,14 @@ extension TrashViewController: ItemsToolbarControllerDelegate { coordinatorDelegate?.showLookup() } } + +extension TrashViewController: DetailCoordinatorAttachmentProvider { + func attachment(for key: String, parentKey: String?, libraryId: LibraryIdentifier) -> (Attachment, UIView, CGRect?)? { + guard + let accessory = viewModel.state.itemDataCache[TrashKey(type: .item, key: parentKey ?? key)]?.accessory, + let attachment = accessory.attachment, + let (sourceView, sourceRect) = handler?.sourceDataForCell(for: (parentKey ?? key)) + else { return nil } + return (attachment, sourceView, sourceRect) + } +} From daa13ebfe4500b80ab2d76b24403004dd696d76b Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Thu, 24 Oct 2024 15:15:07 +0200 Subject: [PATCH 23/23] Fixed broken filters --- .../Scenes/Detail/Trash/Views/TrashViewController.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift index 237322ea1..f8c5f0ed7 100644 --- a/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift +++ b/Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift @@ -248,6 +248,14 @@ final class TrashViewController: BaseItemsViewController { viewModel.process(action: .enableFilter(.tags(selected))) } } + + override func downloadsFilterDidChange(enabled: Bool) { + if enabled { + viewModel.process(action: .enableFilter(.downloadedFiles)) + } else { + viewModel.process(action: .disableFilter(.downloadedFiles)) + } + } } extension TrashViewController: ItemsTableViewHandlerDelegate {