feat(keyboard): add clipboard history and undo pastes

Ship optional keyboard clipboard history with settings, suggestion strip, and paste-permission guidance; let undo roll back clipboard inserts; bump build to 64.
This commit is contained in:
Rocky
2026-08-11 02:49:54 +08:00
parent 3de665d254
commit fd6e0d3e7e
30 changed files with 1478 additions and 118 deletions
@@ -0,0 +1,58 @@
// ClipboardHistoryPolicy.swift
// OSGKeyboard · Shared
//
// Pure rules for accepting clipboard text and simple English/whitespace tokens.
import Foundation
public enum ClipboardHistoryPolicy: Sendable {
public static let maxEntries = 15
/// Reject short all-digit strings (OTP / verification-code shaped).
public static let otpDigitMaxLength = 8
/// Returns trimmed text when it should be stored; otherwise `nil`.
public static func acceptedText(from raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if looksLikeOTP(trimmed) { return nil }
return trimmed
}
/// Pure digits (optionally with spaces/dashes) of length 48 treat as OTP.
public static func looksLikeOTP(_ text: String) -> Bool {
let digits = text.filter(\.isNumber)
guard digits.count == text.filter({ !$0.isWhitespace && $0 != "-" }).count else {
return false
}
return (4...otpDigitMaxLength).contains(digits.count)
}
/// Simple whitespace / ASCII-punctuation split for English-ish snippets.
public static func whitespaceTokens(from text: String) -> [String] {
let separators = CharacterSet.whitespacesAndNewlines
.union(.punctuationCharacters)
return text
.components(separatedBy: separators)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
// Skip pure CJK-only single chars that aren't useful as chips.
.filter { token in
token.count > 1 || token.unicodeScalars.contains { $0.isASCII }
}
}
/// Merge `incoming` onto `existing` (newest first), dedupe by exact text.
public static func merging(
incoming: ClipboardHistoryEntry,
into existing: [ClipboardHistoryEntry],
limit: Int = maxEntries
) -> [ClipboardHistoryEntry] {
var next = existing.filter { $0.text != incoming.text }
next.insert(incoming, at: 0)
if next.count > limit {
next = Array(next.prefix(limit))
}
return next
}
}