From bc7be9ea5995d1c6fd20572ddbdcc2b81ee5d260 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Tue, 30 Jun 2026 04:15:38 -0700 Subject: [PATCH] WIP: add pinboard-style search filters --- README.md | 2 +- docs/SMOKE_TEST.md | 29 +++--- .../views/ClipboardPanelViewModel.swift | 90 ++++++++++++++++--- .../ClipboardPanelViewModelTests.swift | 79 ++++++++++++++++ 4 files changed, 171 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 8e5dbc6..fbccfae 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca - `Shift + Command + N` creates a new collection - 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, structured filters such as `app:Safari`, `type:image`, `date:2026-06-30`, result jump-back to full history, and optional local OCR for copied images +- Search with independent token matching, structured filters such as `app:Safari`, `type:image,pdf`, `pinboard:"Client Work","Read Later"`, `date:2026-06-30`, result jump-back to full history, and optional local OCR for copied images - Sort modes for recent, most used, images, links, text, files, audio, and pinned items - Custom named collections, including empty color-coded collections, for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu - Searchable custom titles for clips, so media, files, links, PDFs, audio, and text can be renamed without changing the copied payload diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index dadef65..f355e92 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -33,20 +33,21 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 1. Open the panel and confirm the search field is focused. 2. Type a query and confirm results filter immediately. -3. Use arrow keys to move selection while the search field is focused. -4. Right-click a filtered result and choose Show in Clipboard, or press `Command + G`, and confirm search clears while the same card stays selected in Most Recent. -5. Press `Esc` once with a non-empty search field and confirm search clears. -6. Press `Esc` again and confirm the panel closes. -7. Reopen the panel, change sort segments, and confirm each segment updates results. -8. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view. -9. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. -10. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored. -11. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. -12. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload. -13. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. -14. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. -15. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected. -16. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. +3. Type a structured query such as `pinboard:"Client Work","Read Later" type:image,pdf` and confirm only clips from those collections and content types remain. +4. Use arrow keys to move selection while the search field is focused. +5. Right-click a filtered result and choose Show in Clipboard, or press `Command + G`, and confirm search clears while the same card stays selected in Most Recent. +6. Press `Esc` once with a non-empty search field and confirm search clears. +7. Press `Esc` again and confirm the panel closes. +8. Reopen the panel, change sort segments, and confirm each segment updates results. +9. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view. +10. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. +11. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored. +12. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. +13. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload. +14. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. +15. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. +16. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected. +17. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. ## Copy And Paste diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index c4f44ee..20f657e 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -13,8 +13,8 @@ final class ClipboardPanelViewModel { private struct ParsedSearchQuery { var textTokens: [String] = [] - var appTokens: [String] = [] - var collectionTokens: [String] = [] + var appTokenGroups: [[String]] = [] + var collectionTokenGroups: [[String]] = [] var typeKinds: Set = [] var createdAfter: Date? var createdBefore: Date? @@ -22,8 +22,8 @@ final class ClipboardPanelViewModel { var isEmpty: Bool { textTokens.isEmpty - && appTokens.isEmpty - && collectionTokens.isEmpty + && appTokenGroups.isEmpty + && collectionTokenGroups.isEmpty && typeKinds.isEmpty && createdAfter == nil && createdBefore == nil @@ -793,19 +793,23 @@ final class ClipboardPanelViewModel { guard query.textTokens.allSatisfy({ text.contains($0) }) else { return false } } - if !query.appTokens.isEmpty { + if !query.appTokenGroups.isEmpty { let source = [item.sourceApp, item.sourceAppBundleId] .compactMap { $0?.lowercased() } .joined(separator: " ") guard !source.isEmpty, - query.appTokens.allSatisfy({ source.contains($0) }) else { + query.appTokenGroups.contains(where: { group in + group.allSatisfy { source.contains($0) } + }) else { return false } } - if !query.collectionTokens.isEmpty { + if !query.collectionTokenGroups.isEmpty { guard let collection = item.collectionName?.lowercased(), - query.collectionTokens.allSatisfy({ collection.contains($0) }) else { + query.collectionTokenGroups.contains(where: { group in + group.allSatisfy { collection.contains($0) } + }) else { return false } } @@ -831,7 +835,7 @@ final class ClipboardPanelViewModel { private func parseSearchQuery(_ query: String) -> ParsedSearchQuery { var parsed = ParsedSearchQuery() - for part in query.split(whereSeparator: { $0.isWhitespace }).map(String.init) { + for part in searchParts(from: query) { guard !part.isEmpty else { continue } guard let delimiter = part.firstIndex(of: ":") else { parsed.textTokens.append(contentsOf: searchTokens(from: part.lowercased())) @@ -852,14 +856,23 @@ final class ClipboardPanelViewModel { private func applyStructuredSearchToken(key: String, value: String, to query: inout ParsedSearchQuery) -> Bool { switch key { case "app", "source", "from": - query.appTokens.append(contentsOf: searchTokens(from: value)) + let groups = structuredTokenGroups(from: value) + guard !groups.isEmpty else { return false } + query.appTokenGroups.append(contentsOf: groups) return true - case "collection", "folder", "list": - query.collectionTokens.append(contentsOf: searchTokens(from: value)) + case "collection", "folder", "list", "pinboard", "pinboards", "board", "boards": + let groups = structuredTokenGroups(from: value) + guard !groups.isEmpty else { return false } + query.collectionTokenGroups.append(contentsOf: groups) return true case "type", "kind": - guard let kinds = itemKinds(matching: value), !kinds.isEmpty else { return false } - query.typeKinds.formUnion(kinds) + var matchedKinds = Set() + for segment in structuredValueSegments(from: value) { + guard let kinds = itemKinds(matching: segment), !kinds.isEmpty else { return false } + matchedKinds.formUnion(kinds) + } + guard !matchedKinds.isEmpty else { return false } + query.typeKinds.formUnion(matchedKinds) return true case "pin", "pinned": guard let pinned = booleanValue(from: value) else { return false } @@ -886,6 +899,55 @@ final class ClipboardPanelViewModel { } } + private func searchParts(from query: String) -> [String] { + var parts: [String] = [] + var current = "" + var quotedBy: Character? + + func flushCurrent() { + let part = current.clipboardTrimmed + if !part.isEmpty { + parts.append(part) + } + current = "" + } + + for character in query { + if character == "\"" || character == "'" { + if quotedBy == character { + quotedBy = nil + continue + } + if quotedBy == nil { + quotedBy = character + continue + } + } + + if character.isWhitespace && quotedBy == nil { + flushCurrent() + } else { + current.append(character) + } + } + + flushCurrent() + return parts + } + + private func structuredValueSegments(from value: String) -> [String] { + value + .split(separator: ",") + .map { String($0).clipboardTrimmed.lowercased() } + .filter { !$0.isEmpty } + } + + private func structuredTokenGroups(from value: String) -> [[String]] { + structuredValueSegments(from: value) + .map { searchTokens(from: $0) } + .filter { !$0.isEmpty } + } + private func itemKinds(matching value: String) -> Set? { switch value { case "text", "plain": diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 40668b1..046e858 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -145,6 +145,85 @@ final class ClipboardPanelViewModelTests: XCTestCase { ) } + func testStructuredSearchSupportsQuotedPinboardAndMultiValueFilters() { + 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: "Launch link", + payload: "https://example.com/launch", + payloadHash: hash("launch-link"), + createdAt: Date(timeIntervalSince1970: 100), + lastUsedAt: Date(timeIntervalSince1970: 100), + useCount: 0, + sourceApp: "Safari", + imagePath: nil, + thumbnailPath: nil, + collectionName: "Useful Links" + ), + ClipboardItem( + id: UUID(), + kind: .image, + displayText: "Moodboard", + payload: "/tmp/moodboard.png", + payloadHash: hash("moodboard"), + createdAt: Date(timeIntervalSince1970: 300), + lastUsedAt: Date(timeIntervalSince1970: 300), + useCount: 0, + sourceApp: "Photos", + imagePath: nil, + thumbnailPath: nil, + collectionName: "Read Later" + ), + ClipboardItem( + id: UUID(), + kind: .file, + displayText: "Launch brief", + payload: "/tmp/brief.pdf", + payloadHash: hash("launch-brief"), + createdAt: Date(timeIntervalSince1970: 200), + lastUsedAt: Date(timeIntervalSince1970: 200), + useCount: 0, + sourceApp: "Finder", + imagePath: nil, + thumbnailPath: nil, + collectionName: "Client Work" + ), + ClipboardItem( + id: UUID(), + kind: .text, + displayText: "Standalone note", + payload: "outside", + payloadHash: hash("outside"), + createdAt: Date(timeIntervalSince1970: 400), + lastUsedAt: Date(timeIntervalSince1970: 400), + useCount: 0, + sourceApp: "Notes", + imagePath: nil, + thumbnailPath: nil + ) + ] + + XCTAssertEqual( + viewModel.computeVisibleItems(from: items, query: "pinboard:\"Client Work\"", sortMode: .mostRecent).map(\.displayText), + ["Launch brief"] + ) + XCTAssertEqual( + viewModel.computeVisibleItems(from: items, query: "pinboard:\"Useful Links\",\"Read Later\"", sortMode: .mostRecent).map(\.displayText), + ["Moodboard", "Launch link"] + ) + XCTAssertEqual( + viewModel.computeVisibleItems(from: items, query: "board:\"Client Work\" type:file,pdf", sortMode: .mostRecent).map(\.displayText), + ["Launch brief"] + ) + XCTAssertTrue( + viewModel.computeVisibleItems(from: items, query: "pinboard:\"Client Work\" type:image", sortMode: .mostRecent).isEmpty + ) + } + func testStructuredSearchFiltersByCreatedDate() { let settings = makeSettings() let store = makeStore(settings: settings)