WIP: add structured clipboard search
This commit is contained in:
@@ -13,7 +13,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
|
|||||||
- `Command + ,` opens settings
|
- `Command + ,` opens settings
|
||||||
- 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, 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
|
- 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
|
- Custom named collections for organizing clips from the card context menu
|
||||||
- Copy and paste actions with Accessibility permission fallback
|
- Copy and paste actions with Accessibility permission fallback
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
toolbarIcon.widthAnchor.constraint(equalToConstant: 22).isActive = true
|
toolbarIcon.widthAnchor.constraint(equalToConstant: 22).isActive = true
|
||||||
toolbarIcon.heightAnchor.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.setAccessibilityLabel("Search clipboard history")
|
||||||
searchField.delegate = self
|
searchField.delegate = self
|
||||||
searchField.target = self
|
searchField.target = self
|
||||||
@@ -132,7 +132,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
searchField.sendsWholeSearchString = false
|
searchField.sendsWholeSearchString = false
|
||||||
searchField.isBezeled = true
|
searchField.isBezeled = true
|
||||||
searchField.placeholderAttributedString = NSAttributedString(
|
searchField.placeholderAttributedString = NSAttributedString(
|
||||||
string: "Search text, URLs, source app",
|
string: "Search clips",
|
||||||
attributes: [
|
attributes: [
|
||||||
.foregroundColor: NSColor.tertiaryLabelColor
|
.foregroundColor: NSColor.tertiaryLabelColor
|
||||||
]
|
]
|
||||||
@@ -140,7 +140,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
searchField.bezelStyle = .roundedBezel
|
searchField.bezelStyle = .roundedBezel
|
||||||
searchField.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.6)
|
searchField.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.6)
|
||||||
searchField.focusRingType = .none
|
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.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
searchField.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
searchField.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
searchField.widthAnchor.constraint(greaterThanOrEqualToConstant: 280).isActive = true
|
searchField.widthAnchor.constraint(greaterThanOrEqualToConstant: 280).isActive = true
|
||||||
|
|||||||
@@ -2,6 +2,35 @@ import Foundation
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
final class ClipboardPanelViewModel {
|
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<ClipboardItemKind> = []
|
||||||
|
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] = [] {
|
private(set) var visibleItems: [ClipboardItem] = [] {
|
||||||
didSet { notifyMain { self.onVisibleItemsChanged?(self.visibleItems) } }
|
didSet { notifyMain { self.onVisibleItemsChanged?(self.visibleItems) } }
|
||||||
}
|
}
|
||||||
@@ -470,12 +499,9 @@ final class ClipboardPanelViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func computeStackVisibleItems(from items: [ClipboardItem], query: String) -> [ClipboardItem] {
|
private func computeStackVisibleItems(from items: [ClipboardItem], query: String) -> [ClipboardItem] {
|
||||||
let tokens = searchTokens(from: query.lowercased())
|
let parsedQuery = parseSearchQuery(query)
|
||||||
guard !tokens.isEmpty else { return items }
|
guard !parsedQuery.isEmpty else { return items }
|
||||||
return items.filter { item in
|
return items.filter { matchesSearchQuery($0, query: parsedQuery) }
|
||||||
let text = searchableText(for: item)
|
|
||||||
return tokens.allSatisfy { text.contains($0) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal func computeVisibleItems(
|
internal func computeVisibleItems(
|
||||||
@@ -484,12 +510,11 @@ final class ClipboardPanelViewModel {
|
|||||||
sortMode: ClipboardSortMode,
|
sortMode: ClipboardSortMode,
|
||||||
collectionName: String? = nil
|
collectionName: String? = nil
|
||||||
) -> [ClipboardItem] {
|
) -> [ClipboardItem] {
|
||||||
let tokens = searchTokens(from: query.lowercased())
|
let parsedQuery = parseSearchQuery(query)
|
||||||
let filtered = tokens.isEmpty
|
let filtered = parsedQuery.isEmpty
|
||||||
? items.enumerated().map { ($0.offset, $0.element) }
|
? items.enumerated().map { ($0.offset, $0.element) }
|
||||||
: items.enumerated().compactMap { index, item in
|
: items.enumerated().compactMap { index, item in
|
||||||
let text = searchableText(for: item)
|
return matchesSearchQuery(item, query: parsedQuery) ? (index, item) : nil
|
||||||
return tokens.allSatisfy { text.contains($0) } ? (index, item) : nil
|
|
||||||
}
|
}
|
||||||
let collectionFiltered: [(Int, ClipboardItem)]
|
let collectionFiltered: [(Int, ClipboardItem)]
|
||||||
if let collectionName = ClipboardCollectionDefaults.normalizedName(collectionName) {
|
if let collectionName = ClipboardCollectionDefaults.normalizedName(collectionName) {
|
||||||
@@ -604,6 +629,156 @@ final class ClipboardPanelViewModel {
|
|||||||
return base
|
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[..<delimiter]).lowercased()
|
||||||
|
let value = String(part[part.index(after: delimiter)...]).clipboardTrimmed.lowercased()
|
||||||
|
guard !value.isEmpty, applyStructuredSearchToken(key: key, value: value, to: &parsed) else {
|
||||||
|
parsed.textTokens.append(contentsOf: searchTokens(from: part.lowercased()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
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))
|
||||||
|
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<ClipboardItemKind>? {
|
||||||
|
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] {
|
private func searchTokens(from query: String) -> [String] {
|
||||||
query
|
query
|
||||||
.split { character in
|
.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
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,97 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(result.map(\.displayText), ["GitHub release token"])
|
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() {
|
func testCollectionsFilterSearchAndPersistSelection() {
|
||||||
let settings = makeSettings()
|
let settings = makeSettings()
|
||||||
let cacheService = makeCacheService()
|
let cacheService = makeCacheService()
|
||||||
@@ -729,4 +820,13 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
|||||||
private func hash(_ value: String) -> String {
|
private func hash(_ value: String) -> String {
|
||||||
value
|
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)!
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user