WIP: add searchable clip titles
This commit is contained in:
@@ -114,9 +114,13 @@ struct ClipboardItem {
|
||||
var sourceAppBundleId: String?
|
||||
var ocrText: String?
|
||||
var collectionName: String?
|
||||
var customTitle: String?
|
||||
|
||||
var searchableText: String {
|
||||
var text = kindLabel + " " + displayText.lowercased() + " " + payload.lowercased()
|
||||
if let customTitle {
|
||||
text += " " + customTitle.lowercased()
|
||||
}
|
||||
if let sourceApp {
|
||||
text += " " + sourceApp.lowercased()
|
||||
}
|
||||
@@ -160,7 +164,8 @@ struct ClipboardItem {
|
||||
isPinned: Bool = false,
|
||||
sourceAppBundleId: String? = nil,
|
||||
ocrText: String? = nil,
|
||||
collectionName: String? = nil
|
||||
collectionName: String? = nil,
|
||||
customTitle: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
@@ -177,5 +182,16 @@ struct ClipboardItem {
|
||||
self.sourceAppBundleId = sourceAppBundleId
|
||||
self.ocrText = ocrText
|
||||
self.collectionName = collectionName
|
||||
self.customTitle = ClipboardItem.normalizedCustomTitle(customTitle)
|
||||
}
|
||||
|
||||
static func normalizedCustomTitle(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let title = value
|
||||
.split { $0.isWhitespace }
|
||||
.joined(separator: " ")
|
||||
.clipboardTrimmed
|
||||
guard !title.isEmpty else { return nil }
|
||||
return String(title.prefix(80))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,12 @@ final class ClipboardStore {
|
||||
persistAsync(.upsert(items[index]))
|
||||
}
|
||||
|
||||
func setCustomTitle(_ id: UUID, title: String?) {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
||||
items[index].customTitle = ClipboardItem.normalizedCustomTitle(title)
|
||||
persistAsync(.upsert(items[index]))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateText(_ id: UUID, text: String) -> Bool {
|
||||
guard !text.isEmpty,
|
||||
@@ -209,6 +215,7 @@ final class ClipboardStore {
|
||||
}
|
||||
existing.sourceApp = incoming.sourceApp
|
||||
existing.sourceAppBundleId = incoming.sourceAppBundleId
|
||||
existing.customTitle = incoming.customTitle ?? existing.customTitle
|
||||
items.insert(existing, at: 0)
|
||||
normalizeHistoryLength()
|
||||
persistAsync(.upsert(existing), purgeCache: existing.kind == .image)
|
||||
@@ -227,6 +234,7 @@ final class ClipboardStore {
|
||||
existing.kind = incoming.kind
|
||||
existing.sourceApp = incoming.sourceApp
|
||||
existing.sourceAppBundleId = incoming.sourceAppBundleId
|
||||
existing.customTitle = incoming.customTitle ?? existing.customTitle
|
||||
|
||||
if incoming.kind == .image || incoming.kind == .url {
|
||||
existing.imagePath = incoming.imagePath
|
||||
@@ -332,7 +340,8 @@ final class ClipboardStore {
|
||||
thumbnail_path TEXT,
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||||
ocr_text TEXT,
|
||||
collection_name TEXT
|
||||
collection_name TEXT,
|
||||
custom_title TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
@@ -347,6 +356,7 @@ final class ClipboardStore {
|
||||
|
||||
_ = execute(createTable)
|
||||
_ = execute("ALTER TABLE clipboard_items ADD COLUMN collection_name TEXT;")
|
||||
_ = execute("ALTER TABLE clipboard_items ADD COLUMN custom_title TEXT;")
|
||||
_ = execute(createIndexes)
|
||||
}
|
||||
|
||||
@@ -411,7 +421,8 @@ final class ClipboardStore {
|
||||
isPinned: row["isPinned"] as? Bool ?? false,
|
||||
sourceAppBundleId: row["sourceAppBundleId"] as? String,
|
||||
ocrText: row["ocrText"] as? String,
|
||||
collectionName: row["collectionName"] as? String
|
||||
collectionName: row["collectionName"] as? String,
|
||||
customTitle: row["customTitle"] as? String
|
||||
)
|
||||
}
|
||||
|
||||
@@ -523,7 +534,8 @@ final class ClipboardStore {
|
||||
SELECT
|
||||
id, kind, display_text, payload, payload_hash, created_at,
|
||||
last_used_at, use_count, source_app, source_app_bundle_id,
|
||||
image_path, thumbnail_path, is_pinned, ocr_text, collection_name
|
||||
image_path, thumbnail_path, is_pinned, ocr_text, collection_name,
|
||||
custom_title
|
||||
FROM clipboard_items
|
||||
ORDER BY created_at DESC, last_used_at DESC
|
||||
"""
|
||||
@@ -604,6 +616,7 @@ final class ClipboardStore {
|
||||
let isPinned = sqlite3_column_int(statement, 12) != 0
|
||||
let ocrTextValue = stringValue(13)
|
||||
let collectionNameValue = stringValue(14)
|
||||
let customTitleValue = stringValue(15)
|
||||
|
||||
needsEncryptionMigration = needsEncryptionMigration
|
||||
|| sourceAppValue.migrationNeeded
|
||||
@@ -612,6 +625,7 @@ final class ClipboardStore {
|
||||
|| thumbnailPathValue.migrationNeeded
|
||||
|| ocrTextValue.migrationNeeded
|
||||
|| collectionNameValue.migrationNeeded
|
||||
|| customTitleValue.migrationNeeded
|
||||
hadDecodeFailure = hadDecodeFailure
|
||||
|| sourceAppValue.decodeFailed
|
||||
|| sourceAppBundleIdValue.decodeFailed
|
||||
@@ -619,6 +633,7 @@ final class ClipboardStore {
|
||||
|| thumbnailPathValue.decodeFailed
|
||||
|| ocrTextValue.decodeFailed
|
||||
|| collectionNameValue.decodeFailed
|
||||
|| customTitleValue.decodeFailed
|
||||
|
||||
loaded.append(
|
||||
ClipboardItem(
|
||||
@@ -636,7 +651,8 @@ final class ClipboardStore {
|
||||
isPinned: isPinned,
|
||||
sourceAppBundleId: sourceAppBundleIdValue.value,
|
||||
ocrText: ocrTextValue.value,
|
||||
collectionName: collectionNameValue.value
|
||||
collectionName: collectionNameValue.value,
|
||||
customTitle: customTitleValue.value
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -668,8 +684,8 @@ final class ClipboardStore {
|
||||
id, kind, display_text, payload, payload_hash,
|
||||
created_at, last_used_at, use_count, source_app,
|
||||
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
|
||||
collection_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
collection_name, custom_title
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
"""
|
||||
|
||||
switch mutation {
|
||||
@@ -768,8 +784,8 @@ final class ClipboardStore {
|
||||
id, kind, display_text, payload, payload_hash,
|
||||
created_at, last_used_at, use_count, source_app,
|
||||
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
|
||||
collection_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
collection_name, custom_title
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
"""
|
||||
|
||||
guard execute("BEGIN IMMEDIATE TRANSACTION;") else {
|
||||
@@ -853,5 +869,6 @@ final class ClipboardStore {
|
||||
sqlite3_bind_int(statement, 13, item.isPinned ? 1 : 0)
|
||||
bindText(statement, 14, encryptionService.protect(item.ocrText))
|
||||
bindText(statement, 15, encryptionService.protect(item.collectionName))
|
||||
bindText(statement, 16, encryptionService.protect(item.customTitle))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,6 +754,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
card.onShowInClipboard = { [weak self] selected in
|
||||
self?.showSelectedInClipboard(at: selected)
|
||||
}
|
||||
card.onRename = { [weak self] selected in
|
||||
self?.renameClip(at: selected)
|
||||
}
|
||||
card.onEditText = { [weak self] selected in
|
||||
self?.editText(at: selected)
|
||||
}
|
||||
@@ -902,7 +905,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
if lower.hasPrefix("captured") || lower.contains("capture running") || lower.contains("capture is running") || lower.contains("capture resumed") {
|
||||
return .ready
|
||||
}
|
||||
if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("added") || lower.hasPrefix("created") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") || lower.hasPrefix("showing") {
|
||||
if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("renamed") || lower.hasPrefix("added") || lower.hasPrefix("created") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") || lower.hasPrefix("showing") {
|
||||
return .action
|
||||
}
|
||||
if lower.hasPrefix("error") || lower.contains("failed") {
|
||||
@@ -960,6 +963,26 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
viewModel.updateSelectedText(to: textView.string)
|
||||
}
|
||||
|
||||
private func renameClip(at index: Int) {
|
||||
viewModel.selectItem(at: index)
|
||||
guard let currentTitle = viewModel.editableTitleForSelected() else { return }
|
||||
|
||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24))
|
||||
input.placeholderString = "Clip title"
|
||||
input.stringValue = currentTitle
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Rename Clip"
|
||||
alert.informativeText = "Give this clip a searchable title. Leave it blank to clear the title."
|
||||
alert.accessoryView = input
|
||||
alert.addButton(withTitle: "Save")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.window.initialFirstResponder = input
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
viewModel.updateSelectedTitle(to: input.stringValue)
|
||||
}
|
||||
|
||||
private func emptyStateView() -> NSView {
|
||||
let width = max(cardDensity.emptyStateMinimumWidth, scrollView.contentView.bounds.width)
|
||||
let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: cardDensity.railHeight))
|
||||
@@ -1365,6 +1388,11 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
viewModel.deleteCollection(named: collectionName)
|
||||
}
|
||||
|
||||
func debugRenameFirstCard(to title: String) {
|
||||
viewModel.selectItem(at: 0)
|
||||
viewModel.updateSelectedTitle(to: title)
|
||||
}
|
||||
|
||||
func debugShowFirstCardInClipboard() {
|
||||
showSelectedInClipboard(at: 0)
|
||||
}
|
||||
@@ -1859,6 +1887,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
var onCopyStackNext: () -> Void = {}
|
||||
var onClearStack: () -> Void = {}
|
||||
var onShowInClipboard: (Int) -> Void = { _ in }
|
||||
var onRename: (Int) -> Void = { _ in }
|
||||
var onEditText: (Int) -> Void = { _ in }
|
||||
var onPreview: (Int) -> Void = { _ in }
|
||||
var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] }
|
||||
@@ -2129,6 +2158,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
if canShowInClipboard {
|
||||
addMenuItem("Show in Clipboard", action: #selector(showInClipboardFromMenu), to: menu)
|
||||
}
|
||||
addMenuItem("Rename...", action: #selector(renameFromMenu), to: menu)
|
||||
addMenuItem(itemIsStacked ? "Remove from Stack" : "Add to Stack", action: #selector(toggleStackFromMenu), to: menu)
|
||||
if stackCount > 0 {
|
||||
addMenuItem("Paste Stack Next", action: #selector(pasteStackNextFromMenu), to: menu)
|
||||
@@ -2431,6 +2461,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
onShowInClipboard(index)
|
||||
}
|
||||
|
||||
@objc private func renameFromMenu() {
|
||||
onRename(index)
|
||||
}
|
||||
|
||||
@objc private func editTextFromMenu() {
|
||||
onEditText(index)
|
||||
}
|
||||
@@ -3226,6 +3260,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
}
|
||||
|
||||
private func titleText(for item: ClipboardItem) -> String {
|
||||
if let customTitle = item.customTitle?.clipboardTrimmed, !customTitle.isEmpty {
|
||||
return customTitle
|
||||
}
|
||||
|
||||
switch item.kind {
|
||||
case .url:
|
||||
return linkTitle(for: item)
|
||||
|
||||
@@ -358,6 +358,24 @@ final class ClipboardPanelViewModel {
|
||||
return item.payload
|
||||
}
|
||||
|
||||
func editableTitleForSelected() -> String? {
|
||||
guard let item = selectedItem else { return nil }
|
||||
return item.customTitle ?? ""
|
||||
}
|
||||
|
||||
func updateSelectedTitle(to title: String) {
|
||||
guard let item = selectedItem else { return }
|
||||
let normalizedTitle = ClipboardItem.normalizedCustomTitle(title)
|
||||
guard item.customTitle != normalizedTitle else {
|
||||
statusMessage = "No changes"
|
||||
return
|
||||
}
|
||||
|
||||
selectedItemID = item.id
|
||||
store.setCustomTitle(item.id, title: normalizedTitle)
|
||||
statusMessage = normalizedTitle == nil ? "Cleared clip title" : "Renamed clip"
|
||||
}
|
||||
|
||||
func updateSelectedText(to text: String) {
|
||||
guard let item = selectedItem, item.kind == .text else { return }
|
||||
let trimmed = text.clipboardTrimmed
|
||||
|
||||
Reference in New Issue
Block a user