From 5943509a429b04f78699387f606e6d69bd457261 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Tue, 28 Nov 2023 18:46:11 +0100 Subject: [PATCH] Emojis in tags improvements (#812) --- Zotero.xcodeproj/project.pbxproj | 22 ++- .../Database/Requests/CleanupUnusedTags.swift | 2 +- .../Requests/CreateNoteDbRequest.swift | 6 +- .../Database/Requests/EditNoteDbRequest.swift | 6 +- .../Requests/EditTagsForItemDbRequest.swift | 6 +- .../Requests/ReadEmojiTagsDbRequest.swift | 24 +++ .../Requests/ReadFilteredTagsDbRequest.swift | 4 +- .../Requests/ReadTagPickerTagsDbRequest.swift | 2 +- .../StoreItemsDbResponseRequest.swift | 5 +- .../Requests/StoreSettingsDbRequest.swift | 7 +- Zotero/Controllers/EmojiExtractor.swift | 39 ++++ Zotero/Models/Database/Database.swift | 12 +- Zotero/Models/Database/RTag.swift | 14 ++ Zotero/Models/Predicates.swift | 4 +- Zotero/Models/Tag.swift | 4 + .../Detail/Items/Models/ItemCellModel.swift | 32 +++- .../Scenes/Detail/Items/Views/ItemCell.swift | 8 +- Zotero/Scenes/Detail/Items/Views/ItemCell.xib | 26 ++- .../Detail/Items/Views/TagCirclesView.swift | 134 -------------- .../Items/Views/TagEmojiCirclesView.swift | 167 ++++++++++++++++++ .../ViewModels/TagPickerActionHandler.swift | 7 +- .../ViewModels/TagFilterActionHandler.swift | 43 +++-- ZoteroTests/EmojiExtractorSpec.swift | 36 ++++ ZoteroTests/SyncControllerSpec.swift | 5 +- 24 files changed, 394 insertions(+), 221 deletions(-) create mode 100644 Zotero/Controllers/Database/Requests/ReadEmojiTagsDbRequest.swift create mode 100644 Zotero/Controllers/EmojiExtractor.swift delete mode 100644 Zotero/Scenes/Detail/Items/Views/TagCirclesView.swift create mode 100644 Zotero/Scenes/Detail/Items/Views/TagEmojiCirclesView.swift create mode 100644 ZoteroTests/EmojiExtractorSpec.swift diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index ea797b6e0..82c647cf9 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -632,7 +632,7 @@ B3593F42241A61C700760E20 /* ItemsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = B3593EF4241A61C700760E20 /* ItemsViewController.xib */; }; B3593F44241A61C700760E20 /* ItemsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EF6241A61C700760E20 /* ItemsViewController.swift */; }; B3593F45241A61C700760E20 /* ItemSortTypePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EF7241A61C700760E20 /* ItemSortTypePickerView.swift */; }; - B3593F47241A61C700760E20 /* TagCirclesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EF9241A61C700760E20 /* TagCirclesView.swift */; }; + B3593F47241A61C700760E20 /* TagEmojiCirclesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EF9241A61C700760E20 /* TagEmojiCirclesView.swift */; }; B3593F48241A61C700760E20 /* LibrariesActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EFD241A61C700760E20 /* LibrariesActionHandler.swift */; }; B3593F49241A61C700760E20 /* LibrariesAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593EFF241A61C700760E20 /* LibrariesAction.swift */; }; B3593F4A241A61C700760E20 /* LibrariesState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3593F00241A61C700760E20 /* LibrariesState.swift */; }; @@ -855,6 +855,7 @@ B3A47C3629015FCE00E7D90D /* TableOfContentsActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A47C3529015FCE00E7D90D /* TableOfContentsActionHandler.swift */; }; B3A47C3829015FDD00E7D90D /* TableOfContentsAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A47C3729015FDD00E7D90D /* TableOfContentsAction.swift */; }; B3A47C3A29015FE800E7D90D /* TableOfContentsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A47C3929015FE800E7D90D /* TableOfContentsState.swift */; }; + B3A53FF72B14CDB2004BB9D7 /* ReadEmojiTagsDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A53FF62B14CDB2004BB9D7 /* ReadEmojiTagsDbRequest.swift */; }; B3A6C59F252CA08300F24CBE /* PSPDFKit in Frameworks */ = {isa = PBXBuildFile; productRef = B3A6C59E252CA08300F24CBE /* PSPDFKit */; }; B3A94B4A2462F5D300BC7910 /* PSPDFKit+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A94B492462F5D300BC7910 /* PSPDFKit+Extensions.swift */; }; B3A95DAA29194BDE00BCCF11 /* DashedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A95DA929194BDE00BCCF11 /* DashedView.swift */; }; @@ -945,6 +946,7 @@ B3C9D60A24DA9D49003EA1EE /* CollectionsSearchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C9D60924DA9D49003EA1EE /* CollectionsSearchAction.swift */; }; B3C9D60C24DA9DEA003EA1EE /* CollectionsSearchActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C9D60B24DA9DEA003EA1EE /* CollectionsSearchActionHandler.swift */; }; B3CAD66228D31EFB007E1905 /* RObjectChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32989D528D1D94F009B61F3 /* RObjectChange.swift */; }; + B3CAE1182B0E38BA0000F8CA /* EmojiExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3CAE1172B0E38BA0000F8CA /* EmojiExtractor.swift */; }; B3CBB121248A439A00C4228F /* Notification+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3CBB120248A439A00C4228F /* Notification+Extensions.swift */; }; B3CBB123248A44D100C4228F /* KeyboardData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3CBB122248A44D100C4228F /* KeyboardData.swift */; }; B3CBB125248A46D500C4228F /* NotificationCenter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3CBB124248A46D500C4228F /* NotificationCenter+Extensions.swift */; }; @@ -1082,6 +1084,8 @@ 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 */; }; + 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 */; }; B3F09AE829CAFF8D0084E4D8 /* TagFilterState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F09AE729CAFF8D0084E4D8 /* TagFilterState.swift */; }; B3F09AEA29CAFF9E0084E4D8 /* TagFilterAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F09AE929CAFF9E0084E4D8 /* TagFilterAction.swift */; }; @@ -1614,7 +1618,7 @@ 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 = ""; }; B3593EF7241A61C700760E20 /* ItemSortTypePickerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemSortTypePickerView.swift; sourceTree = ""; }; - B3593EF9241A61C700760E20 /* TagCirclesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagCirclesView.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 = ""; }; B3593EFF241A61C700760E20 /* LibrariesAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibrariesAction.swift; sourceTree = ""; }; B3593F00241A61C700760E20 /* LibrariesState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibrariesState.swift; sourceTree = ""; }; @@ -1792,6 +1796,7 @@ B3A47C3529015FCE00E7D90D /* TableOfContentsActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsActionHandler.swift; sourceTree = ""; }; B3A47C3729015FDD00E7D90D /* TableOfContentsAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsAction.swift; sourceTree = ""; }; B3A47C3929015FE800E7D90D /* TableOfContentsState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsState.swift; sourceTree = ""; }; + B3A53FF62B14CDB2004BB9D7 /* ReadEmojiTagsDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadEmojiTagsDbRequest.swift; sourceTree = ""; }; B3A94B492462F5D300BC7910 /* PSPDFKit+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PSPDFKit+Extensions.swift"; sourceTree = ""; }; B3A95DA929194BDE00BCCF11 /* DashedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashedView.swift; sourceTree = ""; }; B3AAABD52502A40600031065 /* searchresponse_unknownfields.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = searchresponse_unknownfields.json; sourceTree = ""; }; @@ -1872,6 +1877,7 @@ B3C9D60724DA9D40003EA1EE /* CollectionsSearchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsSearchState.swift; sourceTree = ""; }; B3C9D60924DA9D49003EA1EE /* CollectionsSearchAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsSearchAction.swift; sourceTree = ""; }; B3C9D60B24DA9DEA003EA1EE /* CollectionsSearchActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsSearchActionHandler.swift; sourceTree = ""; }; + B3CAE1172B0E38BA0000F8CA /* EmojiExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiExtractor.swift; sourceTree = ""; }; B3CBB120248A439A00C4228F /* Notification+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Extensions.swift"; sourceTree = ""; }; B3CBB122248A44D100C4228F /* KeyboardData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardData.swift; sourceTree = ""; }; B3CBB124248A46D500C4228F /* NotificationCenter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationCenter+Extensions.swift"; sourceTree = ""; }; @@ -1985,6 +1991,7 @@ B3E9180B25ECE30A002B77AF /* CoreGraphics+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CoreGraphics+Extensions.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 = ""; }; + 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 = ""; }; B3F09AE929CAFF9E0084E4D8 /* TagFilterAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagFilterAction.swift; sourceTree = ""; }; @@ -2142,6 +2149,7 @@ B3DDACE828E6F52E0063407E /* CustomURLController.swift */, B3BC25CC247E6BA000AC27B5 /* DateParser.swift */, B305647F23FC051E003304F2 /* DragDropController.swift */, + B3CAE1172B0E38BA0000F8CA /* EmojiExtractor.swift */, B3D3FCA8267762EC008E243A /* ExportLocaleReader.swift */, B305648123FC051E003304F2 /* Formatter.swift */, B367330C24ACB63300E0CDA8 /* HtmlAttributedStringConverter.swift */, @@ -2259,6 +2267,7 @@ B3F55A1A29EED4EA00A6716E /* ReadColoredTagsDbRequest.swift */, B305644B23FC051E003304F2 /* ReadDeletedObjectsDbRequest.swift */, B37BF0B025A6035D00AE0268 /* ReadDocumentDataDbRequest.swift */, + B3A53FF62B14CDB2004BB9D7 /* ReadEmojiTagsDbRequest.swift */, B34A9F5D25BF12F7007C9A4A /* ReadFilenameDbRequest.swift */, B3F55A1629EED04700A6716E /* ReadFilteredTagsDbRequest.swift */, B3451B54264ECBA9000EDF16 /* ReadGroupDbRequest.swift */, @@ -2678,6 +2687,7 @@ B3B1EDEE250242E700D8BC1E /* CollectionResponseSpec.swift */, B34F9FA223743C36004ED34C /* CreatorSummaryFormatterSpec.swift */, B32A3C79247FFF14009E2C5D /* DateParserSpec.swift */, + B3EFC60A2B0F503E00CB71A0 /* EmojiExtractorSpec.swift */, B3243BC72A5EB2740033A7D6 /* HtmlAttributedStringConverterSpec.swift */, B3B1EDEC250242DB00D8BC1E /* ItemResponseSpec.swift */, B34F9FA423743C42004ED34C /* ItemTitleFormatterSpec.swift */, @@ -3134,7 +3144,7 @@ B31D973B27F5E51100ED3DA2 /* ItemsToolbarDownloadProgressView.swift */, B3593EF6241A61C700760E20 /* ItemsViewController.swift */, B3593EF4241A61C700760E20 /* ItemsViewController.xib */, - B3593EF9241A61C700760E20 /* TagCirclesView.swift */, + B3593EF9241A61C700760E20 /* TagEmojiCirclesView.swift */, ); path = Views; sourceTree = ""; @@ -4877,6 +4887,7 @@ B3830CDC255451AB00910FE0 /* TagPickerState.swift in Sources */, B39D336823FFD96C00EF2ACB /* Note.swift in Sources */, B32DE61F26320271000287EC /* PopoverNavigationViewController.swift in Sources */, + B3CAE1182B0E38BA0000F8CA /* EmojiExtractor.swift in Sources */, B3DEAAA728AD01CA00F72D90 /* URLSession+Extensions.swift in Sources */, B357A28C285B73BD00E73CA1 /* ScannerState.swift in Sources */, B3E8FE5A2714325200F51458 /* SavingSettingsActionHandler.swift in Sources */, @@ -4979,6 +4990,7 @@ B31A5E51286308960026589F /* LookupIdentifierCell.swift in Sources */, B319D6B8265CE0C200E52132 /* ReadAllDownloadedAndForUploadItemsDbRequest.swift in Sources */, B39E01352832883A0091CE4A /* LookupWebViewHandler.swift in Sources */, + B3A53FF72B14CDB2004BB9D7 /* ReadEmojiTagsDbRequest.swift in Sources */, B30566A723FC051F003304F2 /* DelayIntervals.swift in Sources */, B37DD576272AAF500038537D /* FilterAttachmentsDbRequest.swift in Sources */, B3E8FE042714292E00F51458 /* StorageSettingsAction.swift in Sources */, @@ -5127,7 +5139,7 @@ B30565D423FC051E003304F2 /* ReadItemsDbRequest.swift in Sources */, B3E8FE2E271429C300F51458 /* ExportLocalePickerState.swift in Sources */, B3E8FE5227142C2F00F51458 /* DebuggingView.swift in Sources */, - B3593F47241A61C700760E20 /* TagCirclesView.swift in Sources */, + B3593F47241A61C700760E20 /* TagEmojiCirclesView.swift in Sources */, B3593F2E241A61C700760E20 /* ItemDetailAttachmentCell.swift in Sources */, B3DDC0CB2667824D00B2DFD1 /* RegularExpression+Extensions.swift in Sources */, B3B953D42459B62D00FC96DB /* AnnotationCell.swift in Sources */, @@ -5192,6 +5204,7 @@ files = ( 6144B5E02A4AE49800914B3C /* TranslatorsControllerSpec.swift in Sources */, 6144B5D92A4ADEB500914B3C /* ReadUpdatedItemUpdateParametersSpec.swift in Sources */, + B3EFC60B2B0F503E00CB71A0 /* EmojiExtractorSpec.swift in Sources */, 61C817F22A49B5D30085B1E6 /* CollectionResponseSpec.swift in Sources */, 6144B5D52A4ADD3C00914B3C /* DateParserSpec.swift in Sources */, B3202C6A2710488200485BE4 /* SyncControllerAction+Equatable.swift in Sources */, @@ -5373,6 +5386,7 @@ B36459E3264411F300A0C2C0 /* TagColorGenerator.swift in Sources */, B3DF9AD32747AAD9007933CB /* ApiRequest.swift in Sources */, B305673023FC0921003304F2 /* SubmitDeletionSyncAction.swift in Sources */, + B3EFC60C2B0F633A00CB71A0 /* EmojiExtractor.swift in Sources */, B3FC7436272195DE00F55531 /* WebDavDownloadRequest.swift in Sources */, B3FC7437272195E100F55531 /* WebDavNonexistentPropRequest.swift in Sources */, B305676F23FC0A55003304F2 /* ArrayEncoding.swift in Sources */, diff --git a/Zotero/Controllers/Database/Requests/CleanupUnusedTags.swift b/Zotero/Controllers/Database/Requests/CleanupUnusedTags.swift index 82a3ee6a0..aa6ed94dd 100644 --- a/Zotero/Controllers/Database/Requests/CleanupUnusedTags.swift +++ b/Zotero/Controllers/Database/Requests/CleanupUnusedTags.swift @@ -17,7 +17,7 @@ struct CleanupUnusedTags: DbRequest { let toRemoveTyped = database.objects(RTypedTag.self).filter("item == nil") database.delete(toRemoveTyped) - let toRemoveBase = database.objects(RTag.self).filter("tags.@count == 0 and color == \"\"") + let toRemoveBase = database.objects(RTag.self).filter("tags.@count == 0 and (color == \"\" or emojiGroup == nil)") database.delete(toRemoveBase) } } diff --git a/Zotero/Controllers/Database/Requests/CreateNoteDbRequest.swift b/Zotero/Controllers/Database/Requests/CreateNoteDbRequest.swift index 32f0e247f..1c1733953 100644 --- a/Zotero/Controllers/Database/Requests/CreateNoteDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/CreateNoteDbRequest.swift @@ -81,11 +81,7 @@ struct CreateNoteDbRequest: DbResponseRequest { if let existing = allTags.filter(.name(tag.name)).first { rTag = existing } else { - rTag = RTag() - rTag.name = tag.name - rTag.updateSortName() - rTag.color = tag.color - rTag.libraryId = self.libraryId + rTag = .create(name: tag.name, color: tag.color, libraryId: libraryId) database.add(rTag) } diff --git a/Zotero/Controllers/Database/Requests/EditNoteDbRequest.swift b/Zotero/Controllers/Database/Requests/EditNoteDbRequest.swift index 411064666..04f8928f9 100644 --- a/Zotero/Controllers/Database/Requests/EditNoteDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/EditNoteDbRequest.swift @@ -62,11 +62,7 @@ struct EditNoteDbRequest: DbRequest { if let existing = allTags.filter(.name(tag.name)).first { rTag = existing } else { - rTag = RTag() - rTag.name = tag.name - rTag.updateSortName() - rTag.color = tag.color - rTag.libraryId = self.libraryId + rTag = .create(name: tag.name, color: tag.color, libraryId: libraryId) database.add(rTag) } diff --git a/Zotero/Controllers/Database/Requests/EditTagsForItemDbRequest.swift b/Zotero/Controllers/Database/Requests/EditTagsForItemDbRequest.swift index 197266988..338cb6b67 100644 --- a/Zotero/Controllers/Database/Requests/EditTagsForItemDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/EditTagsForItemDbRequest.swift @@ -44,11 +44,7 @@ struct EditTagsForItemDbRequest: DbRequest { if let existing = allTags.filter(.name(tag.name, in: self.libraryId)).first { rTag = existing } else { - rTag = RTag() - rTag.name = tag.name - rTag.updateSortName() - rTag.color = tag.color - rTag.libraryId = self.libraryId + rTag = .create(name: tag.name, color: tag.color, libraryId: libraryId) database.add(rTag) } diff --git a/Zotero/Controllers/Database/Requests/ReadEmojiTagsDbRequest.swift b/Zotero/Controllers/Database/Requests/ReadEmojiTagsDbRequest.swift new file mode 100644 index 000000000..319c0ade1 --- /dev/null +++ b/Zotero/Controllers/Database/Requests/ReadEmojiTagsDbRequest.swift @@ -0,0 +1,24 @@ +// +// ReadEmojiTagsDbRequest.swift +// Zotero +// +// Created by Michal Rentka on 27.11.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +import RealmSwift + +struct ReadEmojiTagsDbRequest: DbResponseRequest { + typealias Response = Results + + let libraryId: LibraryIdentifier + + var needsWrite: Bool { return false } + + func process(in database: Realm) throws -> Results { + return database.objects(RTag.self).filter(.library(with: self.libraryId)) + .filter("emojiGroup != nil && color == \"\"") + } +} diff --git a/Zotero/Controllers/Database/Requests/ReadFilteredTagsDbRequest.swift b/Zotero/Controllers/Database/Requests/ReadFilteredTagsDbRequest.swift index 0b08fa629..994ac8367 100644 --- a/Zotero/Controllers/Database/Requests/ReadFilteredTagsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/ReadFilteredTagsDbRequest.swift @@ -41,8 +41,8 @@ struct ReadFilteredTagsDbRequest: DbResponseRequest { } if !self.showAutomatic { - // Don't apply this filter to colored tags - predicates.append(NSPredicate(format: "tag.color != \"\" or type = %d", RTypedTag.Kind.manual.rawValue)) + // Don't apply this filter to colored or emoji tags + predicates.append(NSPredicate(format: "tag.color != \"\" or tag.emojiGroup != nil or type = %d", RTypedTag.Kind.manual.rawValue)) } for filter in self.filters { diff --git a/Zotero/Controllers/Database/Requests/ReadTagPickerTagsDbRequest.swift b/Zotero/Controllers/Database/Requests/ReadTagPickerTagsDbRequest.swift index b500bcbfd..d1fa0d7c2 100644 --- a/Zotero/Controllers/Database/Requests/ReadTagPickerTagsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/ReadTagPickerTagsDbRequest.swift @@ -19,6 +19,6 @@ struct ReadTagPickerTagsDbRequest: DbResponseRequest { func process(in database: Realm) throws -> Results { return database.objects(RTag.self).filter(.library(with: self.libraryId)) - .filter("tags.@count > 0 OR color != %@", "") + .filter("tags.@count > 0 or color != %@ or emojiGroup != nil", "") } } diff --git a/Zotero/Controllers/Database/Requests/StoreItemsDbResponseRequest.swift b/Zotero/Controllers/Database/Requests/StoreItemsDbResponseRequest.swift index 65e48a2a2..00d4de7fa 100644 --- a/Zotero/Controllers/Database/Requests/StoreItemsDbResponseRequest.swift +++ b/Zotero/Controllers/Database/Requests/StoreItemsDbResponseRequest.swift @@ -421,10 +421,7 @@ struct StoreItemDbRequest: DbResponseRequest { if let existing = allTags.filter(.name(tag.tag, in: libraryId)).first { rTag = existing } else { - rTag = RTag() - rTag.name = tag.tag - rTag.updateSortName() - rTag.libraryId = libraryId + rTag = .create(name: tag.tag, color: nil, libraryId: libraryId) database.add(rTag) } diff --git a/Zotero/Controllers/Database/Requests/StoreSettingsDbRequest.swift b/Zotero/Controllers/Database/Requests/StoreSettingsDbRequest.swift index 0ec2f9991..40a56654e 100644 --- a/Zotero/Controllers/Database/Requests/StoreSettingsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/StoreSettingsDbRequest.swift @@ -79,12 +79,7 @@ struct StoreSettingsDbRequest: DbRequest { } } } else { - let new = RTag() - new.name = tag.name - new.updateSortName() - new.order = idx - new.color = tag.color - new.libraryId = self.libraryId + let new = RTag.create(name: tag.name, color: tag.color, libraryId: libraryId, order: idx) database.add(new) } } diff --git a/Zotero/Controllers/EmojiExtractor.swift b/Zotero/Controllers/EmojiExtractor.swift new file mode 100644 index 000000000..7751aebb3 --- /dev/null +++ b/Zotero/Controllers/EmojiExtractor.swift @@ -0,0 +1,39 @@ +// +// EmojiExtractor.swift +// Zotero +// +// Created by Michal Rentka on 22.11.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +struct EmojiExtractor { + static func extractFirstContiguousGroup(from text: String) -> String? { + var startIndex: Int? + var endIndex: Int = text.count + + for (idx, character) in text.enumerated() { + let isEmoji = isEmoji(character: character) + if startIndex == nil && isEmoji { + startIndex = idx + } else if startIndex != nil && !isEmoji { + endIndex = idx + break + } + } + + guard let startIndex else { return nil } + return String(text[text.index(text.startIndex, offsetBy: startIndex).. Bool { + guard let firstScalar = character.unicodeScalars.first else { return false } + + if character.unicodeScalars.count > 1 { + return firstScalar.properties.isEmoji + } + + return firstScalar.properties.isEmoji && firstScalar.value >= 0x231A + } +} diff --git a/Zotero/Models/Database/Database.swift b/Zotero/Models/Database/Database.swift index 3fb92211e..23cd8c782 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 = 41 + private static let schemaVersion: UInt64 = 42 static func mainConfiguration(url: URL, fileStorage: FileStorage) -> Realm.Configuration { var config = Realm.Configuration( @@ -79,6 +79,16 @@ struct Database { if schemaVersion < 41 { migratePageIndexToString(migration: migration) } + if schemaVersion < 42 { + extractEmojisFromTags(migration: migration) + } + } + } + + private static func extractEmojisFromTags(migration: Migration) { + migration.enumerateObjects(ofType: RTag.className()) { oldObject, newObject in + guard let oldName = oldObject?["name"] as? String else { return } + newObject?["emojiGroup"] = EmojiExtractor.extractFirstContiguousGroup(from: oldName) } } diff --git a/Zotero/Models/Database/RTag.swift b/Zotero/Models/Database/RTag.swift index d9e8253e5..c5f637974 100644 --- a/Zotero/Models/Database/RTag.swift +++ b/Zotero/Models/Database/RTag.swift @@ -25,6 +25,7 @@ final class RTag: Object { @Persisted(indexed: true) var name: String @Persisted var sortName: String @Persisted var color: String + @Persisted var emojiGroup: String? @Persisted var order: Int @Persisted var customLibraryKey: RCustomLibraryType? @Persisted var groupKey: Int? @@ -41,6 +42,19 @@ final class RTag: Object { return name.trimmingCharacters(in: CharacterSet(charactersIn: "[]'\"")).lowercased() } + static func create(name: String, color: String? = nil, libraryId: LibraryIdentifier, order: Int? = nil) -> RTag { + let rTag = RTag() + rTag.name = name + rTag.emojiGroup = EmojiExtractor.extractFirstContiguousGroup(from: name) + rTag.updateSortName() + rTag.color = color ?? "" + if let order { + rTag.order = order + } + rTag.libraryId = libraryId + return rTag + } + // MARK: - Sync properties var libraryId: LibraryIdentifier? { diff --git a/Zotero/Models/Predicates.swift b/Zotero/Models/Predicates.swift index 1337ecb17..5fe87aa25 100644 --- a/Zotero/Models/Predicates.swift +++ b/Zotero/Models/Predicates.swift @@ -390,7 +390,7 @@ extension NSPredicate { static var baseTagsToDelete: NSPredicate { let count = NSPredicate(format: "tag.tags.@count == 1") - let color = NSPredicate(format: "tag.color == %@", "") - return NSCompoundPredicate(andPredicateWithSubpredicates: [color, count]) + let special = NSPredicate(format: "tag.color == %@ or tag.emojiGroup == nil", "") + return NSCompoundPredicate(andPredicateWithSubpredicates: [special, count]) } } diff --git a/Zotero/Models/Tag.swift b/Zotero/Models/Tag.swift index 2f11f75ae..46b4f99ad 100644 --- a/Zotero/Models/Tag.swift +++ b/Zotero/Models/Tag.swift @@ -11,6 +11,7 @@ import UIKit struct Tag: Identifiable, Equatable, Hashable { let name: String let color: String + let emojiGroup: String? let type: RTypedTag.Kind var id: String { return self.name } @@ -18,18 +19,21 @@ struct Tag: Identifiable, Equatable, Hashable { init(name: String, color: String) { self.name = name self.color = color + self.emojiGroup = EmojiExtractor.extractFirstContiguousGroup(from: name) self.type = .manual } init(tag: RTag) { self.name = tag.name self.color = tag.color + self.emojiGroup = tag.emojiGroup self.type = .manual } init(tag: RTypedTag) { self.name = tag.tag?.name ?? "" self.color = tag.tag?.color ?? "" + self.emojiGroup = tag.tag?.emojiGroup self.type = tag.type } } diff --git a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift index 76cff181d..2c88f988d 100644 --- a/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift +++ b/Zotero/Scenes/Detail/Items/Models/ItemCellModel.swift @@ -24,6 +24,7 @@ struct ItemCellModel { let subtitle: String let hasNote: Bool let tagColors: [UIColor] + let tagEmojis: [String] let accessory: Accessory? init(item: RItem, typeName: String, title: NSAttributedString, accessory: Accessory?) { @@ -34,21 +35,34 @@ struct ItemCellModel { self.title = title self.subtitle = ItemCellModel.subtitle(for: item) self.hasNote = ItemCellModel.hasNote(item: item) - self.tagColors = ItemCellModel.tagColors(item: item) self.accessory = accessory + let (colors, emojis) = ItemCellModel.tagData(item: item) + self.tagColors = colors + self.tagEmojis = emojis } fileprivate static func hasNote(item: RItem) -> Bool { - return !item.children.filter(.items(type: ItemTypes.note, notSyncState: .dirty)) - .filter(.isTrash(false)) - .isEmpty + return !item.children + .filter(.items(type: ItemTypes.note, notSyncState: .dirty)) + .filter(.isTrash(false)) + .isEmpty } - fileprivate static func tagColors(item: RItem) -> [UIColor] { - return item.tags.compactMap({ - let (color, style) = TagColorGenerator.uiColor(for: ($0.tag?.color ?? "")) - return style == .filled ? color : nil - }) + fileprivate static func tagData(item: RItem) -> ([UIColor], [String]) { + var colors: [UIColor] = [] + var emojis: [String] = [] + for tag in item.tags { + if let emoji = tag.tag?.emojiGroup, !emoji.isEmpty { + emojis.append(emoji) + continue + } + + let (color, style) = TagColorGenerator.uiColor(for: (tag.tag?.color ?? "")) + if style == .filled { + colors.append(color) + } + } + return (colors, emojis) } private static func subtitle(for item: RItem) -> String { diff --git a/Zotero/Scenes/Detail/Items/Views/ItemCell.swift b/Zotero/Scenes/Detail/Items/Views/ItemCell.swift index b9c415cf5..9fac7cbf4 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemCell.swift +++ b/Zotero/Scenes/Detail/Items/Views/ItemCell.swift @@ -15,7 +15,7 @@ final class ItemCell: UITableViewCell { @IBOutlet private weak var titleLabel: UILabel! @IBOutlet private weak var titleLabelsToContainerBottom: NSLayoutConstraint! @IBOutlet private weak var subtitleLabel: InsetLabel! - @IBOutlet private weak var tagCircles: TagCirclesView! + @IBOutlet private weak var tagCircles: TagEmojiCirclesView! @IBOutlet private weak var noteIcon: UIImageView! @IBOutlet private weak var accessoryContainer: UIView! @IBOutlet private weak var fileView: FileAttachmentView! @@ -112,13 +112,15 @@ final class ItemCell: UITableViewCell { self.noteIcon.isHidden = !item.hasNote self.noteIcon.isAccessibilityElement = false - self.tagCircles.isHidden = item.tagColors.isEmpty + self.tagCircles.isHidden = item.tagColors.isEmpty && item.tagEmojis.isEmpty self.tagCircles.isAccessibilityElement = false if !self.tagCircles.isHidden { - self.tagCircles.colors = item.tagColors + self.tagCircles.set(emojis: item.tagEmojis, colors: item.tagColors) } self.set(accessory: item.accessory) + + self.layoutIfNeeded() } func set(accessory: ItemCellModel.Accessory?) { diff --git a/Zotero/Scenes/Detail/Items/Views/ItemCell.xib b/Zotero/Scenes/Detail/Items/Views/ItemCell.xib index ac1310ee1..4ad81444c 100644 --- a/Zotero/Scenes/Detail/Items/Views/ItemCell.xib +++ b/Zotero/Scenes/Detail/Items/Views/ItemCell.xib @@ -1,9 +1,8 @@ - + - - + @@ -28,30 +27,30 @@ - + - @@ -120,11 +119,6 @@ - - - - - diff --git a/Zotero/Scenes/Detail/Items/Views/TagCirclesView.swift b/Zotero/Scenes/Detail/Items/Views/TagCirclesView.swift deleted file mode 100644 index a1b751094..000000000 --- a/Zotero/Scenes/Detail/Items/Views/TagCirclesView.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// TagCirclesView.swift -// Zotero -// -// Created by Michal Rentka on 16/09/2019. -// Copyright © 2019 Corporation for Digital Scholarship. All rights reserved. -// - -import UIKit - -final class TagCirclesView: UIView { - private static let borderWidth: CGFloat = 1 - private var aspectRatioConstraint: NSLayoutConstraint? - - var colors: [UIColor] = [] { - didSet { - if let sublayers = self.layer.sublayers { - sublayers.forEach({ $0.removeFromSuperlayer() }) - } - self.createLayers(for: self.colors).forEach { self.layer.addSublayer($0) } - self.updateAspectRatio() - self.setNeedsLayout() - } - } - - private var aspectRatioMultiplier: CGFloat { - guard !self.colors.isEmpty else { return 0 } - guard self.colors.count > 1 else { return 1 } - return 1 / (1 + (CGFloat(self.colors.count - 1) * 0.5)) - } - - var borderColor: CGColor = UIColor.white.cgColor { - didSet { - self.updateBorderColors() - } - } - - // MARK: - Lifecycle - - override init(frame: CGRect) { - super.init(frame: frame) - self.setup() - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - self.setup() - } - - override func layoutSubviews() { - super.layoutSubviews() - - let mainHeight = self.layer.frame.height - let mainHalfHeight = mainHeight / 2.0 - var mainXPos = self.layer.frame.width - mainHeight - - let borderHeight = mainHeight + (TagCirclesView.borderWidth * 2) - let borderHalfHeight = mainHalfHeight + TagCirclesView.borderWidth - - self.layer.sublayers?.enumerated().forEach { index, circle in - let mainLayer = index % 2 == 1 - let height = mainLayer ? mainHeight : borderHeight - let yPos = mainLayer ? 0 : -TagCirclesView.borderWidth - let xPos = mainLayer ? mainXPos : (mainXPos - TagCirclesView.borderWidth) - let halfHeight = mainLayer ? mainHalfHeight : borderHalfHeight - - circle.frame = CGRect(x: xPos, y: yPos, width: height, height: height) - circle.cornerRadius = halfHeight - - if !mainLayer { - circle.backgroundColor = self.borderColor - } - - if mainLayer { - mainXPos -= mainHalfHeight - } - } - } - - private func updateBorderColors() { - self.layer.sublayers?.enumerated().forEach { index, circle in - if index % 2 == 0 { - circle.backgroundColor = self.borderColor - } - } - } - - override var intrinsicContentSize: CGSize { - return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) - } - - // MARK: - Actions - - private func createLayers(for colors: [UIColor]) -> [CALayer] { - var layers: [CALayer] = [] - for color in colors { - // Border layer - let border = CALayer() - border.backgroundColor = self.borderColor - border.masksToBounds = true - border.shouldRasterize = true - border.rasterizationScale = UIScreen.main.scale - border.actions = ["backgroundColor": NSNull()] - layers.append(border) - // Main circle layer - let main = CALayer() - main.backgroundColor = color.cgColor - main.shouldRasterize = true - main.rasterizationScale = UIScreen.main.scale - main.masksToBounds = true - layers.append(main) - } - return layers - } - - private func updateAspectRatio() { - let newMultiplier = self.aspectRatioMultiplier - guard newMultiplier > 0 && self.aspectRatioConstraint?.multiplier != newMultiplier else { return } - - if let constraint = self.aspectRatioConstraint { - self.removeConstraint(constraint) - } - - self.aspectRatioConstraint = self.heightAnchor.constraint(equalTo: self.widthAnchor, multiplier: newMultiplier, constant: 0) - self.aspectRatioConstraint?.isActive = true - } - - // MARK: - Setups - - private func setup() { - self.backgroundColor = .clear - self.translatesAutoresizingMaskIntoConstraints = false - } -} diff --git a/Zotero/Scenes/Detail/Items/Views/TagEmojiCirclesView.swift b/Zotero/Scenes/Detail/Items/Views/TagEmojiCirclesView.swift new file mode 100644 index 000000000..fe4d83745 --- /dev/null +++ b/Zotero/Scenes/Detail/Items/Views/TagEmojiCirclesView.swift @@ -0,0 +1,167 @@ +// +// TagEmojiCirclesView.swift +// Zotero +// +// Created by Michal Rentka on 16/09/2019. +// Copyright © 2019 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +final class TagEmojiCirclesView: UIView { + private static let borderWidth: CGFloat = 1 + private static let circleSize: CGFloat = 12 + private static let emojisToCirclesSpacing: CGFloat = 8 + private static let emojiSpacing: CGFloat = 6 + private static let emojiLayerName = "emoji" + private static let circleLayerName = "circle" + private static let borderLayerName = "circleBorder" + + private var height: CGFloat = 0 + private var width: CGFloat = 0 + + var borderColor: CGColor = UIColor.white.cgColor { + didSet { + self.updateBorderColors() + } + } + + // MARK: - Lifecycle + + override init(frame: CGRect) { + super.init(frame: frame) + self.setup() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + self.setup() + } + + override func layoutSubviews() { + super.layoutSubviews() + + guard let layers = layer.sublayers, !layers.isEmpty else { return } + + let firstCircleIndex = layers.firstIndex(where: { $0.name == Self.borderLayerName }) + var xPos: CGFloat = 0 + + for idx in 0..<(firstCircleIndex ?? layers.count) { + let layer = layers[idx] + layer.frame = CGRect(origin: CGPoint(x: xPos, y: (height - layer.frame.height) / 2), size: layer.frame.size) + xPos += layer.frame.width + Self.emojiSpacing + } + + guard let firstCircleIndex else { return } + + if xPos > 0 && firstCircleIndex != layers.count { + xPos += Self.emojisToCirclesSpacing - Self.emojiSpacing + } + + for idx in 0..<(layers.count - firstCircleIndex) { + let layer = layers[layers.count - idx - 1] + let isMain = layer.name == Self.circleLayerName + layer.frame = CGRect(origin: CGPoint(x: xPos + (isMain ? Self.borderWidth : 0), y: (height - layer.frame.height) / 2), size: layer.frame.size) + if !isMain { + xPos += layer.frame.width / 2 + } + } + } + + private func updateBorderColors() { + guard let layers = layer.sublayers else { return } + for layer in layers { + guard layer.name == "circleBorder" else { continue } + layer.backgroundColor = borderColor + } + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: width, height: height) + } + + // MARK: - Actions + + func set(emojis: [String], colors: [UIColor]) { + if let sublayers = self.layer.sublayers { + sublayers.forEach({ $0.removeFromSuperlayer() }) + } + + let font = UIFont.preferredFont(forTextStyle: .body) + var height: CGFloat = 0 + var width: CGFloat = 0 + + for emoji in emojis { + let attributedText = NSAttributedString(string: emoji, attributes: [.font: font]) + let size = attributedText.boundingRect( + with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), + options: .usesLineFragmentOrigin, + context: nil + ).integral.size + + if size.height > height { + height = size.height + } + width += size.width + Self.emojiSpacing + + let text = CATextLayer() + text.frame = CGRect(origin: .zero, size: size) + text.string = emoji + text.font = CTFontCreateUIFontForLanguage(.system, font.pointSize, nil) + text.fontSize = font.pointSize + text.alignmentMode = .center + text.shouldRasterize = true + text.rasterizationScale = UIScreen.main.scale + text.contentsScale = text.rasterizationScale + text.masksToBounds = true + text.name = Self.emojiLayerName + self.layer.addSublayer(text) + } + + let borderSize = Self.circleSize + (2 * Self.borderWidth) + + for color in colors.reversed() { + // Border layer + let border = CALayer() + border.frame = CGRect(origin: .zero, size: CGSize(width: borderSize, height: borderSize)) + border.backgroundColor = self.borderColor + border.masksToBounds = true + border.shouldRasterize = true + border.cornerRadius = borderSize / 2 + border.rasterizationScale = UIScreen.main.scale + border.actions = ["backgroundColor": NSNull()] + border.name = Self.borderLayerName + self.layer.addSublayer(border) + // Main circle layer + let main = CALayer() + main.frame = CGRect(origin: .zero, size: CGSize(width: Self.circleSize, height: Self.circleSize)) + main.backgroundColor = color.cgColor + main.cornerRadius = Self.circleSize / 2 + main.shouldRasterize = true + main.rasterizationScale = UIScreen.main.scale + main.masksToBounds = true + main.name = Self.circleLayerName + self.layer.addSublayer(main) + } + + if !colors.isEmpty { + if width > 0 { + width += Self.emojisToCirclesSpacing + } + width += ((borderSize / 2) * CGFloat(colors.count)) + (borderSize / 2) + } + + self.width = width + self.height = max(height, borderSize) + + self.invalidateIntrinsicContentSize() + } + + // MARK: - Setups + + private func setup() { + self.backgroundColor = .clear + self.translatesAutoresizingMaskIntoConstraints = false + self.layer.masksToBounds = true + } +} diff --git a/Zotero/Scenes/General/ViewModels/TagPickerActionHandler.swift b/Zotero/Scenes/General/ViewModels/TagPickerActionHandler.swift index cb5093c7a..d3c33f59d 100644 --- a/Zotero/Scenes/General/ViewModels/TagPickerActionHandler.swift +++ b/Zotero/Scenes/General/ViewModels/TagPickerActionHandler.swift @@ -92,9 +92,10 @@ struct TagPickerActionHandler: ViewModelActionHandler { do { let request = ReadTagPickerTagsDbRequest(libraryId: viewModel.state.libraryId) let results = try self.dbStorage.perform(request: request, on: .main) - let colored = results.filter("color != \"\"").sorted(byKeyPath: "name") - let others = results.filter("color = \"\"").sorted(byKeyPath: "name") - let tags = Array(colored.map(Tag.init)) + Array(others.map(Tag.init)) + let colored = results.filter("color != \"\"").sorted(byKeyPath: "order") + let emoji = results.filter("emojiGroup != nil and color = \"\"").sorted(byKeyPath: "name") + let others = results.filter("color = \"\" && emojiGroup = nil").sorted(byKeyPath: "name") + let tags = Array(colored.map(Tag.init)) + Array(emoji.map(Tag.init)) + Array(others.map(Tag.init)) self.update(viewModel: viewModel) { state in state.tags = tags state.changes = [.tags, .selection] diff --git a/Zotero/Scenes/Master/TagFiltering/ViewModels/TagFilterActionHandler.swift b/Zotero/Scenes/Master/TagFiltering/ViewModels/TagFilterActionHandler.swift index 78e307ae1..3b15a3db5 100644 --- a/Zotero/Scenes/Master/TagFiltering/ViewModels/TagFilterActionHandler.swift +++ b/Zotero/Scenes/Master/TagFiltering/ViewModels/TagFilterActionHandler.swift @@ -165,18 +165,15 @@ struct TagFilterActionHandler: ViewModelActionHandler, BackgroundDbProcessingAct var snapshot: [TagFilterState.FilterTag]? var sorted: [TagFilterState.FilterTag] = [] let comparator: (TagFilterState.FilterTag, TagFilterState.FilterTag) -> Bool = { - if !$0.tag.color.isEmpty && $1.tag.color.isEmpty { - return true - } - if $0.tag.color.isEmpty && !$1.tag.color.isEmpty { - return false - } return $0.tag.name.localizedCaseInsensitiveCompare($1.tag.name) == .orderedAscending } try self.dbStorage.perform(on: self.backgroundQueue) { coordinator in - let filtered = try coordinator.perform(request: ReadFilteredTagsDbRequest(collectionId: collectionId, libraryId: libraryId, showAutomatic: viewModel.state.showAutomatic, filters: filters)) + let filtered = try coordinator.perform( + request: ReadFilteredTagsDbRequest(collectionId: collectionId, libraryId: libraryId, showAutomatic: viewModel.state.showAutomatic, filters: filters) + ) let colored = try coordinator.perform(request: ReadColoredTagsDbRequest(libraryId: libraryId)) + let emoji = try coordinator.perform(request: ReadEmojiTagsDbRequest(libraryId: libraryId)) // Update selection based on current filter to exclude selected tags which were filtered out by some change. for tag in filtered { @@ -185,33 +182,47 @@ struct TagFilterActionHandler: ViewModelActionHandler, BackgroundDbProcessingAct } // Add colored tags - for rTag in colored { + var sortedColored: [TagFilterState.FilterTag] = [] + for rTag in colored.sorted(byKeyPath: "order") { + let tag = Tag(tag: rTag) + let isActive = filtered.contains(tag) + let filterTag = TagFilterState.FilterTag(tag: tag, isActive: isActive) + sortedColored.append(filterTag) + } + sorted.append(contentsOf: sortedColored) + + // Add emoji tags + var sortedEmoji: [TagFilterState.FilterTag] = [] + for rTag in emoji { let tag = Tag(tag: rTag) let isActive = filtered.contains(tag) let filterTag = TagFilterState.FilterTag(tag: tag, isActive: isActive) - let index = sorted.index(of: filterTag, sortedBy: comparator) - sorted.insert(filterTag, at: index) + let index = sortedEmoji.index(of: filterTag, sortedBy: comparator) + sortedEmoji.insert(filterTag, at: index) } + sorted.append(contentsOf: sortedEmoji) + var sortedOther: [TagFilterState.FilterTag] = [] if !viewModel.state.displayAll { // Add remaining filtered tags, ignore colored for tag in filtered { - guard tag.color.isEmpty else { continue } + guard tag.color.isEmpty && tag.emojiGroup == nil else { continue } let filterTag = TagFilterState.FilterTag(tag: tag, isActive: true) - let index = sorted.index(of: filterTag, sortedBy: comparator) - sorted.insert(filterTag, at: index) + let index = sortedOther.index(of: filterTag, sortedBy: comparator) + sortedOther.insert(filterTag, at: index) } } else { // Add all remaining tags with proper isActive flag let tags = try coordinator.perform(request: ReadFilteredTagsDbRequest(collectionId: .custom(.all), libraryId: libraryId, showAutomatic: viewModel.state.showAutomatic, filters: [])) for tag in tags { - guard tag.color.isEmpty else { continue } + guard tag.color.isEmpty && tag.emojiGroup == nil else { continue } let isActive = filtered.contains(tag) let filterTag = TagFilterState.FilterTag(tag: tag, isActive: isActive) - let index = sorted.index(of: filterTag, sortedBy: comparator) - sorted.insert(filterTag, at: index) + let index = sortedOther.index(of: filterTag, sortedBy: comparator) + sortedOther.insert(filterTag, at: index) } } + sorted.append(contentsOf: sortedOther) coordinator.invalidate() diff --git a/ZoteroTests/EmojiExtractorSpec.swift b/ZoteroTests/EmojiExtractorSpec.swift new file mode 100644 index 000000000..a8e6a3238 --- /dev/null +++ b/ZoteroTests/EmojiExtractorSpec.swift @@ -0,0 +1,36 @@ +// +// EmojiExtractorSpec.swift +// ZoteroTests +// +// Created by Michal Rentka on 23.11.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +@testable import Zotero + +import Nimble +import Quick + +final class EmojiExtractorSpec: QuickSpec { + override class func spec() { + describe("emoji exctractor") { + it("should return first emoji span") { + expect(EmojiExtractor.extractFirstContiguousGroup(from: "🐩🐩🐩 🐩🐩🐩🐩")).to(equal("🐩🐩🐩")) + } + + it("should return first emoji span") { + expect(EmojiExtractor.extractFirstContiguousGroup(from: "./'!@#$ 🐩🐩🐩 🐩🐩🐩🐩")).to(equal("🐩🐩🐩")) + } + + it("should return first emoji span") { + expect(EmojiExtractor.extractFirstContiguousGroup(from: "Here are ⭐️⭐️⭐️⭐️⭐️")).to(equal("⭐️⭐️⭐️⭐️⭐️")) + } + + it("should return first emoji span") { + expect(EmojiExtractor.extractFirstContiguousGroup(from: "We are 👨‍🌾👨‍🌾. And I am a 👨‍🏫.")).to(equal("👨‍🌾👨‍🌾")) + } + } + } +} diff --git a/ZoteroTests/SyncControllerSpec.swift b/ZoteroTests/SyncControllerSpec.swift index e8ad55eb3..e495f62f0 100644 --- a/ZoteroTests/SyncControllerSpec.swift +++ b/ZoteroTests/SyncControllerSpec.swift @@ -2314,10 +2314,7 @@ final class SyncControllerSpec: QuickSpec { versions.trash = newVersion library?.versions = versions - let tag = RTag() - tag.name = tagName - tag.color = oldColor - tag.libraryId = .custom(.myLibrary) + let tag = RTag.create(name: tagName, color: oldColor, libraryId: .custom(.myLibrary)) let outdatedItem = RItem() outdatedItem.key = outdatedKey