WIP: add pinboard-style search filters

This commit is contained in:
Akshay Kolli
2026-06-30 04:15:38 -07:00
parent a0130752eb
commit bc7be9ea59
4 changed files with 171 additions and 29 deletions

View File

@@ -16,7 +16,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
- `Shift + Command + N` creates a new collection - `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 - 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 - 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 - 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 - 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 - Searchable custom titles for clips, so media, files, links, PDFs, audio, and text can be renamed without changing the copied payload

View File

@@ -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. 1. Open the panel and confirm the search field is focused.
2. Type a query and confirm results filter immediately. 2. Type a query and confirm results filter immediately.
3. Use arrow keys to move selection while the search field is focused. 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. 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. 4. Use arrow keys to move selection while the search field is focused.
5. Press `Esc` once with a non-empty search field and confirm search clears. 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` again and confirm the panel closes. 6. Press `Esc` once with a non-empty search field and confirm search clears.
7. Reopen the panel, change sort segments, and confirm each segment updates results. 7. Press `Esc` again and confirm the panel closes.
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. 8. Reopen the panel, change sort segments, and confirm each segment updates results.
9. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. 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. 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. 10. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
11. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. 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 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. 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. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. 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. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. 14. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
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. 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. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. 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 ## Copy And Paste

View File

@@ -13,8 +13,8 @@ final class ClipboardPanelViewModel {
private struct ParsedSearchQuery { private struct ParsedSearchQuery {
var textTokens: [String] = [] var textTokens: [String] = []
var appTokens: [String] = [] var appTokenGroups: [[String]] = []
var collectionTokens: [String] = [] var collectionTokenGroups: [[String]] = []
var typeKinds: Set<ClipboardItemKind> = [] var typeKinds: Set<ClipboardItemKind> = []
var createdAfter: Date? var createdAfter: Date?
var createdBefore: Date? var createdBefore: Date?
@@ -22,8 +22,8 @@ final class ClipboardPanelViewModel {
var isEmpty: Bool { var isEmpty: Bool {
textTokens.isEmpty textTokens.isEmpty
&& appTokens.isEmpty && appTokenGroups.isEmpty
&& collectionTokens.isEmpty && collectionTokenGroups.isEmpty
&& typeKinds.isEmpty && typeKinds.isEmpty
&& createdAfter == nil && createdAfter == nil
&& createdBefore == nil && createdBefore == nil
@@ -793,19 +793,23 @@ final class ClipboardPanelViewModel {
guard query.textTokens.allSatisfy({ text.contains($0) }) else { return false } guard query.textTokens.allSatisfy({ text.contains($0) }) else { return false }
} }
if !query.appTokens.isEmpty { if !query.appTokenGroups.isEmpty {
let source = [item.sourceApp, item.sourceAppBundleId] let source = [item.sourceApp, item.sourceAppBundleId]
.compactMap { $0?.lowercased() } .compactMap { $0?.lowercased() }
.joined(separator: " ") .joined(separator: " ")
guard !source.isEmpty, guard !source.isEmpty,
query.appTokens.allSatisfy({ source.contains($0) }) else { query.appTokenGroups.contains(where: { group in
group.allSatisfy { source.contains($0) }
}) else {
return false return false
} }
} }
if !query.collectionTokens.isEmpty { if !query.collectionTokenGroups.isEmpty {
guard let collection = item.collectionName?.lowercased(), 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 return false
} }
} }
@@ -831,7 +835,7 @@ final class ClipboardPanelViewModel {
private func parseSearchQuery(_ query: String) -> ParsedSearchQuery { private func parseSearchQuery(_ query: String) -> ParsedSearchQuery {
var parsed = 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 !part.isEmpty else { continue }
guard let delimiter = part.firstIndex(of: ":") else { guard let delimiter = part.firstIndex(of: ":") else {
parsed.textTokens.append(contentsOf: searchTokens(from: part.lowercased())) 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 { private func applyStructuredSearchToken(key: String, value: String, to query: inout ParsedSearchQuery) -> Bool {
switch key { switch key {
case "app", "source", "from": 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 return true
case "collection", "folder", "list": case "collection", "folder", "list", "pinboard", "pinboards", "board", "boards":
query.collectionTokens.append(contentsOf: searchTokens(from: value)) let groups = structuredTokenGroups(from: value)
guard !groups.isEmpty else { return false }
query.collectionTokenGroups.append(contentsOf: groups)
return true return true
case "type", "kind": case "type", "kind":
guard let kinds = itemKinds(matching: value), !kinds.isEmpty else { return false } var matchedKinds = Set<ClipboardItemKind>()
query.typeKinds.formUnion(kinds) 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 return true
case "pin", "pinned": case "pin", "pinned":
guard let pinned = booleanValue(from: value) else { return false } 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<ClipboardItemKind>? { private func itemKinds(matching value: String) -> Set<ClipboardItemKind>? {
switch value { switch value {
case "text", "plain": case "text", "plain":

View File

@@ -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() { func testStructuredSearchFiltersByCreatedDate() {
let settings = makeSettings() let settings = makeSettings()
let store = makeStore(settings: settings) let store = makeStore(settings: settings)