Files
OSGKeyboard/OSGKeyboardShared/Services/ClipboardHistoryStore.swift
T
Rocky 9f308fadd2 feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
2026-08-13 01:00:51 +08:00

164 lines
5.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ClipboardHistoryStore.swift
// OSGKeyboard · Shared
//
// App Groupbacked clipboard history (local only; not iCloud-synced).
import Foundation
import Combine
@MainActor
public final class ClipboardHistoryStore: ObservableObject {
public static let shared = ClipboardHistoryStore()
public enum Keys {
public static let entries = "clipboard.history.v1"
public static let lastChangeCount = "clipboard.history.lastChangeCount"
public static let suggestionDismissedChangeCount =
"clipboard.history.suggestionDismissedChangeCount"
}
@Published public private(set) var entries: [ClipboardHistoryEntry] = []
private let defaults: UserDefaults
public init(defaults: UserDefaults? = nil) {
if let defaults {
self.defaults = defaults
} else if let suite = AppGroup.defaultsIfAvailable {
self.defaults = suite
} else {
self.defaults = .standard
}
entries = Self.loadEntries(from: self.defaults)
}
public var lastObservedChangeCount: Int {
get { defaults.integer(forKey: Keys.lastChangeCount) }
set { defaults.set(newValue, forKey: Keys.lastChangeCount) }
}
public var suggestionDismissedChangeCount: Int? {
get {
guard defaults.object(forKey: Keys.suggestionDismissedChangeCount) != nil else {
return nil
}
return defaults.integer(forKey: Keys.suggestionDismissedChangeCount)
}
set {
if let newValue {
defaults.set(newValue, forKey: Keys.suggestionDismissedChangeCount)
} else {
defaults.removeObject(forKey: Keys.suggestionDismissedChangeCount)
}
}
}
/// Inserts accepted text (dedupe + pin). Returns the new head when stored.
@discardableResult
public func ingest(
rawText: String?,
changeCount: Int?
) -> ClipboardHistoryEntry? {
let sanitized = ClipboardHistoryPolicy.sanitizedEntries(entries)
if sanitized != entries {
entries = sanitized
persist()
}
guard let text = ClipboardHistoryPolicy.acceptedText(from: rawText) else {
return nil
}
let entry = ClipboardHistoryEntry(text: text, changeCount: changeCount)
let merged = ClipboardHistoryPolicy.merging(incoming: entry, into: entries)
let bounded = ClipboardHistoryPolicy.sanitizedEntries(merged)
guard bounded.first?.id == entry.id else {
return nil
}
entries = bounded
persist()
if let changeCount {
lastObservedChangeCount = changeCount
// New content clears a previous suggestion dismiss for that older change.
if suggestionDismissedChangeCount != changeCount {
suggestionDismissedChangeCount = nil
}
}
return entry
}
public func remove(id: UUID) {
entries.removeAll { $0.id == id }
persist()
}
public func clearAll() {
entries = []
persist()
}
public func reload() {
entries = Self.loadEntries(from: defaults)
}
public var newestEntry: ClipboardHistoryEntry? {
entries.first
}
/// Newest entry still inside the AI clipboard-hint window, if any.
public func newestAIHintEligibleEntry(now: Date = Date()) -> ClipboardHistoryEntry? {
guard let newest = newestEntry,
ClipboardHistoryPolicy.isEligibleForAIHint(newest, now: now)
else { return nil }
return newest
}
/// Whether the suggestion strip should offer `newestEntry` for this changeCount.
public func shouldShowSuggestion(
forChangeCount changeCount: Int?,
candidateBarEnabled: Bool,
historyEnabled: Bool
) -> Bool {
guard historyEnabled, candidateBarEnabled else { return false }
guard newestEntry != nil else { return false }
if let changeCount,
let dismissed = suggestionDismissedChangeCount,
dismissed == changeCount {
return false
}
return true
}
public func dismissSuggestion(forChangeCount changeCount: Int?) {
if let changeCount {
suggestionDismissedChangeCount = changeCount
}
}
private func persist() {
do {
let data = try JSONEncoder().encode(entries)
defaults.set(data, forKey: Keys.entries)
} catch {
OSGLog.config.warning(
"clipboard history encode failed: \(error.localizedDescription, privacy: .public)"
)
}
}
private static func loadEntries(from defaults: UserDefaults) -> [ClipboardHistoryEntry] {
guard let data = defaults.data(forKey: Keys.entries) else { return [] }
do {
let decoded = try JSONDecoder().decode([ClipboardHistoryEntry].self, from: data)
let sanitized = ClipboardHistoryPolicy.sanitizedEntries(decoded)
if sanitized != decoded, let cleanedData = try? JSONEncoder().encode(sanitized) {
defaults.set(cleanedData, forKey: Keys.entries)
}
return sanitized
} catch {
OSGLog.config.warning(
"clipboard history decode failed: \(error.localizedDescription, privacy: .public)"
)
return []
}
}
}