WIP: add pinboard-style search filters
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user