Files
clipbored/sources/clipbored/views/ClipboardPanelViewModel.swift
2026-06-30 04:01:57 -07:00

979 lines
30 KiB
Swift

import Foundation
import AppKit
final class ClipboardPanelViewModel {
private static let searchDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar.searchCalendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
private struct ParsedSearchQuery {
var textTokens: [String] = []
var appTokens: [String] = []
var collectionTokens: [String] = []
var typeKinds: Set<ClipboardItemKind> = []
var createdAfter: Date?
var createdBefore: Date?
var pinned: Bool?
var isEmpty: Bool {
textTokens.isEmpty
&& appTokens.isEmpty
&& collectionTokens.isEmpty
&& typeKinds.isEmpty
&& createdAfter == nil
&& createdBefore == nil
&& pinned == nil
}
}
private(set) var visibleItems: [ClipboardItem] = [] {
didSet { notifyMain { self.onVisibleItemsChanged?(self.visibleItems) } }
}
var searchText: String = "" {
didSet {
guard oldValue != searchText else { return }
selectedItemID = selectedItem?.id
recomputeVisibleItems()
}
}
var sortMode: ClipboardSortMode {
didSet {
guard oldValue != sortMode else { return }
isStackFilterSelected = false
selectedCollectionName = nil
settings.defaultSortMode = sortMode
recomputeVisibleItems()
onSortModeChanged?(sortMode)
}
}
private(set) var selectedCollectionName: String? {
didSet {
guard oldValue != selectedCollectionName else { return }
recomputeVisibleItems()
onCollectionsChanged?()
}
}
private(set) var isStackFilterSelected = false {
didSet {
guard oldValue != isStackFilterSelected else { return }
recomputeVisibleItems()
onStackChanged?()
}
}
var selectedIndex: Int = 0 {
didSet {
guard oldValue != selectedIndex else { return }
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
}
}
private(set) var statusMessage: String = "" {
didSet { notifyMain { self.onStatusMessageChanged?(self.statusMessage) } }
}
private var items: [ClipboardItem] = []
private let store: ClipboardStore
private let settings: SettingsModel
private let cacheService: ClipboardCacheService
private let pasteService: PasteActionService
private var selectedItemID: UUID?
private var stackItemIDs: [UUID] = [] {
didSet {
guard oldValue != stackItemIDs else { return }
notifyMain { self.onStackChanged?() }
}
}
var targetApplicationProvider: () -> NSRunningApplication? = { nil }
var willPasteToTarget: () -> Void = {}
var onVisibleItemsChanged: (([ClipboardItem]) -> Void)?
var onSelectedIndexChanged: ((Int) -> Void)?
var onStatusMessageChanged: ((String) -> Void)?
var onSortModeChanged: ((ClipboardSortMode) -> Void)?
var onCollectionsChanged: (() -> Void)?
var onStackChanged: (() -> Void)?
var onCaptureStatusChanged: (() -> Void)?
init(store: ClipboardStore, settings: SettingsModel, cacheService: ClipboardCacheService) {
self.store = store
self.settings = settings
self.cacheService = cacheService
self.sortMode = settings.defaultSortMode
self.pasteService = PasteActionService(cacheService: cacheService)
store.observeItems { [weak self] list in
self?.notifyMain {
self?.items = list
self?.recomputeVisibleItems()
}
}
settings.observe { [weak self] change in
self?.notifyMain {
switch change {
case .captureStatus:
self?.statusMessage = ""
self?.onStatusMessageChanged?("")
self?.onCaptureStatusChanged?()
case .collections:
self?.recomputeVisibleItems()
self?.onCollectionsChanged?()
default:
break
}
}
}
}
var selectedItem: ClipboardItem? {
guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil }
return visibleItems[selectedIndex]
}
var canShowSelectedInClipboard: Bool {
selectedItem != nil && canShowVisibleItemsInClipboard
}
var canShowVisibleItemsInClipboard: Bool {
!visibleItems.isEmpty
&& (!searchText.clipboardTrimmed.isEmpty
|| sortMode != .mostRecent
|| selectedCollectionName != nil
|| isStackFilterSelected)
}
var totalItemCount: Int {
items.count
}
var stackCount: Int {
stackItemIDs.count
}
var stackTitle: String {
"Stack"
}
var collectionNames: [String] {
let assignedNames = Set(
items.compactMap { item -> String? in
ClipboardCollectionDefaults.normalizedName(item.collectionName)
}
)
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 }
return defaultNames + configuredCustomNames + assignedCustomNames
}
func collectionCount(for sortMode: ClipboardSortMode) -> Int {
let query = searchText.clipboardTrimmed.lowercased()
return computeVisibleItems(from: items, query: query, sortMode: sortMode).count
}
func collectionCount(named name: String) -> Int {
let query = searchText.clipboardTrimmed.lowercased()
return computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: name).count
}
var captureStatusMessage: String {
settings.captureStatusMessage
}
func thumbnail(for item: ClipboardItem) -> NSImage? {
cacheService.previewThumbnail(for: item)
}
func selectItem(at index: Int) {
guard index >= 0 && index < visibleItems.count else { return }
selectedIndex = index
}
func selectFirstItem() {
guard !visibleItems.isEmpty else { return }
selectedItemID = nil
if selectedIndex == 0 {
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
} else {
selectedIndex = 0
}
}
func moveSelection(_ delta: Int) {
let count = visibleItems.count
guard count > 0 else { return }
let target = max(0, min(count - 1, selectedIndex + delta))
selectedIndex = target
}
func pasteSelected() {
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() {
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() {
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() {
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 stackItemIDs.contains(visibleItems[index].id)
}
func toggleSelectedStackMembership() {
guard let item = selectedItem else { return }
if let existingIndex = stackItemIDs.firstIndex(of: item.id) {
stackItemIDs.remove(at: existingIndex)
statusMessage = "Removed from Stack"
return
}
stackItemIDs.append(item.id)
statusMessage = "Added to Stack"
}
func selectStack() {
guard !stackItemIDs.isEmpty else { return }
selectedCollectionName = nil
isStackFilterSelected = true
}
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 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 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 else { return nil }
return item.payload
}
func updateSelectedText(to text: String) {
guard let item = selectedItem, item.kind == .text 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 = "Updated text clip"
}
}
func previewURLForSelected() -> URL? {
guard let item = selectedItem else { return nil }
return previewURL(for: item)
}
internal func previewURL(for item: ClipboardItem) -> URL? {
cacheService.temporaryPreviewURL(for: item)
}
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)
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])
default:
break
}
}
func deleteSelected() {
guard let item = selectedItem else { return }
store.remove(item.id)
let next = max(0, min(visibleItems.count - 2, selectedIndex))
selectedIndex = next
}
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)
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
selectedCollectionName = normalizedName
}
func createCollection(named name: String, colorHex: String? = nil, selectAfterCreate: Bool = true) {
guard let normalizedName = settings.ensureCollection(named: name, colorHex: colorHex) else { return }
statusMessage = "Created \(normalizedName)"
if selectAfterCreate {
selectCollection(named: normalizedName)
} else {
recomputeVisibleItems()
onCollectionsChanged?()
}
}
func collectionColorHex(named name: String) -> String? {
settings.collectionColorHex(forCollectionNamed: name)
}
func updateCollection(named currentName: String, to newName: String, colorHex: String? = nil) {
guard let normalizedCurrentName = ClipboardCollectionDefaults.normalizedName(currentName),
let normalizedNewName = settings.updateCollection(named: normalizedCurrentName, to: newName, colorHex: colorHex) else {
return
}
for item in items where item.collectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame {
store.setCollection(item.id, name: normalizedNewName)
}
if selectedCollectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame {
selectedCollectionName = normalizedNewName
} else {
recomputeVisibleItems()
}
statusMessage = "Updated \(normalizedNewName)"
}
func deleteCollection(named name: String) {
guard let normalizedName = settings.deleteCollection(named: name) else { return }
let matchingIDs = items
.filter { $0.collectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame }
.map(\.id)
for id in matchingIDs {
store.remove(id)
}
if selectedCollectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame {
selectedCollectionName = nil
} else {
recomputeVisibleItems()
}
statusMessage = "Deleted \(normalizedName)"
}
func clearSearch() {
searchText = ""
}
func showSelectedInClipboard() {
guard canShowSelectedInClipboard, let item = selectedItem else { return }
selectedItemID = item.id
if !searchText.isEmpty {
searchText = ""
}
if isStackFilterSelected {
isStackFilterSelected = false
}
if selectedCollectionName != nil {
selectedCollectionName = nil
}
if sortMode != .mostRecent {
sortMode = .mostRecent
}
if let index = visibleItems.firstIndex(where: { $0.id == item.id }) {
selectedIndex = index
selectedItemID = item.id
}
statusMessage = "Showing in Clipboard"
}
func recomputeVisibleItems() {
pruneStackItems()
let previousSelection = selectedItemID
let query = searchText.clipboardTrimmed.lowercased()
if isStackFilterSelected {
let stackedItems = stackItemIDs.compactMap { id in
items.first { $0.id == id }
}
visibleItems = computeStackVisibleItems(from: stackedItems, query: query)
} else {
visibleItems = computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: selectedCollectionName)
}
if let selectedID = previousSelection, let index = visibleItems.firstIndex(where: { $0.id == 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
onCollectionsChanged?()
}
private func nextStackItem() -> ClipboardItem? {
pruneStackItems()
guard let id = stackItemIDs.first else { return nil }
return items.first { $0.id == id }
}
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 consumeStackItem(_ id: UUID) {
guard let index = stackItemIDs.firstIndex(of: id) else { return }
stackItemIDs.remove(at: index)
}
private func pruneStackItems() {
guard !stackItemIDs.isEmpty else { return }
let existingIDs = Set(items.map(\.id))
let pruned = stackItemIDs.filter { existingIDs.contains($0) }
if pruned != stackItemIDs {
stackItemIDs = pruned
}
if stackItemIDs.isEmpty && isStackFilterSelected {
isStackFilterSelected = false
}
}
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) }
}
internal func computeVisibleItems(
from items: [ClipboardItem],
query: String,
sortMode: ClipboardSortMode,
collectionName: String? = nil
) -> [ClipboardItem] {
let parsedQuery = parseSearchQuery(query)
let filtered = parsedQuery.isEmpty
? items.enumerated().map { ($0.offset, $0.element) }
: items.enumerated().compactMap { index, item in
return matchesSearchQuery(item, query: parsedQuery) ? (index, item) : nil
}
let collectionFiltered: [(Int, ClipboardItem)]
if let collectionName = ClipboardCollectionDefaults.normalizedName(collectionName) {
collectionFiltered = filtered.filter {
$0.1.collectionName?.caseInsensitiveCompare(collectionName) == .orderedSame
}
} else {
collectionFiltered = filtered
}
func fallback(_ lhs: (Int, ClipboardItem), _ rhs: (Int, ClipboardItem)) -> Bool {
return lhs.0 < rhs.0
}
func sortByUsage(_ lhs: (Int, ClipboardItem), _ rhs: (Int, ClipboardItem)) -> Bool {
if lhs.1.lastUsedAt == rhs.1.lastUsedAt {
return fallback(lhs, rhs)
}
return lhs.1.lastUsedAt > rhs.1.lastUsedAt
}
if ClipboardCollectionDefaults.normalizedName(collectionName) != nil {
return collectionFiltered
.sorted {
if $0.1.lastUsedAt == $1.1.lastUsedAt {
if $0.1.createdAt == $1.1.createdAt { return fallback($0, $1) }
return $0.1.createdAt > $1.1.createdAt
}
return $0.1.lastUsedAt > $1.1.lastUsedAt
}
.map(\.1)
}
switch sortMode {
case .mostRecent:
return collectionFiltered
.sorted {
if $0.1.lastUsedAt == $1.1.lastUsedAt {
if $0.1.createdAt == $1.1.createdAt { return fallback($0, $1) }
return $0.1.createdAt > $1.1.createdAt
}
return $0.1.lastUsedAt > $1.1.lastUsedAt
}
.map(\.1)
case .mostUsed:
return collectionFiltered
.sorted {
if $0.1.useCount == $1.1.useCount { return sortByUsage($0, $1) }
return $0.1.useCount > $1.1.useCount
}
.map(\.1)
case .images:
return collectionFiltered
.filter { $0.1.kind == .image }
.sorted(by: sortByUsage)
.map(\.1)
case .links:
return collectionFiltered
.filter { $0.1.kind == .url }
.sorted(by: sortByUsage)
.map(\.1)
case .text:
return collectionFiltered
.filter { $0.1.kind == .text || $0.1.kind == .richText }
.sorted(by: sortByUsage)
.map(\.1)
case .files:
return collectionFiltered
.filter { $0.1.kind == .file || $0.1.kind == .pdf }
.sorted(by: sortByUsage)
.map(\.1)
case .audio:
return collectionFiltered
.filter { $0.1.kind == .audio }
.sorted(by: sortByUsage)
.map(\.1)
case .pinned:
return collectionFiltered
.filter { $0.1.isPinned }
.sorted {
if $0.1.lastUsedAt == $1.1.lastUsedAt {
if $0.1.createdAt == $1.1.createdAt {
return fallback($0, $1)
}
return $0.1.createdAt > $1.1.createdAt
}
return $0.1.lastUsedAt > $1.1.lastUsedAt
}
.map(\.1)
@unknown default:
return collectionFiltered
.sorted(by: { lhs, rhs in
return fallback(lhs, rhs)
})
.map(\.1)
}
}
private func searchableText(for item: ClipboardItem) -> String {
var base = item.searchableText
if settings.includeImageTextInSearch, let ocrText = item.ocrText {
base += " \(ocrText.lowercased())"
}
return base
}
private func matchesSearchQuery(_ item: ClipboardItem, query: ParsedSearchQuery) -> Bool {
if !query.textTokens.isEmpty {
let text = searchableText(for: item)
guard query.textTokens.allSatisfy({ text.contains($0) }) else { return false }
}
if !query.appTokens.isEmpty {
let source = [item.sourceApp, item.sourceAppBundleId]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
guard !source.isEmpty,
query.appTokens.allSatisfy({ source.contains($0) }) else {
return false
}
}
if !query.collectionTokens.isEmpty {
guard let collection = item.collectionName?.lowercased(),
query.collectionTokens.allSatisfy({ collection.contains($0) }) else {
return false
}
}
if !query.typeKinds.isEmpty, !query.typeKinds.contains(item.kind) {
return false
}
if let pinned = query.pinned, item.isPinned != pinned {
return false
}
if let createdAfter = query.createdAfter, item.createdAt < createdAfter {
return false
}
if let createdBefore = query.createdBefore, item.createdAt >= createdBefore {
return false
}
return true
}
private func parseSearchQuery(_ query: String) -> ParsedSearchQuery {
var parsed = ParsedSearchQuery()
for part in query.split(whereSeparator: { $0.isWhitespace }).map(String.init) {
guard !part.isEmpty else { continue }
guard let delimiter = part.firstIndex(of: ":") else {
parsed.textTokens.append(contentsOf: searchTokens(from: part.lowercased()))
continue
}
let key = String(part[..<delimiter]).lowercased()
let value = String(part[part.index(after: delimiter)...]).clipboardTrimmed.lowercased()
guard !value.isEmpty, applyStructuredSearchToken(key: key, value: value, to: &parsed) else {
parsed.textTokens.append(contentsOf: searchTokens(from: part.lowercased()))
continue
}
}
return parsed
}
@discardableResult
private func applyStructuredSearchToken(key: String, value: String, to query: inout ParsedSearchQuery) -> Bool {
switch key {
case "app", "source", "from":
query.appTokens.append(contentsOf: searchTokens(from: value))
return true
case "collection", "folder", "list":
query.collectionTokens.append(contentsOf: searchTokens(from: value))
return true
case "type", "kind":
guard let kinds = itemKinds(matching: value), !kinds.isEmpty else { return false }
query.typeKinds.formUnion(kinds)
return true
case "pin", "pinned":
guard let pinned = booleanValue(from: value) else { return false }
query.pinned = pinned
return true
case "after", "since":
guard let start = startOfDay(from: value) else { return false }
query.createdAfter = maxDate(query.createdAfter, start)
return true
case "before", "until":
guard let start = startOfDay(from: value) else { return false }
query.createdBefore = minDate(query.createdBefore, start)
return true
case "on", "date":
guard let start = startOfDay(from: value),
let end = Calendar.searchCalendar.date(byAdding: .day, value: 1, to: start) else {
return false
}
query.createdAfter = maxDate(query.createdAfter, start)
query.createdBefore = minDate(query.createdBefore, end)
return true
default:
return false
}
}
private func itemKinds(matching value: String) -> Set<ClipboardItemKind>? {
switch value {
case "text", "plain":
return [.text]
case "richtext", "rich-text", "rtf", "html":
return [.richText]
case "note", "notes", "writing":
return [.text, .richText]
case "link", "links", "url", "urls", "web":
return [.url]
case "image", "images", "photo", "photos", "picture", "pictures":
return [.image]
case "file", "files", "finder":
return [.file, .pdf]
case "pdf", "pdfs", "document", "documents":
return [.pdf]
case "audio", "sound", "music":
return [.audio]
case "unknown", "item":
return [.unknown]
default:
return nil
}
}
private func booleanValue(from value: String) -> Bool? {
switch value {
case "1", "true", "yes", "y", "on":
return true
case "0", "false", "no", "n", "off":
return false
default:
return nil
}
}
private func startOfDay(from value: String) -> Date? {
guard let date = Self.searchDateFormatter.date(from: value) else { return nil }
return Calendar.searchCalendar.startOfDay(for: date)
}
private func maxDate(_ lhs: Date?, _ rhs: Date) -> Date {
guard let lhs else { return rhs }
return max(lhs, rhs)
}
private func minDate(_ lhs: Date?, _ rhs: Date) -> Date {
guard let lhs else { return rhs }
return min(lhs, rhs)
}
private func assign(item: ClipboardItem, to collectionName: String?) {
let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName)
if let normalizedName {
settings.ensureCollection(named: normalizedName)
}
store.setCollection(item.id, name: normalizedName)
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] {
query
.split { character in
character.isWhitespace || character.isPunctuation
}
.map(String.init)
}
private func notifyMain(_ block: @escaping () -> Void) {
if Thread.isMainThread {
block()
} else {
DispatchQueue.main.async(execute: block)
}
}
}
private extension Calendar {
static let searchCalendar: Calendar = {
var calendar = Calendar(identifier: .gregorian)
calendar.locale = Locale(identifier: "en_US_POSIX")
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt
return calendar
}()
}