diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af004aad..97fe0ca98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Summary * Bugfix - Disable Markup Action for Mime-Type Gif: [#952](https://github.com/owncloud/ios-app/issues/952) * Change - "Go to Page" reallocated in PDF previews: [#4448](https://github.com/owncloud/enterprise/issues/4448) * Change - French Localization: [#4450](https://github.com/owncloud/enterprise/issues/4450) +* Change - Local account-wide search using custom queries: [#53](https://github.com/owncloud/ios-app/issues/53) * Change - Presentation Mode: [#704](https://github.com/owncloud/ios-app/issues/704) * Change - Shortcut uploads and error handling improvements: [#858](https://github.com/owncloud/ios-app/issues/858) * Change - Added Actions to File Provider: Sharing & Public Links: [#910](https://github.com/owncloud/ios-app/pull/910) @@ -76,6 +77,13 @@ Details https://github.com/owncloud/enterprise/issues/4450 +* Change - Local account-wide search using custom queries: [#53](https://github.com/owncloud/ios-app/issues/53) + + User can switch between local folder or local account-wide search. Search terms and filter + keywords can be combined inside the search field to get granular search results. + + https://github.com/owncloud/ios-app/issues/53 + * Change - Presentation Mode: [#704](https://github.com/owncloud/ios-app/issues/704) Added an action in detail view menu which enables presentation mode. Presentation mode diff --git a/changelog/unreleased/53 b/changelog/unreleased/53 new file mode 100644 index 000000000..7c74204b6 --- /dev/null +++ b/changelog/unreleased/53 @@ -0,0 +1,6 @@ +Change: Local account-wide search using custom queries + +User can switch between local folder or local account-wide search. +Search terms and filter keywords can be combined inside the search field to get granular search results. + +https://github.com/owncloud/ios-app/issues/53 diff --git a/ios-sdk b/ios-sdk index 22f793a1b..c45571bf7 160000 --- a/ios-sdk +++ b/ios-sdk @@ -1 +1 @@ -Subproject commit 22f793a1bd9213b0de064272a53dc709604a5458 +Subproject commit c45571bf7cde74dee991b26088910192ee0d530d diff --git a/ownCloud File Provider/FileProviderExtension.m b/ownCloud File Provider/FileProviderExtension.m index 6043049dc..289c6b451 100644 --- a/ownCloud File Provider/FileProviderExtension.m +++ b/ownCloud File Provider/FileProviderExtension.m @@ -175,25 +175,44 @@ - (NSFileProviderItem)itemForIdentifier:(NSFileProviderItemIdentifier)identifier OCSyncExec(itemRetrieval, { // Resolve the given identifier to a record in the model - if ([identifier isEqual:NSFileProviderRootContainerItemIdentifier]) + NSError *coreError = nil; + OCCore *core = [self coreWithError:&coreError]; + + if (core != nil) { - // Root item - [self.core.vault.database retrieveCacheItemsAtPath:@"/" itemOnly:YES completionHandler:^(OCDatabase *db, NSError *error, OCSyncAnchor syncAnchor, NSArray *items) { - item = items.firstObject; - returnError = error; + if (coreError != nil) + { + returnError = coreError; + } + else + { + if ([identifier isEqual:NSFileProviderRootContainerItemIdentifier]) + { + // Root item + [self.core.vault.database retrieveCacheItemsAtPath:@"/" itemOnly:YES completionHandler:^(OCDatabase *db, NSError *error, OCSyncAnchor syncAnchor, NSArray *items) { + item = items.firstObject; + returnError = error; - OCSyncExecDone(itemRetrieval); - }]; + OCSyncExecDone(itemRetrieval); + }]; + } + else + { + // Other item + [self.core retrieveItemFromDatabaseForLocalID:(OCLocalID)identifier completionHandler:^(NSError *error, OCSyncAnchor syncAnchor, OCItem *itemFromDatabase) { + item = itemFromDatabase; + returnError = error; + + OCSyncExecDone(itemRetrieval); + }]; + } + } } else { - // Other item - [self.core retrieveItemFromDatabaseForLocalID:(OCLocalID)identifier completionHandler:^(NSError *error, OCSyncAnchor syncAnchor, OCItem *itemFromDatabase) { - item = itemFromDatabase; - returnError = error; + returnError = coreError; - OCSyncExecDone(itemRetrieval); - }]; + OCSyncExecDone(itemRetrieval); } }); @@ -1001,11 +1020,17 @@ - (OCBookmark *)bookmark } - (OCCore *)core +{ + return ([self coreWithError:nil]); +} + +- (OCCore *)coreWithError:(NSError **)outError { OCLogDebug(@"FileProviderExtension[%p].core[enter]: _core=%p, bookmark=%@", self, _core, self.bookmark); OCBookmark *bookmark = self.bookmark; __block OCCore *retCore = nil; + __block NSError *retError = nil; @synchronized(self) { @@ -1037,6 +1062,11 @@ - (OCCore *)core retCore = self->_core; } } + + if (error != nil) + { + retError = error; + } } completionHandler:^(OCCore *core, NSError *error) { if (!hasCore) { @@ -1052,6 +1082,12 @@ - (OCCore *)core retCore = self->_core; } + if (error != nil) + { + retError = error; + retCore = nil; + } + OCSyncExecDone(waitForCore); if (hasCore) @@ -1066,7 +1102,12 @@ - (OCCore *)core if (retCore == nil) { - OCLogError(@"Error getting core for domain %@ (UUID %@)", OCLogPrivate(self.domain.displayName), OCLogPrivate(self.domain.identifier)); + OCLogError(@"Error getting core for domain %@ (UUID %@): %@", OCLogPrivate(self.domain.displayName), OCLogPrivate(self.domain.identifier), OCLogPrivate(retError)); + } + + if (outError != NULL) + { + *outError = retError; } OCLogDebug(@"FileProviderExtension[%p].core[leave]: _core=%p, bookmark=%@", self, retCore, bookmark); diff --git a/ownCloud.xcodeproj/project.pbxproj b/ownCloud.xcodeproj/project.pbxproj index fb49ce01d..6b0492c5d 100644 --- a/ownCloud.xcodeproj/project.pbxproj +++ b/ownCloud.xcodeproj/project.pbxproj @@ -340,6 +340,8 @@ DC68057A212EAB5E006C3B1F /* ThemeCertificateViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC680579212EAB5E006C3B1F /* ThemeCertificateViewController.swift */; }; DC6C68362574FD0400E46BD4 /* PLCrashReporter.LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = DC6C68352574FD0400E46BD4 /* PLCrashReporter.LICENSE */; }; DC6CF7FB219446050013B9F9 /* LogSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6CF7FA219446050013B9F9 /* LogSettingsViewController.swift */; }; + DC70398526128B89009F2DC1 /* NSString+ByteCountParser.h in Headers */ = {isa = PBXBuildFile; fileRef = DC70398326128B89009F2DC1 /* NSString+ByteCountParser.h */; }; + DC70398626128B89009F2DC1 /* NSString+ByteCountParser.m in Sources */ = {isa = PBXBuildFile; fileRef = DC70398426128B89009F2DC1 /* NSString+ByteCountParser.m */; }; DC774E5F22F44E57000B11A1 /* ZIPArchive.m in Sources */ = {isa = PBXBuildFile; fileRef = DC774E5D22F44E4A000B11A1 /* ZIPArchive.m */; }; DC774E6022F44E57000B11A1 /* ZIPArchive.h in Headers */ = {isa = PBXBuildFile; fileRef = DC774E5C22F44E4A000B11A1 /* ZIPArchive.h */; settings = {ATTRIBUTES = (Public, ); }; }; DC774E6322F44E6D000B11A1 /* OCCore+BundleImport.h in Headers */ = {isa = PBXBuildFile; fileRef = DC774E6122F44E6D000B11A1 /* OCCore+BundleImport.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -368,6 +370,9 @@ DCAEB06121F9FC510067E147 /* EarlGrey+Tools.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAEB06021F9FC510067E147 /* EarlGrey+Tools.swift */; }; DCB2C05F250C1F9E001083CA /* BrandingClassSettingsSource.h in Headers */ = {isa = PBXBuildFile; fileRef = DCB2C05D250C1F9E001083CA /* BrandingClassSettingsSource.h */; }; DCB2C061250C253C001083CA /* BrandingClassSettingsSource.m in Sources */ = {isa = PBXBuildFile; fileRef = DCB2C05E250C1F9E001083CA /* BrandingClassSettingsSource.m */; }; + DCB458ED2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.h in Headers */ = {isa = PBXBuildFile; fileRef = DCB458EB2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DCB458EE2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.m in Sources */ = {isa = PBXBuildFile; fileRef = DCB458EC2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.m */; }; + DCB459052604AD2A006A02AB /* SearchSegmentationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DCB459042604AD2A006A02AB /* SearchSegmentationTests.m */; }; DCB5D60B25FC14B6004C52D9 /* OCIssue+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB5D60A25FC14B6004C52D9 /* OCIssue+Extension.swift */; }; DCB6C4D72453A6CA00C1EAE1 /* ClientAuthenticationUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB6C4D62453A6CA00C1EAE1 /* ClientAuthenticationUpdater.swift */; }; DCB6C4DE24559B1600C1EAE1 /* ClientAuthenticationUpdaterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB6C4DD24559B1600C1EAE1 /* ClientAuthenticationUpdaterViewController.swift */; }; @@ -399,6 +404,8 @@ DCC83304242CF3AD00153F8C /* AlertViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC83303242CF3AC00153F8C /* AlertViewController.swift */; }; DCC8535823CE1236007BA3EB /* LicenseInAppProductListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC8535723CE1236007BA3EB /* LicenseInAppProductListViewController.swift */; }; DCC8536023CE1AF8007BA3EB /* PurchasesSettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC8535F23CE1AF8007BA3EB /* PurchasesSettingsSection.swift */; }; + DCCD77792604C91600098573 /* NSDate+ComputedTimes.h in Headers */ = {isa = PBXBuildFile; fileRef = DCCD776A2604C81B00098573 /* NSDate+ComputedTimes.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DCCD778C2604C91B00098573 /* NSDate+ComputedTimes.m in Sources */ = {isa = PBXBuildFile; fileRef = DCCD776B2604C81B00098573 /* NSDate+ComputedTimes.m */; }; DCD1300A23A191C000255779 /* LicenseOfferButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD1300923A191C000255779 /* LicenseOfferButton.swift */; }; DCD1301123A23F4E00255779 /* OCLicenseManager+AppStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD1301023A23F4E00255779 /* OCLicenseManager+AppStore.swift */; }; DCD2D40622F06ECA0071FB8F /* DataSettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD2D40522F06ECA0071FB8F /* DataSettingsSection.swift */; }; @@ -1287,6 +1294,8 @@ DC680579212EAB5E006C3B1F /* ThemeCertificateViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeCertificateViewController.swift; sourceTree = ""; }; DC6C68352574FD0400E46BD4 /* PLCrashReporter.LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = PLCrashReporter.LICENSE; sourceTree = ""; }; DC6CF7FA219446050013B9F9 /* LogSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogSettingsViewController.swift; sourceTree = ""; }; + DC70398326128B89009F2DC1 /* NSString+ByteCountParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSString+ByteCountParser.h"; sourceTree = ""; }; + DC70398426128B89009F2DC1 /* NSString+ByteCountParser.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSString+ByteCountParser.m"; sourceTree = ""; }; DC774E5C22F44E4A000B11A1 /* ZIPArchive.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ZIPArchive.h; sourceTree = ""; }; DC774E5D22F44E4A000B11A1 /* ZIPArchive.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ZIPArchive.m; sourceTree = ""; }; DC774E6122F44E6D000B11A1 /* OCCore+BundleImport.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OCCore+BundleImport.h"; sourceTree = ""; }; @@ -1331,6 +1340,9 @@ DCB44D7C2186F0F600DAA4CC /* ThemeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeStyle.swift; sourceTree = ""; }; DCB44D842186FEF700DAA4CC /* ThemeStyle+DefaultStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThemeStyle+DefaultStyles.swift"; sourceTree = ""; }; DCB44D86218718BA00DAA4CC /* VendorServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VendorServices.swift; sourceTree = ""; }; + DCB458EB2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OCQueryCondition+SearchSegmenter.h"; sourceTree = ""; }; + DCB458EC2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "OCQueryCondition+SearchSegmenter.m"; sourceTree = ""; }; + DCB459042604AD2A006A02AB /* SearchSegmentationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SearchSegmentationTests.m; sourceTree = ""; }; DCB504D7221EF07E007638BE /* status-flash.tvg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = "status-flash.tvg"; path = "img/filetypes-tvg/status-flash.tvg"; sourceTree = SOURCE_ROOT; }; DCB5D60A25FC14B6004C52D9 /* OCIssue+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OCIssue+Extension.swift"; sourceTree = ""; }; DCB6C4D62453A6CA00C1EAE1 /* ClientAuthenticationUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientAuthenticationUpdater.swift; sourceTree = ""; }; @@ -1367,6 +1379,8 @@ DCC83303242CF3AC00153F8C /* AlertViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertViewController.swift; sourceTree = ""; }; DCC8535723CE1236007BA3EB /* LicenseInAppProductListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseInAppProductListViewController.swift; sourceTree = ""; }; DCC8535F23CE1AF8007BA3EB /* PurchasesSettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesSettingsSection.swift; sourceTree = ""; }; + DCCD776A2604C81B00098573 /* NSDate+ComputedTimes.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSDate+ComputedTimes.h"; sourceTree = ""; }; + DCCD776B2604C81B00098573 /* NSDate+ComputedTimes.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSDate+ComputedTimes.m"; sourceTree = ""; }; DCD1300923A191C000255779 /* LicenseOfferButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseOfferButton.swift; sourceTree = ""; }; DCD1301023A23F4E00255779 /* OCLicenseManager+AppStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OCLicenseManager+AppStore.swift"; sourceTree = ""; }; DCD2D40522F06ECA0071FB8F /* DataSettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSettingsSection.swift; sourceTree = ""; }; @@ -2399,6 +2413,8 @@ DC774E6122F44E6D000B11A1 /* OCCore+BundleImport.h */, DC7C100F24B5F81E00227085 /* OCBookmark+AppExtensions.m */, DC7C100E24B5F81E00227085 /* OCBookmark+AppExtensions.h */, + DCB458EC2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.m */, + DCB458EB2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.h */, ); path = "SDK Extensions"; sourceTree = ""; @@ -2650,6 +2666,7 @@ isa = PBXGroup; children = ( DCC0856B2293F1FD008CC05C /* LicensingTests.m */, + DCB459042604AD2A006A02AB /* SearchSegmentationTests.m */, DCC0856D2293F1FD008CC05C /* Info.plist */, ); path = ownCloudAppFrameworkTests; @@ -2660,6 +2677,10 @@ children = ( DCC5E445232654DE002E5B84 /* NSObject+AnnotatedProperties.m */, DCC5E444232654DE002E5B84 /* NSObject+AnnotatedProperties.h */, + DCCD776B2604C81B00098573 /* NSDate+ComputedTimes.m */, + DCCD776A2604C81B00098573 /* NSDate+ComputedTimes.h */, + DC70398426128B89009F2DC1 /* NSString+ByteCountParser.m */, + DC70398326128B89009F2DC1 /* NSString+ByteCountParser.h */, ); path = "Foundation Extensions"; sourceTree = ""; @@ -3132,6 +3153,7 @@ DC4332002472E1B4002DC0E5 /* OCLicenseEMMProvider.h in Headers */, DCFEFE39236877A7009A142F /* OCLicenseFeature.h in Headers */, DC23D1DA238F391200423F62 /* OCLicenseAppStoreReceipt.h in Headers */, + DC70398526128B89009F2DC1 /* NSString+ByteCountParser.h in Headers */, DCF2DA8324C83BFB0026D790 /* OCFileProviderService.h in Headers */, DCF2DA8624C87A330026D790 /* OCCore+FPServices.h in Headers */, DC774E6022F44E57000B11A1 /* ZIPArchive.h in Headers */, @@ -3157,6 +3179,8 @@ DCF2DA8124C836240026D790 /* OCBookmark+FPServices.h in Headers */, DC66F3A523965A1400CF4812 /* NSDate+RFC3339.h in Headers */, DC0030C22350B1CE00BB8570 /* NSData+Encoding.h in Headers */, + DCCD77792604C91600098573 /* NSDate+ComputedTimes.h in Headers */, + DCB458ED2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.h in Headers */, DCC5E4472326564F002E5B84 /* NSObject+AnnotatedProperties.h in Headers */, DC66F3AB23965C9C00CF4812 /* OCLicenseAppStoreReceiptInAppPurchase.h in Headers */, DC049156258C00C400DEDC27 /* OCFileProviderServiceStandby.h in Headers */, @@ -4245,6 +4269,7 @@ files = ( DCFEFE9D2368D7FA009A142F /* OCLicenseObserver.m in Sources */, DC66F39D239659C000CF4812 /* OCASN1.m in Sources */, + DCCD778C2604C91B00098573 /* NSDate+ComputedTimes.m in Sources */, DC66F3A623965A1400CF4812 /* NSDate+RFC3339.m in Sources */, DCF2DA8724C87A330026D790 /* OCCore+FPServices.m in Sources */, DC7C101224B5FD6500227085 /* OCBookmark+AppExtensions.m in Sources */, @@ -4260,6 +4285,7 @@ DCFEFE2B236876BD009A142F /* OCLicenseManager.m in Sources */, DCDC20A22399A715003CFF5B /* OCCore+LicenseEnvironment.m in Sources */, DCDC20AC2399A8CF003CFF5B /* OCLicenseEnterpriseProvider.m in Sources */, + DC70398626128B89009F2DC1 /* NSString+ByteCountParser.m in Sources */, DCFEFE3A236877A7009A142F /* OCLicenseFeature.m in Sources */, DCFEFE50236880B5009A142F /* OCLicenseOffer.m in Sources */, DC0030C12350B1CE00BB8570 /* NSData+Encoding.m in Sources */, @@ -4273,6 +4299,7 @@ DCFEFE4A23687C83009A142F /* OCLicenseEntitlement.m in Sources */, DC66F3AC23965C9C00CF4812 /* OCLicenseAppStoreReceiptInAppPurchase.m in Sources */, DCD8109B23984AF6003B0053 /* OCLicenseDuration.m in Sources */, + DCB458EE2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.m in Sources */, DC080CF3238C92480044C5D2 /* OCLicenseAppStoreItem.m in Sources */, DCFEFE982368D099009A142F /* OCLicenseEnvironment.m in Sources */, DC049157258C00C400DEDC27 /* OCFileProviderServiceStandby.m in Sources */, @@ -4284,6 +4311,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DCB459052604AD2A006A02AB /* SearchSegmentationTests.m in Sources */, DCE442CE2387452000940A6D /* LicensingTests.m in Sources */, 39057AA7233BA7A60008E6C0 /* Intents.intentdefinition in Sources */, ); diff --git a/ownCloud.xcodeproj/xcshareddata/xcschemes/ownCloud File Provider.xcscheme b/ownCloud.xcodeproj/xcshareddata/xcschemes/ownCloud File Provider.xcscheme index 365f97ab7..5741fd331 100644 --- a/ownCloud.xcodeproj/xcshareddata/xcschemes/ownCloud File Provider.xcscheme +++ b/ownCloud.xcodeproj/xcshareddata/xcschemes/ownCloud File Provider.xcscheme @@ -147,6 +147,18 @@ isEnabled = "YES"> + + + + + + ActionPosition { diff --git a/ownCloud/Client/Actions/Actions+Extensions/OpenInAction.swift b/ownCloud/Client/Actions/Actions+Extensions/OpenInAction.swift index 3e70cde65..803eae522 100644 --- a/ownCloud/Client/Actions/Actions+Extensions/OpenInAction.swift +++ b/ownCloud/Client/Actions/Actions+Extensions/OpenInAction.swift @@ -109,7 +109,7 @@ class OpenInAction: Action { self.interactionController = UIDocumentInteractionController(url: fileURL) self.interactionController?.delegate = self - if let _ = self.context.sender as? UIKeyCommand, let hostViewController = hostViewController { + if self.context.sender as? UIKeyCommand != nil, let hostViewController = hostViewController { var sourceRect = hostViewController.view.frame sourceRect.origin.x = viewController.view.center.x sourceRect.origin.y = viewController.navigationController?.navigationBar.frame.size.height ?? 0.0 diff --git a/ownCloud/Client/ClientRootViewController.swift b/ownCloud/Client/ClientRootViewController.swift index 09f2f8072..548ed09b1 100644 --- a/ownCloud/Client/ClientRootViewController.swift +++ b/ownCloud/Client/ClientRootViewController.swift @@ -172,12 +172,19 @@ class ClientRootViewController: UITabBarController, BookmarkContainer, ToolAndTa } // MARK: - Startup - func afterCoreStart(_ lastVisibleItemId: String?, completionHandler: @escaping ((_ error: Error?) -> Void)) { + func afterCoreStart(_ lastVisibleItemId: String?, busyHandler: OCCoreBusyStatusHandler? = nil, completionHandler: @escaping ((_ error: Error?) -> Void)) { OCCoreManager.shared.requestCore(for: bookmark, setup: { (core, _) in self.coreRequested = true self.core = core core?.delegate = self + if let busyHandler = busyHandler { + // Wrap busyHandler to ensure that a ObjC block is generated and not a Swift block is passed + core?.busyStatusHandler = { (progress) in + busyHandler(progress) + } + } + // Add message presenters if let notificationPresenter = self.notificationPresenter { core?.messageQueue.add(presenter: notificationPresenter) @@ -189,14 +196,14 @@ class ClientRootViewController: UITabBarController, BookmarkContainer, ToolAndTa // Remove skip available offline when user opens the bookmark core?.vault.keyValueStore?.storeObject(nil, forKey: .coreSkipAvailableOfflineKey) - - // Set up FP standby - if let core = core { - self.fpServiceStandby = OCFileProviderServiceStandby(core: core) - self.fpServiceStandby?.start() - } }, completionHandler: { (core, error) in if error == nil { + // Set up FP standby + if let core = core { + self.fpServiceStandby = OCFileProviderServiceStandby(core: core) + self.fpServiceStandby?.start() + } + // Core is ready self.coreReady(lastVisibleItemId) @@ -207,6 +214,9 @@ class ClientRootViewController: UITabBarController, BookmarkContainer, ToolAndTa }) } } else { + self.core = nil + self.coreRequested = false + Log.error("Error requesting/starting core: \(String(describing: error))") } diff --git a/ownCloud/Key Commands/KeyCommands.swift b/ownCloud/Key Commands/KeyCommands.swift index 53143e57d..e1323a45b 100644 --- a/ownCloud/Key Commands/KeyCommands.swift +++ b/ownCloud/Key Commands/KeyCommands.swift @@ -260,11 +260,27 @@ extension ClientRootViewController { if excludeViewControllers.contains(where: {$0 == type(of: visibleController)}) { return shortcuts } else if let controller = visibleController as? PDFSearchViewController { - return controller.keyCommands } } + if let navigationController = self.selectedViewController as? ThemeNavigationController, navigationController.visibleViewController?.navigationItem.searchController?.isActive ?? false { + let cancelCommand = UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(dismissSearch), discoverabilityTitle: "Cancel".localized) + shortcuts.append(cancelCommand) + + if let visibleViewController = navigationController.visibleViewController, let keyCommands = visibleViewController.keyCommands { + let newKeyCommands = keyCommands.map { (keyCommand) -> UIKeyCommand in + if let input = keyCommand.input, let discoverabilityTitle = keyCommand.discoverabilityTitle { + return UIKeyCommand(input: input, modifierFlags: keyCommand.modifierFlags, action: #selector(performActionOnVisibleViewController), discoverabilityTitle: discoverabilityTitle) + } + + return UIKeyCommand(input: keyCommand.input!, modifierFlags: keyCommand.modifierFlags, action: #selector(performActionOnVisibleViewController)) + } + + shortcuts.append(contentsOf: newKeyCommands) + } + } + if let navigationController = self.selectedViewController as? ThemeNavigationController, !((navigationController.visibleViewController as? UIAlertController) != nil) { let keyCommands = self.tabBar.items?.enumerated().map { (index, item) -> UIKeyCommand in let tabIndex = String(index + 1) @@ -275,14 +291,24 @@ extension ClientRootViewController { } } - if let navigationController = self.selectedViewController as? ThemeNavigationController, navigationController.visibleViewController?.navigationItem.searchController?.isActive ?? false { - let cancelCommand = UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(dismissSearch), discoverabilityTitle: "Cancel".localized) - shortcuts.append(cancelCommand) - } - return shortcuts } + @objc func performActionOnVisibleViewController(sender: UIKeyCommand) { + if let navigationController = self.selectedViewController as? ThemeNavigationController, let visibleController = navigationController.visibleViewController, let keyCommands = visibleController.keyCommands { + let commands = keyCommands.filter { (keyCommand) -> Bool in + if keyCommand.discoverabilityTitle == sender.discoverabilityTitle { + return true + } + return false + } + + if let command = commands.first { + visibleController.perform(command.action, with: sender) + } + } + } + @objc func dismissSearch(sender: UIKeyCommand) { if let navigationController = self.selectedViewController as? ThemeNavigationController { if let searchController = navigationController.visibleViewController?.navigationItem.searchController { @@ -449,7 +475,7 @@ extension PublicLinkEditTableViewController { let showInfoObjectCommand = UIKeyCommand(input: "H", modifierFlags: [.command, .alternate], action: #selector(showInfoSubtitles), discoverabilityTitle: "Help".localized) shortcuts.append(showInfoObjectCommand) } else { - let shareObjectCommand = UIKeyCommand(input: "S", modifierFlags: [.command, .alternate], action: #selector(shareLinkURL), discoverabilityTitle: "Share".localized) + let shareObjectCommand = UIKeyCommand(input: "S", modifierFlags: [.command], action: #selector(shareLinkURL), discoverabilityTitle: "Share".localized) shortcuts.append(shareObjectCommand) } @@ -627,6 +653,12 @@ extension ClientQueryViewController { open override var keyCommands: [UIKeyCommand]? { var shortcuts = [UIKeyCommand]() + + let scopeCommand = UIKeyCommand(input: "F", modifierFlags: [.command], action: #selector(changeSearchScope(_:)), discoverabilityTitle: "Toggle Search Scope".localized) + if let searchController = searchController, searchController.isActive { + shortcuts.append(scopeCommand) + } + if let superKeyCommands = super.keyCommands { shortcuts.append(contentsOf: superKeyCommands) } @@ -647,7 +679,7 @@ extension ClientQueryViewController { shortcuts.append(nextObjectCommand) } - if let core = core, let rootItem = query.rootItem { + if let core = core, let rootItem = query.rootItem, !isMoreButtonPermanentlyHidden { var item = rootItem if let indexPath = self.tableView?.indexPathForSelectedRow, let selectedItem = itemAt(indexPath: indexPath) { item = selectedItem @@ -667,6 +699,17 @@ extension ClientQueryViewController { return shortcuts } + @objc func changeSearchScope(_ command : UIKeyCommand) { + if self.sortBar?.searchScope == .global { + self.sortBar?.searchScope = .local + } else { + self.sortBar?.searchScope = .global + } + updateCustomSearchQuery() + self.searchController?.isActive = true + self.searchController?.searchBar.becomeFirstResponder() + } + @objc func performFolderAction(_ command : UIKeyCommand) { if let core = core, let rootItem = query.rootItem { var item = rootItem @@ -735,10 +778,10 @@ extension QueryFileListTableViewController { let selectObjectCommand = UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(selectCurrent), discoverabilityTitle: "Open Selected".localized) let scrollTopCommand = UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [.command, .shift], action: #selector(scrollToFirstRow), discoverabilityTitle: "Scroll to Top".localized) let scrollBottomCommand = UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [.command, .shift], action: #selector(scrollToLastRow), discoverabilityTitle: "Scroll to Bottom".localized) - let toggleSortCommand = UIKeyCommand(input: "S", modifierFlags: [.command, .shift], action: #selector(toggleSortOrder), discoverabilityTitle: "Change Sort Order".localized) + let toggleSortCommand = UIKeyCommand(input: "S", modifierFlags: [.alternate], action: #selector(toggleSortOrder), discoverabilityTitle: "Change Sort Order".localized) let searchCommand = UIKeyCommand(input: "F", modifierFlags: [.command], action: #selector(enableSearch), discoverabilityTitle: "Search".localized) // Add key commands for file name letters - if sortMethod == .alphabetically { + if sortMethod == .alphabetically, let searchController = searchController, !searchController.isActive { let indexTitles = Array( Set( self.items.map { String(( $0.name?.first!.uppercased())!) })).sorted() for title in indexTitles { let letterCommand = UIKeyCommand(input: title, modifierFlags: [], action: #selector(selectLetter)) @@ -746,7 +789,7 @@ extension QueryFileListTableViewController { } } - if let core = core, let rootItem = query.rootItem { + if let core = core, let rootItem = query.rootItem, !isMoreButtonPermanentlyHidden { var item = rootItem if let indexPath = self.tableView?.indexPathForSelectedRow, let selectedItem = itemAt(indexPath: indexPath) { item = selectedItem @@ -763,11 +806,13 @@ extension QueryFileListTableViewController { }) } - shortcuts.append(searchCommand) + if let searchController = searchController, !searchController.isActive { + shortcuts.append(searchCommand) + } shortcuts.append(toggleSortCommand) for (index, method) in SortMethod.all.enumerated() { - let sortTitle = String(format: "Sort by %@".localized, method.localizedName()) + let sortTitle = String(format: "Sort by %@".localized, method.localizedName) let sortCommand = UIKeyCommand(input: String(index + 1), modifierFlags: [.alternate], action: #selector(changeSortMethod), discoverabilityTitle: sortTitle) shortcuts.append(sortCommand) } @@ -835,7 +880,7 @@ extension QueryFileListTableViewController { @objc func changeSortMethod(_ command : UIKeyCommand) { for method in SortMethod.all { - let sortTitle = String(format: "Sort by %@".localized, method.localizedName()) + let sortTitle = String(format: "Sort by %@".localized, method.localizedName) if command.discoverabilityTitle == sortTitle { self.sortBar?.sortMethod = method break @@ -869,22 +914,8 @@ extension ClientDirectoryPickerViewController { open override var keyCommands: [UIKeyCommand]? { var shortcuts = [UIKeyCommand]() - let nextObjectCommand = UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(selectNext), discoverabilityTitle: "Select Next".localized) - let previousObjectCommand = UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(selectPrevious), discoverabilityTitle: "Select Previous".localized) - let selectObjectCommand = UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(selectCurrent), discoverabilityTitle: "Open Selected".localized) - - if let selectedIndexPath = self.tableView?.indexPathForSelectedRow { - if selectedIndexPath.row < self.items.count - 1 { - shortcuts.append(nextObjectCommand) - } - if selectedIndexPath.row > 0 || selectedIndexPath.section > 0 { - shortcuts.append(previousObjectCommand) - } - if let item : OCItem = self.itemAt(indexPath: selectedIndexPath), item.type == OCItemType.collection { - shortcuts.append(selectObjectCommand) - } - } else { - shortcuts.append(nextObjectCommand) + if let superKeyCommands = super.keyCommands { + shortcuts.append(contentsOf: superKeyCommands) } if let selectButtonTitle = selectButton?.title, let selector = selectButton?.action { diff --git a/ownCloud/Resources/en.lproj/Localizable.strings b/ownCloud/Resources/en.lproj/Localizable.strings index 671bc5783..f9b560aa7 100644 --- a/ownCloud/Resources/en.lproj/Localizable.strings +++ b/ownCloud/Resources/en.lproj/Localizable.strings @@ -95,7 +95,8 @@ "kind" = "kind"; "size" = "size"; "date" = "date"; -"Search this folder" = "Search this folder"; +"Search folder" = "Search folder"; +"Search account" = "Search account"; "Pending" = "Pending"; "Show parent paths" = "Show parent paths"; @@ -104,6 +105,12 @@ "%@ item | " = "%@ item | "; "%@ items | " = "%@ items | "; +"Show more results" = "Show more results"; + +/* Search scope */ +"Search scope" = "Search scope"; +"Toggle Search Scope" = "Toggle Search Scope"; + /* Static Login Setup */ "Server error" = "Server error"; "The server doesn't support any allowed authentication method." = "The server doesn't support any allowed authentication method."; diff --git a/ownCloud/SceneDelegate.swift b/ownCloud/SceneDelegate.swift index b5349e7a2..b1c6391b2 100644 --- a/ownCloud/SceneDelegate.swift +++ b/ownCloud/SceneDelegate.swift @@ -86,22 +86,29 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } @discardableResult func configure(window: ThemeWindow?, with activity: NSUserActivity) -> Bool { - guard let bookmarkUUIDString = activity.userInfo?[OCBookmark.ownCloudOpenAccountAccountUuidKey] as? String, let bookmarkUUID = UUID(uuidString: bookmarkUUIDString), let bookmark = OCBookmarkManager.shared.bookmark(for: bookmarkUUID), let navigationController = window?.rootViewController as? ThemeNavigationController, let serverListController = navigationController.topViewController as? ServerListTableViewController else { - return false - } + if let bookmarkUUIDString = activity.userInfo?[OCBookmark.ownCloudOpenAccountAccountUuidKey] as? String, + let bookmarkUUID = UUID(uuidString: bookmarkUUIDString), + let bookmark = OCBookmarkManager.shared.bookmark(for: bookmarkUUID), + let navigationController = window?.rootViewController as? ThemeNavigationController, + let serverListController = navigationController.topViewController as? ServerListTableViewController { + if activity.title == OCBookmark.ownCloudOpenAccountPath { + serverListController.connect(to: bookmark, lastVisibleItemId: nil, animated: false) + window?.windowScene?.userActivity = bookmark.openAccountUserActivity + + return true + } else if activity.title == OpenItemUserActivity.ownCloudOpenItemPath { + guard let itemLocalID = activity.userInfo?[OpenItemUserActivity.ownCloudOpenItemUuidKey] as? String else { + return false + } - if activity.title == OCBookmark.ownCloudOpenAccountPath { - serverListController.connect(to: bookmark, lastVisibleItemId: nil, animated: false) - window?.windowScene?.userActivity = bookmark.openAccountUserActivity + // At first connect to the bookmark for the item + serverListController.connect(to: bookmark, lastVisibleItemId: itemLocalID, animated: false) + window?.windowScene?.userActivity = activity - return true - } else if activity.title == OpenItemUserActivity.ownCloudOpenItemPath { - guard let itemLocalID = activity.userInfo?[OpenItemUserActivity.ownCloudOpenItemUuidKey] as? String else { - return false + return true } - - // At first connect to the bookmark for the item - serverListController.connect(to: bookmark, lastVisibleItemId: itemLocalID, animated: false) + } else if activity.activityType == ServerListTableViewController.showServerListActivityType { + // Show server list window?.windowScene?.userActivity = activity return true diff --git a/ownCloud/Server List/ServerListTableViewController.swift b/ownCloud/Server List/ServerListTableViewController.swift index 225e8472b..443f28562 100644 --- a/ownCloud/Server List/ServerListTableViewController.swift +++ b/ownCloud/Server List/ServerListTableViewController.swift @@ -32,6 +32,19 @@ class ServerListTableViewController: UITableViewController, Themeable { @IBOutlet var welcomeLogoTVGView : VectorImageView! // @IBOutlet var welcomeLogoSVGView : SVGImageView! + // MARK: - User Activity + static let showServerListActivityType = "com.owncloud.ios-app.showServerList" + static let showServerListActivityTitle = "showServerList" + + static var showServerListActivity : NSUserActivity { + let userActivity = NSUserActivity(activityType: showServerListActivityType) + + userActivity.title = showServerListActivityTitle + userActivity.userInfo = [ : ] + + return userActivity + } + // MARK: - Internals var shownFirstTime = true var hasToolbar : Bool = true @@ -105,7 +118,10 @@ class ServerListTableViewController: UITableViewController, Themeable { self.navigationItem.title = VendorServices.shared.appName - NotificationCenter.default.addObserver(self, selector: #selector(considerAutoLogin), name: UIApplication.didBecomeActiveNotification, object: nil) + if #available(iOS 13, *) { } else { + // Log in automatically on iOS 12 (handled by scene restoration in iOS 13+) + NotificationCenter.default.addObserver(self, selector: #selector(considerAutoLogin), name: UIApplication.didBecomeActiveNotification, object: nil) + } if ReleaseNotesDatasource().shouldShowReleaseNotes { let releaseNotesHostController = ReleaseNotesHostViewController() @@ -194,8 +210,6 @@ class ServerListTableViewController: UITableViewController, Themeable { } override func viewDidAppear(_ animated: Bool) { - var showBetaWarning = VendorServices.shared.showBetaWarning - super.viewDidAppear(animated) ClientSessionManager.shared.add(delegate: self) @@ -214,17 +228,17 @@ class ServerListTableViewController: UITableViewController, Themeable { PasscodeSetupCoordinator(parentViewController: self, action: .setup).start() } - if showBetaWarning, shownFirstTime { - showBetaWarning = !considerAutoLogin() - } - - if showBetaWarning { + if VendorServices.shared.showBetaWarning, shownFirstTime { considerBetaWarning() } if !shownFirstTime { VendorServices.shared.considerReviewPrompt() } + + if #available(iOS 13, *) { + view.window?.windowScene?.userActivity = ServerListTableViewController.showServerListActivity + } } @objc func considerAutoLogin() -> Bool { @@ -532,10 +546,11 @@ class ServerListTableViewController: UITableViewController, Themeable { let clientRootViewController = ClientSessionManager.shared.startSession(for: bookmark)! - let bookmarkRow = self.tableView.cellForRow(at: indexPath) + let bookmarkRow = self.tableView.cellForRow(at: indexPath) as? ServerListBookmarkCell let activityIndicator = UIActivityIndicatorView(style: Theme.shared.activeCollection.activityIndicatorViewStyle) var bookmarkRowAccessoryView : UIView? + var bookmarkDetailLabelContent : String? if bookmarkRow != nil { bookmarkRowAccessoryView = bookmarkRow?.accessoryView @@ -552,7 +567,32 @@ class ServerListTableViewController: UITableViewController, Themeable { clientRootViewController.authDelegate = self clientRootViewController.modalPresentationStyle = .overFullScreen - clientRootViewController.afterCoreStart(lastVisibleItemId, completionHandler: { (error) in + clientRootViewController.afterCoreStart(lastVisibleItemId, busyHandler: { (progress) in + OnMainThread { + if let bookmarkRow = self.tableView.cellForRow(at: indexPath) as? ServerListBookmarkCell { + if progress != nil { + let progressView = ProgressView(frame: CGRect(x: 0, y: 0, width: 30, height: 30)) + progressView.progress = progress + + bookmarkDetailLabelContent = bookmarkRow.detailLabel.text + + activityIndicator.stopAnimating() + + bookmarkRow.detailLabel.text = progress?.localizedDescription + bookmarkRow.accessoryView = progressView + + bookmarkRow.detailLabel.beginPulsing() + } else { + bookmarkRow.detailLabel.endPulsing() + + bookmarkRow.detailLabel.text = bookmarkDetailLabelContent + bookmarkRow.accessoryView = activityIndicator + + activityIndicator.startAnimating() + } + } + } + }, completionHandler: { (error) in if self.lastSelectedBookmark?.uuid == bookmark.uuid, // Make sure only the UI for the last selected bookmark is actually presented (in case of other bookmarks facing a huge delay and users selecting another bookmark in the meantime) self.activeClientRootViewController == nil { // Make sure we don't present this ClientRootViewController while still presenting another if let fromViewController = self.pushFromViewController ?? self.navigationController { @@ -569,7 +609,13 @@ class ServerListTableViewController: UITableViewController, Themeable { self.activeClientRootViewController = clientRootViewController // save this ClientRootViewController as the active one (only weakly referenced) // Set up custom push transition for presentation - let transitionDelegate = PushTransitionDelegate() + let transitionDelegate = PushTransitionDelegate(with: { (toViewController, window) in + window.addSubview(toViewController.view) + + if #available(iOS 13, *) { + window.windowScene?.userActivity = ServerListTableViewController.showServerListActivity + } + }) clientRootViewController.pushTransition = transitionDelegate // Keep a reference, so it's still around on dismissal clientRootViewController.transitioningDelegate = transitionDelegate diff --git a/ownCloudAppFramework/Foundation Extensions/NSDate+ComputedTimes.h b/ownCloudAppFramework/Foundation Extensions/NSDate+ComputedTimes.h new file mode 100644 index 000000000..7abd30217 --- /dev/null +++ b/ownCloudAppFramework/Foundation Extensions/NSDate+ComputedTimes.h @@ -0,0 +1,34 @@ +// +// NSDate+ComputedTimes.h +// ownCloud +// +// Created by Felix Schwarz on 19.03.21. +// Copyright © 2021 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2021, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSDate (ComputedTimes) + ++ (instancetype)startOfRelativeDay:(NSInteger)dayOffset; ++ (instancetype)startOfRelativeWeek:(NSInteger)weekOffset; ++ (instancetype)startOfRelativeMonth:(NSInteger)monthOffset; ++ (instancetype)startOfRelativeYear:(NSInteger)yearOffset; + ++ (nullable instancetype)dateFromKeywordString:(NSString *)dateString; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ownCloudAppFramework/Foundation Extensions/NSDate+ComputedTimes.m b/ownCloudAppFramework/Foundation Extensions/NSDate+ComputedTimes.m new file mode 100644 index 000000000..8d89d7ded --- /dev/null +++ b/ownCloudAppFramework/Foundation Extensions/NSDate+ComputedTimes.m @@ -0,0 +1,140 @@ +// +// NSDate+ComputedTimes.m +// ownCloud +// +// Created by Felix Schwarz on 19.03.21. +// Copyright © 2021 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2021, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import "NSDate+ComputedTimes.h" + +@implementation NSDate (ComputedTimes) + +- (instancetype)recomputeWithUnits:(NSCalendarUnit)units modifier:(void(^)(NSDateComponents *components))componentModifier +{ + NSCalendar *calendar = NSCalendar.autoupdatingCurrentCalendar; + NSDateComponents *components = [calendar components:units fromDate:self]; + + if (componentModifier != nil) + { + componentModifier(components); + } + + return ([calendar dateFromComponents:components]); +} + ++ (instancetype)startOfRelativeDay:(NSInteger)dayOffset +{ + return ([[NSDate dateWithTimeIntervalSinceNow:(NSTimeInterval)(dayOffset * 24 * 60 * 60)] recomputeWithUnits:NSCalendarUnitDay|NSCalendarUnitMonth|NSCalendarUnitYear modifier:nil]); +} + ++ (instancetype)startOfRelativeWeek:(NSInteger)weekOffset +{ + return ([[NSDate dateWithTimeIntervalSinceNow:(NSTimeInterval)(weekOffset * 7 * 24 * 60 * 60)] recomputeWithUnits:NSCalendarUnitWeekday|NSCalendarUnitWeekOfMonth|NSCalendarUnitMonth|NSCalendarUnitYear modifier:^(NSDateComponents *components) { + components.weekday = 2; // Monday, 1 = Sunday + }]); +} + ++ (instancetype)startOfRelativeMonth:(NSInteger)monthOffset +{ + return ([NSDate.date recomputeWithUnits:NSCalendarUnitMonth|NSCalendarUnitYear modifier:^(NSDateComponents *components) { + if (monthOffset < 0) + { + NSInteger remainingMonths = -monthOffset; + + while (remainingMonths > 0) + { + if (components.month > 1) + { + components.month -= 1; + } + else + { + components.year -= 1; + components.month = 12; + } + + remainingMonths--; + }; + } + else + { + NSInteger remainingMonths = monthOffset; + + while (remainingMonths > 0) + { + if (components.month > 11) + { + components.year += 1; + components.month = 1; + } + else + { + components.month += 1; + } + + remainingMonths--; + }; + } + }]); +} + ++ (instancetype)startOfRelativeYear:(NSInteger)yearOffset +{ + return ([NSDate.date recomputeWithUnits:NSCalendarUnitDay|NSCalendarUnitMonth|NSCalendarUnitYear modifier:^(NSDateComponents *components) { + components.day = 1; + components.month = 1; + components.year += yearOffset; + }]); +} + ++ (nullable instancetype)dateFromKeywordString:(NSString *)dateString +{ + NSArray *components = [dateString componentsSeparatedByString:@"-"]; + NSString *yearString = ((components.firstObject != nil) && (components.firstObject.length == 4)) ? components.firstObject : nil; + NSString *monthString = ((components.count >= 2) && (components[1].length > 0)) ? components[1] : nil; + NSString *dayString = ((components.count == 3) && (components[2].length > 0)) ? components[2] : nil; + + if (yearString != nil) + { + if (components.count == 1) + { + return ([NSDate.date recomputeWithUnits:NSCalendarUnitDay|NSCalendarUnitMonth|NSCalendarUnitYear modifier:^(NSDateComponents *components) { + components.year = yearString.integerValue; + components.month = 1; + components.day = 1; + }]); + } + else if ((components.count == 2) && (monthString != nil)) + { + return ([NSDate.date recomputeWithUnits:NSCalendarUnitDay|NSCalendarUnitMonth|NSCalendarUnitYear modifier:^(NSDateComponents *components) { + components.year = yearString.integerValue; + components.month = monthString.integerValue; + components.day = 1; + }]); + } + else if ((components.count == 3) && (monthString != nil) && (dayString != nil)) + { + return ([NSDate.date recomputeWithUnits:NSCalendarUnitDay|NSCalendarUnitMonth|NSCalendarUnitYear modifier:^(NSDateComponents *components) { + components.year = yearString.integerValue; + components.month = monthString.integerValue; + components.day = dayString.integerValue; + }]); + } + } + + return (nil); +} + + +@end diff --git a/ownCloudAppFramework/Foundation Extensions/NSString+ByteCountParser.h b/ownCloudAppFramework/Foundation Extensions/NSString+ByteCountParser.h new file mode 100644 index 000000000..ff50c9c93 --- /dev/null +++ b/ownCloudAppFramework/Foundation Extensions/NSString+ByteCountParser.h @@ -0,0 +1,29 @@ +// +// NSString+ByteCountParser.h +// ownCloudApp +// +// Created by Felix Schwarz on 30.03.21. +// Copyright © 2021 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2021, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSString (ByteCountParser) + +- (nullable NSNumber *)byteCountNumber; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ownCloudAppFramework/Foundation Extensions/NSString+ByteCountParser.m b/ownCloudAppFramework/Foundation Extensions/NSString+ByteCountParser.m new file mode 100644 index 000000000..d3d17847f --- /dev/null +++ b/ownCloudAppFramework/Foundation Extensions/NSString+ByteCountParser.m @@ -0,0 +1,88 @@ +// +// NSString+ByteCountParser.m +// ownCloudApp +// +// Created by Felix Schwarz on 30.03.21. +// Copyright © 2021 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2021, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import "NSString+ByteCountParser.h" + +@implementation NSString (ByteCountParser) + +- (nullable NSNumber *)byteCountNumber +{ + NSNumber *byteCountNumber = nil; + NSString *lcString = self.lowercaseString, *bcString = nil; + NSUInteger multiplier = 0; + + if ([lcString hasSuffix:@"tb"]) + { + bcString = [lcString substringToIndex:self.length-2]; + multiplier = 1000000000000; + } + else if ([lcString hasSuffix:@"tib"]) + { + bcString = [lcString substringToIndex:self.length-3]; + multiplier = 1099511627776; + } + else if ([lcString hasSuffix:@"gb"]) + { + bcString = [lcString substringToIndex:self.length-2]; + multiplier = 1000000000; + } + else if ([lcString hasSuffix:@"gib"]) + { + bcString = [lcString substringToIndex:self.length-3]; + multiplier = 1073741824; + } + else if ([lcString hasSuffix:@"mb"]) + { + bcString = [lcString substringToIndex:self.length-2]; + multiplier = 1000000; + } + else if ([lcString hasSuffix:@"mib"]) + { + bcString = [lcString substringToIndex:self.length-3]; + multiplier = 1048576; + } + else if ([lcString hasSuffix:@"kb"]) + { + bcString = [lcString substringToIndex:self.length-2]; + multiplier = 1000; + } + else if ([lcString hasSuffix:@"kib"]) + { + bcString = [lcString substringToIndex:self.length-3]; + multiplier = 1024; + } + else if ([lcString hasSuffix:@"b"]) + { + bcString = [lcString substringToIndex:self.length-1]; + multiplier = 1; + } + else if (lcString.length > 0) + { + bcString = lcString; + multiplier = 1; + } + + if (multiplier != 0) + { + byteCountNumber = @(((NSUInteger)bcString.integerValue) * multiplier); + } + + return (byteCountNumber); +} + +@end diff --git a/ownCloudAppFramework/Resources/de.lproj/Localizable.strings b/ownCloudAppFramework/Resources/de.lproj/Localizable.strings index 556aa51d6..bd8c0df0f 100644 --- a/ownCloudAppFramework/Resources/de.lproj/Localizable.strings +++ b/ownCloudAppFramework/Resources/de.lproj/Localizable.strings @@ -41,3 +41,24 @@ "%lu years" = "%lu Jahre"; "%@ already unlocked for %@." = "%@ bereits freigeschaltet für %@."; + +/* Search keywords */ +"keyword_type" = "typ"; +"keyword_before" = "vor"; +"keyword_after" = "nach"; +"keyword_on" = "am"; +"keyword_smaller" = "kleiner"; +"keyword_greater" = "größer"; +"keyword_owner" = "besitzer"; +"keyword_file" = "datei"; +"keyword_folder" = "ordner"; +"keyword_image" = "bild"; +"keyword_video" = "video"; +"keyword_today" = "heute"; +"keyword_week" = "woche"; +"keyword_month" = "monat"; +"keyword_year" = "jahr"; +"keyword_d" = "t"; /* short-form for "day" */ +"keyword_w" = "w"; /* short-form for "week" */ +"keyword_m" = "m"; /* short-form for "month" */ +"keyword_y" = "j"; /* short-form for "year" */ diff --git a/ownCloudAppFramework/Resources/en.lproj/Localizable.strings b/ownCloudAppFramework/Resources/en.lproj/Localizable.strings index b9c25f1fe..df22ef621 100644 --- a/ownCloudAppFramework/Resources/en.lproj/Localizable.strings +++ b/ownCloudAppFramework/Resources/en.lproj/Localizable.strings @@ -43,3 +43,24 @@ "%lu years" = "%lu years"; "%@ already unlocked for %@." = "%@ already unlocked for %@."; + +/* Search keywords */ +"keyword_type" = "type"; +"keyword_before" = "before"; +"keyword_after" = "after"; +"keyword_on" = "on"; +"keyword_smaller" = "smaller"; +"keyword_greater" = "greater"; +"keyword_owner" = "owner"; +"keyword_file" = "file"; +"keyword_folder" = "folder"; +"keyword_image" = "image"; +"keyword_video" = "video"; +"keyword_today" = "today"; +"keyword_week" = "week"; +"keyword_month" = "month"; +"keyword_year" = "year"; +"keyword_d" = "d"; /* short-form for "day" */ +"keyword_w" = "w"; /* short-form for "week" */ +"keyword_m" = "m"; /* short-form for "month" */ +"keyword_y" = "y"; /* short-form for "year" */ diff --git a/ownCloudAppFramework/SDK Extensions/OCBookmark+AppExtensions.m b/ownCloudAppFramework/SDK Extensions/OCBookmark+AppExtensions.m index 2f2e30619..01305ee30 100644 --- a/ownCloudAppFramework/SDK Extensions/OCBookmark+AppExtensions.m +++ b/ownCloudAppFramework/SDK Extensions/OCBookmark+AppExtensions.m @@ -41,12 +41,12 @@ - (NSString *)shortName NSString *userNamePrefix = @""; NSString *displayName = nil, *userName = nil; - if ((displayName = self.displayName) != nil) + if (((displayName = self.displayName) != nil) && (displayName.length > 0)) { userNamePrefix = [displayName stringByAppendingString:@"@"]; } - if ((userNamePrefix.length == 0) && ((userName = self.userName) != nil)) + if ((userNamePrefix.length == 0) && ((userName = self.userName) != nil) && (userName.length > 0)) { userNamePrefix = [userName stringByAppendingString:@"@"]; } diff --git a/ownCloudAppFramework/SDK Extensions/OCCore+BundleImport.m b/ownCloudAppFramework/SDK Extensions/OCCore+BundleImport.m index 18b3c856c..d949f906e 100644 --- a/ownCloudAppFramework/SDK Extensions/OCCore+BundleImport.m +++ b/ownCloudAppFramework/SDK Extensions/OCCore+BundleImport.m @@ -62,9 +62,17 @@ - (nullable NSProgress *)importItemNamed:(nullable NSString *)newFileName at:(OC if ((error = [ZIPArchive compressContentsOf:sourceURL asZipFile:zipURL]) == nil) { - if ([[NSFileManager defaultManager] removeItemAtURL:sourceURL error:&error]) + BOOL success = [[NSFileManager defaultManager] removeItemAtURL:sourceURL error:&error]; + + OCFileOpLog(@"rm", error, @"Removed ZIP source at %@", sourceURL.path); + + if (success) { - if (![[NSFileManager defaultManager] moveItemAtURL:zipURL toURL:sourceURL error:&error]) + success = [[NSFileManager defaultManager] moveItemAtURL:zipURL toURL:sourceURL error:&error]; + + OCFileOpLog(@"mv", error, @"Renamed from ZIPped %@ to %@", zipURL.path, sourceURL.path); + + if (!success) { OCLogDebug(@"Moving %@ to %@ failed with error=%@", OCLogPrivate(zipURL), OCLogPrivate(sourceURL), OCLogPrivate(error)); } diff --git a/ownCloudAppFramework/SDK Extensions/OCQueryCondition+SearchSegmenter.h b/ownCloudAppFramework/SDK Extensions/OCQueryCondition+SearchSegmenter.h new file mode 100644 index 000000000..03a5d35c0 --- /dev/null +++ b/ownCloudAppFramework/SDK Extensions/OCQueryCondition+SearchSegmenter.h @@ -0,0 +1,36 @@ +// +// OCQueryCondition+SearchSegmenter.h +// ownCloudApp +// +// Created by Felix Schwarz on 19.03.21. +// Copyright © 2021 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2021, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSString (SearchSegmenter) + +- (NSArray *)segmentedForSearchWithQuotationMarks:(BOOL)withQuotationMarks; + +@end + +@interface OCQueryCondition (SearchSegmenter) + ++ (nullable instancetype)forSearchSegment:(NSString *)segmentString; ++ (nullable instancetype)fromSearchTerm:(NSString *)searchTerm; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ownCloudAppFramework/SDK Extensions/OCQueryCondition+SearchSegmenter.m b/ownCloudAppFramework/SDK Extensions/OCQueryCondition+SearchSegmenter.m new file mode 100644 index 000000000..dd40d0aa0 --- /dev/null +++ b/ownCloudAppFramework/SDK Extensions/OCQueryCondition+SearchSegmenter.m @@ -0,0 +1,456 @@ +// +// OCQueryCondition+SearchSegmenter.m +// ownCloudApp +// +// Created by Felix Schwarz on 19.03.21. +// Copyright © 2021 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2021, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import "OCQueryCondition+SearchSegmenter.h" +#import "NSDate+ComputedTimes.h" +#import "NSString+ByteCountParser.h" +#import "OCLicenseManager.h" // needed as localization "anchor" + +@implementation NSString (SearchSegmenter) + +- (BOOL)isQuotationMark +{ + return ([@"“”‘‛‟„‚'\"′″´˝❛❜❝❞" containsString:self]); +} + +- (BOOL)hasQuotationMarkSuffix +{ + if (self.length > 0) + { + return ([[self substringWithRange:NSMakeRange(self.length-1, 1)] isQuotationMark]); + } + + return (NO); +} + +- (BOOL)hasQuotationMarkPrefix +{ + if (self.length > 0) + { + return ([[self substringWithRange:NSMakeRange(0, 1)] isQuotationMark]); + } + + return (NO); +} + +- (NSArray *)segmentedForSearchWithQuotationMarks:(BOOL)withQuotationMarks +{ + NSMutableArray *segments = [NSMutableArray new]; + NSArray *terms; + + if ((terms = [self componentsSeparatedByString:@" "]) != nil) + { + __block NSString *segmentString = nil; + __block BOOL segmentOpen = NO; + __block BOOL isNegated = NO; + + void (^SubmitSegment)(void) = ^{ + if (segmentString.length > 0) + { + if (segmentOpen && withQuotationMarks) + { + [segments addObject:[NSString stringWithFormat:@"%@\"%@\"", (isNegated ? @"-" : @""), segmentString]]; + } + else + { + [segments addObject:(isNegated ? [@"-" stringByAppendingString:segmentString] : segmentString)]; + } + } + + segmentString = nil; + }; + + for (NSString *inTerm in terms) + { + NSString *term = inTerm; + BOOL closingSegment = NO; + + if (!segmentOpen) + { + isNegated = NO; + } + + if ([term hasPrefix:@"-"]) + { + // Negate segment + isNegated = YES; + term = [term substringFromIndex:1]; + } + + if ([term hasQuotationMarkPrefix]) + { + // Submit any open segment + SubmitSegment(); + + // Start new segment + term = [term substringFromIndex:1]; + segmentOpen = YES; + } + + if ([term hasQuotationMarkSuffix]) + { + // End segment + term = [term substringToIndex:term.length-1]; + closingSegment = YES; + } + + // Append term to current segment + if (segmentString.length == 0) + { + segmentString = term; + + if (!segmentOpen) + { + // Submit standalone segment + SubmitSegment(); + } + } + else + { + // Append to segment string + segmentString = [segmentString stringByAppendingFormat:@" %@", term]; + } + + // Submit closed segments + if (closingSegment) + { + SubmitSegment(); + segmentOpen = NO; + } + } + + SubmitSegment(); + } + + return (segments); +} + +@end + +@implementation OCQueryCondition (SearchSegmenter) + ++ (nullable NSString *)normalizeKeyword:(NSString *)keyword +{ + static dispatch_once_t onceToken; + static NSArray *keywords; + static NSDictionary *keywordByLocalizedKeyword; + + dispatch_once(&onceToken, ^{ + NSBundle *localizationBundle = [NSBundle bundleForClass:OCLicenseManager.class]; + + #define TranslateKeyword(keyword) [[localizationBundle localizedStringForKey:@"keyword_" keyword value:keyword table:@"Localizable"] lowercaseString] : keyword + + keywordByLocalizedKeyword = @{ + // Standalone keywords + TranslateKeyword(@"file"), + TranslateKeyword(@"folder"), + TranslateKeyword(@"image"), + TranslateKeyword(@"video"), + TranslateKeyword(@"today"), + TranslateKeyword(@"week"), + TranslateKeyword(@"month"), + TranslateKeyword(@"year"), + + // Modifier keywords + TranslateKeyword(@"type"), + TranslateKeyword(@"after"), + TranslateKeyword(@"before"), + TranslateKeyword(@"on"), + TranslateKeyword(@"smaller"), + TranslateKeyword(@"greater"), + TranslateKeyword(@"owner"), + + // Suffix keywords + TranslateKeyword(@"d"), + TranslateKeyword(@"w"), + TranslateKeyword(@"m"), + TranslateKeyword(@"y") + }; + + keywords = [keywordByLocalizedKeyword allValues]; + }); + + NSString *normalizedKeyword = nil; + + if (keyword != nil) + { + keyword = [keyword lowercaseString]; + + if ((normalizedKeyword = keywordByLocalizedKeyword[keyword]) == nil) + { + if ([keywords containsObject:keyword]) + { + normalizedKeyword = keyword; + } + } + } + + if ((normalizedKeyword == nil) && (keyword.length == 0)) + { + normalizedKeyword = keyword; + } + + return (normalizedKeyword); +} + ++ (instancetype)forSearchSegment:(NSString *)segmentString +{ + NSString *segmentStringLowercase = nil; + BOOL negateCondition = NO; + BOOL literalSearch = NO; + + if ([segmentString hasPrefix:@"-"]) + { + negateCondition = YES; + segmentString = [segmentString substringFromIndex:1]; + } + + if ([segmentString hasPrefix:@"\""] && [segmentString hasSuffix:@"\""] && (segmentString.length >= 2)) + { + literalSearch = YES; + segmentString = [segmentString substringWithRange:NSMakeRange(1, segmentString.length-2)]; + } + + if (segmentString.length == 0) + { + return (nil); + } + + segmentStringLowercase = segmentString.lowercaseString; + + if ([segmentStringLowercase hasPrefix:@":"] && !literalSearch) + { + NSString *keyword = [segmentStringLowercase substringFromIndex:1]; + + if ((keyword = [OCQueryCondition normalizeKeyword:keyword]) != nil) + { + if ([keyword isEqual:@"folder"]) + { + return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameType isEqualTo:@(OCItemTypeCollection)]]); + } + else if ([keyword isEqual:@"file"]) + { + return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameType isEqualTo:@(OCItemTypeFile)]]); + } + else if ([keyword isEqual:@"image"]) + { + return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameMIMEType startsWith:@"image/"]]); + } + else if ([keyword isEqual:@"video"]) + { + return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameMIMEType startsWith:@"video/"]]); + } + else if ([keyword isEqual:@"today"]) + { + return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeDay:0]]]); + } + else if ([keyword isEqual:@"week"]) + { + return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeWeek:0]]]); + } + else if ([keyword isEqual:@"month"]) + { + return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeMonth:0]]]); + } + else if ([keyword isEqual:@"year"]) + { + return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeYear:0]]]); + } + } + } + + if ([segmentStringLowercase containsString:@":"] && !literalSearch) + { + NSArray *parts = [segmentString componentsSeparatedByString:@":"]; + NSString *modifier = nil; + + if ((modifier = parts.firstObject.lowercaseString) != nil) + { + NSArray *parameters = [[segmentString substringFromIndex:modifier.length+1] componentsSeparatedByString:@","]; + NSMutableArray *orConditions = [NSMutableArray new]; + NSString *modifierKeyword; + + if ((modifierKeyword = [OCQueryCondition normalizeKeyword:modifier]) != nil) + { + for (NSString *parameter in parameters) + { + if (parameter.length > 0) + { + OCQueryCondition *condition = nil; + + if ([modifierKeyword isEqual:@"type"]) + { + condition = [OCQueryCondition where:OCItemPropertyNameName endsWith:[@"." stringByAppendingString:parameter]]; + } + else if ([modifierKeyword isEqual:@"after"]) + { + NSDate *afterDate; + + if ((afterDate = [NSDate dateFromKeywordString:parameter]) != nil) + { + condition = [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:afterDate]; + } + } + else if ([modifierKeyword isEqual:@"before"]) + { + NSDate *beforeDate; + + if ((beforeDate = [NSDate dateFromKeywordString:parameter]) != nil) + { + condition = [OCQueryCondition where:OCItemPropertyNameLastModified isLessThan:beforeDate]; + } + } + else if ([modifierKeyword isEqual:@"on"]) + { + NSDate *onStartDate = nil, *onEndDate = nil; + + if ((onStartDate = [NSDate dateFromKeywordString:parameter]) != nil) + { + onStartDate = [onStartDate dateByAddingTimeInterval:-1]; + onEndDate = [onStartDate dateByAddingTimeInterval:60*60*24+2]; + + condition = [OCQueryCondition require:@[ + [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:onStartDate], + [OCQueryCondition where:OCItemPropertyNameLastModified isLessThan:onEndDate] + ]]; + } + } + else if ([modifierKeyword isEqual:@"smaller"]) + { + NSNumber *byteCount = [parameter byteCountNumber]; + + if (byteCount != nil) + { + condition = [OCQueryCondition where:OCItemPropertyNameSize isLessThan:byteCount]; + } + } + else if ([modifierKeyword isEqual:@"greater"]) + { + NSNumber *byteCount = [parameter byteCountNumber]; + + if (byteCount != nil) + { + condition = [OCQueryCondition where:OCItemPropertyNameSize isGreaterThan:byteCount]; + } + } + else if ([modifierKeyword isEqual:@"owner"]) + { + condition = [OCQueryCondition where:OCItemPropertyNameOwnerUserName startsWith:parameter]; + } + else if ([modifier isEqual:@""]) + { + // Parse time formats, f.ex.: 7d, 2w, 1m, 2y + NSString *numString = nil; + + if ((parameter.length == 1) || // :d :w :m :y + ((parameter.length > 1) && // :7d :2w :1m :2y + ((numString = [parameter substringToIndex:parameter.length-1]) != nil) && + [@([numString integerValue]).stringValue isEqual:numString] + ) + ) + { + NSInteger numParam = numString.integerValue; + NSString *timeLabel = [parameter substringFromIndex:parameter.length-1].lowercaseString; + + timeLabel = [OCQueryCondition normalizeKeyword:timeLabel]; + + if ([timeLabel isEqual:@"d"]) + { + condition = [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeDay:-numParam]]; + } + else if ([timeLabel isEqual:@"w"]) + { + condition = [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeWeek:-numParam]]; + } + else if ([timeLabel isEqual:@"m"]) + { + condition = [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeMonth:-numParam]]; + } + else if ([timeLabel isEqual:@"y"]) + { + condition = [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeYear:-numParam]]; + } + } + } + + if (condition != nil) + { + [orConditions addObject:condition]; + } + } + } + + if (orConditions.count == 1) + { + return ([OCQueryCondition negating:negateCondition condition:orConditions.firstObject]); + } + else if (orConditions.count > 0) + { + return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition anyOf:orConditions]]); + } + else + { + if ([modifierKeyword isEqual:@"type"] || + [modifierKeyword isEqual:@"after"] || + [modifierKeyword isEqual:@"before"] || + [modifierKeyword isEqual:@"on"] || + [modifierKeyword isEqual:@"greater"] || + [modifierKeyword isEqual:@"smaller"] || + [modifierKeyword isEqual:@"owner"] + ) + { + // Modifiers without parameters + return (nil); + } + } + } + } + } + + return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameName contains:segmentString]]); +} + ++ (instancetype)fromSearchTerm:(NSString *)searchTerm +{ + NSArray *segments = [searchTerm segmentedForSearchWithQuotationMarks:YES]; + NSMutableArray *conditions = [NSMutableArray new]; + OCQueryCondition *queryCondition = nil; + + for (NSString *segment in segments) + { + OCQueryCondition *condition; + + if ((condition = [self forSearchSegment:segment]) != nil) + { + [conditions addObject:condition]; + } + } + + if (conditions.count == 1) + { + queryCondition = conditions.firstObject; + } + else if (conditions.count > 0) + { + queryCondition = [OCQueryCondition require:conditions]; + } + + return (queryCondition); +} + +@end diff --git a/ownCloudAppFramework/ownCloudApp.h b/ownCloudAppFramework/ownCloudApp.h index 1bd986e3c..39c5b50a2 100644 --- a/ownCloudAppFramework/ownCloudApp.h +++ b/ownCloudAppFramework/ownCloudApp.h @@ -30,8 +30,10 @@ FOUNDATION_EXPORT const unsigned char ownCloudAppVersionString[]; #import #import #import +#import #import #import +#import #import #import diff --git a/ownCloudAppFrameworkTests/SearchSegmentationTests.m b/ownCloudAppFrameworkTests/SearchSegmentationTests.m new file mode 100644 index 000000000..56400f7da --- /dev/null +++ b/ownCloudAppFrameworkTests/SearchSegmentationTests.m @@ -0,0 +1,79 @@ +// +// SearchSegmentationTests.m +// ownCloudAppTests +// +// Created by Felix Schwarz on 19.03.21. +// Copyright © 2021 ownCloud GmbH. All rights reserved. +// + +#import +#import + +@interface SearchSegmentationTests : XCTestCase + +@end + +@implementation SearchSegmentationTests + +- (void)testStringSegmentation +{ + NSDictionary *> *expectedSegmentsByStrings = @{ + @"\"Hello world\" term2" : @[ + @"Hello world", + @"term2" + ], + + @"\"Hello" : @[ + @"Hello" + ], + + @"Hello\"" : @[ + @"Hello" + ], + + @"Hello\" \"World" : @[ + @"Hello", @"World" + ], + + @"\"Hello World \"hello world\" term3" : @[ + @"Hello World", + @"hello world", + @"term3" + ], + + @"\"Hello World \"term2" : @[ + @"Hello World", + @"term2" + ], + + @"\"Hello World \"term2 \"term3" : @[ + @"Hello World", + @"term2", + @"term3" + ], + }; + + [expectedSegmentsByStrings enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull term, NSArray * _Nonnull expectedSegments, BOOL * _Nonnull stop) { + NSArray *segments = [term segmentedForSearchWithQuotationMarks:NO]; + + XCTAssert([segments isEqual:expectedSegments], @"segments %@ doesn't match expectation %@", segments, expectedSegments); + }]; +} + +- (void)testDateComputations +{ + NSLog(@"Start of day(-2): %@", [NSDate startOfRelativeDay:-2]); + NSLog(@"Start of day( 0): %@", [NSDate startOfRelativeDay:0]); + NSLog(@"Start of day(+2): %@", [NSDate startOfRelativeDay:2]); + NSLog(@"Start of week(-1): %@", [NSDate startOfRelativeWeek:-1]); + NSLog(@"Start of week( 0): %@", [NSDate startOfRelativeWeek:0]); + NSLog(@"Start of week(+1): %@", [NSDate startOfRelativeWeek:1]); + NSLog(@"Start of month(-1): %@", [NSDate startOfRelativeMonth:-1]); + NSLog(@"Start of month( 0): %@", [NSDate startOfRelativeMonth:0]); + NSLog(@"Start of month(+1): %@", [NSDate startOfRelativeMonth:1]); + NSLog(@"Start of year(-1): %@", [NSDate startOfRelativeYear:-1]); + NSLog(@"Start of year( 0): %@", [NSDate startOfRelativeYear: 0]); + NSLog(@"Start of year(+1): %@", [NSDate startOfRelativeYear:+2]); +} + +@end diff --git a/ownCloudAppShared/Client/File Lists/ClientDirectoryPickerViewController.swift b/ownCloudAppShared/Client/File Lists/ClientDirectoryPickerViewController.swift index 4c6b0e3e7..b97f32780 100644 --- a/ownCloudAppShared/Client/File Lists/ClientDirectoryPickerViewController.swift +++ b/ownCloudAppShared/Client/File Lists/ClientDirectoryPickerViewController.swift @@ -36,7 +36,6 @@ open class ClientDirectoryPickerViewController: ClientQueryViewController { open var selectButton: UIBarButtonItem? private var selectButtonTitle: String? private var cancelBarButton: UIBarButtonItem? - open var directoryPath : String? open var choiceHandler: ClientDirectoryPickerChoiceHandler? @@ -115,6 +114,8 @@ open class ClientDirectoryPickerViewController: ClientQueryViewController { // Disable pull to refresh allowPullToRefresh = false + + isMoreButtonPermanentlyHidden = true } required public init?(coder aDecoder: NSCoder) { @@ -142,7 +143,7 @@ open class ClientDirectoryPickerViewController: ClientQueryViewController { // Cancel button creation cancelBarButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelBarButtonPressed)) - sortBar?.showSelectButton = false + sortBar?.allowMultiSelect = false tableView.dragInteractionEnabled = false } @@ -169,12 +170,6 @@ open class ClientDirectoryPickerViewController: ClientQueryViewController { } } - override open func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - sortBar?.showSelectButton = false - } - private func allowNavigationFor(item: OCItem?) -> Bool { guard let item = item else { return false } @@ -263,6 +258,7 @@ open class ClientDirectoryPickerViewController: ClientQueryViewController { let pickerController = ClientDirectoryPickerViewController(core: core, path: path, selectButtonTitle: selectButtonTitle, allowedPathFilter: allowedPathFilter, navigationPathFilter: navigationPathFilter, choiceHandler: choiceHandler) pickerController.cancelAction = cancelAction + pickerController.breadCrumbsPush = self.breadCrumbsPush self.navigationController?.pushViewController(pickerController, animated: true) } @@ -369,4 +365,18 @@ open class ClientDirectoryPickerViewController: ClientQueryViewController { super.queryHasChangesAvailable(query) } } + + public override func revealViewController(core: OCCore, path: String, item: OCItem, rootViewController: UIViewController?) -> UIViewController? { + guard let selectButtonTitle = selectButtonTitle, let choiceHandler = choiceHandler else { + return nil + } + + let pickerController = ClientDirectoryPickerViewController(core: core, path: path, selectButtonTitle: selectButtonTitle, allowedPathFilter: allowedPathFilter, navigationPathFilter: navigationPathFilter, choiceHandler: choiceHandler) + + pickerController.revealItemLocalID = item.localID + pickerController.cancelAction = cancelAction + pickerController.breadCrumbsPush = true + + return pickerController + } } diff --git a/ownCloudAppShared/Client/File Lists/ClientQueryViewController.swift b/ownCloudAppShared/Client/File Lists/ClientQueryViewController.swift index ee81071c7..43ce982c6 100644 --- a/ownCloudAppShared/Client/File Lists/ClientQueryViewController.swift +++ b/ownCloudAppShared/Client/File Lists/ClientQueryViewController.swift @@ -29,7 +29,7 @@ public struct OCItemDraggingValue { var bookmarkUUID : String } -open class ClientQueryViewController: QueryFileListTableViewController, UIDropInteractionDelegate, UIPopoverPresentationControllerDelegate { +open class ClientQueryViewController: QueryFileListTableViewController, UIDropInteractionDelegate, UIPopoverPresentationControllerDelegate, UISearchControllerDelegate { public var folderActionBarButton: UIBarButtonItem? public var plusBarButton: UIBarButtonItem? @@ -37,19 +37,62 @@ open class ClientQueryViewController: QueryFileListTableViewController, UIDropIn public var quotaObservation : NSKeyValueObservation? public var titleButtonThemeApplierToken : ThemeApplierToken? + public var breadCrumbsPush : Bool = false + weak public var clientRootViewController : UIViewController? private var _actionProgressHandler : ActionProgressHandler? + public var revealItemLocalID : String? + private var revealItemFound : Bool = false private let ItemDataUTI = "com.owncloud.ios-app.item-data" + private let moreCellIdentifier = "moreCell" + private let moreCellAccessibilityIdentifier = "more-results" + + open override var activeQuery : OCQuery { + if let customSearchQuery = customSearchQuery { + return customSearchQuery + } else { + return query + } + } + + var customSearchQuery : OCQuery? { + willSet { + if customSearchQuery != newValue, let customQuery = customSearchQuery { + core?.stop(customQuery) + customQuery.delegate = nil + } + } + + didSet { + if customSearchQuery != nil, let customQuery = customSearchQuery { + customQuery.delegate = self + customQuery.sortComparator = sortMethod.comparator(direction: sortDirection) + core?.start(customQuery) + } + } + } + open override var searchScope: SearchScope { + set { + UserDefaults.standard.setValue(newValue.rawValue, forKey: "search-scope") + } + + get { + let scope = SearchScope(rawValue: UserDefaults.standard.integer(forKey: "search-scope")) ?? SearchScope.local + return scope + } + } // MARK: - Init & Deinit public override convenience init(core inCore: OCCore, query inQuery: OCQuery) { self.init(core: inCore, query: inQuery, rootViewController: nil) } - public init(core inCore: OCCore, query inQuery: OCQuery, rootViewController: UIViewController?) { + public init(core inCore: OCCore, query inQuery: OCQuery, reveal inItem: OCItem? = nil, rootViewController: UIViewController?) { clientRootViewController = rootViewController + revealItemLocalID = inItem?.localID + breadCrumbsPush = revealItemLocalID != nil super.init(core: inCore, query: inQuery) updateTitleView() @@ -97,6 +140,8 @@ open class ClientQueryViewController: QueryFileListTableViewController, UIDropIn } deinit { + customSearchQuery = nil + queryStateObservation = nil quotaObservation = nil @@ -106,6 +151,84 @@ open class ClientQueryViewController: QueryFileListTableViewController, UIDropIn } } + open override func registerCellClasses() { + super.registerCellClasses() + + self.tableView.register(ThemeTableViewCell.self, forCellReuseIdentifier: moreCellIdentifier) + } + + // MARK: - Search events + open func willPresentSearchController(_ searchController: UISearchController) { + self.sortBar?.showSearchScope = true + self.tableView.setContentOffset(.zero, animated: false) + } + + open func willDismissSearchController(_ searchController: UISearchController) { + self.sortBar?.showSearchScope = false + } + + // MARK: - Search scope support + private var searchText: String? + private let maxResultCountDefault = 100 // Maximum number of results to return from database (default) + private var maxResultCount = 100 // Maximum number of results to return from database (flexible) + + open override func applySearchFilter(for searchText: String?, to query: OCQuery) { + self.searchText = searchText + + updateCustomSearchQuery() + } + + open override func sortBar(_ sortBar: SortBar, didUpdateSearchScope: SearchScope) { + updateCustomSearchQuery() + } + + open override func sortBar(_ sortBar: SortBar, didUpdateSortMethod: SortMethod) { + sortMethod = didUpdateSortMethod + + let comparator = sortMethod.comparator(direction: sortDirection) + + query.sortComparator = comparator + customSearchQuery?.sortComparator = comparator + + if (customSearchQuery?.queryResults?.count ?? 0) >= maxResultCount { + updateCustomSearchQuery() + } + } + + private var lastSearchText : String? + private var scrollToTopWithNextRefresh : Bool = false + + public func updateCustomSearchQuery() { + if lastSearchText != searchText { + // Reset max result count when search text changes + maxResultCount = maxResultCountDefault + lastSearchText = searchText + + // Scroll to top when search text changes + scrollToTopWithNextRefresh = true + } + + if let searchText = searchText, + let searchScope = sortBar?.searchScope, + searchScope == .global, + let condition = OCQueryCondition.fromSearchTerm(searchText) { + if let sortPropertyName = sortBar?.sortMethod.sortPropertyName { + condition.sortBy = sortPropertyName + condition.sortAscending = (sortDirection != .ascendant) + } + + condition.maxResultCount = NSNumber(value: maxResultCount) + + self.customSearchQuery = OCQuery(condition:condition, inputFilter: nil) + } else { + self.customSearchQuery = nil + } + + super.applySearchFilter(for: searchText, to: query) + + self.queryHasChangesAvailable(activeQuery) + } + // MARK: - View controller events open override func viewDidLoad() { super.viewDidLoad() @@ -136,6 +259,12 @@ open class ClientQueryViewController: QueryFileListTableViewController, UIDropIn private var viewControllerVisible : Bool = false + open override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + searchController?.delegate = self + } + open override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) @@ -164,6 +293,52 @@ open class ClientQueryViewController: QueryFileListTableViewController, UIDropIn self.quotaLabel.textColor = collection.tableRowColors.secondaryLabelColor } + // MARK: - Table view datasource + open override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + var numberOfRows = super.tableView(tableView, numberOfRowsInSection: section) + + if customSearchQuery != nil, numberOfRows >= maxResultCount { + numberOfRows += 1 + } + + return numberOfRows + } + + open override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let numberOfRows = super.tableView(tableView, numberOfRowsInSection: 0) + var cell : UITableViewCell? + + if indexPath.row < numberOfRows { + cell = super.tableView(tableView, cellForRowAt: indexPath) + + if revealItemLocalID != nil, let itemCell = cell as? ClientItemCell, let itemLocalID = itemCell.item?.localID { + itemCell.revealHighlight = (itemLocalID == revealItemLocalID) + } + } else { + let moreCell = tableView.dequeueReusableCell(withIdentifier: moreCellIdentifier, for: indexPath) as? ThemeTableViewCell + + moreCell?.accessibilityIdentifier = moreCellAccessibilityIdentifier + moreCell?.textLabel?.text = "Show more results".localized + + cell = moreCell + } + + return cell! + } + + public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if let cell = tableView.cellForRow(at: indexPath), cell.accessibilityIdentifier == moreCellAccessibilityIdentifier { + maxResultCount += maxResultCountDefault + updateCustomSearchQuery() + } else { + super.tableView(tableView, didSelectRowAt: indexPath) + } + } + + public override func showReveal(at path: IndexPath) -> Bool { + return (customSearchQuery != nil) + } + // MARK: - Table view delegate open override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { @@ -225,8 +400,11 @@ open class ClientQueryViewController: QueryFileListTableViewController, UIDropIn } // MARK: - UIBarButtonItem Drop Delegate - open func dropInteraction(_ interaction: UIDropInteraction, canHandle session: UIDropSession) -> Bool { + if customSearchQuery != nil { + // No dropping on a smart search toolbar + return false + } return true } @@ -333,6 +511,16 @@ open class ClientQueryViewController: QueryFileListTableViewController, UIDropIn if let shortName = core?.bookmark.shortName { tableViewController.bookmarkShortName = shortName } + if breadCrumbsPush { + tableViewController.navigationHandler = { [weak self] (path) in + if let self = self, let core = self.core { + let queryViewController = ClientQueryViewController(core: core, query: OCQuery(forPath: path)) + queryViewController.breadCrumbsPush = true + + self.navigationController?.pushViewController(queryViewController, animated: true) + } + } + } if #available(iOS 13, *) { // On iOS 13.0/13.1, the table view's content needs to be inset by the height of the arrow @@ -363,7 +551,28 @@ open class ClientQueryViewController: QueryFileListTableViewController, UIDropIn // MARK: - Updates open override func performUpdatesWithQueryChanges(query: OCQuery, changeSet: OCQueryChangeSet?) { - if let rootItem = self.query.rootItem { + guard query == activeQuery else { + return + } + + super.performUpdatesWithQueryChanges(query: query, changeSet: changeSet) + + if let revealItemLocalID = revealItemLocalID, !revealItemFound { + var rowIdx : Int = 0 + + for item in items { + if item.localID == revealItemLocalID { + OnMainThread { + self.tableView.scrollToRow(at: IndexPath(row: rowIdx, section: 0), at: .middle, animated: true) + } + revealItemFound = true + break + } + rowIdx += 1 + } + } + + if let rootItem = self.query.rootItem, searchText == nil { if query.queryPath != "/" { var totalSize = String(format: "Total: %@".localized, rootItem.sizeLocalized) if self.items.count == 1 { @@ -381,6 +590,20 @@ open class ClientQueryViewController: QueryFileListTableViewController, UIDropIn view.window?.windowScene?.userActivity = activity.openItemUserActivity } } + } else { + self.updateFooter(text: nil) + } + } + + open override func delegatedTableViewDataReload() { + super.delegatedTableViewDataReload() + + if scrollToTopWithNextRefresh { + scrollToTopWithNextRefresh = false + + OnMainThread { + self.tableView.setContentOffset(.zero, animated: false) + } } } diff --git a/ownCloudAppShared/Client/File Lists/FileListTableViewController.swift b/ownCloudAppShared/Client/File Lists/FileListTableViewController.swift index b38fc9c4e..052a8c720 100644 --- a/ownCloudAppShared/Client/File Lists/FileListTableViewController.swift +++ b/ownCloudAppShared/Client/File Lists/FileListTableViewController.swift @@ -27,6 +27,11 @@ public protocol MoreItemHandling { @discardableResult func moreOptions(for item: OCItem, at location: OCExtensionLocationIdentifier, core: OCCore, query: OCQuery?, sender: AnyObject?) -> Bool } +public protocol RevealItemHandling { + @discardableResult func reveal(item: OCItem, core: OCCore, sender: AnyObject?) -> Bool + func showReveal(at path: IndexPath) -> Bool +} + public protocol InlineMessageSupport { func hasInlineMessage(for item: OCItem) -> Bool func showInlineMessageFor(item: OCItem) @@ -89,6 +94,16 @@ open class FileListTableViewController: UITableViewController, ClientItemCellDel } } + open func revealButtonTapped(cell: ClientItemCell) { + guard let item = self.item(for: cell), let core = core else { + return + } + + if let revealItemHandling = self as? RevealItemHandling { + revealItemHandling.reveal(item: item, core: core, sender: cell) + } + } + // MARK: - Inline message support open func hasMessage(for item: OCItem) -> Bool { if let inlineMessageSupport = self as? InlineMessageSupport { @@ -213,7 +228,7 @@ open class FileListTableViewController: UITableViewController, ClientItemCellDel } if !ifNeeded || (ifNeeded && tableReloadNeeded) { - self.tableView.reloadData() + self.delegatedTableViewDataReload() if viewControllerVisible { tableReloadNeeded = false @@ -223,6 +238,10 @@ open class FileListTableViewController: UITableViewController, ClientItemCellDel } } + open func delegatedTableViewDataReload() { + self.tableView.reloadData() + } + open func restoreSelectionAfterTableReload() { } diff --git a/ownCloudAppShared/Client/File Lists/QueryFileListTableViewController.swift b/ownCloudAppShared/Client/File Lists/QueryFileListTableViewController.swift index 02b5c8172..8769f6092 100644 --- a/ownCloudAppShared/Client/File Lists/QueryFileListTableViewController.swift +++ b/ownCloudAppShared/Client/File Lists/QueryFileListTableViewController.swift @@ -39,9 +39,12 @@ public protocol MultiSelectSupport { func populateToolbar() } -open class QueryFileListTableViewController: FileListTableViewController, SortBarDelegate, OCQueryDelegate, UISearchResultsUpdating { +open class QueryFileListTableViewController: FileListTableViewController, SortBarDelegate, RevealItemHandling, OCQueryDelegate, UISearchResultsUpdating { public var query : OCQuery + open var activeQuery : OCQuery { + return query + } public var queryRefreshRateLimiter : OCRateLimiter = OCRateLimiter(minimumTime: 0.2) @@ -113,14 +116,19 @@ open class QueryFileListTableViewController: FileListTableViewController, SortBa return sort } } + open var searchScope: SearchScope = .local { + didSet { + updateSearchPlaceholder() + } + } open var sortDirection: SortDirection { set { UserDefaults.standard.setValue(newValue.rawValue, forKey: "sort-direction") } get { - let sort = SortDirection(rawValue: UserDefaults.standard.integer(forKey: "sort-direction")) ?? SortDirection.ascendant - return sort + let direction = SortDirection(rawValue: UserDefaults.standard.integer(forKey: "sort-direction")) ?? SortDirection.ascendant + return direction } } @@ -129,30 +137,35 @@ open class QueryFileListTableViewController: FileListTableViewController, SortBa // MARK: - Search: UISearchResultsUpdating Delegate open func updateSearchResults(for searchController: UISearchController) { - let searchText = searchController.searchBar.text! + let searchText = searchController.searchBar.text ?? "" - let filterHandler: OCQueryFilterHandler = { (_, _, item) -> Bool in - if let itemName = item?.name { - return itemName.localizedCaseInsensitiveContains(searchText) - } - return false - } - - if searchText == "" { - if let filter = query.filter(withIdentifier: "text-search") { - query.removeFilter(filter) - } - } else { - if let filter = query.filter(withIdentifier: "text-search") { - query.updateFilter(filter, applyChanges: { filterToChange in - (filterToChange as? OCQueryFilter)?.filterHandler = filterHandler - }) - } else { - query.addFilter(OCQueryFilter.init(handler: filterHandler), withIdentifier: "text-search") - } - } + applySearchFilter(for: (searchText == "") ? nil : searchText, to: query) } + open func applySearchFilter(for searchText: String?, to query: OCQuery) { + if let searchText = searchText { + let queryCondition = OCQueryCondition.fromSearchTerm(searchText) + let filterHandler: OCQueryFilterHandler = { (_, _, item) -> Bool in + if let item = item, let queryCondition = queryCondition { + return queryCondition.fulfilled(by: item) + } + return false + } + + if let filter = query.filter(withIdentifier: "text-search") { + query.updateFilter(filter, applyChanges: { filterToChange in + (filterToChange as? OCQueryFilter)?.filterHandler = filterHandler + }) + } else { + query.addFilter(OCQueryFilter.init(handler: filterHandler), withIdentifier: "text-search") + } + } else { + if let filter = query.filter(withIdentifier: "text-search") { + query.removeFilter(filter) + } + } + } + // MARK: - Query progress reporting open var showQueryProgress : Bool = true @@ -179,7 +192,7 @@ open class QueryFileListTableViewController: FileListTableViewController, SortBa override open func performPullToRefreshAction() { super.performPullToRefreshAction() - core?.reload(query) + core?.reload(activeQuery) } open func updateQueryProgressSummary() { @@ -209,6 +222,8 @@ open class QueryFileListTableViewController: FileListTableViewController, SortBa summary.message = "Please wait…".localized } + Log.debug("Query(p=\(unsafeBitCast(query, to: Int.self)), custom=\(query.isCustom)) status=\(summary.message ?? "?")") + if pullToRefreshControl != nil { if query.state == .idle { self.pullToRefreshBegan() @@ -238,59 +253,65 @@ open class QueryFileListTableViewController: FileListTableViewController, SortBa } open func queryHasChangesAvailable(_ query: OCQuery) { - queryRefreshRateLimiter.runRateLimitedBlock { - query.requestChangeSet(withFlags: .onlyResults) { (query, changeSet) in - OnMainThread { - if query.state.isFinal { - OnMainThread { - if self.pullToRefreshControl?.isRefreshing == true { - self.pullToRefreshControl?.endRefreshing() - } - } + if query == activeQuery { + queryRefreshRateLimiter.runRateLimitedBlock { + query.requestChangeSet(withFlags: .onlyResults) { (query, changeSet) in + OnMainThread { + self.performUpdatesWithQueryChanges(query: query, changeSet: changeSet) } + } + } + } + } - let previousItemCount = self.items.count + open func performUpdatesWithQueryChanges(query: OCQuery, changeSet: OCQueryChangeSet?) { + guard query == activeQuery else { + return + } - self.items = changeSet?.queryResult ?? [] + if query.state.isFinal { + OnMainThread { + if self.pullToRefreshControl?.isRefreshing == true { + self.pullToRefreshControl?.endRefreshing() + } + } + } - // Setup new action context - if let core = self.core { - let actionsLocation = OCExtensionLocation(ofType: .action, identifier: .toolbar) - self.actionContext = ActionContext(viewController: self, core: core, query: query, items: [OCItem](), location: actionsLocation) - } + let previousItemCount = self.items.count - switch query.state { - case .contentsFromCache, .idle, .waitingForServerReply: - if previousItemCount == 0, self.items.count == 0, query.state == .waitingForServerReply { - break - } - - if self.items.count == 0 { - if self.searchController?.searchBar.text != "" { - self.messageView?.message(show: true, imageName: "icon-search", title: "No matches".localized, message: "There is no results for this search".localized) - } else { - self.messageView?.message(show: true, imageName: "folder", title: "Empty folder".localized, message: "This folder contains no files or folders.".localized) - } - } else { - self.messageView?.message(show: false) - } - - self.tableView.reloadData() - case .targetRemoved: - self.messageView?.message(show: true, imageName: "folder", title: "Folder removed".localized, message: "This folder no longer exists on the server.".localized) - self.tableView.reloadData() - - default: - self.messageView?.message(show: false) - } + self.items = changeSet?.queryResult ?? [] - self.performUpdatesWithQueryChanges(query: query, changeSet: changeSet) - } - } + // Setup new action context + if let core = self.core { + let actionsLocation = OCExtensionLocation(ofType: .action, identifier: .toolbar) + self.actionContext = ActionContext(viewController: self, core: core, query: query, items: [OCItem](), location: actionsLocation) } - } - open func performUpdatesWithQueryChanges(query: OCQuery, changeSet: OCQueryChangeSet?) { + switch query.state { + case .contentsFromCache, .idle, .waitingForServerReply: + if previousItemCount == 0, self.items.count == 0, query.state == .waitingForServerReply { + break + } + + if self.items.count == 0 { + if self.searchController?.searchBar.text != "" { + self.messageView?.message(show: true, with: UIEdgeInsets(top: sortBar?.frame.size.height ?? 0, left: 0, bottom: 0, right: 0), imageName: "icon-search", title: "No matches".localized, message: "There is no results for this search".localized) + } else { + self.messageView?.message(show: true, imageName: "folder", title: "Empty folder".localized, message: "This folder contains no files or folders.".localized) + } + } else { + self.messageView?.message(show: false) + } + + self.reloadTableData() + + case .targetRemoved: + self.messageView?.message(show: true, imageName: "folder", title: "Folder removed".localized, message: "This folder no longer exists on the server.".localized) + self.reloadTableData() + + default: + self.messageView?.message(show: false) + } } // MARK: - Themeable @@ -322,6 +343,7 @@ open class QueryFileListTableViewController: FileListTableViewController, SortBa sortBar = SortBar(frame: CGRect(x: 0, y: 0, width: self.tableView.frame.width, height: 40), sortMethod: sortMethod) sortBar?.delegate = self sortBar?.sortMethod = self.sortMethod + sortBar?.searchScope = self.searchScope sortBar?.updateForCurrentTraitCollection() sortBar?.showSelectButton = showSelectButton @@ -338,20 +360,28 @@ open class QueryFileListTableViewController: FileListTableViewController, SortBa open override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() + updateSearchPlaceholder() + } + + func updateSearchPlaceholder() { // Needs to be done here, because of an iOS 13 bug. Do not move to viewDidLoad! + let placeholderString = (searchScope == .global) ? "Search account".localized : "Search folder".localized + if #available(iOS 13.0, *) { let attributedStringColor = [NSAttributedString.Key.foregroundColor : Theme.shared.activeCollection.searchBarColors.secondaryLabelColor] - let attributedString = NSAttributedString(string: "Search this folder".localized, attributes: attributedStringColor) + let attributedString = NSAttributedString(string: placeholderString, attributes: attributedStringColor) searchController?.searchBar.searchTextField.attributedPlaceholder = attributedString } else { // Fallback on earlier versions - searchController?.searchBar.placeholder = "Search this folder".localized + searchController?.searchBar.placeholder = placeholderString } } open override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + Log.debug("Query(p=\(unsafeBitCast(query, to: Int.self)), start/viewWillAppear") + core?.start(query) queryStateObservation = query.observe(\OCQuery.state, options: .initial, changeHandler: { [weak self] (_, _) in @@ -364,15 +394,14 @@ open class QueryFileListTableViewController: FileListTableViewController, SortBa open override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + Log.debug("Query(p=\(unsafeBitCast(query, to: Int.self)), stop/viewWillDisappear") + queryStateObservation?.invalidate() queryStateObservation = nil core?.stop(query) queryProgressSummary = nil - - searchController?.searchBar.text = "" - searchController?.dismiss(animated: true, completion: nil) } // MARK: - Item retrieval @@ -401,6 +430,8 @@ open class QueryFileListTableViewController: FileListTableViewController, SortBa cell?.delegate = self } + cell?.showRevealButton = self.showReveal(at: indexPath) + // UITableView can call this method several times for the same cell, and .dequeueReusableCell will then return the same cell again. // Make sure we don't request the thumbnail multiple times in that case. if newItem.displaysDifferent(than: cell?.item, in: core) { @@ -419,6 +450,33 @@ open class QueryFileListTableViewController: FileListTableViewController, SortBa return cell! } + public func revealViewController(core: OCCore, path: String, item: OCItem, rootViewController: UIViewController?) -> UIViewController? { + return ClientQueryViewController(core: core, query: OCQuery(forPath: path), reveal: item, rootViewController: nil) + } + + public func reveal(item: OCItem, core: OCCore, sender: AnyObject?) -> Bool { + if let parentPath = item.path?.parentPath, + let revealQueryViewController = revealViewController(core: core, path: parentPath, item: item, rootViewController: nil) { + + self.navigationController?.pushViewController(revealQueryViewController, animated: true) + + return true + } + return false + } + + public func showReveal(at path: IndexPath) -> Bool { + return showRevealButtons + } + + public var showRevealButtons : Bool = false { + didSet { + if oldValue != showRevealButtons { + self.reloadTableData(ifNeeded: true) + } + } + } + // MARK: - Table view delegate open override func sectionIndexTitles(for tableView: UITableView) -> [String]? { @@ -449,6 +507,9 @@ open class QueryFileListTableViewController: FileListTableViewController, SortBa return 0 } + open func sortBar(_ sortBar: SortBar, didUpdateSearchScope: SearchScope) { + } + open func toggleSelectMode() { if let multiSelectionSupport = self as? MultiSelectSupport { if !tableView.isEditing { diff --git a/ownCloudAppShared/Client/Sharing/GroupSharingTableViewController.swift b/ownCloudAppShared/Client/Sharing/GroupSharingTableViewController.swift index 67956469e..77b41483b 100644 --- a/ownCloudAppShared/Client/Sharing/GroupSharingTableViewController.swift +++ b/ownCloudAppShared/Client/Sharing/GroupSharingTableViewController.swift @@ -376,7 +376,7 @@ open class GroupSharingTableViewController: SharingTableViewController, UISearch } } - func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { self.resetTable(showShares: true) self.messageView?.message(show: false) diff --git a/ownCloudAppShared/Client/User Interface/BreadCrumbTableViewController.swift b/ownCloudAppShared/Client/User Interface/BreadCrumbTableViewController.swift index 658bc1760..ad6321a7c 100644 --- a/ownCloudAppShared/Client/User Interface/BreadCrumbTableViewController.swift +++ b/ownCloudAppShared/Client/User Interface/BreadCrumbTableViewController.swift @@ -30,6 +30,7 @@ open class BreadCrumbTableViewController: StaticTableViewController { open var parentNavigationController : UINavigationController? open var queryPath : NSString = "" open var bookmarkShortName : String? + open var navigationHandler : ((_ path: String) -> Void)? open override func viewDidLoad() { super.viewDidLoad() @@ -52,7 +53,7 @@ open class BreadCrumbTableViewController: StaticTableViewController { let contentWidth : CGFloat = (view.frame.size.width < maxContentWidth) ? view.frame.size.width : maxContentWidth self.preferredContentSize = CGSize(width: contentWidth, height: contentHeight) - for (_, currentPath) in pathComp.enumerated().reversed() { + for (idx, currentPath) in pathComp.enumerated().reversed() { var stackIndex = stackViewControllers.count - currentViewContollerIndex if stackIndex < 0 { stackIndex = 0 @@ -61,10 +62,18 @@ open class BreadCrumbTableViewController: StaticTableViewController { if currentPath.isRootPath, let shortName = self.bookmarkShortName { pathTitle = shortName } + var fullPath = ((pathComp as NSArray).subarray(with: NSRange(location: 1, length: idx)) as NSArray).componentsJoined(by: "/") + "/" + if !fullPath.hasPrefix("/") { + fullPath = "/" + fullPath + } let aRow = StaticTableViewRow(rowWithAction: { [weak self] (_, _) in guard let self = self else { return } - if stackViewControllers.indices.contains(stackIndex) { - self.parentNavigationController?.popToViewController((stackViewControllers[stackIndex]), animated: true) + if let navigationHandler = self.navigationHandler { + navigationHandler(fullPath) + } else { + if stackViewControllers.indices.contains(stackIndex) { + self.parentNavigationController?.popToViewController((stackViewControllers[stackIndex]), animated: true) + } } self.dismiss(animated: false, completion: nil) }, title: pathTitle, image: Theme.shared.image(for: "folder", size: CGSize(width: imageWidth, height: imageHeight))) diff --git a/ownCloudAppShared/Client/User Interface/ClientItemCell.swift b/ownCloudAppShared/Client/User Interface/ClientItemCell.swift index 346090205..378ac5bd8 100644 --- a/ownCloudAppShared/Client/User Interface/ClientItemCell.swift +++ b/ownCloudAppShared/Client/User Interface/ClientItemCell.swift @@ -23,6 +23,7 @@ public protocol ClientItemCellDelegate: class { func moreButtonTapped(cell: ClientItemCell) func messageButtonTapped(cell: ClientItemCell) + func revealButtonTapped(cell: ClientItemCell) func hasMessage(for item: OCItem) -> Bool } @@ -37,6 +38,7 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { private let iconViewWidth : CGFloat = 40 private let detailIconViewHeight : CGFloat = 15 private let moreButtonWidth : CGFloat = 60 + private let revealButtonWidth : CGFloat = 35 private let verticalLabelMarginFromCenter : CGFloat = 2 private let iconSize : CGSize = CGSize(width: 40, height: 40) private let thumbnailSize : CGSize = CGSize(width: 60, height: 60) @@ -56,9 +58,11 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { open var publicLinkStatusIconView : UIImageView = UIImageView() open var moreButton : UIButton = UIButton() open var messageButton : UIButton = UIButton() + open var revealButton : UIButton = UIButton() open var progressView : ProgressView? open var moreButtonWidthConstraint : NSLayoutConstraint? + open var revealButtonWidthConstraint : NSLayoutConstraint? open var sharedStatusIconViewZeroWidthConstraint : NSLayoutConstraint? open var publicLinkStatusIconViewZeroWidthConstraint : NSLayoutConstraint? @@ -77,7 +81,7 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { if isMoreButtonPermanentlyHidden { moreButtonWidthConstraint?.constant = 0 } else { - moreButtonWidthConstraint?.constant = moreButtonWidth + moreButtonWidthConstraint?.constant = showRevealButton ? revealButtonWidth : moreButtonWidth } } } @@ -136,6 +140,8 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { moreButton.translatesAutoresizingMaskIntoConstraints = false + revealButton.translatesAutoresizingMaskIntoConstraints = false + messageButton.translatesAutoresizingMaskIntoConstraints = false cloudStatusIconView.translatesAutoresizingMaskIntoConstraints = false @@ -164,6 +170,7 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { self.contentView.addSubview(publicLinkStatusIconView) self.contentView.addSubview(cloudStatusIconView) self.contentView.addSubview(moreButton) + self.contentView.addSubview(revealButton) self.contentView.addSubview(messageButton) moreButton.setImage(UIImage(named: "more-dots"), for: .normal) @@ -172,6 +179,15 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { moreButton.isPointerInteractionEnabled = true } + if #available(iOS 13.4, *) { + revealButton.setImage(UIImage(systemName: "arrow.right.circle.fill"), for: .normal) + revealButton.isPointerInteractionEnabled = true + } else { + revealButton.setTitle("→", for: .normal) + } + revealButton.contentMode = .center + revealButton.isHidden = !showRevealButton + messageButton.setTitle("⚠️", for: .normal) messageButton.contentMode = .center if #available(iOS 13.4, *) { @@ -180,6 +196,7 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { messageButton.isHidden = true moreButton.addTarget(self, action: #selector(moreButtonTapped), for: .touchUpInside) + revealButton.addTarget(self, action: #selector(revealButtonTapped), for: .touchUpInside) messageButton.addTarget(self, action: #selector(messageButtonTapped), for: .touchUpInside) sharedStatusIconView.setContentHuggingPriority(.required, for: .vertical) @@ -202,7 +219,8 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) detailLabel.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) - moreButtonWidthConstraint = moreButton.widthAnchor.constraint(equalToConstant: moreButtonWidth) + moreButtonWidthConstraint = moreButton.widthAnchor.constraint(equalToConstant: showRevealButton ? revealButtonWidth : moreButtonWidth) + revealButtonWidthConstraint = revealButton.widthAnchor.constraint(equalToConstant: showRevealButton ? revealButtonWidth : 0) cloudStatusIconViewZeroWidthConstraint = cloudStatusIconView.widthAnchor.constraint(equalToConstant: 0) sharedStatusIconViewZeroWidthConstraint = sharedStatusIconView.widthAnchor.constraint(equalToConstant: 0) @@ -244,11 +262,15 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { sharedStatusIconView.heightAnchor.constraint(equalToConstant: detailIconViewHeight), publicLinkStatusIconView.heightAnchor.constraint(equalToConstant: detailIconViewHeight), - moreButton.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor), moreButton.topAnchor.constraint(equalTo: self.contentView.topAnchor), moreButton.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), moreButtonWidthConstraint!, - moreButton.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor), + moreButton.trailingAnchor.constraint(equalTo: revealButton.leadingAnchor), + + revealButton.topAnchor.constraint(equalTo: self.contentView.topAnchor), + revealButton.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), + revealButtonWidthConstraint!, + revealButton.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor), messageButton.leadingAnchor.constraint(equalTo: moreButton.leadingAnchor), messageButton.trailingAnchor.constraint(equalTo: moreButton.trailingAnchor), @@ -489,6 +511,16 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { } // MARK: - Themeing + open var revealHighlight : Bool = false { + didSet { + if revealHighlight { + Log.debug("Highlighted!") + } + + applyThemeCollectionToCellContents(theme: Theme.shared, collection: Theme.shared.activeCollection) + } + } + override open func applyThemeCollectionToCellContents(theme: Theme, collection: ThemeCollection) { let itemState = ThemeItemState(selected: self.isSelected) @@ -505,6 +537,12 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { if showingIcon, let item = item { iconView.image = item.icon(fitInSize: iconSize) } + + if revealHighlight { + backgroundColor = collection.tableRowHighlightColors.backgroundColor?.withAlphaComponent(0.5) + } else { + backgroundColor = .clear + } } // MARK: - Editing mode @@ -512,7 +550,7 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { if hidden || isMoreButtonPermanentlyHidden { moreButtonWidthConstraint?.constant = 0 } else { - moreButtonWidthConstraint?.constant = moreButtonWidth + moreButtonWidthConstraint?.constant = showRevealButton ? revealButtonWidth : moreButtonWidth } moreButton.isHidden = ((item?.isPlaceholder == true) || (progressView != nil)) ? true : hidden if animated { @@ -524,10 +562,35 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { } } + var showRevealButton : Bool = false { + didSet { + if showRevealButton != oldValue { + self.setRevealButton(hidden: !showRevealButton, animated: false) + } + } + } + + open func setRevealButton(hidden:Bool, animated: Bool = false) { + if hidden { + revealButtonWidthConstraint?.constant = 0 + } else { + revealButtonWidthConstraint?.constant = revealButtonWidth + } + revealButton.isHidden = hidden + if animated { + UIView.animate(withDuration: 0.25) { + self.contentView.layoutIfNeeded() + } + } else { + self.contentView.layoutIfNeeded() + } + } + override open func setEditing(_ editing: Bool, animated: Bool) { super.setEditing(editing, animated: animated) setMoreButton(hidden: editing, animated: animated) + setRevealButton(hidden: editing ? true : !showRevealButton, animated: animated) } // MARK: - Actions @@ -537,6 +600,9 @@ open class ClientItemCell: ThemeTableViewCell, ItemContainer { @objc open func messageButtonTapped() { self.delegate?.messageButtonTapped(cell: self) } + @objc open func revealButtonTapped() { + self.delegate?.revealButtonTapped(cell: self) + } } public extension NSNotification.Name { diff --git a/ownCloudAppShared/Client/User Interface/MessageView.swift b/ownCloudAppShared/Client/User Interface/MessageView.swift index 802168e8f..13778d943 100644 --- a/ownCloudAppShared/Client/User Interface/MessageView.swift +++ b/ownCloudAppShared/Client/User Interface/MessageView.swift @@ -56,7 +56,7 @@ open class MessageView: UIView { } } - open func message(show: Bool, imageName : String? = nil, title : String? = nil, message : String? = nil) { + open func message(show: Bool, with insets: UIEdgeInsets? = nil, imageName : String? = nil, title : String? = nil, message : String? = nil) { if !show { if messageView?.superview != nil { messageView?.removeFromSuperview() @@ -174,9 +174,9 @@ open class MessageView: UIView { } NSLayoutConstraint.activate([ - rootView.leftAnchor.constraint(equalTo: self.mainView.leftAnchor), - rootView.widthAnchor.constraint(equalTo: self.mainView.widthAnchor), - rootView.topAnchor.constraint(equalTo: self.mainView.safeAreaLayoutGuide.topAnchor), + rootView.leftAnchor.constraint(equalTo: self.mainView.leftAnchor, constant: insets?.left ?? 0), + rootView.widthAnchor.constraint(equalTo: self.mainView.widthAnchor, constant: -(insets?.right ?? 0)), + rootView.topAnchor.constraint(equalTo: self.mainView.safeAreaLayoutGuide.topAnchor, constant: insets?.top ?? 0), self.composeViewBottomConstraint ]) diff --git a/ownCloudAppShared/Client/User Interface/SortBar.swift b/ownCloudAppShared/Client/User Interface/SortBar.swift index 2de0f9817..51855784c 100644 --- a/ownCloudAppShared/Client/User Interface/SortBar.swift +++ b/ownCloudAppShared/Client/User Interface/SortBar.swift @@ -35,13 +35,32 @@ public class SegmentedControl: UISegmentedControl { } } +public enum SearchScope : Int, CaseIterable { + case global + case local + + var label : String { + var name : String! + + switch self { + case .global: name = "Account".localized + case .local: name = "Folder".localized + } + + return name + } +} + public protocol SortBarDelegate: class { var sortDirection: SortDirection { get set } var sortMethod: SortMethod { get set } + var searchScope: SearchScope { get set } func sortBar(_ sortBar: SortBar, didUpdateSortMethod: SortMethod) + func sortBar(_ sortBar: SortBar, didUpdateSearchScope: SearchScope) + func sortBar(_ sortBar: SortBar, presentViewController: UIViewController, animated: Bool, completionHandler: (() -> Void)?) func toggleSelectMode() @@ -60,6 +79,7 @@ public class SortBar: UIView, Themeable, UIPopoverPresentationControllerDelegate let leftPadding: CGFloat = 20.0 let rightPadding: CGFloat = 20.0 let rightSelectButtonPadding: CGFloat = 8.0 + let rightSearchScopePadding: CGFloat = 15.0 let topPadding: CGFloat = 10.0 let bottomPadding: CGFloat = 10.0 @@ -67,14 +87,40 @@ public class SortBar: UIView, Themeable, UIPopoverPresentationControllerDelegate public var sortSegmentedControl: SegmentedControl? public var sortButton: UIButton? + public var searchScopeSegmentedControl : SegmentedControl? public var selectButton: UIButton? + public var allowMultiSelect: Bool = true { + didSet { + updateSelectButtonVisibility() + } + } public var showSelectButton: Bool = false { didSet { - selectButton?.isHidden = !showSelectButton - selectButton?.accessibilityElementsHidden = !showSelectButton - selectButton?.isEnabled = showSelectButton + updateSelectButtonVisibility() + } + } + + private func updateSelectButtonVisibility() { + let showButton = showSelectButton && allowMultiSelect + + selectButton?.isHidden = !showButton + selectButton?.accessibilityElementsHidden = !showButton + selectButton?.isEnabled = showButton - UIAccessibility.post(notification: .layoutChanged, argument: nil) + UIAccessibility.post(notification: .layoutChanged, argument: nil) + } + + var showSearchScope: Bool = false { + didSet { + showSelectButton = !self.showSearchScope + self.searchScopeSegmentedControl?.isHidden = false + self.searchScopeSegmentedControl?.alpha = oldValue ? 1.0 : 0.0 + + UIView.animate(withDuration: 0.3, animations: { + self.searchScopeSegmentedControl?.alpha = self.showSearchScope ? 1.0 : 0.0 + }, completion: { (_) in + self.searchScopeSegmentedControl?.isHidden = !self.showSearchScope + }) } } @@ -93,57 +139,79 @@ public class SortBar: UIView, Themeable, UIPopoverPresentationControllerDelegate } updateSortButtonTitle() - sortButton?.accessibilityLabel = NSString(format: "Sort by %@".localized as NSString, sortMethod.localizedName()) as String + sortButton?.accessibilityLabel = NSString(format: "Sort by %@".localized as NSString, sortMethod.localizedName) as String sortButton?.sizeToFit() if let oldSementIndex = SortMethod.all.index(of: oldValue) { - sortSegmentedControl?.setTitle(oldValue.localizedName(), forSegmentAt: oldSementIndex) + sortSegmentedControl?.setTitle(oldValue.localizedName, forSegmentAt: oldSementIndex) } if let segmentIndex = SortMethod.all.index(of: sortMethod) { sortSegmentedControl?.selectedSegmentIndex = segmentIndex - sortSegmentedControl?.setTitle(sortDirectionTitle(sortMethod.localizedName()), forSegmentAt: segmentIndex) + sortSegmentedControl?.setTitle(sortDirectionTitle(sortMethod.localizedName), forSegmentAt: segmentIndex) } delegate?.sortBar(self, didUpdateSortMethod: sortMethod) } } + public var searchScope : SearchScope { + didSet { + delegate?.searchScope = searchScope + searchScopeSegmentedControl?.selectedSegmentIndex = searchScope.rawValue + } + } + // MARK: - Init & Deinit - public init(frame: CGRect, sortMethod: SortMethod) { + public init(frame: CGRect, sortMethod: SortMethod, searchScope: SearchScope = .local) { sortSegmentedControl = SegmentedControl() selectButton = UIButton() sortButton = UIButton(type: .system) + searchScopeSegmentedControl = SegmentedControl() self.sortMethod = sortMethod + self.searchScope = searchScope super.init(frame: frame) - if let sortButton = sortButton, let sortSegmentedControl = sortSegmentedControl, let selectButton = selectButton { + if let sortButton = sortButton, let sortSegmentedControl = sortSegmentedControl, let searchScopeSegmentedControl = searchScopeSegmentedControl, let selectButton = selectButton { sortButton.translatesAutoresizingMaskIntoConstraints = false sortSegmentedControl.translatesAutoresizingMaskIntoConstraints = false selectButton.translatesAutoresizingMaskIntoConstraints = false + searchScopeSegmentedControl.translatesAutoresizingMaskIntoConstraints = false sortButton.accessibilityIdentifier = "sort-bar.sortButton" sortSegmentedControl.accessibilityIdentifier = "sort-bar.segmentedControl" + searchScopeSegmentedControl.accessibilityIdentifier = "sort-bar.searchScopeSegmentedControl" + searchScopeSegmentedControl.accessibilityLabel = "Search scope".localized + + for scope in SearchScope.allCases { + searchScopeSegmentedControl.insertSegment(withTitle: scope.label, at: scope.rawValue, animated: false) + } + searchScopeSegmentedControl.selectedSegmentIndex = searchScope.rawValue + searchScopeSegmentedControl.isHidden = !self.showSearchScope + searchScopeSegmentedControl.addTarget(self, action: #selector(searchScopeValueChanged), for: .valueChanged) self.addSubview(sortSegmentedControl) self.addSubview(sortButton) + self.addSubview(searchScopeSegmentedControl) self.addSubview(selectButton) // Sort segmented control NSLayoutConstraint.activate([ sortSegmentedControl.topAnchor.constraint(equalTo: self.topAnchor, constant: topPadding), sortSegmentedControl.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -bottomPadding), - sortSegmentedControl.centerXAnchor.constraint(equalTo: self.centerXAnchor), - sortSegmentedControl.leadingAnchor.constraint(greaterThanOrEqualTo: self.leadingAnchor, constant: leftPadding), - sortSegmentedControl.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -rightPadding) + sortSegmentedControl.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor, constant: leftPadding), + + searchScopeSegmentedControl.trailingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.trailingAnchor, constant: -rightSearchScopePadding), + searchScopeSegmentedControl.topAnchor.constraint(equalTo: self.topAnchor, constant: topPadding), + searchScopeSegmentedControl.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -bottomPadding) ]) var longestTitleWidth : CGFloat = 0.0 for method in SortMethod.all { - sortSegmentedControl.insertSegment(withTitle: method.localizedName(), at: SortMethod.all.index(of: method)!, animated: false) - let titleWidth = method.localizedName().appending(" ↓").width(withConstrainedHeight: sortSegmentedControl.frame.size.height, font: UIFont.systemFont(ofSize: 16.0)) + sortSegmentedControl.insertSegment(withTitle: method.localizedName, at: SortMethod.all.index(of: method)!, animated: false) + let titleWidth = method.localizedName.appending(" ↓").width(withConstrainedHeight: sortSegmentedControl.frame.size.height, font: UIFont.systemFont(ofSize: 16.0)) if titleWidth > longestTitleWidth { longestTitleWidth = titleWidth } @@ -203,8 +271,6 @@ public class SortBar: UIView, Themeable, UIPopoverPresentationControllerDelegate Theme.shared.register(client: self) selectButton?.isHidden = !showSelectButton - selectButton?.accessibilityElementsHidden = !showSelectButton - selectButton?.isEnabled = showSelectButton updateForCurrentTraitCollection() } @@ -222,6 +288,7 @@ public class SortBar: UIView, Themeable, UIPopoverPresentationControllerDelegate self.sortButton?.applyThemeCollection(collection) self.selectButton?.applyThemeCollection(collection) self.sortSegmentedControl?.applyThemeCollection(collection) + self.searchScopeSegmentedControl?.applyThemeCollection(collection) self.backgroundColor = collection.navigationBarColors.backgroundColor } @@ -256,7 +323,7 @@ public class SortBar: UIView, Themeable, UIPopoverPresentationControllerDelegate // MARK: - Sort Direction Title func updateSortButtonTitle() { - let title = NSString(format: "Sort by %@".localized as NSString, sortMethod.localizedName()) as String + let title = NSString(format: "Sort by %@".localized as NSString, sortMethod.localizedName) as String sortButton?.setTitle(sortDirectionTitle(title), for: .normal) } @@ -306,6 +373,13 @@ public class SortBar: UIView, Themeable, UIPopoverPresentationControllerDelegate } } + @objc private func searchScopeValueChanged() { + if let selectedIndex = searchScopeSegmentedControl?.selectedSegmentIndex { + self.searchScope = SearchScope(rawValue: selectedIndex)! + delegate?.sortBar(self, didUpdateSearchScope: self.searchScope) + } + } + @objc private func toggleSelectMode() { delegate?.toggleSelectMode() } diff --git a/ownCloudAppShared/Client/User Interface/SortMethod.swift b/ownCloudAppShared/Client/User Interface/SortMethod.swift index d02ee2c9c..075eedfd1 100644 --- a/ownCloudAppShared/Client/User Interface/SortMethod.swift +++ b/ownCloudAppShared/Client/User Interface/SortMethod.swift @@ -37,25 +37,43 @@ public enum SortMethod: Int { public static var all: [SortMethod] = [alphabetically, kind, size, date, shared] - public func localizedName() -> String { + public var localizedName : String { var name = "" switch self { - case .alphabetically: - name = "name".localized - case .kind: - name = "kind".localized - case .size: - name = "size".localized - case .date: - name = "date".localized - case .shared: - name = "shared".localized + case .alphabetically: + name = "name".localized + case .kind: + name = "kind".localized + case .size: + name = "size".localized + case .date: + name = "date".localized + case .shared: + name = "shared".localized } return name } + public var sortPropertyName : OCItemPropertyName? { + var propertyName : OCItemPropertyName? + + switch self { + case .alphabetically: + propertyName = .name + case .kind: + propertyName = .mimeType + case .size: + propertyName = .size + case .date: + propertyName = .lastModified + case .shared: break + } + + return propertyName + } + public func comparator(direction: SortDirection) -> OCSort { var comparator: OCSort var combinedComparator: OCSort? diff --git a/ownCloudAppShared/Client/User Interface/SortMethodTableViewController.swift b/ownCloudAppShared/Client/User Interface/SortMethodTableViewController.swift index 8ffab04e8..5d051b1d3 100644 --- a/ownCloudAppShared/Client/User Interface/SortMethodTableViewController.swift +++ b/ownCloudAppShared/Client/User Interface/SortMethodTableViewController.swift @@ -40,7 +40,7 @@ class SortMethodTableViewController: StaticTableViewController { self.preferredContentSize = CGSize(width: contentWidth, height: contentHeight) for method in SortMethod.all { - let title = method.localizedName() + let title = method.localizedName var sortDirectionTitle = "" if sortBarDelegate?.sortMethod == method { diff --git a/ownCloudAppShared/UIKit Extension/UIView+Extension.swift b/ownCloudAppShared/UIKit Extension/UIView+Extension.swift index 960f1a5a1..b2d6f6b3d 100644 --- a/ownCloudAppShared/UIKit Extension/UIView+Extension.swift +++ b/ownCloudAppShared/UIKit Extension/UIView+Extension.swift @@ -30,6 +30,22 @@ public extension UIView { self.layer.add(animation, forKey: "shakeHorizontally") } + func beginPulsing(duration: TimeInterval = 1) { + let pulseAnimation = CABasicAnimation(keyPath: "opacity") + + pulseAnimation.fromValue = 1 + pulseAnimation.toValue = 0.3 + pulseAnimation.repeatCount = .infinity + pulseAnimation.autoreverses = true + pulseAnimation.duration = duration + + layer.add(pulseAnimation, forKey: "opacity") + } + + func endPulsing() { + layer.removeAnimation(forKey: "opacity") + } + // MARK: - View hierarchy func findSubviewInTree(where filter: (UIView) -> Bool) -> UIView? { for subview in subviews { diff --git a/ownCloudAppShared/User Interface/Theme/Theme.swift b/ownCloudAppShared/User Interface/Theme/Theme.swift index 27a2efe8e..dd2524a5e 100644 --- a/ownCloudAppShared/User Interface/Theme/Theme.swift +++ b/ownCloudAppShared/User Interface/Theme/Theme.swift @@ -237,7 +237,7 @@ public class Theme: NSObject { } UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self]).keyboardAppearance = collection.keyboardAppearance UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = collection.tintColor - UITextField.appearance().tintColor = collection.tableRowColors.labelColor + UITextField.appearance().tintColor = collection.searchBarColors.tintColor } }