diff --git a/README.md b/README.md index 84de7da..39451d4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca - Custom named collections for organizing clips from the card context menu - Copy and paste actions with Accessibility permission fallback - Image thumbnail cache with byte and file-count pruning -- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior +- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type - Local-only storage, with optional sensitive-content exclusion for common secrets ## Requirements diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 0916e85..3c17161 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -41,6 +41,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 8. Right-click another card and confirm Add to Collection offers Client Work as a reusable destination. 9. Select the Client Work chip and confirm the rail filters to assigned items; quit and reopen ClipBored and confirm the assignment persists. 10. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. +11. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. ## Copy And Paste diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 80b114a..89acb73 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -595,6 +595,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { self?.viewModel.selectItem(at: selected) self?.viewModel.assignSelected(to: collectionName) } + card.onIgnoreSourceApp = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.viewModel.ignoreSelectedSourceApp() + } + card.onIgnoreKind = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.viewModel.ignoreSelectedKind() + } card.onDelete = { [weak self] selected in self?.viewModel.selectItem(at: selected) self?.viewModel.deleteSelected() @@ -710,7 +718,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("removed") || lower.hasPrefix("cleared") { + if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("added") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") { return .action } if lower.hasPrefix("error") || lower.contains("failed") { @@ -1014,6 +1022,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardViews.first?.debugCollectionMenuTitles ?? [] } + var debugFirstCardCaptureRuleMenuTitles: [String] { + cardViews.first?.debugCaptureRuleMenuTitles ?? [] + } + var debugFirstCardVisibleActionLabels: [String] { cardViews.first?.debugVisibleActionLabels ?? [] } @@ -1388,6 +1400,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { var onReveal: (Int) -> Void = { _ in } var onTogglePin: (Int) -> Void = { _ in } var onAssignCollection: (Int, String?) -> Void = { _, _ in } + var onIgnoreSourceApp: (Int) -> Void = { _ in } + var onIgnoreKind: (Int) -> Void = { _ in } var onDelete: (Int) -> Void = { _ in } private let index: Int @@ -1395,6 +1409,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private let itemIsPinned: Bool private let itemIsStacked: Bool private let stackCount: Int + private let itemSourceAppName: String? + private let itemSourceAppBundleID: String? private let itemCollectionName: String? private let collectionNames: [String] private let contentView = NSView() @@ -1422,6 +1438,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { self.itemIsPinned = item.isPinned self.itemIsStacked = isStacked self.stackCount = stackCount + self.itemSourceAppName = Self.presentSourceText(item.sourceApp) + self.itemSourceAppBundleID = Self.presentSourceText(item.sourceAppBundleId) self.itemCollectionName = ClipboardCollectionDefaults.normalizedName(item.collectionName) self.collectionNames = collectionNames.compactMap { ClipboardCollectionDefaults.normalizedName($0) } super.init(frame: .zero) @@ -1551,6 +1569,13 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return collectionMenu.items.map { $0.isSeparatorItem ? "-" : $0.title } } + var debugCaptureRuleMenuTitles: [String] { + guard let rulesMenu = contextMenu().items.first(where: { $0.title == "Capture Rules" })?.submenu else { + return [] + } + return rulesMenu.items.map { $0.isSeparatorItem ? "-" : $0.title } + } + var debugVisibleActionLabels: [String] { actionRail.isHidden ? [] : actionRailButtons.map { $0.toolTip ?? "" } } @@ -1598,6 +1623,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } addMenuItem(itemIsPinned ? "Unpin" : "Pin", action: #selector(togglePinFromMenu), to: menu) addCollectionMenu(to: menu) + addCaptureRulesMenu(to: menu) menu.addItem(NSMenuItem.separator()) let open = addMenuItem("Open", action: #selector(openFromMenu), to: menu) open.isEnabled = canOpen @@ -1639,6 +1665,48 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { menu.setSubmenu(submenu, for: parent) } + private func addCaptureRulesMenu(to menu: NSMenu) { + let parent = NSMenuItem(title: "Capture Rules", action: nil, keyEquivalent: "") + let submenu = NSMenu(title: "Capture Rules") + submenu.autoenablesItems = false + + let ignoreSource = NSMenuItem( + title: ignoreSourceTitle(), + action: #selector(ignoreSourceAppFromMenu), + keyEquivalent: "" + ) + ignoreSource.target = self + ignoreSource.isEnabled = itemSourceAppName != nil || itemSourceAppBundleID != nil + submenu.addItem(ignoreSource) + + let ignoreKind = NSMenuItem( + title: "Ignore \(kindLabel(for: itemKind)) Items", + action: #selector(ignoreKindFromMenu), + keyEquivalent: "" + ) + ignoreKind.target = self + ignoreKind.isEnabled = true + submenu.addItem(ignoreKind) + + menu.addItem(parent) + menu.setSubmenu(submenu, for: parent) + } + + private func ignoreSourceTitle() -> String { + if let itemSourceAppName { + return "Ignore \(itemSourceAppName)" + } + if let itemSourceAppBundleID { + return "Ignore \(itemSourceAppBundleID)" + } + return "Ignore Source App" + } + + private static func presentSourceText(_ value: String?) -> String? { + guard let text = value?.clipboardTrimmed, !text.isEmpty else { return nil } + return text + } + private func availableCollectionNames() -> [String] { var names: [String] = [] var seen = Set() @@ -1876,6 +1944,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onAssignCollection(index, nil) } + @objc private func ignoreSourceAppFromMenu() { + onIgnoreSourceApp(index) + } + + @objc private func ignoreKindFromMenu() { + onIgnoreKind(index) + } + @objc private func deleteFromMenu() { onDelete(index) } diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 3af3c5d..ef82b52 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -426,6 +426,34 @@ final class ClipboardPanelViewModel { } } + func ignoreSelectedSourceApp() { + guard let item = selectedItem else { return } + guard let rule = sourceIgnoreRule(for: item) else { + statusMessage = "Source app unavailable" + return + } + + let existing = settings.ignoredApps.map { $0.clipboardTrimmed.lowercased() } + guard !existing.contains(rule.value.lowercased()) else { + statusMessage = "\(rule.displayName) is already ignored" + return + } + + settings.ignoredApps.append(rule.value) + statusMessage = "Ignored \(rule.displayName) for future captures" + } + + func ignoreSelectedKind() { + guard let item = selectedItem else { return } + guard !settings.ignoredItemKindsRaw.contains(item.kind.rawValue) else { + statusMessage = "\(Self.statusKindName(item.kind)) items are already ignored" + return + } + + settings.ignoredItemKindsRaw.append(item.kind.rawValue) + statusMessage = "Ignored \(Self.statusKindName(item.kind)) items for future captures" + } + func selectCollection(named name: String) { guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return } isStackFilterSelected = false @@ -791,6 +819,25 @@ final class ClipboardPanelViewModel { return min(lhs, rhs) } + private func sourceIgnoreRule(for item: ClipboardItem) -> (value: String, displayName: String)? { + if let bundleID = item.sourceAppBundleId?.clipboardTrimmed, !bundleID.isEmpty { + let sourceApp = item.sourceApp?.clipboardTrimmed + let display = sourceApp?.isEmpty == false ? sourceApp ?? bundleID : bundleID + return (bundleID, display) + } + + if let sourceApp = item.sourceApp?.clipboardTrimmed, !sourceApp.isEmpty { + return (sourceApp, sourceApp) + } + + return nil + } + + private static func statusKindName(_ kind: ClipboardItemKind) -> String { + let name = kind.displayName + return name == name.uppercased() ? name : name.capitalized + } + private func searchTokens(from query: String) -> [String] { query .split { character in diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 796f626..cf764a0 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -504,6 +504,63 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(viewModel.statusMessage, "Cleared Stack") } + func testIgnoreSelectedSourceAppAddsPreciseCaptureRule() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + var item = makeTextItem("source rule clip", createdAt: Date(timeIntervalSince1970: 100)) + item.sourceApp = "Slack" + item.sourceAppBundleId = "com.tinyspeck.slackmacgap" + store.upsert(item) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 1) + let initialIgnoredApps = settings.ignoredApps + + viewModel.ignoreSelectedSourceApp() + + XCTAssertEqual(settings.ignoredApps, initialIgnoredApps + ["com.tinyspeck.slackmacgap"]) + XCTAssertEqual(viewModel.statusMessage, "Ignored Slack for future captures") + + viewModel.ignoreSelectedSourceApp() + XCTAssertEqual(settings.ignoredApps, initialIgnoredApps + ["com.tinyspeck.slackmacgap"]) + XCTAssertEqual(viewModel.statusMessage, "Slack is already ignored") + } + + func testIgnoreSelectedKindAddsContentTypeCaptureRule() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + let item = ClipboardItem( + id: UUID(), + kind: .image, + displayText: "Image", + payload: "/tmp/image.png", + payloadHash: hash("image"), + createdAt: Date(timeIntervalSince1970: 100), + lastUsedAt: Date(timeIntervalSince1970: 100), + useCount: 0, + sourceApp: "Photos", + imagePath: nil, + thumbnailPath: nil + ) + store.upsert(item) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 1) + + viewModel.ignoreSelectedKind() + + XCTAssertEqual(settings.ignoredItemKindsRaw, [ClipboardItemKind.image.rawValue]) + XCTAssertEqual(viewModel.statusMessage, "Ignored Image items for future captures") + + viewModel.ignoreSelectedKind() + XCTAssertEqual(settings.ignoredItemKindsRaw, [ClipboardItemKind.image.rawValue]) + XCTAssertEqual(viewModel.statusMessage, "Image items are already ignored") + } + func testSelectingStackFiltersVisibleItemsInQueueOrder() { let settings = makeSettings() let cacheService = makeCacheService() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 3c17d3f..7e76f33 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -345,12 +345,16 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Add to Stack", "Edit", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"] + ["Paste", "Copy", "Add to Stack", "Edit", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] ) XCTAssertEqual( fixture.view.debugFirstCardCollectionMenuTitles, ["Useful Links", "Important Notes", "Code Snippets", "Read Later", "-", "New Collection..."] ) + XCTAssertEqual( + fixture.view.debugFirstCardCaptureRuleMenuTitles, + ["Ignore Ghostty", "Ignore Text Items"] + ) } func testPreviewableCardsExposeQuickLookContextMenuAction() { @@ -361,7 +365,11 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Add to Stack", "Quick Look", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"] + ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Add to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] + ) + XCTAssertEqual( + fixture.view.debugFirstCardCaptureRuleMenuTitles, + ["Ignore Ghostty", "Ignore File Items"] ) } @@ -377,7 +385,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Remove from Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack", "Edit", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"] + ["Paste", "Copy", "Remove from Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack", "Edit", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] ) XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Remove from Stack", "Edit", "Delete"]) }