Commit aa49c4e3da
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -517,7 +517,7 @@ | ||
| 517 | 517 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 518 | 518 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 519 | 519 | CODE_SIGN_STYLE = Automatic; |
| 520 | CURRENT_PROJECT_VERSION = 80; | |
| 520 | CURRENT_PROJECT_VERSION = 81; | |
| 521 | 521 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 522 | 522 | ENABLE_PREVIEWS = YES; |
| 523 | 523 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -534,7 +534,7 @@ | ||
| 534 | 534 | "$(inherited)", |
| 535 | 535 | "@executable_path/Frameworks", |
| 536 | 536 | ); |
| 537 | MARKETING_VERSION = 3.2.0; | |
| 537 | MARKETING_VERSION = 3.2.1; | |
| 538 | 538 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 539 | 539 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 540 | 540 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -554,7 +554,7 @@ | ||
| 554 | 554 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 555 | 555 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 556 | 556 | CODE_SIGN_STYLE = Automatic; |
| 557 | CURRENT_PROJECT_VERSION = 80; | |
| 557 | CURRENT_PROJECT_VERSION = 81; | |
| 558 | 558 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 559 | 559 | ENABLE_PREVIEWS = YES; |
| 560 | 560 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -571,7 +571,7 @@ | ||
| 571 | 571 | "$(inherited)", |
| 572 | 572 | "@executable_path/Frameworks", |
| 573 | 573 | ); |
| 574 | MARKETING_VERSION = 3.2.0; | |
| 574 | MARKETING_VERSION = 3.2.1; | |
| 575 | 575 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 576 | 576 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 577 | 577 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -634,7 +634,7 @@ | ||
| 634 | 634 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 635 | 635 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 636 | 636 | CODE_SIGN_STYLE = Automatic; |
| 637 | CURRENT_PROJECT_VERSION = 80; | |
| 637 | CURRENT_PROJECT_VERSION = 81; | |
| 638 | 638 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 639 | 639 | GENERATE_INFOPLIST_FILE = NO; |
| 640 | 640 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -644,7 +644,7 @@ | ||
| 644 | 644 | "@executable_path/Frameworks", |
| 645 | 645 | "@executable_path/../../Frameworks", |
| 646 | 646 | ); |
| 647 | MARKETING_VERSION = 3.2.0; | |
| 647 | MARKETING_VERSION = 3.2.1; | |
| 648 | 648 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 649 | 649 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 650 | 650 | SKIP_INSTALL = YES; |
| @@ -663,7 +663,7 @@ | ||
| 663 | 663 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 664 | 664 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 665 | 665 | CODE_SIGN_STYLE = Automatic; |
| 666 | CURRENT_PROJECT_VERSION = 80; | |
| 666 | CURRENT_PROJECT_VERSION = 81; | |
| 667 | 667 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 668 | 668 | GENERATE_INFOPLIST_FILE = NO; |
| 669 | 669 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -673,7 +673,7 @@ | ||
| 673 | 673 | "@executable_path/Frameworks", |
| 674 | 674 | "@executable_path/../../Frameworks", |
| 675 | 675 | ); |
| 676 | MARKETING_VERSION = 3.2.0; | |
| 676 | MARKETING_VERSION = 3.2.1; | |
| 677 | 677 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 678 | 678 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 679 | 679 | SKIP_INSTALL = YES; |
Hutch/App/AppStorageKeys.swift +1
| @@ -18,6 +18,7 @@ enum AppStorageKeys { | ||
| 18 | 18 | static let homeBuildsExpanded = "homeBuildsExpanded" |
| 19 | 19 | static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" |
| 20 | 20 | static let buildsRepoFilter = "buildsRepoFilter" |
| 21 | static let buildsLookbackDays = "buildsLookbackDays" | |
| 21 | 22 | static let ticketFilterState = "ticketFilterState" |
| 22 | 23 | static let ticketSavedFilters = "ticketSavedFilters" |
| 23 | 24 | static let appTheme = "appTheme" |
Hutch/Models/Meta.swift +7 −1
| @@ -22,10 +22,16 @@ struct UserProfile: Codable, Sendable { | ||
| 22 | 22 | |
| 23 | 23 | struct SSHKey: Codable, Sendable, Identifiable { |
| 24 | 24 | let id: Int |
| 25 | let fingerprint: String | |
| 26 | 25 | let comment: String? |
| 27 | 26 | let created: Date |
| 28 | 27 | let lastUsed: Date? |
| 28 | ||
| 29 | var displayLabel: String { | |
| 30 | if let comment, !comment.isEmpty { | |
| 31 | return comment | |
| 32 | } | |
| 33 | return "SSH key #\(id)" | |
| 34 | } | |
| 29 | 35 | } |
| 30 | 36 | |
| 31 | 37 | struct SSHKeyPage: Codable, Sendable { |
Hutch/Views/Builds/BuildListView.swift +26
| @@ -4,6 +4,8 @@ struct BuildListView: View { | ||
| 4 | 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 5 | 5 | @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0 |
| 6 | 6 | @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = "" |
| 7 | @AppStorage(AppStorageKeys.buildsLookbackDays, store: .standard) | |
| 8 | private var lookbackDays = BuildListViewModel.defaultLookbackDays | |
| 7 | 9 | @Environment(AppState.self) private var appState |
| 8 | 10 | @Environment(\.isAMOLEDTheme) private var isAMOLED |
| 9 | 11 | @State private var viewModel: BuildListViewModel? |
| @@ -65,6 +67,20 @@ struct BuildListView: View { | ||
| 65 | 67 | } |
| 66 | 68 | } |
| 67 | 69 | } |
| 70 | Section("Timeframe") { | |
| 71 | ForEach(HomeViewModel.allowedFailedBuildLookbackDays, id: \.self) { days in | |
| 72 | Button { | |
| 73 | lookbackDays = days | |
| 74 | viewModel.lookbackDays = days | |
| 75 | } label: { | |
| 76 | if lookbackDays == days { | |
| 77 | Label(HomeViewModel.failedBuildLookbackLabel(days: days), systemImage: "checkmark") | |
| 78 | } else { | |
| 79 | Text(HomeViewModel.failedBuildLookbackLabel(days: days)) | |
| 80 | } | |
| 81 | } | |
| 82 | } | |
| 83 | } | |
| 68 | 84 | } label: { |
| 69 | 85 | Image(systemName: "line.3.horizontal.decrease.circle") |
| 70 | 86 | } |
| @@ -107,6 +123,7 @@ struct BuildListView: View { | ||
| 107 | 123 | if viewModel == nil { |
| 108 | 124 | let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults) |
| 109 | 125 | vm.repoFilter = savedRepoFilter |
| 126 | vm.lookbackDays = lookbackDays | |
| 110 | 127 | viewModel = vm |
| 111 | 128 | await vm.loadJobs() |
| 112 | 129 | } |
| @@ -114,6 +131,9 @@ struct BuildListView: View { | ||
| 114 | 131 | // onDisappear stops it when navigating away. |
| 115 | 132 | viewModel?.startAutoRefresh(interval: autoRefreshInterval) |
| 116 | 133 | } |
| 134 | .onChange(of: lookbackDays) { _, newValue in | |
| 135 | viewModel?.lookbackDays = newValue | |
| 136 | } | |
| 117 | 137 | .onDisappear { |
| 118 | 138 | viewModel?.stopAutoRefresh() |
| 119 | 139 | } |
| @@ -236,6 +256,12 @@ struct BuildListView: View { | ||
| 236 | 256 | systemImage: "magnifyingglass", |
| 237 | 257 | description: Text("No builds matched “\(viewModel.searchText)”.") |
| 238 | 258 | ) |
| 259 | } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty { | |
| 260 | ContentUnavailableView( | |
| 261 | "No Builds In Timeframe", | |
| 262 | systemImage: "calendar.badge.clock", | |
| 263 | description: Text("No builds were updated \(HomeViewModel.failedBuildLookbackLabel(days: lookbackDays)).") | |
| 264 | ) | |
| 239 | 265 | } else if viewModel.jobs.isEmpty, viewModel.error == nil { |
| 240 | 266 | ContentUnavailableView( |
| 241 | 267 | "No Builds", |
Hutch/Views/Builds/BuildListViewModel.swift +25 −3
| @@ -45,6 +45,7 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable { | ||
| 45 | 45 | @MainActor |
| 46 | 46 | final class BuildListViewModel { |
| 47 | 47 | private static let searchHistoryScopeID = "builds" |
| 48 | nonisolated static let defaultLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays | |
| 48 | 49 | |
| 49 | 50 | private(set) var jobs: [JobSummary] = [] { |
| 50 | 51 | didSet { updateFilteredJobs() } |
| @@ -68,6 +69,9 @@ final class BuildListViewModel { | ||
| 68 | 69 | updateFilteredJobs() |
| 69 | 70 | } |
| 70 | 71 | } |
| 72 | var lookbackDays = defaultLookbackDays { | |
| 73 | didSet { updateFilteredJobs() } | |
| 74 | } | |
| 71 | 75 | // Cached filtered result. Updated whenever jobs, filter, searchText, or |
| 72 | 76 | // repoFilter changes. Only notifies observers when the content actually |
| 73 | 77 | // differs, which prevents the list from re-rendering on auto-refresh when |
| @@ -99,7 +103,7 @@ final class BuildListViewModel { | ||
| 99 | 103 | } |
| 100 | 104 | |
| 101 | 105 | private func updateFilteredJobs() { |
| 102 | var result = Self.filterJobs(jobs, filter: filter) | |
| 106 | var result = Self.filterJobs(jobs, filter: filter, lookbackDays: lookbackDays) | |
| 103 | 107 | if !repoFilter.isEmpty { |
| 104 | 108 | result = result.filter { $0.tags.contains(repoFilter) } |
| 105 | 109 | } |
| @@ -382,8 +386,14 @@ final class BuildListViewModel { | ||
| 382 | 386 | let cancel: CancelResult |
| 383 | 387 | } |
| 384 | 388 | |
| 385 | nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] { | |
| 386 | jobs.filter { job in | |
| 389 | nonisolated static func filterJobs( | |
| 390 | _ jobs: [JobSummary], | |
| 391 | filter: BuildListFilter, | |
| 392 | lookbackDays: Int, | |
| 393 | now: Date = .now, | |
| 394 | calendar: Calendar = .current | |
| 395 | ) -> [JobSummary] { | |
| 396 | let filteredByStatus = jobs.filter { job in | |
| 387 | 397 | switch filter { |
| 388 | 398 | case .attention: |
| 389 | 399 | switch job.status { |
| @@ -403,6 +413,18 @@ final class BuildListViewModel { | ||
| 403 | 413 | return true |
| 404 | 414 | } |
| 405 | 415 | } |
| 416 | ||
| 417 | let normalizedLookbackDays = HomeViewModel.allowedFailedBuildLookbackDays.contains(lookbackDays) | |
| 418 | ? lookbackDays | |
| 419 | : defaultLookbackDays | |
| 420 | let startOfToday = calendar.startOfDay(for: now) | |
| 421 | let windowStart = calendar.date( | |
| 422 | byAdding: .day, | |
| 423 | value: -(normalizedLookbackDays - 1), | |
| 424 | to: startOfToday | |
| 425 | ) ?? startOfToday | |
| 426 | ||
| 427 | return filteredByStatus.filter { $0.updated >= windowStart } | |
| 406 | 428 | } |
| 407 | 429 | |
| 408 | 430 | nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] { |
Hutch/Views/Home/HomeView.swift +3
| @@ -56,6 +56,9 @@ struct HomeView: View { | ||
| 56 | 56 | loadRecentActivity() |
| 57 | 57 | } |
| 58 | 58 | } |
| 59 | .onChange(of: failedBuildLookbackDays) { _, _ in | |
| 60 | viewModel?.refreshNeedsAttentionSnapshot() | |
| 61 | } | |
| 59 | 62 | } |
| 60 | 63 | |
| 61 | 64 | @ViewBuilder |
Hutch/Views/Home/HomeViewModel.swift +4
| @@ -670,6 +670,10 @@ final class HomeViewModel { | ||
| 670 | 670 | persistNeedsAttentionSnapshot() |
| 671 | 671 | } |
| 672 | 672 | |
| 673 | func refreshNeedsAttentionSnapshot() { | |
| 674 | persistNeedsAttentionSnapshot() | |
| 675 | } | |
| 676 | ||
| 673 | 677 | private func loadProjects() async -> Result<[Project], Error> { |
| 674 | 678 | do { |
| 675 | 679 | return .success(try await projectService.fetchProjects()) |
Hutch/Views/More/ProfileView.swift +2 −2
| @@ -243,7 +243,7 @@ struct ProfileView: View { | ||
| 243 | 243 | Section { |
| 244 | 244 | ForEach(viewModel.sshKeys) { key in |
| 245 | 245 | VStack(alignment: .leading, spacing: 2) { |
| 246 | Text(key.fingerprint) | |
| 246 | Text(key.displayLabel) | |
| 247 | 247 | .font(.caption.monospaced()) |
| 248 | 248 | .lineLimit(1) |
| 249 | 249 | .truncationMode(.middle) |
| @@ -677,7 +677,7 @@ private enum ProfileDestructiveAction { | ||
| 677 | 677 | var message: String { |
| 678 | 678 | switch self { |
| 679 | 679 | case .deleteSSHKey(let key): |
| 680 | "Remove SSH key \(key.fingerprint) from your account?" | |
| 680 | "Remove \(key.displayLabel) from your account?" | |
| 681 | 681 | case .deletePGPKey(let key): |
| 682 | 682 | "Remove PGP key \(key.fingerprint) from your account?" |
| 683 | 683 | } |
Hutch/Views/Settings/SettingsViewModel.swift +2 −2
| @@ -90,7 +90,7 @@ final class SettingsViewModel { | ||
| 90 | 90 | avatar |
| 91 | 91 | userType |
| 92 | 92 | sshKeys { |
| 93 | results { id fingerprint comment created lastUsed } | |
| 93 | results { id comment created lastUsed } | |
| 94 | 94 | cursor |
| 95 | 95 | } |
| 96 | 96 | pgpKeys { |
| @@ -114,7 +114,7 @@ final class SettingsViewModel { | ||
| 114 | 114 | private static let createSSHKeyMutation = """ |
| 115 | 115 | mutation createSSHKey($key: String!) { |
| 116 | 116 | createSSHKey(key: $key) { |
| 117 | id fingerprint comment created lastUsed | |
| 117 | id comment created lastUsed | |
| 118 | 118 | } |
| 119 | 119 | } |
| 120 | 120 | """ |
HutchTests/BuildListViewModelTests.swift +53 −5
| @@ -49,22 +49,70 @@ struct BuildListViewModelTests { | ||
| 49 | 49 | makeJob(id: 4, status: .cancelled, tags: []) |
| 50 | 50 | ] |
| 51 | 51 | |
| 52 | let attention = BuildListViewModel.filterJobs(jobs, filter: .attention) | |
| 53 | let active = BuildListViewModel.filterJobs(jobs, filter: .active) | |
| 52 | let attention = BuildListViewModel.filterJobs( | |
| 53 | jobs, | |
| 54 | filter: .attention, | |
| 55 | lookbackDays: BuildListViewModel.defaultLookbackDays | |
| 56 | ) | |
| 57 | let active = BuildListViewModel.filterJobs( | |
| 58 | jobs, | |
| 59 | filter: .active, | |
| 60 | lookbackDays: BuildListViewModel.defaultLookbackDays | |
| 61 | ) | |
| 54 | 62 | |
| 55 | 63 | #expect(attention.map(\.id) == [2, 3]) |
| 56 | 64 | #expect(active.map(\.id) == [3]) |
| 57 | 65 | } |
| 58 | 66 | |
| 67 | @Test | |
| 68 | func buildFilterRestrictsJobsToSelectedLookbackWindow() { | |
| 69 | let now = Date(timeIntervalSince1970: 60 * 60 * 24 * 20) | |
| 70 | let jobs = [ | |
| 71 | makeJob( | |
| 72 | id: 1, | |
| 73 | status: .failed, | |
| 74 | tags: [], | |
| 75 | updated: now.addingTimeInterval(-(60 * 60 * 24)) | |
| 76 | ), | |
| 77 | makeJob( | |
| 78 | id: 2, | |
| 79 | status: .running, | |
| 80 | tags: [], | |
| 81 | updated: now.addingTimeInterval(-(60 * 60 * 24 * 8)) | |
| 82 | ), | |
| 83 | makeJob( | |
| 84 | id: 3, | |
| 85 | status: .success, | |
| 86 | tags: [], | |
| 87 | updated: now.addingTimeInterval(-(60 * 60 * 24 * 2)) | |
| 88 | ), | |
| 89 | ] | |
| 90 | ||
| 91 | let filtered = BuildListViewModel.filterJobs( | |
| 92 | jobs, | |
| 93 | filter: .all, | |
| 94 | lookbackDays: 3, | |
| 95 | now: now, | |
| 96 | calendar: Calendar(identifier: .gregorian) | |
| 97 | ) | |
| 98 | ||
| 99 | #expect(filtered.map(\.id) == [1, 3]) | |
| 100 | } | |
| 101 | ||
| 59 | 102 | private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] { |
| 60 | 103 | BuildListViewModel.searchJobs(jobs, matching: query) |
| 61 | 104 | } |
| 62 | 105 | |
| 63 | private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary { | |
| 106 | private func makeJob( | |
| 107 | id: Int, | |
| 108 | status: JobStatus = .success, | |
| 109 | tags: [String], | |
| 110 | updated: Date = Date() | |
| 111 | ) -> JobSummary { | |
| 64 | 112 | JobSummary( |
| 65 | 113 | id: id, |
| 66 | created: Date(), | |
| 67 | updated: Date(), | |
| 114 | created: updated, | |
| 115 | updated: updated, | |
| 68 | 116 | status: status, |
| 69 | 117 | note: nil, |
| 70 | 118 | tags: tags, |
HutchTests/SettingsViewModelTests.swift +55
| @@ -2,6 +2,37 @@ import Foundation | ||
| 2 | 2 | import Testing |
| 3 | 3 | @testable import Hutch |
| 4 | 4 | |
| 5 | private final class SettingsViewModelCapturingURLProtocol: URLProtocol, @unchecked Sendable { | |
| 6 | nonisolated(unsafe) static var capturedRequests: [URLRequest] = [] | |
| 7 | ||
| 8 | override class func canInit(with _: URLRequest) -> Bool { true } | |
| 9 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } | |
| 10 | ||
| 11 | override func startLoading() { | |
| 12 | Self.capturedRequests.append(request) | |
| 13 | ||
| 14 | let response = HTTPURLResponse( | |
| 15 | url: request.url!, | |
| 16 | statusCode: 401, | |
| 17 | httpVersion: nil, | |
| 18 | headerFields: nil | |
| 19 | )! | |
| 20 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) | |
| 21 | client?.urlProtocol(self, didLoad: Data()) | |
| 22 | client?.urlProtocolDidFinishLoading(self) | |
| 23 | } | |
| 24 | ||
| 25 | override func stopLoading() { | |
| 26 | // No cleanup is needed because the stub responds immediately in `startLoading()`. | |
| 27 | } | |
| 28 | ||
| 29 | static func makeSession() -> URLSession { | |
| 30 | let config = URLSessionConfiguration.ephemeral | |
| 31 | config.protocolClasses = [Self.self] | |
| 32 | return URLSession(configuration: config) | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 5 | 36 | private struct DeletePGPKeyEnvelope: Decodable { |
| 6 | 37 | let deletePGPKey: DeleteResultPayload? |
| 7 | 38 | } |
| @@ -10,6 +41,7 @@ private struct DeleteResultPayload: Decodable { | ||
| 10 | 41 | let id: Int? |
| 11 | 42 | } |
| 12 | 43 | |
| 44 | @Suite(.serialized) | |
| 13 | 45 | struct SettingsViewModelTests { |
| 14 | 46 | |
| 15 | 47 | @Test |
| @@ -36,4 +68,27 @@ struct SettingsViewModelTests { | ||
| 36 | 68 | #expect(decoded.data?.deletePGPKey == nil) |
| 37 | 69 | #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true) |
| 38 | 70 | } |
| 71 | ||
| 72 | @Test | |
| 73 | @MainActor | |
| 74 | func loadProfileDoesNotRequestSSHKeyFingerprintField() async throws { | |
| 75 | SettingsViewModelCapturingURLProtocol.capturedRequests = [] | |
| 76 | ||
| 77 | let client = SRHTClient( | |
| 78 | session: SettingsViewModelCapturingURLProtocol.makeSession(), | |
| 79 | token: "test-token" | |
| 80 | ) | |
| 81 | let viewModel = SettingsViewModel(client: client) | |
| 82 | ||
| 83 | await viewModel.loadProfile() | |
| 84 | ||
| 85 | let request = try #require(SettingsViewModelCapturingURLProtocol.capturedRequests.first) | |
| 86 | let body = try #require(request.httpBody) | |
| 87 | let jsonObject = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) | |
| 88 | let query = try #require(jsonObject["query"] as? String) | |
| 89 | ||
| 90 | #expect(query.contains("sshKeys")) | |
| 91 | #expect(!query.contains("results { id fingerprint comment created lastUsed }")) | |
| 92 | #expect(!query.contains("fingerprint comment created lastUsed")) | |
| 93 | } | |
| 39 | 94 | } |