Files
clipbored/sources/clipbored/views/ClipboardPanelViewModel.swift
Akshay Kolli d86d392c3a
Some checks failed
CI / Test And Build (push) Has been cancelled
UI fixes
2026-07-08 01:13:02 -04:00

2730 lines
89 KiB
Swift

import Foundation
import AppKit
enum ClipboardSelectionMode {
case replace
case toggle
case range
case hover
case activate
}
struct ClipboardCollectionCountSummary {
fileprivate let sortModeCounts: [Int: Int]
fileprivate let collectionCounts: [String: Int]
func count(for sortMode: ClipboardSortMode) -> Int {
sortModeCounts[sortMode.rawValue] ?? 0
}
func count(named name: String) -> Int {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return 0 }
return collectionCounts[normalizedName.lowercased()] ?? 0
}
}
struct ClipboardCategorySelectionSnapshot: Equatable {
let sortMode: ClipboardSortMode
let selectedCollectionName: String?
let isStackFilterSelected: Bool
let selectedSortModeFilterRawValues: Set<Int>
let selectedCollectionNameFilters: [String]
let selectedItemID: UUID?
let selectedItemIDs: [UUID]
let selectionAnchorItemID: UUID?
let selectedIndex: Int
}
final class ClipboardPanelViewModel {
private static let searchDateFormatter: DateFormatter = {
let formatter = DateFormatter()
let calendar = Calendar.searchCalendar
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
private static let searchLocale = Locale(identifier: "en_US_POSIX")
private struct StructuredFilterMatcher {
let tokens: [String]
let exactValue: String?
}
private struct StructuredFilterValue {
let value: String
let isExact: Bool
}
private struct ParsedSearchQuery {
var textTokens: [String] = []
var appTokenGroups: [StructuredFilterMatcher] = []
var deviceTokenGroups: [StructuredFilterMatcher] = []
var collectionTokenGroups: [StructuredFilterMatcher] = []
var typeKinds: Set<ClipboardItemKind> = []
var createdAfter: Date?
var createdBefore: Date?
var pinned: Bool?
var isEmpty: Bool {
textTokens.isEmpty
&& appTokenGroups.isEmpty
&& deviceTokenGroups.isEmpty
&& collectionTokenGroups.isEmpty
&& typeKinds.isEmpty
&& createdAfter == nil
&& createdBefore == nil
&& pinned == nil
}
}
private struct VisibleItemsCacheKey: Hashable {
let query: String
let sortMode: Int
let collectionNameKey: String?
let sortModeFilterRawValues: [Int]
let collectionNameFilterKeys: [String]
}
private struct CategoryFilterSelection: Hashable {
let sortModeRawValues: [Int]
let collectionNameKeys: [String]
static let empty = CategoryFilterSelection(sortModeRawValues: [], collectionNameKeys: [])
var isEmpty: Bool {
sortModeRawValues.isEmpty && collectionNameKeys.isEmpty
}
var totalCount: Int {
sortModeRawValues.count + collectionNameKeys.count
}
}
private struct IndexedClipboardItem {
let offset: Int
let item: ClipboardItem
}
private enum VisibleItemsSortOrdering {
case recency
case useCount
case lastUsed
case original
}
private(set) var visibleItems: [ClipboardItem] = [] {
didSet {
var nextItemByID: [UUID: ClipboardItem] = [:]
nextItemByID.reserveCapacity(visibleItems.count)
var nextIndexByID: [UUID: Int] = [:]
nextIndexByID.reserveCapacity(visibleItems.count)
for (index, item) in visibleItems.enumerated() {
if nextItemByID[item.id] == nil {
nextItemByID[item.id] = item
}
if nextIndexByID[item.id] == nil {
nextIndexByID[item.id] = index
}
}
visibleItemByID = nextItemByID
visibleIndexByID = nextIndexByID
guard !isRecomputingVisibleItems else { return }
notifyVisibleItemsChanged()
}
}
var searchText: String = "" {
didSet {
guard oldValue != searchText else { return }
guard !isBatchingFilterSelectionChanges else { return }
guard Self.normalizedSearchValue(oldValue) != Self.normalizedSearchValue(searchText) else {
notifyMain { self.onSearchTextChanged?(self.searchText) }
return
}
selectedItemID = selectedItem?.id
recomputeVisibleItems()
notifyMain { self.onSearchTextChanged?(self.searchText) }
}
}
var sortMode: ClipboardSortMode {
didSet {
guard oldValue != sortMode else { return }
guard !isBatchingFilterSelectionChanges else {
if !suppressDefaultSortModePersistence {
settings.defaultSortMode = sortMode
}
return
}
selectedSortModeFilterRawValues.removeAll(keepingCapacity: true)
selectedCollectionNameFilters.removeAll(keepingCapacity: true)
isBatchingFilterSelectionChanges = true
isStackFilterSelected = false
selectedCollectionName = nil
isBatchingFilterSelectionChanges = false
if !suppressDefaultSortModePersistence {
settings.defaultSortMode = sortMode
}
recomputeVisibleItems()
onSortModeChanged?(sortMode)
}
}
private(set) var selectedCollectionName: String? {
didSet {
guard oldValue != selectedCollectionName else { return }
guard !isBatchingFilterSelectionChanges else { return }
recomputeVisibleItems()
onCollectionsChanged?()
}
}
private(set) var isStackFilterSelected = false {
didSet {
guard oldValue != isStackFilterSelected else { return }
guard !isBatchingFilterSelectionChanges else { return }
recomputeVisibleItems()
onStackChanged?()
}
}
private(set) var isStackCaptureEnabled = false {
didSet {
guard oldValue != isStackCaptureEnabled else { return }
if !isStackCaptureEnabled, isStackFilterSelected, stackItemIDs.isEmpty {
isStackFilterSelected = false
}
notifyMain { self.onStackChanged?() }
}
}
var selectedIndex: Int = 0 {
didSet {
guard oldValue != selectedIndex else { return }
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
}
}
private(set) var selectedItemIDs: [UUID] = [] {
didSet {
selectedItemIDSet = Set(selectedItemIDs)
guard oldValue != selectedItemIDs else { return }
notifyMain { self.onSelectedItemsChanged?() }
}
}
private(set) var statusMessage: String = "" {
didSet { notifyMain { self.onStatusMessageChanged?(self.statusMessage) } }
}
private var items: [ClipboardItem] = [] {
didSet {
rebuildItemIndexes()
collectionNamesCache = nil
visibleItemsCache.removeAll(keepingCapacity: true)
collectionCountCache = nil
}
}
private let store: ClipboardStore
private let settings: SettingsModel
private let cacheService: ClipboardCacheService
private let pasteService: PasteActionService
private let imageTextExtractor: (NSImage) -> String?
private var selectedItemID: UUID?
private var selectionAnchorItemID: UUID?
private var isRecomputingVisibleItems = false
private var isBatchingFilterSelectionChanges = false
private var selectedSortModeFilterRawValues: Set<Int> = []
private var selectedCollectionNameFilters: [String] = []
private var suppressDefaultSortModePersistence = false
private var isApplyingLocalCollectionMutation = false
private var isDeferringLocalStoreRecompute = false
private var pendingDeletionUndo: [ClipboardStoreRemoval] = []
private var collectionCountCache: (query: String, summary: ClipboardCollectionCountSummary)?
private var collectionNamesCache: [String]?
private var visibleItemsCache: [VisibleItemsCacheKey: [ClipboardItem]] = [:]
private var itemByID: [UUID: ClipboardItem] = [:]
private var itemIDSet: Set<UUID> = []
private var indexedItemsByCollectionKey: [String: [IndexedClipboardItem]] = [:]
private var indexedItemsBySortMode: [Int: [IndexedClipboardItem]] = [:]
private var assignedCollectionNamesByKey: [String: String] = [:]
private var visibleItemByID: [UUID: ClipboardItem] = [:]
private var visibleIndexByID: [UUID: Int] = [:]
private var selectedItemIDSet: Set<UUID> = []
private var stackItemIDSet: Set<UUID> = []
private var stackItemIDs: [UUID] = [] {
didSet {
stackItemIDSet = Set(stackItemIDs)
guard oldValue != stackItemIDs else { return }
notifyMain { self.onStackChanged?() }
}
}
deinit {
purgePendingDeletionUndo()
}
var targetApplicationProvider: () -> NSRunningApplication? = { nil }
var willPasteToTarget: () -> Void = {}
var onVisibleItemsChanged: (([ClipboardItem]) -> Void)?
var onSearchTextChanged: ((String) -> Void)?
var onSelectedIndexChanged: ((Int) -> Void)?
var onSelectedItemsChanged: (() -> Void)?
var onStatusMessageChanged: ((String) -> Void)?
var onSortModeChanged: ((ClipboardSortMode) -> Void)?
var onCollectionsChanged: (() -> Void)?
var onStackChanged: (() -> Void)?
var onCaptureStatusChanged: (() -> Void)?
var onCompactModeChanged: (() -> Void)?
var onPanelLayoutChanged: (() -> Void)?
#if DEBUG
private(set) var debugVisibleItemsFullScanCount = 0
private(set) var debugVisibleItemsIndexedLookupCount = 0
private(set) var debugCollectionCountFullScanCount = 0
private(set) var debugCollectionCountIndexedLookupCount = 0
#endif
init(
store: ClipboardStore,
settings: SettingsModel,
cacheService: ClipboardCacheService,
imageTextExtractor: @escaping (NSImage) -> String? = ImageTextExtractor.recognizedText(in:)
) {
self.store = store
self.settings = settings
self.cacheService = cacheService
self.sortMode = settings.defaultSortMode
self.pasteService = PasteActionService(cacheService: cacheService)
self.imageTextExtractor = imageTextExtractor
store.observeItems { [weak self] list in
guard let self else { return }
let shouldDeferRecompute = self.isDeferringLocalStoreRecompute
self.notifyMain {
self.items = list
if shouldDeferRecompute {
return
}
self.recomputeVisibleItems()
}
}
settings.observe { [weak self] change in
self?.notifyMain {
switch change {
case .captureStatus:
self?.statusMessage = ""
self?.onStatusMessageChanged?("")
self?.onCaptureStatusChanged?()
case .collections:
guard let self else { return }
self.collectionNamesCache = nil
guard !self.isApplyingLocalCollectionMutation else { return }
self.recomputeVisibleItems()
self.onCollectionsChanged?()
case .compactMode:
self?.onCompactModeChanged?()
case .panelLayout:
self?.onPanelLayoutChanged?()
case .defaultSortMode:
guard let self else { return }
self.sortMode = self.settings.defaultSortMode
case .includeImageTextInSearch:
self?.collectionCountCache = nil
self?.visibleItemsCache.removeAll(keepingCapacity: true)
self?.recomputeVisibleItems()
default:
break
}
}
}
}
#if DEBUG
func debugResetVisibleItemsPerformanceCounters() {
debugVisibleItemsFullScanCount = 0
debugVisibleItemsIndexedLookupCount = 0
debugCollectionCountFullScanCount = 0
debugCollectionCountIndexedLookupCount = 0
}
#endif
var selectedItem: ClipboardItem? {
guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil }
return visibleItems[selectedIndex]
}
var selectedItemCount: Int {
selectedItemsInSelectionOrder().count
}
var canShowSelectedInClipboard: Bool {
selectedItem != nil && canShowVisibleItemsInClipboard
}
var canShowVisibleItemsInClipboard: Bool {
!visibleItems.isEmpty
&& (!searchText.clipboardTrimmed.isEmpty
|| sortMode != .mostRecent
|| selectedCollectionName != nil
|| !activeCategoryFilterSelection().isEmpty
|| isStackFilterSelected)
}
var totalItemCount: Int {
items.count
}
var stackCount: Int {
stackItemIDs.count
}
var stackTitle: String {
"Stack"
}
var isCompactModeEnabled: Bool {
false
}
var panelLayout: ClipboardPanelLayout {
.vertical
}
func toggleCompactMode() {
statusMessage = "Compact Mode was removed"
}
var collectionNames: [String] {
if let collectionNamesCache {
return collectionNamesCache
}
let assignedNames = Set(assignedCollectionNamesByKey.values)
let configuredNames = settings.customCollectionNames
let configuredNameSet = Set(configuredNames.map { $0.lowercased() })
let allNames = assignedNames.union(configuredNames)
let defaultNames = ClipboardCollectionDefaults.names.filter { allNames.contains($0) }
var configuredCustomNames: [String] = []
for name in configuredNames where !ClipboardCollectionDefaults.names.contains(name) {
guard !configuredCustomNames.contains(where: { $0.caseInsensitiveCompare(name) == .orderedSame }) else { continue }
configuredCustomNames.append(name)
}
let assignedCustomNames = assignedNames
.filter { !ClipboardCollectionDefaults.names.contains($0) }
.filter { !configuredNameSet.contains($0.lowercased()) }
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
let names = defaultNames + configuredCustomNames + assignedCustomNames
collectionNamesCache = names
return names
}
var searchFilterSourceAppNames: [String] {
uniqueSearchFacetValues(items.compactMap(\.sourceApp))
}
var searchFilterDeviceNames: [String] {
uniqueSearchFacetValues(items.map { Optional($0.effectiveSourceDeviceName) })
}
func collectionCount(for sortMode: ClipboardSortMode) -> Int {
collectionCountSummary().count(for: sortMode)
}
func collectionCount(named name: String) -> Int {
collectionCountSummary().count(named: name)
}
func collectionCountSummary() -> ClipboardCollectionCountSummary {
let query = searchText.clipboardTrimmed.lowercased()
if let collectionCountCache, collectionCountCache.query == query {
return collectionCountCache.summary
}
let parsedQuery = parseSearchQuery(query)
var sortModeCounts = Dictionary(uniqueKeysWithValues: ClipboardSortMode.allCases.map { ($0.rawValue, 0) })
var collectionCounts: [String: Int] = [:]
if parsedQuery.isEmpty {
for mode in ClipboardSortMode.allCases {
sortModeCounts[mode.rawValue] = indexedItemsBySortMode[mode.rawValue]?.count ?? 0
}
for (collectionKey, indexedItems) in indexedItemsByCollectionKey {
collectionCounts[collectionKey] = indexedItems.count
}
#if DEBUG
debugCollectionCountIndexedLookupCount += 1
#endif
} else {
for item in items {
if !matchesSearchQuery(item, query: parsedQuery) {
continue
}
for mode in ClipboardSortMode.allCases where mode.includes(item) {
sortModeCounts[mode.rawValue, default: 0] += 1
}
if let collectionName = ClipboardCollectionDefaults.normalizedName(item.collectionName) {
collectionCounts[collectionName.lowercased(), default: 0] += 1
}
}
#if DEBUG
debugCollectionCountFullScanCount += 1
#endif
}
let summary = ClipboardCollectionCountSummary(
sortModeCounts: sortModeCounts,
collectionCounts: collectionCounts
)
collectionCountCache = (query, summary)
return summary
}
func shouldShowCategory(for sortMode: ClipboardSortMode) -> Bool {
collectionCount(for: sortMode) > 0 || isSortModeCategorySelected(sortMode)
}
func isSortModeCategorySelected(_ mode: ClipboardSortMode) -> Bool {
guard !isStackFilterSelected else { return false }
let selection = activeCategoryFilterSelection()
if !selection.isEmpty {
return selection.sortModeRawValues.contains(mode.rawValue)
}
return selectedCollectionName == nil && sortMode == mode
}
func isCollectionCategorySelected(named name: String) -> Bool {
guard !isStackFilterSelected else { return false }
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return false }
let key = normalizedName.lowercased()
let selection = activeCategoryFilterSelection()
if !selection.isEmpty {
return selection.collectionNameKeys.contains(key)
}
return selectedCollectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame
}
var captureStatusMessage: String {
settings.captureStatusMessage
}
func thumbnail(for item: ClipboardItem) -> NSImage? {
cacheService.previewThumbnail(for: item)
}
func selectItem(at index: Int, mode: ClipboardSelectionMode = .replace) {
guard index >= 0 && index < visibleItems.count else { return }
let item = visibleItems[index]
switch mode {
case .replace:
setActiveSelection(item, at: index, selectedIDs: [item.id], anchorID: item.id)
case .toggle:
var nextIDs = selectedItemIDs.isEmpty ? selectedItem.map { [$0.id] } ?? [] : selectedItemIDs
if nextIDs.contains(item.id) {
if nextIDs.count > 1 {
nextIDs.removeAll { $0 == item.id }
}
} else {
nextIDs.append(item.id)
}
setActiveSelection(item, at: index, selectedIDs: nextIDs, anchorID: item.id)
case .range:
let anchorIndex = selectionAnchorItemID
.flatMap { anchorID in visibleItems.firstIndex { $0.id == anchorID } }
?? selectedIndex
let lower = min(anchorIndex, index)
let upper = max(anchorIndex, index)
let rangeIDs = visibleItems[lower...upper].map(\.id)
let anchorID = visibleItems[anchorIndex].id
setActiveSelection(item, at: index, selectedIDs: rangeIDs, anchorID: anchorID)
case .hover:
setActiveSelection(item, at: index, selectedIDs: [item.id], anchorID: item.id)
case .activate:
if selectedItemIDs.contains(item.id), selectedItemIDs.count > 1 {
setActiveSelection(item, at: index, selectedIDs: selectedItemIDs, anchorID: selectionAnchorItemID ?? item.id)
} else {
setActiveSelection(item, at: index, selectedIDs: [item.id], anchorID: item.id)
}
}
}
func selectFirstItem() {
guard !visibleItems.isEmpty else { return }
let previousIndex = selectedIndex
setIndexBasedSelection(at: 0)
if previousIndex == 0 {
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
}
}
func selectLastItem() {
guard !visibleItems.isEmpty else { return }
let lastIndex = visibleItems.count - 1
let previousIndex = selectedIndex
setIndexBasedSelection(at: lastIndex)
if previousIndex == lastIndex {
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
}
}
func moveSelection(_ delta: Int) {
let count = visibleItems.count
guard count > 0 else { return }
let target = max(0, min(count - 1, selectedIndex + delta))
setIndexBasedSelection(at: target)
}
func selectAllVisibleItems() {
guard !visibleItems.isEmpty else {
selectedItemIDs = []
statusMessage = "No clips to select"
return
}
let activeIndex = max(0, min(visibleItems.count - 1, selectedIndex))
let activeItem = visibleItems[activeIndex]
selectedItemID = activeItem.id
selectionAnchorItemID = activeItem.id
selectedItemIDs = visibleItems.map(\.id)
if selectedIndex == activeIndex {
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
} else {
selectedIndex = activeIndex
}
let noun = visibleItems.count == 1 ? "clip" : "clips"
statusMessage = "Selected \(visibleItems.count) \(noun)"
}
func pasteSelected() {
if selectedItemCount > 1 {
pasteSelectedItems()
return
}
guard let item = selectedItem else { return }
let result = pasteService.paste(item, targetApp: targetApplicationProvider())
if case .pasted = result {
willPasteToTarget()
}
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
}
func pasteSelectedPlainText() {
if selectedItemCount > 1 {
pasteSelectedItemsAsText()
return
}
guard let item = selectedItem else { return }
let result = pasteService.pastePlainText(item, targetApp: targetApplicationProvider())
if case .pastedPlainText = result {
willPasteToTarget()
}
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
}
func pasteItem(at index: Int) {
guard index >= 0 && index < visibleItems.count else { return }
selectItem(at: index)
pasteSelected()
}
func pasteItemPlainText(at index: Int) {
guard index >= 0 && index < visibleItems.count else { return }
selectItem(at: index)
pasteSelectedPlainText()
}
func copySelected() {
if selectedItemCount > 1 {
copySelectedItems()
return
}
guard let item = selectedItem else { return }
let result = pasteService.copy(item)
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
}
func copySelectedPlainText() {
if selectedItemCount > 1 {
copySelectedItemsAsText()
return
}
guard let item = selectedItem else { return }
let result = pasteService.copyPlainText(item)
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
}
func isItemStacked(at index: Int) -> Bool {
guard index >= 0 && index < visibleItems.count else { return false }
return stackItemIDSet.contains(visibleItems[index].id)
}
func isItemSelected(at index: Int) -> Bool {
guard index >= 0 && index < visibleItems.count else { return false }
return selectedItemIDSet.contains(visibleItems[index].id)
}
func toggleSelectedStackMembership() {
guard let item = selectedItem else { return }
if stackItemIDs.contains(item.id) {
consumeStackItem(item.id)
statusMessage = "Removed from Stack"
return
}
stackItemIDs.append(item.id)
statusMessage = "Added to Stack"
}
func addVisibleItemsToStack() {
pruneStackItems()
guard !visibleItems.isEmpty else {
statusMessage = "No visible clips to stack"
return
}
let newIDs = visibleItems
.map(\.id)
.filter { !stackItemIDSet.contains($0) }
guard !newIDs.isEmpty else {
statusMessage = "Visible clips are already in Stack"
return
}
stackItemIDs.append(contentsOf: newIDs)
let noun = newIDs.count == 1 ? "clip" : "clips"
statusMessage = "Added \(newIDs.count) \(noun) to Stack"
}
func toggleStackCaptureMode() {
isStackCaptureEnabled.toggle()
statusMessage = isStackCaptureEnabled ? "Stack capture is on" : "Stack capture is off"
}
func addCapturedItemToStack(_ item: ClipboardItem) {
guard isStackCaptureEnabled else { return }
pruneStackItems()
stackItemIDs.append(item.id)
if isStackFilterSelected {
selectedItemID = item.id
selectionAnchorItemID = item.id
selectedItemIDs = [item.id]
recomputeVisibleItems()
}
let noun = stackItemIDs.count == 1 ? "clip" : "clips"
statusMessage = "Captured to Stack (\(stackItemIDs.count) \(noun))"
}
func selectStack() {
guard !stackItemIDs.isEmpty || isStackCaptureEnabled else { return }
let collectionChanged = selectedCollectionName != nil
|| !selectedSortModeFilterRawValues.isEmpty
|| !selectedCollectionNameFilters.isEmpty
let stackChanged = !isStackFilterSelected
guard collectionChanged || stackChanged else { return }
isBatchingFilterSelectionChanges = true
selectedCollectionName = nil
selectedSortModeFilterRawValues.removeAll(keepingCapacity: true)
selectedCollectionNameFilters.removeAll(keepingCapacity: true)
isStackFilterSelected = true
isBatchingFilterSelectionChanges = false
recomputeVisibleItems()
if collectionChanged {
onCollectionsChanged?()
}
}
func clearStackSelection() {
guard isStackFilterSelected else { return }
isStackFilterSelected = false
}
func clearStack() {
guard !stackItemIDs.isEmpty else {
statusMessage = "Stack is empty"
return
}
stackItemIDs.removeAll()
isStackFilterSelected = false
statusMessage = "Cleared Stack"
}
func copyNextStackItem() {
guard let item = nextStackItem() else {
statusMessage = "Stack is empty"
return
}
let result = pasteService.copy(item)
handleStackActionResult(result, item: item)
}
func pasteNextStackItem() {
guard let item = nextStackItem() else {
statusMessage = "Stack is empty"
return
}
let result = pasteService.paste(item, targetApp: targetApplicationProvider())
if case .pasted = result {
willPasteToTarget()
}
handleStackActionResult(result, item: item)
}
func copyStackAsText() {
guard let package = stackPlainTextPackage() else {
statusMessage = "Stack has no text to copy"
return
}
let result = pasteService.copyPlainText(package.text)
handleStackPlainTextActionResult(result, items: package.items)
}
func pasteStackAsText() {
guard let package = stackPlainTextPackage() else {
statusMessage = "Stack has no text to paste"
return
}
let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider())
if case .pastedPlainText = result {
willPasteToTarget()
}
handleStackPlainTextActionResult(result, items: package.items)
}
func addSelectedItemsToStack() {
pruneStackItems()
let selectedItems = selectedItemsInSelectionOrder()
guard !selectedItems.isEmpty else {
statusMessage = "No selected clips to stack"
return
}
let newIDs = selectedItems
.map(\.id)
.filter { !stackItemIDSet.contains($0) }
guard !newIDs.isEmpty else {
statusMessage = "Selected clips are already in Stack"
return
}
stackItemIDs.append(contentsOf: newIDs)
let noun = newIDs.count == 1 ? "clip" : "clips"
statusMessage = "Added \(newIDs.count) selected \(noun) to Stack"
}
func copySelectedItems() {
let selectedItems = selectedItemsInSelectionOrder()
guard selectedItems.count > 1 else {
copySelected()
return
}
let result = pasteService.copy(selectedItems)
handleSelectedActionResult(result, items: selectedItems)
}
func pasteSelectedItems() {
let selectedItems = selectedItemsInSelectionOrder()
guard selectedItems.count > 1 else {
pasteSelected()
return
}
let result = pasteService.paste(selectedItems, targetApp: targetApplicationProvider())
if case .pasted = result {
willPasteToTarget()
}
handleSelectedActionResult(result, items: selectedItems)
}
func copySelectedItemsAsText() {
guard let package = selectedPlainTextPackage() else {
statusMessage = "Selection has no text to copy"
return
}
let result = pasteService.copyPlainText(package.text)
handleSelectedPlainTextActionResult(result, items: package.items)
}
func pasteSelectedItemsAsText() {
guard let package = selectedPlainTextPackage() else {
statusMessage = "Selection has no text to paste"
return
}
let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider())
if case .pastedPlainText = result {
willPasteToTarget()
}
handleSelectedPlainTextActionResult(result, items: package.items)
}
func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] {
guard index >= 0 && index < visibleItems.count else { return [] }
return pasteService.pasteboardWriters(for: visibleItems[index])
}
func editableTextForSelected() -> String? {
guard let item = selectedItem, item.kind == .text || item.kind == .code else { return nil }
return item.payload
}
func editableTextForItem(at index: Int) -> String? {
guard index >= 0 && index < visibleItems.count else { return nil }
let item = visibleItems[index]
guard item.kind == .text || item.kind == .code else { return nil }
return item.payload
}
func editableTitleForSelected() -> String? {
guard let item = selectedItem else { return nil }
return item.customTitle ?? ""
}
@discardableResult
func createTextClip(_ text: String, now: Date = Date()) -> ClipboardItem? {
let trimmed = text.clipboardTrimmed
guard !trimmed.isEmpty else {
statusMessage = "Text clip cannot be empty"
return nil
}
let kind: ClipboardItemKind = CodeSnippetPayload.isLikelyCode(trimmed) ? .code : .text
let displayText = kind == .code ? CodeSnippetPayload.title(from: trimmed) : trimmed
let collectionName = selectedCollectionName.flatMap(ClipboardCollectionDefaults.normalizedName)
let payloadHash = store.hashString(trimmed)
let item = ClipboardItem(
id: UUID(),
kind: kind,
displayText: displayText,
payload: trimmed,
payloadHash: payloadHash,
createdAt: now,
lastUsedAt: now,
useCount: 0,
sourceApp: AppConfiguration.appName,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: Bundle.main.bundleIdentifier,
collectionName: collectionName
)
let selectedID = settings.pruneDuplicates
? items.first(where: { $0.payloadHash == payloadHash })?.id ?? item.id
: item.id
selectedItemID = selectedID
selectionAnchorItemID = selectedID
selectedItemIDs = [selectedID]
let searchChanged = !searchText.clipboardTrimmed.isEmpty
let stackChanged = isStackFilterSelected
let targetSortMode: ClipboardSortMode? = collectionName == nil && !sortMode.includes(item)
? (kind == .code ? .code : .text)
: nil
isBatchingFilterSelectionChanges = true
if stackChanged {
isStackFilterSelected = false
}
if searchChanged {
searchText = ""
}
if let targetSortMode {
sortMode = targetSortMode
}
isBatchingFilterSelectionChanges = false
let storedItem = store.upsert(item)
if searchChanged {
notifyMain { self.onSearchTextChanged?(self.searchText) }
}
if let targetSortMode {
onSortModeChanged?(targetSortMode)
}
if store.items.contains(where: { $0.id == storedItem.id }) {
selectedItemID = storedItem.id
selectionAnchorItemID = storedItem.id
selectedItemIDs = [storedItem.id]
statusMessage = kind == .code ? "Created code clip" : "Created text clip"
return storedItem
}
statusMessage = kind == .code ? "Created code clip" : "Created text clip"
return item
}
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 || item.kind == .code else { return }
let trimmed = text.clipboardTrimmed
guard !trimmed.isEmpty else {
statusMessage = "Text clip cannot be empty"
return
}
guard item.payload != text else {
statusMessage = "No changes"
return
}
selectedItemID = item.id
if store.updateText(item.id, text: text) {
statusMessage = item.kind == .code ? "Updated code clip" : "Updated text clip"
}
}
func rotateSelectedImageClockwise() {
guard let item = selectedItem, item.kind == .image else { return }
let imagePath = item.imagePath ?? item.payload
guard let data = cacheService.data(for: imagePath),
let image = NSImage(data: data),
let rotated = image.rotatedClockwise(),
let fullData = rotated.pngData(),
let thumbnailData = rotated.resized(to: CGSize(width: 320, height: 320)).pngData(),
let fullPath = cacheService.cacheImageSidecarData(fullData, id: item.id),
let thumbnailPath = cacheService.cacheImageSidecarData(thumbnailData, id: item.id, fileNamePrefix: "thumb") else {
statusMessage = "Could not rotate image"
return
}
selectedItemID = item.id
let payloadHash = store.hashData(fullData)
if store.updateImage(
item.id,
imagePath: fullPath,
thumbnailPath: thumbnailPath,
payloadHash: payloadHash
) {
statusMessage = "Rotated image"
} else {
statusMessage = "Could not rotate image"
}
}
func extractTextFromSelectedImage() {
guard let item = selectedItem, item.kind == .image else { return }
let imagePath = item.imagePath ?? item.payload
guard let data = cacheService.data(for: imagePath),
let image = NSImage(data: data) else {
statusMessage = "Could not read image"
return
}
guard let text = ImageTextExtractor.normalizedRecognizedText(imageTextExtractor(image)) else {
statusMessage = "No text found in image"
return
}
selectedItemID = item.id
if store.updateImageText(item.id, ocrText: text) {
statusMessage = "Extracted text from image"
} else {
statusMessage = "Could not save image text"
}
}
func previewURLForSelected() -> URL? {
guard let item = selectedItem else { return nil }
return previewURL(for: item)
}
func linkPreviewRequestForSelected() -> LinkPreviewRequest? {
guard let item = selectedItem else { return nil }
return linkPreviewRequest(for: item)
}
internal func previewURL(for item: ClipboardItem) -> URL? {
cacheService.temporaryPreviewURL(for: item)
}
internal func linkPreviewRequest(for item: ClipboardItem) -> LinkPreviewRequest? {
guard item.kind == .url,
let url = URL(string: item.payload.clipboardTrimmed),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return nil
}
let title = item.displayText.clipboardTrimmed.isEmpty ? url.absoluteString : item.displayText
return LinkPreviewRequest(url: url, title: title)
}
func openSelected() {
guard let item = selectedItem else { return }
switch item.kind {
case .url:
guard let url = URL(string: item.payload) else { return }
NSWorkspace.shared.open(url)
case .file:
let urls = FilePayload.urls(from: item.payload)
guard !urls.isEmpty, urls.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) else { return }
NSWorkspace.shared.activateFileViewerSelecting(urls)
case .image:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .pdf:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .video:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .color:
break
default:
break
}
}
func revealSelected() {
guard let item = selectedItem else { return }
switch item.kind {
case .image:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .file:
let urls = FilePayload.urls(from: item.payload)
guard !urls.isEmpty, urls.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) else { return }
NSWorkspace.shared.activateFileViewerSelecting(urls)
case .pdf:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .video:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .color:
break
default:
break
}
}
func deleteSelected() {
let selectedItems = selectedItemsInSelectionOrder()
guard !selectedItems.isEmpty else { return }
purgePendingDeletionUndo()
let originalVisibleCount = visibleItems.count
let firstDeletedVisibleIndex = selectedItems
.compactMap { item in visibleItems.firstIndex { $0.id == item.id } }
.min() ?? selectedIndex
let removalOrder = selectedItems.sorted { lhs, rhs in
let lhsIndex = items.firstIndex { $0.id == lhs.id } ?? -1
let rhsIndex = items.firstIndex { $0.id == rhs.id } ?? -1
return lhsIndex > rhsIndex
}
let removals = removalOrder.compactMap { item in
store.remove(item.id, purgeManagedCache: false)
}
guard !removals.isEmpty else { return }
pendingDeletionUndo = removals
let removedIDs = Set(removals.map(\.item.id))
stackItemIDs.removeAll { removedIDs.contains($0) }
if visibleItems.isEmpty {
selectedIndex = 0
selectedItemID = nil
selectionAnchorItemID = nil
selectedItemIDs = []
} else {
let remainingVisibleCount = originalVisibleCount - removals.count
let nextIndex = max(0, min(visibleItems.count - 1, min(firstDeletedVisibleIndex, remainingVisibleCount)))
setIndexBasedSelection(at: nextIndex)
}
let noun = removals.count == 1 ? "clip" : "clips"
statusMessage = "Deleted \(removals.count) \(noun)"
}
func clearHistory(since cutoff: Date) {
purgePendingDeletionUndo()
let ids = items.filter { $0.createdAt >= cutoff }.map(\.id)
guard !ids.isEmpty else {
finishClearHistory(removedCount: 0)
return
}
isDeferringLocalStoreRecompute = true
for id in ids {
store.remove(id)
}
isDeferringLocalStoreRecompute = false
recomputeVisibleItems()
finishClearHistory(removedCount: ids.count)
}
func undoLastDelete() {
guard !pendingDeletionUndo.isEmpty else {
statusMessage = "Nothing to undo"
return
}
let removals = pendingDeletionUndo
pendingDeletionUndo = []
store.restore(removals)
let restoredIDs = removals
.sorted(by: { $0.index < $1.index })
.map(\.item.id)
let visibleRestoredIDs = restoredIDs.filter { id in
visibleItems.contains { $0.id == id }
}
if let firstRestoredID = visibleRestoredIDs.first,
let restoredIndex = visibleItems.firstIndex(where: { $0.id == firstRestoredID }) {
setActiveSelection(
visibleItems[restoredIndex],
at: restoredIndex,
selectedIDs: visibleRestoredIDs,
anchorID: firstRestoredID
)
}
let noun = removals.count == 1 ? "clip" : "clips"
statusMessage = "Restored \(removals.count) \(noun)"
}
func togglePinSelected() {
guard let item = selectedItem else { return }
store.togglePin(item.id)
}
func assignSelected(to collectionName: String?) {
guard let item = selectedItem else { return }
selectedItemID = item.id
assign(item: item, to: collectionName)
}
func assignItem(withID id: UUID, to collectionName: String?) {
guard let item = items.first(where: { $0.id == id }) else { return }
selectedItemID = selectedItem?.id
assign(item: item, to: collectionName)
}
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)
if settings.ignoredItemKindsRaw.contains(item.kind.rawValue) {
statusMessage = "Ignored \(Self.statusKindName(item.kind)) items for future captures"
} else {
statusMessage = "At least one content type must stay enabled."
}
}
func categorySelectionSnapshot() -> ClipboardCategorySelectionSnapshot {
ClipboardCategorySelectionSnapshot(
sortMode: sortMode,
selectedCollectionName: selectedCollectionName,
isStackFilterSelected: isStackFilterSelected,
selectedSortModeFilterRawValues: selectedSortModeFilterRawValues,
selectedCollectionNameFilters: selectedCollectionNameFilters,
selectedItemID: selectedItemID,
selectedItemIDs: selectedItemIDs,
selectionAnchorItemID: selectionAnchorItemID,
selectedIndex: selectedIndex
)
}
func previewSortMode(_ mode: ClipboardSortMode) {
applyCategorySelectionSnapshot(
ClipboardCategorySelectionSnapshot(
sortMode: mode,
selectedCollectionName: nil,
isStackFilterSelected: false,
selectedSortModeFilterRawValues: [],
selectedCollectionNameFilters: [],
selectedItemID: selectedItemID,
selectedItemIDs: selectedItemIDs,
selectionAnchorItemID: selectionAnchorItemID,
selectedIndex: selectedIndex
),
persistDefaultSortMode: false,
restoreItemSelection: false
)
}
func previewCollection(named name: String) {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return }
applyCategorySelectionSnapshot(
ClipboardCategorySelectionSnapshot(
sortMode: sortMode,
selectedCollectionName: normalizedName,
isStackFilterSelected: false,
selectedSortModeFilterRawValues: [],
selectedCollectionNameFilters: [],
selectedItemID: selectedItemID,
selectedItemIDs: selectedItemIDs,
selectionAnchorItemID: selectionAnchorItemID,
selectedIndex: selectedIndex
),
persistDefaultSortMode: false,
restoreItemSelection: false
)
}
func previewStack() {
applyCategorySelectionSnapshot(
ClipboardCategorySelectionSnapshot(
sortMode: sortMode,
selectedCollectionName: nil,
isStackFilterSelected: true,
selectedSortModeFilterRawValues: [],
selectedCollectionNameFilters: [],
selectedItemID: selectedItemID,
selectedItemIDs: selectedItemIDs,
selectionAnchorItemID: selectionAnchorItemID,
selectedIndex: selectedIndex
),
persistDefaultSortMode: false,
restoreItemSelection: false
)
}
func restoreCategorySelection(_ snapshot: ClipboardCategorySelectionSnapshot) {
applyCategorySelectionSnapshot(snapshot, persistDefaultSortMode: false, restoreItemSelection: true)
}
func commitCategorySelection() {
settings.defaultSortMode = sortMode
}
private func applyCategorySelectionSnapshot(
_ snapshot: ClipboardCategorySelectionSnapshot,
persistDefaultSortMode: Bool,
restoreItemSelection: Bool
) {
let categoryChanged = sortMode != snapshot.sortMode
|| selectedCollectionName != snapshot.selectedCollectionName
|| isStackFilterSelected != snapshot.isStackFilterSelected
|| selectedSortModeFilterRawValues != snapshot.selectedSortModeFilterRawValues
|| selectedCollectionNameFilters != snapshot.selectedCollectionNameFilters
let selectionChanged = restoreItemSelection && (
selectedItemID != snapshot.selectedItemID
|| selectedItemIDs != snapshot.selectedItemIDs
|| selectionAnchorItemID != snapshot.selectionAnchorItemID
|| selectedIndex != snapshot.selectedIndex
)
guard categoryChanged || selectionChanged else {
if persistDefaultSortMode {
settings.defaultSortMode = sortMode
}
return
}
let previousSortMode = sortMode
let previousStackSelection = isStackFilterSelected
let previousBatching = isBatchingFilterSelectionChanges
let previousPersistenceSuppression = suppressDefaultSortModePersistence
isBatchingFilterSelectionChanges = true
suppressDefaultSortModePersistence = !persistDefaultSortMode
isStackFilterSelected = snapshot.isStackFilterSelected
selectedCollectionName = snapshot.selectedCollectionName
selectedSortModeFilterRawValues = snapshot.selectedSortModeFilterRawValues
selectedCollectionNameFilters = snapshot.selectedCollectionNameFilters
sortMode = snapshot.sortMode
isBatchingFilterSelectionChanges = previousBatching
suppressDefaultSortModePersistence = previousPersistenceSuppression
if restoreItemSelection {
selectedItemID = snapshot.selectedItemID
selectedItemIDs = snapshot.selectedItemIDs
selectionAnchorItemID = snapshot.selectionAnchorItemID
selectedIndex = snapshot.selectedIndex
}
if persistDefaultSortMode {
settings.defaultSortMode = sortMode
}
recomputeVisibleItems()
if previousSortMode != sortMode {
onSortModeChanged?(sortMode)
} else {
onSortModeChanged?(sortMode)
}
onCollectionsChanged?()
if previousStackSelection != isStackFilterSelected {
onStackChanged?()
}
}
func selectSortMode(_ mode: ClipboardSortMode, extending: Bool = false) {
if extending {
toggleSortModeCategoryFilter(mode)
return
}
let categoryFiltersChanged = !selectedSortModeFilterRawValues.isEmpty || !selectedCollectionNameFilters.isEmpty
let stackChanged = isStackFilterSelected
let collectionChanged = selectedCollectionName != nil
if categoryFiltersChanged || stackChanged || collectionChanged {
isBatchingFilterSelectionChanges = true
isStackFilterSelected = false
selectedCollectionName = nil
selectedSortModeFilterRawValues.removeAll(keepingCapacity: true)
selectedCollectionNameFilters.removeAll(keepingCapacity: true)
sortMode = mode
isBatchingFilterSelectionChanges = false
settings.defaultSortMode = mode
recomputeVisibleItems()
onSortModeChanged?(mode)
onCollectionsChanged?()
return
}
sortMode = mode
}
func selectCollection(named name: String) {
selectCollection(named: name, extending: false)
}
func selectCollection(named name: String, extending: Bool) {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return }
if extending {
toggleCollectionCategoryFilter(named: normalizedName)
return
}
let stackChanged = isStackFilterSelected
let collectionChanged = selectedCollectionName != normalizedName
let categoryFiltersChanged = !selectedSortModeFilterRawValues.isEmpty || !selectedCollectionNameFilters.isEmpty
guard stackChanged || collectionChanged || categoryFiltersChanged else { return }
isBatchingFilterSelectionChanges = true
isStackFilterSelected = false
selectedSortModeFilterRawValues.removeAll(keepingCapacity: true)
selectedCollectionNameFilters.removeAll(keepingCapacity: true)
selectedCollectionName = normalizedName
isBatchingFilterSelectionChanges = false
recomputeVisibleItems()
}
private func toggleSortModeCategoryFilter(_ mode: ClipboardSortMode) {
var sortModeRawValues = selectedSortModeFilterRawValues
var collectionNames = selectedCollectionNameFilters
let seeded = seedCategoryFiltersIfNeeded(sortModeRawValues: &sortModeRawValues, collectionNames: &collectionNames)
if seeded, mode != .mostRecent, sortModeRawValues == Set([ClipboardSortMode.mostRecent.rawValue]) {
sortModeRawValues.remove(ClipboardSortMode.mostRecent.rawValue)
}
if sortModeRawValues.contains(mode.rawValue) {
sortModeRawValues.remove(mode.rawValue)
} else {
sortModeRawValues.insert(mode.rawValue)
}
applyCategoryFilters(sortModeRawValues: sortModeRawValues, collectionNames: collectionNames)
}
private func toggleCollectionCategoryFilter(named name: String) {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return }
var sortModeRawValues = selectedSortModeFilterRawValues
var collectionNames = selectedCollectionNameFilters
let seeded = seedCategoryFiltersIfNeeded(sortModeRawValues: &sortModeRawValues, collectionNames: &collectionNames)
if seeded, sortModeRawValues == Set([ClipboardSortMode.mostRecent.rawValue]) {
sortModeRawValues.remove(ClipboardSortMode.mostRecent.rawValue)
}
if let existingIndex = collectionNames.firstIndex(where: { $0.caseInsensitiveCompare(normalizedName) == .orderedSame }) {
collectionNames.remove(at: existingIndex)
} else {
collectionNames.append(normalizedName)
}
applyCategoryFilters(sortModeRawValues: sortModeRawValues, collectionNames: collectionNames)
}
@discardableResult
private func seedCategoryFiltersIfNeeded(sortModeRawValues: inout Set<Int>, collectionNames: inout [String]) -> Bool {
guard sortModeRawValues.isEmpty && collectionNames.isEmpty else { return false }
if let selectedCollectionName {
collectionNames = [selectedCollectionName]
} else if !isStackFilterSelected {
sortModeRawValues = [sortMode.rawValue]
}
return true
}
private func applyCategoryFilters(sortModeRawValues: Set<Int>, collectionNames: [String]) {
let normalizedCollectionNames = uniqueNormalizedCollectionNames(collectionNames)
if sortModeRawValues.isEmpty && normalizedCollectionNames.isEmpty {
let shouldManuallyRecompute = sortMode == .mostRecent
selectedSortModeFilterRawValues.removeAll(keepingCapacity: true)
selectedCollectionNameFilters.removeAll(keepingCapacity: true)
isBatchingFilterSelectionChanges = true
isStackFilterSelected = false
selectedCollectionName = nil
isBatchingFilterSelectionChanges = false
sortMode = .mostRecent
if shouldManuallyRecompute {
recomputeVisibleItems()
onSortModeChanged?(sortMode)
onCollectionsChanged?()
}
statusMessage = "Selected Clipboard"
return
}
isBatchingFilterSelectionChanges = true
isStackFilterSelected = false
selectedCollectionName = nil
selectedSortModeFilterRawValues = sortModeRawValues
selectedCollectionNameFilters = normalizedCollectionNames
isBatchingFilterSelectionChanges = false
recomputeVisibleItems()
onSortModeChanged?(sortMode)
onCollectionsChanged?()
let count = activeCategoryFilterSelection().totalCount
let noun = count == 1 ? "category" : "categories"
statusMessage = "Filtered \(count) \(noun)"
}
private func uniqueNormalizedCollectionNames(_ names: [String]) -> [String] {
var result: [String] = []
var seen = Set<String>()
for name in names {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { continue }
let key = normalizedName.lowercased()
guard seen.insert(key).inserted else { continue }
result.append(normalizedName)
}
return result
}
func selectAdjacentCollection(delta: Int) {
guard delta != 0 else { return }
let names = collectionNames
guard !names.isEmpty else {
statusMessage = "No collections"
return
}
let targetIndex: Int
if let selectedCollectionName,
let currentIndex = names.firstIndex(where: { $0.caseInsensitiveCompare(selectedCollectionName) == .orderedSame }) {
targetIndex = (currentIndex + delta + names.count) % names.count
} else {
targetIndex = delta > 0 ? 0 : names.count - 1
}
let targetName = names[targetIndex]
selectCollection(named: targetName)
statusMessage = "Selected \(targetName)"
}
func createCollection(named name: String, colorHex: String? = nil, selectAfterCreate: Bool = true) {
isApplyingLocalCollectionMutation = true
let createdName = settings.ensureCollection(named: name, colorHex: colorHex)
isApplyingLocalCollectionMutation = false
collectionNamesCache = nil
guard let normalizedName = createdName else { return }
statusMessage = "Created \(normalizedName)"
if selectAfterCreate {
selectCollection(named: normalizedName)
} else {
recomputeVisibleItems()
onCollectionsChanged?()
}
}
func collectionColorHex(named name: String) -> String? {
settings.collectionColorHex(forCollectionNamed: name)
}
@discardableResult
func exportCollection(named name: String, to url: URL) throws -> ClipboardArchiveSummary {
let summary = try store.exportCollection(named: name, to: url)
if let normalizedName = ClipboardCollectionDefaults.normalizedName(name) {
let noun = summary.itemCount == 1 ? "clip" : "clips"
statusMessage = "Exported \(normalizedName) Pinboard with \(summary.itemCount) \(noun)"
}
return summary
}
func reportCollectionExportFailure(_ error: Error) {
statusMessage = "Export failed: \(error.localizedDescription)"
}
func updateCollection(named currentName: String, to newName: String, colorHex: String? = nil) {
guard let normalizedCurrentName = ClipboardCollectionDefaults.normalizedName(currentName) else {
return
}
isApplyingLocalCollectionMutation = true
let updatedName = settings.updateCollection(named: normalizedCurrentName, to: newName, colorHex: colorHex)
isApplyingLocalCollectionMutation = false
collectionNamesCache = nil
guard let normalizedNewName = updatedName else { return }
let selectionChanged = selectedCollectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame
isDeferringLocalStoreRecompute = true
for item in items where item.collectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame {
store.setCollection(item.id, name: normalizedNewName)
}
if selectionChanged {
isBatchingFilterSelectionChanges = true
selectedCollectionName = normalizedNewName
isBatchingFilterSelectionChanges = false
}
isDeferringLocalStoreRecompute = false
onCollectionsChanged?()
recomputeVisibleItems()
statusMessage = "Updated \(normalizedNewName)"
}
func deleteCollection(named name: String) {
isApplyingLocalCollectionMutation = true
let deletedName = settings.deleteCollection(named: name)
isApplyingLocalCollectionMutation = false
collectionNamesCache = nil
guard let normalizedName = deletedName else { return }
let matchingIDs = items
.filter { $0.collectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame }
.map(\.id)
let selectionChanged = selectedCollectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame
isDeferringLocalStoreRecompute = true
for id in matchingIDs {
store.remove(id)
}
if selectionChanged {
isBatchingFilterSelectionChanges = true
selectedCollectionName = nil
selectedCollectionNameFilters.removeAll { $0.caseInsensitiveCompare(normalizedName) == .orderedSame }
isBatchingFilterSelectionChanges = false
} else {
selectedCollectionNameFilters.removeAll { $0.caseInsensitiveCompare(normalizedName) == .orderedSame }
}
isDeferringLocalStoreRecompute = false
onCollectionsChanged?()
recomputeVisibleItems()
statusMessage = "Deleted \(normalizedName)"
}
func clearSearch() {
searchText = ""
}
func showSelectedInClipboard() {
guard canShowSelectedInClipboard, let item = selectedItem else { return }
selectedItemID = item.id
let searchChanged = !searchText.isEmpty
let stackChanged = isStackFilterSelected
let collectionChanged = selectedCollectionName != nil
|| !selectedSortModeFilterRawValues.isEmpty
|| !selectedCollectionNameFilters.isEmpty
let sortChanged = sortMode != .mostRecent
isBatchingFilterSelectionChanges = true
if searchChanged {
searchText = ""
}
if stackChanged {
isStackFilterSelected = false
}
if collectionChanged {
selectedCollectionName = nil
selectedSortModeFilterRawValues.removeAll(keepingCapacity: true)
selectedCollectionNameFilters.removeAll(keepingCapacity: true)
}
if sortChanged {
sortMode = .mostRecent
}
isBatchingFilterSelectionChanges = false
recomputeVisibleItems()
if searchChanged {
notifyMain { self.onSearchTextChanged?(self.searchText) }
}
if sortChanged {
onSortModeChanged?(sortMode)
}
if collectionChanged {
onCollectionsChanged?()
}
if let index = visibleItems.firstIndex(where: { $0.id == item.id }) {
selectedIndex = index
selectedItemID = item.id
}
statusMessage = "Showing in Clipboard"
}
func recomputeVisibleItems() {
pruneStackItems()
isRecomputingVisibleItems = true
defer {
isRecomputingVisibleItems = false
notifyVisibleItemsChanged()
}
let previousSelection = selectedItemID
let previousSelectedIDs = selectedItemIDs
let query = searchText.clipboardTrimmed.lowercased()
if isStackFilterSelected {
let stackedItems = stackItemIDs.compactMap { itemByID[$0] }
visibleItems = computeStackVisibleItems(from: stackedItems, query: query)
} else {
visibleItems = cachedVisibleItems(
query: query,
sortMode: sortMode,
collectionName: selectedCollectionName,
categoryFilters: activeCategoryFilterSelection()
)
}
if let selectedID = previousSelection, let index = visibleIndexByID[selectedID] {
selectedIndex = index
} else if selectedIndex >= visibleItems.count {
selectedIndex = max(0, visibleItems.count - 1)
} else if selectedIndex < 0 {
selectedIndex = 0
}
if visibleItems.isEmpty {
selectedIndex = 0
}
selectedItemID = selectedItem?.id
let visibleIDs = Set(visibleIndexByID.keys)
let visibleSelectedIDs = previousSelectedIDs.filter { visibleIDs.contains($0) }
if previousSelection == nil {
selectedItemIDs = selectedItemID.map { [$0] } ?? []
} else if !visibleSelectedIDs.isEmpty {
selectedItemIDs = visibleSelectedIDs
} else if let selectedItemID {
selectedItemIDs = [selectedItemID]
} else {
selectedItemIDs = []
}
if let selectionAnchorItemID, !visibleIDs.contains(selectionAnchorItemID) {
self.selectionAnchorItemID = selectedItemID
}
}
private func notifyVisibleItemsChanged() {
notifyMain { self.onVisibleItemsChanged?(self.visibleItems) }
}
private func setActiveSelection(
_ item: ClipboardItem,
at index: Int,
selectedIDs: [UUID],
anchorID: UUID?
) {
selectedItemID = item.id
selectionAnchorItemID = anchorID
selectedItemIDs = uniqueIDs(selectedIDs)
if selectedIndex == index {
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
} else {
selectedIndex = index
}
}
private func setIndexBasedSelection(at index: Int) {
guard index >= 0 && index < visibleItems.count else { return }
let item = visibleItems[index]
selectedItemID = nil
selectionAnchorItemID = item.id
selectedItemIDs = [item.id]
if selectedIndex != index {
selectedIndex = index
}
}
private func uniqueIDs(_ ids: [UUID]) -> [UUID] {
var seen = Set<UUID>()
return ids.filter { seen.insert($0).inserted }
}
private func rebuildItemIndexes() {
var nextItemByID: [UUID: ClipboardItem] = [:]
nextItemByID.reserveCapacity(items.count)
var nextItemIDSet = Set<UUID>()
nextItemIDSet.reserveCapacity(items.count)
var nextCollectionItems: [String: [IndexedClipboardItem]] = [:]
var nextAssignedCollectionNames: [String: String] = [:]
var nextSortModeItems: [Int: [IndexedClipboardItem]] = [:]
var allItems: [IndexedClipboardItem] = []
allItems.reserveCapacity(items.count)
for (offset, item) in items.enumerated() {
if nextItemByID[item.id] == nil {
nextItemByID[item.id] = item
}
nextItemIDSet.insert(item.id)
let indexedItem = IndexedClipboardItem(offset: offset, item: item)
allItems.append(indexedItem)
if let collectionName = ClipboardCollectionDefaults.normalizedName(item.collectionName) {
let collectionKey = collectionName.lowercased()
nextAssignedCollectionNames[collectionKey] = collectionName
nextCollectionItems[collectionKey, default: []].append(indexedItem)
}
appendIndexedItem(indexedItem, for: item, to: &nextSortModeItems)
}
nextSortModeItems[ClipboardSortMode.mostRecent.rawValue] = allItems
nextSortModeItems[ClipboardSortMode.mostUsed.rawValue] = allItems
itemByID = nextItemByID
itemIDSet = nextItemIDSet
indexedItemsByCollectionKey = nextCollectionItems
indexedItemsBySortMode = nextSortModeItems
assignedCollectionNamesByKey = nextAssignedCollectionNames
}
private func appendIndexedItem(
_ indexedItem: IndexedClipboardItem,
for item: ClipboardItem,
to indexedItemsBySortMode: inout [Int: [IndexedClipboardItem]]
) {
switch item.kind {
case .image:
indexedItemsBySortMode[ClipboardSortMode.images.rawValue, default: []].append(indexedItem)
case .url:
indexedItemsBySortMode[ClipboardSortMode.links.rawValue, default: []].append(indexedItem)
case .text, .richText:
indexedItemsBySortMode[ClipboardSortMode.text.rawValue, default: []].append(indexedItem)
case .code:
indexedItemsBySortMode[ClipboardSortMode.text.rawValue, default: []].append(indexedItem)
indexedItemsBySortMode[ClipboardSortMode.code.rawValue, default: []].append(indexedItem)
case .file, .pdf:
indexedItemsBySortMode[ClipboardSortMode.files.rawValue, default: []].append(indexedItem)
case .audio:
indexedItemsBySortMode[ClipboardSortMode.audio.rawValue, default: []].append(indexedItem)
case .color:
indexedItemsBySortMode[ClipboardSortMode.colors.rawValue, default: []].append(indexedItem)
case .video:
indexedItemsBySortMode[ClipboardSortMode.videos.rawValue, default: []].append(indexedItem)
case .unknown:
break
}
if item.isPinned {
indexedItemsBySortMode[ClipboardSortMode.pinned.rawValue, default: []].append(indexedItem)
}
}
private func uniqueSearchFacetValues(_ values: [String?]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for value in values {
guard let normalized = value?.clipboardTrimmed, !normalized.isEmpty else { continue }
let key = Self.normalizedSearchValue(normalized)
guard seen.insert(key).inserted else { continue }
result.append(normalized)
}
return result.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
private func selectedItemsInSelectionOrder() -> [ClipboardItem] {
let selectedItems = selectedItemIDs.compactMap { visibleItemByID[$0] }
if !selectedItems.isEmpty {
return selectedItems
}
return selectedItem.map { [$0] } ?? []
}
private func nextStackItem() -> ClipboardItem? {
pruneStackItems()
guard let id = stackItemIDs.first else { return nil }
return itemByID[id]
}
private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
pruneStackItems()
let pairs: [(item: ClipboardItem, text: String)] = stackItemIDs.compactMap { id in
guard let item = itemByID[id],
let text = pasteService.plainText(for: item)?.clipboardTrimmed,
!text.isEmpty else {
return nil
}
return (item, text)
}
guard !pairs.isEmpty else { return nil }
return (pairs.map(\.text).joined(separator: "\n\n"), pairs.map(\.item))
}
private func selectedPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
let pairs: [(item: ClipboardItem, text: String)] = selectedItemsInSelectionOrder().compactMap { item in
guard let text = pasteService.plainText(for: item)?.clipboardTrimmed, !text.isEmpty else {
return nil
}
return (item, text)
}
guard !pairs.isEmpty else { return nil }
return (pairs.map(\.text).joined(separator: "\n\n"), pairs.map(\.item))
}
private func handleStackActionResult(_ result: PasteActionService.PasteActionResult, item: ClipboardItem) {
if case .failed(let message) = result {
statusMessage = message
return
}
consumeStackItem(item.id)
store.markUsed(item.id)
selectedItemID = item.id
switch result {
case .copiedNeedsPermission:
statusMessage = "Copied from Stack. Grant Accessibility access to paste automatically."
case .pasted:
statusMessage = "Pasted from Stack"
case .copied:
statusMessage = "Copied from Stack"
default:
statusMessage = result.message
}
settings.setPasteStatus(message: statusMessage)
}
private func handleStackPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
if case .failed(let message) = result {
statusMessage = message
return
}
for item in items {
store.markUsed(item.id)
consumeStackItem(item.id, refreshActiveStackFilter: false)
}
if stackItemIDs.isEmpty {
isStackFilterSelected = false
} else if isStackFilterSelected {
recomputeVisibleItems()
}
selectedItemID = items.first?.id
let noun = items.count == 1 ? "clip" : "clips"
switch result {
case .pastedPlainText:
statusMessage = "Pasted \(items.count) Stack \(noun) as Text"
case .copiedPlainTextNeedsPermission:
statusMessage = "Copied \(items.count) Stack \(noun) as Text. Grant Accessibility access to paste automatically."
case .copiedPlainText:
statusMessage = "Copied \(items.count) Stack \(noun) as Text"
default:
statusMessage = result.message
}
settings.setPasteStatus(message: statusMessage)
}
private func handleSelectedActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
if case .failed(let message) = result {
statusMessage = message
return
}
for item in items {
store.markUsed(item.id)
}
selectedItemID = items.first?.id
let noun = items.count == 1 ? "clip" : "clips"
switch result {
case .pasted:
statusMessage = "Pasted \(items.count) selected \(noun)"
case .copiedNeedsPermission:
statusMessage = "Copied \(items.count) selected \(noun). Grant Accessibility access to paste automatically."
case .copied:
statusMessage = "Copied \(items.count) selected \(noun)"
default:
statusMessage = result.message
}
settings.setPasteStatus(message: statusMessage)
}
private func handleSelectedPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
if case .failed(let message) = result {
statusMessage = message
return
}
for item in items {
store.markUsed(item.id)
}
selectedItemID = items.first?.id
let noun = items.count == 1 ? "clip" : "clips"
switch result {
case .pastedPlainText:
statusMessage = "Pasted \(items.count) selected \(noun) as Text"
case .copiedPlainTextNeedsPermission:
statusMessage = "Copied \(items.count) selected \(noun) as Text. Grant Accessibility access to paste automatically."
case .copiedPlainText:
statusMessage = "Copied \(items.count) selected \(noun) as Text"
default:
statusMessage = result.message
}
settings.setPasteStatus(message: statusMessage)
}
private func consumeStackItem(_ id: UUID, refreshActiveStackFilter: Bool = true) {
guard let index = stackItemIDs.firstIndex(of: id) else { return }
stackItemIDs.remove(at: index)
if refreshActiveStackFilter && isStackFilterSelected {
recomputeVisibleItems()
}
}
private func pruneStackItems() {
guard !stackItemIDs.isEmpty else { return }
let pruned = stackItemIDs.filter { itemIDSet.contains($0) }
if pruned != stackItemIDs {
stackItemIDs = pruned
}
if stackItemIDs.isEmpty && isStackFilterSelected && !isStackCaptureEnabled {
isStackFilterSelected = false
}
}
private func purgePendingDeletionUndo() {
guard !pendingDeletionUndo.isEmpty else { return }
store.purgeManagedCacheReferences(for: pendingDeletionUndo)
pendingDeletionUndo = []
}
private func finishClearHistory(removedCount: Int) {
guard removedCount > 0 else {
statusMessage = "No clips to clear"
return
}
let noun = removedCount == 1 ? "clip" : "clips"
statusMessage = "Cleared \(removedCount) \(noun)"
}
private func computeStackVisibleItems(from items: [ClipboardItem], query: String) -> [ClipboardItem] {
let parsedQuery = parseSearchQuery(query)
guard !parsedQuery.isEmpty else { return items }
return items.filter { matchesSearchQuery($0, query: parsedQuery) }
}
private func cachedVisibleItems(
query: String,
sortMode: ClipboardSortMode,
collectionName: String?,
categoryFilters: CategoryFilterSelection = .empty
) -> [ClipboardItem] {
let collectionNameKey = ClipboardCollectionDefaults.normalizedName(collectionName)?.lowercased()
let key = VisibleItemsCacheKey(
query: query,
sortMode: sortMode.rawValue,
collectionNameKey: collectionNameKey,
sortModeFilterRawValues: categoryFilters.sortModeRawValues,
collectionNameFilterKeys: categoryFilters.collectionNameKeys
)
if let cached = visibleItemsCache[key] {
return cached
}
let computed: [ClipboardItem]
if query.isEmpty,
var indexedItems = indexedVisibleItems(
sortMode: sortMode,
collectionName: collectionName,
categoryFilters: categoryFilters
) {
sortIndexedItems(
&indexedItems,
ordering: sortOrdering(sortMode: sortMode, collectionName: collectionName, categoryFilters: categoryFilters)
)
computed = indexedItems.map(\.item)
#if DEBUG
debugVisibleItemsIndexedLookupCount += 1
#endif
} else {
computed = computeVisibleItems(
from: items,
query: query,
sortMode: sortMode,
collectionName: collectionName,
categoryFilters: categoryFilters
)
#if DEBUG
debugVisibleItemsFullScanCount += 1
#endif
}
if visibleItemsCache.count > 24 {
visibleItemsCache.removeAll(keepingCapacity: true)
}
visibleItemsCache[key] = computed
return computed
}
private func indexedVisibleItems(
sortMode: ClipboardSortMode,
collectionName: String?,
categoryFilters: CategoryFilterSelection = .empty
) -> [IndexedClipboardItem]? {
if !categoryFilters.isEmpty {
return indexedVisibleItems(matching: categoryFilters)
}
if let collectionKey = ClipboardCollectionDefaults.normalizedName(collectionName)?.lowercased() {
return indexedItemsByCollectionKey[collectionKey] ?? []
}
return indexedItemsBySortMode[sortMode.rawValue]
}
private func indexedVisibleItems(matching categoryFilters: CategoryFilterSelection) -> [IndexedClipboardItem] {
var indexedItemsByID: [UUID: IndexedClipboardItem] = [:]
for rawValue in categoryFilters.sortModeRawValues {
for indexedItem in indexedItemsBySortMode[rawValue] ?? [] {
if indexedItemsByID[indexedItem.item.id] == nil {
indexedItemsByID[indexedItem.item.id] = indexedItem
}
}
}
for collectionKey in categoryFilters.collectionNameKeys {
for indexedItem in indexedItemsByCollectionKey[collectionKey] ?? [] {
if indexedItemsByID[indexedItem.item.id] == nil {
indexedItemsByID[indexedItem.item.id] = indexedItem
}
}
}
return Array(indexedItemsByID.values)
}
internal func computeVisibleItems(
from items: [ClipboardItem],
query: String,
sortMode: ClipboardSortMode,
collectionName: String? = nil
) -> [ClipboardItem] {
computeVisibleItems(
from: items,
query: query,
sortMode: sortMode,
collectionName: collectionName,
categoryFilters: .empty
)
}
private func computeVisibleItems(
from items: [ClipboardItem],
query: String,
sortMode: ClipboardSortMode,
collectionName: String? = nil,
categoryFilters: CategoryFilterSelection = .empty
) -> [ClipboardItem] {
let parsedQuery = parseSearchQuery(query)
let normalizedCollectionName = ClipboardCollectionDefaults.normalizedName(collectionName)
var filtered: [IndexedClipboardItem] = []
filtered.reserveCapacity(items.count)
for (offset, item) in items.enumerated() {
if !parsedQuery.isEmpty, !matchesSearchQuery(item, query: parsedQuery) {
continue
}
if !categoryFilters.isEmpty {
guard itemMatchesCategoryFilters(item, filters: categoryFilters) else {
continue
}
} else if let normalizedCollectionName {
guard item.collectionName?.caseInsensitiveCompare(normalizedCollectionName) == .orderedSame else {
continue
}
} else if !sortMode.includes(item) {
continue
}
filtered.append(IndexedClipboardItem(offset: offset, item: item))
}
sortIndexedItems(
&filtered,
ordering: sortOrdering(sortMode: sortMode, collectionName: collectionName, categoryFilters: categoryFilters)
)
return filtered.map(\.item)
}
private func sortOrdering(
sortMode: ClipboardSortMode,
collectionName: String?,
categoryFilters: CategoryFilterSelection = .empty
) -> VisibleItemsSortOrdering {
if categoryFilters.totalCount > 1 {
return .recency
}
if let rawValue = categoryFilters.sortModeRawValues.first,
let mode = ClipboardSortMode(rawValue: rawValue),
categoryFilters.collectionNameKeys.isEmpty {
return sortOrdering(sortMode: mode, collectionName: nil)
}
if !categoryFilters.collectionNameKeys.isEmpty {
return .recency
}
if ClipboardCollectionDefaults.normalizedName(collectionName) != nil {
return .recency
}
switch sortMode {
case .mostRecent, .pinned:
return .recency
case .mostUsed:
return .useCount
case .images, .links, .text, .files, .audio, .videos, .colors, .code:
return .lastUsed
@unknown default:
return .original
}
}
private func activeCategoryFilterSelection() -> CategoryFilterSelection {
guard !selectedSortModeFilterRawValues.isEmpty || !selectedCollectionNameFilters.isEmpty else {
return .empty
}
let sortModeRawValues = selectedSortModeFilterRawValues.sorted()
let collectionKeys = selectedCollectionNameFilters
.compactMap { ClipboardCollectionDefaults.normalizedName($0)?.lowercased() }
.sorted()
return CategoryFilterSelection(
sortModeRawValues: sortModeRawValues,
collectionNameKeys: collectionKeys
)
}
private func itemMatchesCategoryFilters(_ item: ClipboardItem, filters: CategoryFilterSelection) -> Bool {
for rawValue in filters.sortModeRawValues {
if let mode = ClipboardSortMode(rawValue: rawValue), mode.includes(item) {
return true
}
}
guard let collectionName = ClipboardCollectionDefaults.normalizedName(item.collectionName)?.lowercased() else {
return false
}
return filters.collectionNameKeys.contains(collectionName)
}
private func sortIndexedItems(
_ items: inout [IndexedClipboardItem],
ordering: VisibleItemsSortOrdering
) {
guard items.count > 1 else { return }
items.sort { lhs, rhs in
switch ordering {
case .recency:
return recencyPrecedes(lhs, rhs)
case .useCount:
return useCountPrecedes(lhs, rhs)
case .lastUsed:
return lastUsedPrecedes(lhs, rhs)
case .original:
return lhs.offset < rhs.offset
}
}
}
private func recencyPrecedes(_ lhs: IndexedClipboardItem, _ rhs: IndexedClipboardItem) -> Bool {
if lhs.item.lastUsedAt == rhs.item.lastUsedAt {
if lhs.item.createdAt == rhs.item.createdAt {
return lhs.offset < rhs.offset
}
return lhs.item.createdAt > rhs.item.createdAt
}
return lhs.item.lastUsedAt > rhs.item.lastUsedAt
}
private func useCountPrecedes(_ lhs: IndexedClipboardItem, _ rhs: IndexedClipboardItem) -> Bool {
if lhs.item.useCount == rhs.item.useCount {
return lastUsedPrecedes(lhs, rhs)
}
return lhs.item.useCount > rhs.item.useCount
}
private func lastUsedPrecedes(_ lhs: IndexedClipboardItem, _ rhs: IndexedClipboardItem) -> Bool {
if lhs.item.lastUsedAt == rhs.item.lastUsedAt {
return lhs.offset < rhs.offset
}
return lhs.item.lastUsedAt > rhs.item.lastUsedAt
}
private func searchableText(for item: ClipboardItem) -> String {
var base = Self.normalizedSearchValue(item.searchableText)
if settings.includeImageTextInSearch, let ocrText = item.ocrText {
base += " \(Self.normalizedSearchValue(ocrText))"
}
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.appTokenGroups.isEmpty {
let sourceValues = [item.sourceApp, item.sourceAppBundleId]
.compactMap { $0 }
.map(normalizedStructuredValue)
.filter { !$0.isEmpty }
let sourceText = sourceValues.joined(separator: " ")
guard !sourceValues.isEmpty,
query.appTokenGroups.contains(where: { group in
if let exactValue = group.exactValue {
return sourceValues.contains(exactValue)
}
return group.tokens.allSatisfy { sourceText.contains($0) }
}) else {
return false
}
}
if !query.deviceTokenGroups.isEmpty {
let device = normalizedStructuredValue(item.effectiveSourceDeviceName)
guard query.deviceTokenGroups.contains(where: { group in
if let exactValue = group.exactValue {
return device == exactValue
}
return group.tokens.allSatisfy { device.contains($0) }
}) else {
return false
}
}
if !query.collectionTokenGroups.isEmpty {
guard let collectionName = item.collectionName,
!collectionName.clipboardTrimmed.isEmpty else {
return false
}
let collection = normalizedStructuredValue(collectionName)
guard !collection.isEmpty,
query.collectionTokenGroups.contains(where: { group in
if let exactValue = group.exactValue {
return collection == exactValue
}
return group.tokens.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 searchParts(from: query) {
guard !part.isEmpty else { continue }
guard let delimiter = part.firstIndex(of: ":") else {
parsed.textTokens.append(contentsOf: searchTokens(from: part))
continue
}
let key = Self.normalizedSearchValue(String(part[..<delimiter]))
let value = String(part[part.index(after: delimiter)...]).clipboardTrimmed
guard !value.isEmpty, applyStructuredSearchToken(key: key, value: value, to: &parsed) else {
parsed.textTokens.append(contentsOf: searchTokens(from: part))
continue
}
}
return parsed
}
@discardableResult
private func applyStructuredSearchToken(key: String, value: String, to query: inout ParsedSearchQuery) -> Bool {
switch key {
case "app", "source", "from":
let groups = structuredTokenGroups(from: value)
guard !groups.isEmpty else { return false }
query.appTokenGroups.append(contentsOf: groups)
return true
case "device", "devices", "machine", "computer":
let groups = structuredTokenGroups(from: value)
guard !groups.isEmpty else { return false }
query.deviceTokenGroups.append(contentsOf: groups)
return true
case "collection", "folder", "list", "pinboard", "pinboards", "board", "boards":
let groups = structuredTokenGroups(from: value)
guard !groups.isEmpty else { return false }
query.collectionTokenGroups.append(contentsOf: groups)
return true
case "type", "kind":
var matchedKinds = Set<ClipboardItemKind>()
for segment in structuredValueSegments(from: value) {
guard let kinds = itemKinds(matching: segment.value), !kinds.isEmpty else { return false }
matchedKinds.formUnion(kinds)
}
guard !matchedKinds.isEmpty else { return false }
query.typeKinds.formUnion(matchedKinds)
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 searchParts(from query: String) -> [String] {
var parts: [String] = []
var current = ""
var quotedBy: Character?
var isEscaping = false
func flushCurrent() {
let part = current.clipboardTrimmed
if !part.isEmpty {
parts.append(part)
}
current = ""
}
for character in query {
if isEscaping {
current.append("\\")
current.append(character)
isEscaping = false
continue
}
if character == "\\" && quotedBy != nil {
isEscaping = true
continue
}
if character == "\"" || character == "'" {
if quotedBy == character {
current.append(character)
quotedBy = nil
continue
}
if quotedBy == nil {
quotedBy = character
current.append(character)
continue
}
}
if character.isWhitespace && quotedBy == nil {
flushCurrent()
} else {
current.append(character)
}
}
if isEscaping {
current.append("\\")
}
flushCurrent()
return parts
}
private func structuredValueSegments(from value: String) -> [StructuredFilterValue] {
splitStructuredValues(from: value)
.compactMap { segment in
let trimmed = segment.clipboardTrimmed
guard !trimmed.isEmpty else { return nil }
if let unquoted = unquotedStructuredValue(trimmed) {
let normalized = normalizedStructuredValue(unquoted)
return normalized.isEmpty ? nil : StructuredFilterValue(value: normalized, isExact: true)
}
let normalized = normalizedStructuredValue(trimmed)
return normalized.isEmpty ? nil : StructuredFilterValue(value: normalized, isExact: false)
}
}
private func structuredTokenGroups(from value: String) -> [StructuredFilterMatcher] {
structuredValueSegments(from: value)
.compactMap { segment in
let tokens = searchTokens(from: segment.value)
guard !tokens.isEmpty else { return nil }
return StructuredFilterMatcher(tokens: tokens, exactValue: segment.isExact ? segment.value : nil)
}
}
private func splitStructuredValues(from value: String) -> [String] {
var segments: [String] = []
var current = ""
var quotedBy: Character?
var isEscaping = false
func flushCurrent() {
segments.append(current)
current = ""
}
for character in value {
if isEscaping {
current.append("\\")
current.append(character)
isEscaping = false
continue
}
if character == "\\" && quotedBy != nil {
isEscaping = true
continue
}
if character == "\"" || character == "'" {
if quotedBy == character {
current.append(character)
quotedBy = nil
continue
}
if quotedBy == nil {
quotedBy = character
current.append(character)
continue
}
}
if character == "," && quotedBy == nil {
flushCurrent()
} else {
current.append(character)
}
}
if isEscaping {
current.append("\\")
}
flushCurrent()
return segments
}
private func unquotedStructuredValue(_ value: String) -> String? {
guard let first = value.first,
(first == "\"" || first == "'"),
value.last == first,
value.count >= 2 else {
return nil
}
let inner = String(value.dropFirst().dropLast())
var unescaped = ""
var isEscaping = false
for character in inner {
if isEscaping {
unescaped.append(character)
isEscaping = false
} else if character == "\\" {
isEscaping = true
} else {
unescaped.append(character)
}
}
if isEscaping {
unescaped.append("\\")
}
return unescaped
}
private func normalizedStructuredValue(_ value: String) -> String {
Self.normalizedSearchValue(value)
}
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 "code", "snippet", "snippets", "source", "programming", "script", "scripts", "json", "css", "sql":
return [.code]
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 "video", "videos", "movie", "movies", "mp4", "quicktime", "mov":
return [.video]
case "color", "colors", "swatch", "swatches", "hex":
return [.color]
case "unknown", "item":
return [.unknown]
default:
return nil
}
}
private func booleanValue(from value: String) -> Bool? {
switch Self.normalizedSearchValue(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 assign(item: ClipboardItem, to collectionName: String?) {
let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName)
if let normalizedName {
isApplyingLocalCollectionMutation = true
settings.ensureCollection(named: normalizedName)
isApplyingLocalCollectionMutation = false
}
isDeferringLocalStoreRecompute = true
store.setCollection(item.id, name: normalizedName)
isDeferringLocalStoreRecompute = false
recomputeVisibleItems()
onCollectionsChanged?()
if let normalizedName {
statusMessage = "Added to \(normalizedName)"
} else {
statusMessage = "Removed from collection"
}
}
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] {
Self.normalizedSearchValue(query)
.split { character in
character.isWhitespace || character.isPunctuation
}
.map(String.init)
}
private static func normalizedSearchValue(_ value: String) -> String {
value.clipboardTrimmed
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: searchLocale)
.lowercased()
}
private func notifyMain(_ block: @escaping () -> Void) {
if Thread.isMainThread {
block()
} else {
DispatchQueue.main.async(execute: block)
}
}
}
private extension Calendar {
static var searchCalendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.locale = Locale(identifier: "en_US_POSIX")
calendar.timeZone = .autoupdatingCurrent
return calendar
}
}