From 158086fdc999ad23eabd286e8bd03b2d7cea7434 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Tue, 30 Jun 2026 02:32:08 -0700 Subject: [PATCH] WIP: add structured clipboard search --- README.md | 2 +- .../clipbored/views/ClipboardPanelView.swift | 6 +- .../views/ClipboardPanelViewModel.swift | 204 +++++++++++++++++- .../ClipboardPanelViewModelTests.swift | 100 +++++++++ 4 files changed, 298 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3d67b71..a72230b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca - `Command + ,` opens settings - Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references - SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads -- Search with independent token matching, plus optional local OCR for copied images +- Search with independent token matching, structured filters such as `app:Safari`, `type:image`, `date:2026-06-30`, and optional local OCR for copied images - Sort modes for recent, most used, images, links, text, files, audio, and pinned items - Custom named collections for organizing clips from the card context menu - Copy and paste actions with Accessibility permission fallback diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index b76fdc9..b1b2c22 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -123,7 +123,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { toolbarIcon.widthAnchor.constraint(equalToConstant: 22).isActive = true toolbarIcon.heightAnchor.constraint(equalToConstant: 22).isActive = true - searchField.placeholderString = "Search text, URLs, source app" + searchField.placeholderString = "Search clips" searchField.setAccessibilityLabel("Search clipboard history") searchField.delegate = self searchField.target = self @@ -132,7 +132,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { searchField.sendsWholeSearchString = false searchField.isBezeled = true searchField.placeholderAttributedString = NSAttributedString( - string: "Search text, URLs, source app", + string: "Search clips", attributes: [ .foregroundColor: NSColor.tertiaryLabelColor ] @@ -140,7 +140,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { searchField.bezelStyle = .roundedBezel searchField.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.6) searchField.focusRingType = .none - searchField.toolTip = "Search clipboard history" + searchField.toolTip = "Search clipboard history. Supports app:Safari, type:image, date:2026-06-30, after:2026-06-01, and pinned:on." searchField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) searchField.setContentHuggingPriority(.defaultLow, for: .horizontal) searchField.widthAnchor.constraint(greaterThanOrEqualToConstant: 280).isActive = true diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 87a5996..41b66d3 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -2,6 +2,35 @@ import Foundation import AppKit final class ClipboardPanelViewModel { + private static let searchDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar.searchCalendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private struct ParsedSearchQuery { + var textTokens: [String] = [] + var appTokens: [String] = [] + var collectionTokens: [String] = [] + var typeKinds: Set = [] + var createdAfter: Date? + var createdBefore: Date? + var pinned: Bool? + + var isEmpty: Bool { + textTokens.isEmpty + && appTokens.isEmpty + && collectionTokens.isEmpty + && typeKinds.isEmpty + && createdAfter == nil + && createdBefore == nil + && pinned == nil + } + } + private(set) var visibleItems: [ClipboardItem] = [] { didSet { notifyMain { self.onVisibleItemsChanged?(self.visibleItems) } } } @@ -470,12 +499,9 @@ final class ClipboardPanelViewModel { } private func computeStackVisibleItems(from items: [ClipboardItem], query: String) -> [ClipboardItem] { - let tokens = searchTokens(from: query.lowercased()) - guard !tokens.isEmpty else { return items } - return items.filter { item in - let text = searchableText(for: item) - return tokens.allSatisfy { text.contains($0) } - } + let parsedQuery = parseSearchQuery(query) + guard !parsedQuery.isEmpty else { return items } + return items.filter { matchesSearchQuery($0, query: parsedQuery) } } internal func computeVisibleItems( @@ -484,12 +510,11 @@ final class ClipboardPanelViewModel { sortMode: ClipboardSortMode, collectionName: String? = nil ) -> [ClipboardItem] { - let tokens = searchTokens(from: query.lowercased()) - let filtered = tokens.isEmpty + let parsedQuery = parseSearchQuery(query) + let filtered = parsedQuery.isEmpty ? items.enumerated().map { ($0.offset, $0.element) } : items.enumerated().compactMap { index, item in - let text = searchableText(for: item) - return tokens.allSatisfy { text.contains($0) } ? (index, item) : nil + return matchesSearchQuery(item, query: parsedQuery) ? (index, item) : nil } let collectionFiltered: [(Int, ClipboardItem)] if let collectionName = ClipboardCollectionDefaults.normalizedName(collectionName) { @@ -604,6 +629,156 @@ final class ClipboardPanelViewModel { return base } + private func matchesSearchQuery(_ item: ClipboardItem, query: ParsedSearchQuery) -> Bool { + if !query.textTokens.isEmpty { + let text = searchableText(for: item) + guard query.textTokens.allSatisfy({ text.contains($0) }) else { return false } + } + + if !query.appTokens.isEmpty { + let source = [item.sourceApp, item.sourceAppBundleId] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + guard !source.isEmpty, + query.appTokens.allSatisfy({ source.contains($0) }) else { + return false + } + } + + if !query.collectionTokens.isEmpty { + guard let collection = item.collectionName?.lowercased(), + query.collectionTokens.allSatisfy({ collection.contains($0) }) else { + return false + } + } + + if !query.typeKinds.isEmpty, !query.typeKinds.contains(item.kind) { + return false + } + + if let pinned = query.pinned, item.isPinned != pinned { + return false + } + + if let createdAfter = query.createdAfter, item.createdAt < createdAfter { + return false + } + + if let createdBefore = query.createdBefore, item.createdAt >= createdBefore { + return false + } + + return true + } + + private func parseSearchQuery(_ query: String) -> ParsedSearchQuery { + var parsed = ParsedSearchQuery() + for part in query.split(whereSeparator: { $0.isWhitespace }).map(String.init) { + guard !part.isEmpty else { continue } + guard let delimiter = part.firstIndex(of: ":") else { + parsed.textTokens.append(contentsOf: searchTokens(from: part.lowercased())) + continue + } + + let key = String(part[.. Bool { + switch key { + case "app", "source", "from": + query.appTokens.append(contentsOf: searchTokens(from: value)) + return true + case "collection", "folder", "list": + query.collectionTokens.append(contentsOf: searchTokens(from: value)) + return true + case "type", "kind": + guard let kinds = itemKinds(matching: value), !kinds.isEmpty else { return false } + query.typeKinds.formUnion(kinds) + return true + case "pin", "pinned": + guard let pinned = booleanValue(from: value) else { return false } + query.pinned = pinned + return true + case "after", "since": + guard let start = startOfDay(from: value) else { return false } + query.createdAfter = maxDate(query.createdAfter, start) + return true + case "before", "until": + guard let start = startOfDay(from: value) else { return false } + query.createdBefore = minDate(query.createdBefore, start) + return true + case "on", "date": + guard let start = startOfDay(from: value), + let end = Calendar.searchCalendar.date(byAdding: .day, value: 1, to: start) else { + return false + } + query.createdAfter = maxDate(query.createdAfter, start) + query.createdBefore = minDate(query.createdBefore, end) + return true + default: + return false + } + } + + private func itemKinds(matching value: String) -> Set? { + switch value { + case "text", "plain": + return [.text] + case "richtext", "rich-text", "rtf", "html": + return [.richText] + case "note", "notes", "writing": + return [.text, .richText] + case "link", "links", "url", "urls", "web": + return [.url] + case "image", "images", "photo", "photos", "picture", "pictures": + return [.image] + case "file", "files", "finder": + return [.file, .pdf] + case "pdf", "pdfs", "document", "documents": + return [.pdf] + case "audio", "sound", "music": + return [.audio] + case "unknown", "item": + return [.unknown] + default: + return nil + } + } + + private func booleanValue(from value: String) -> Bool? { + switch value { + case "1", "true", "yes", "y", "on": + return true + case "0", "false", "no", "n", "off": + return false + default: + return nil + } + } + + private func startOfDay(from value: String) -> Date? { + guard let date = Self.searchDateFormatter.date(from: value) else { return nil } + return Calendar.searchCalendar.startOfDay(for: date) + } + + private func maxDate(_ lhs: Date?, _ rhs: Date) -> Date { + guard let lhs else { return rhs } + return max(lhs, rhs) + } + + private func minDate(_ lhs: Date?, _ rhs: Date) -> Date { + guard let lhs else { return rhs } + return min(lhs, rhs) + } + private func searchTokens(from query: String) -> [String] { query .split { character in @@ -620,3 +795,12 @@ final class ClipboardPanelViewModel { } } } + +private extension Calendar { + static let searchCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + return calendar + }() +} diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 113cef6..532fa9d 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -77,6 +77,97 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(result.map(\.displayText), ["GitHub release token"]) } + func testStructuredSearchFiltersBySourceTypeCollectionAndPinState() { + let settings = makeSettings() + let store = makeStore(settings: settings) + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService()) + let items = [ + ClipboardItem( + id: UUID(), + kind: .url, + displayText: "Release notes", + payload: "https://example.com/releases", + payloadHash: hash("release"), + createdAt: Date(timeIntervalSince1970: 200), + lastUsedAt: Date(timeIntervalSince1970: 200), + useCount: 0, + sourceApp: "Safari", + imagePath: nil, + thumbnailPath: nil, + sourceAppBundleId: "com.apple.Safari", + collectionName: "Useful Links" + ), + ClipboardItem( + id: UUID(), + kind: .image, + displayText: "Campaign portrait", + payload: "/tmp/campaign.png", + payloadHash: hash("campaign"), + createdAt: Date(timeIntervalSince1970: 300), + lastUsedAt: Date(timeIntervalSince1970: 300), + useCount: 0, + sourceApp: "Photos", + imagePath: nil, + thumbnailPath: nil, + isPinned: true, + sourceAppBundleId: "com.apple.Photos", + collectionName: "Visual References" + ), + ClipboardItem( + id: UUID(), + kind: .text, + displayText: "Meeting note", + payload: "Budget follow-up", + payloadHash: hash("meeting"), + createdAt: Date(timeIntervalSince1970: 100), + lastUsedAt: Date(timeIntervalSince1970: 100), + useCount: 0, + sourceApp: "Notes", + imagePath: nil, + thumbnailPath: nil + ) + ] + + XCTAssertEqual( + viewModel.computeVisibleItems(from: items, query: "app:safari type:link", sortMode: .mostRecent).map(\.displayText), + ["Release notes"] + ) + XCTAssertEqual( + viewModel.computeVisibleItems(from: items, query: "app:apple.photos type:photo pinned:on", sortMode: .mostRecent).map(\.displayText), + ["Campaign portrait"] + ) + XCTAssertEqual( + viewModel.computeVisibleItems(from: items, query: "collection:visual type:image", sortMode: .mostRecent).map(\.displayText), + ["Campaign portrait"] + ) + XCTAssertTrue( + viewModel.computeVisibleItems(from: items, query: "app:safari type:image", sortMode: .mostRecent).isEmpty + ) + } + + func testStructuredSearchFiltersByCreatedDate() { + let settings = makeSettings() + let store = makeStore(settings: settings) + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService()) + let first = makeTextItem("June twenty nine", createdAt: isoDate("2026-06-29")) + let second = makeTextItem("June thirty", createdAt: isoDate("2026-06-30")) + let third = makeTextItem("July first", createdAt: isoDate("2026-07-01")) + let items = [first, second, third] + + XCTAssertEqual( + viewModel.computeVisibleItems(from: items, query: "date:2026-06-30", sortMode: .mostRecent).map(\.payload), + ["June thirty"] + ) + XCTAssertEqual( + viewModel.computeVisibleItems(from: items, query: "after:2026-06-30", sortMode: .mostRecent).map(\.payload), + ["July first", "June thirty"] + ) + XCTAssertEqual( + viewModel.computeVisibleItems(from: items, query: "before:2026-06-30", sortMode: .mostRecent).map(\.payload), + ["June twenty nine"] + ) + } + func testCollectionsFilterSearchAndPersistSelection() { let settings = makeSettings() let cacheService = makeCacheService() @@ -729,4 +820,13 @@ final class ClipboardPanelViewModelTests: XCTestCase { private func hash(_ value: String) -> String { value } + + private func isoDate(_ value: String) -> Date { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: value)! + } }