Files
OSGKeyboard/OSGKeyboardShared/Services/EditTransactionStore.swift
T
Rocky 2bc8c1b87d fix(ipad): ship iPad P0 layout/globe fixes, edit-last-input, drop clipboard commands
Adapt typing/voice surfaces for iPad width and height, add the system globe
key and last-input editing flow, harden host-only Rime deployment, and remove
clipboard voice commands. Bump build to 61.
2026-08-10 13:50:50 +08:00

225 lines
7.2 KiB
Swift

// EditTransactionStore.swift
// OSGKeyboard · Shared
//
// Durable commit records for field edits and eventual history synchronization.
import Foundation
public struct HistoryMutation: Codable, Equatable, Sendable, Identifiable {
public enum Action: String, Codable, Sendable {
case update
case restore
case delete
case append
}
public let id: UUID
public let sequence: Int64
public let action: Action
public let entryID: UUID
public let expectedRevision: Int64?
public let text: String?
public let engineMode: String?
public let createdAt: TimeInterval
public init(
id: UUID = UUID(),
sequence: Int64 = Int64(Date().timeIntervalSince1970 * 1_000),
action: Action,
entryID: UUID,
expectedRevision: Int64? = nil,
text: String? = nil,
engineMode: String? = nil,
createdAt: TimeInterval = Date().timeIntervalSince1970
) {
self.id = id
self.sequence = sequence
self.action = action
self.entryID = entryID
self.expectedRevision = expectedRevision
self.text = text
self.engineMode = engineMode
self.createdAt = createdAt
}
}
public enum HistoryMutationOutbox {
private static let key = "editLastInput.historyMutations.v1"
public static func enqueue(
_ mutation: HistoryMutation,
defaults: UserDefaults? = nil
) {
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
var mutations = pending(defaults: store)
guard !mutations.contains(where: { $0.id == mutation.id }) else { return }
mutations.append(mutation)
persist(mutations.sorted { $0.sequence < $1.sequence }, store: store)
}
public static func pending(defaults: UserDefaults? = nil) -> [HistoryMutation] {
guard let store = defaults ?? AppGroup.defaultsIfAvailable,
store.synchronize(),
let data = store.data(forKey: key),
let decoded = try? JSONDecoder().decode([HistoryMutation].self, from: data)
else {
return []
}
return decoded.sorted { $0.sequence < $1.sequence }
}
public static func acknowledge(
_ mutationID: UUID,
defaults: UserDefaults? = nil
) {
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
let remaining = pending(defaults: store).filter { $0.id != mutationID }
persist(remaining, store: store)
}
private static func persist(_ mutations: [HistoryMutation], store: UserDefaults) {
if mutations.isEmpty {
store.removeObject(forKey: key)
} else if let data = try? JSONEncoder().encode(mutations) {
store.set(data, forKey: key)
}
store.synchronize()
}
}
public struct HistoryMutationReceipt: Codable, Equatable, Sendable {
public let mutationID: UUID
public let entryID: UUID?
public let revision: Int64?
public let appliedAt: TimeInterval
public init(
mutationID: UUID,
entryID: UUID?,
revision: Int64?,
appliedAt: TimeInterval = Date().timeIntervalSince1970
) {
self.mutationID = mutationID
self.entryID = entryID
self.revision = revision
self.appliedAt = appliedAt
}
}
public enum HistoryMutationReceiptStore {
private static let key = "editLastInput.historyMutationReceipts.v1"
public static func save(
_ receipt: HistoryMutationReceipt,
defaults: UserDefaults? = nil
) {
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
var receipts = all(defaults: store)
receipts[receipt.mutationID] = receipt
if receipts.count > 64 {
let keep = receipts.values
.sorted { $0.appliedAt > $1.appliedAt }
.prefix(64)
receipts = Dictionary(uniqueKeysWithValues: keep.map { ($0.mutationID, $0) })
}
if let data = try? JSONEncoder().encode(receipts) {
store.set(data, forKey: key)
store.synchronize()
}
}
public static func receipt(
for mutationID: UUID,
defaults: UserDefaults? = nil
) -> HistoryMutationReceipt? {
all(defaults: defaults)[mutationID]
}
private static func all(
defaults: UserDefaults? = nil
) -> [UUID: HistoryMutationReceipt] {
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return [:] }
store.synchronize()
guard let data = store.data(forKey: key) else { return [:] }
return (try? JSONDecoder().decode(
[UUID: HistoryMutationReceipt].self,
from: data
)) ?? [:]
}
}
public struct PendingTextEditTransaction: Codable, Equatable, Sendable {
public enum DeliveryMode: String, Codable, Sendable {
case replace
case append
}
public enum Phase: String, Codable, Sendable {
case prepared
case fieldApplied
case committed
}
public let transactionID: UUID
public let deliveryMode: DeliveryMode
public let beforeText: String
public let afterText: String
/// Exact string inserted into the field, including a computed separator.
public var appliedInsertedText: String?
public let expectedFieldFingerprint: String?
public let historyMutation: HistoryMutation
public var phase: Phase
public let createdAt: TimeInterval
public init(
transactionID: UUID = UUID(),
deliveryMode: DeliveryMode,
beforeText: String,
afterText: String,
appliedInsertedText: String? = nil,
expectedFieldFingerprint: String?,
historyMutation: HistoryMutation,
phase: Phase = .prepared,
createdAt: TimeInterval = Date().timeIntervalSince1970
) {
self.transactionID = transactionID
self.deliveryMode = deliveryMode
self.beforeText = beforeText
self.afterText = afterText
self.appliedInsertedText = appliedInsertedText
self.expectedFieldFingerprint = expectedFieldFingerprint
self.historyMutation = historyMutation
self.phase = phase
self.createdAt = createdAt
}
}
public enum PendingTextEditTransactionStore {
private static let key = "editLastInput.pendingTransaction.v1"
public static func load(defaults: UserDefaults? = nil) -> PendingTextEditTransaction? {
guard let store = defaults ?? AppGroup.defaultsIfAvailable,
let data = store.data(forKey: key) else {
return nil
}
return try? JSONDecoder().decode(PendingTextEditTransaction.self, from: data)
}
public static func save(
_ transaction: PendingTextEditTransaction,
defaults: UserDefaults? = nil
) {
guard let store = defaults ?? AppGroup.defaultsIfAvailable,
let data = try? JSONEncoder().encode(transaction) else {
return
}
store.set(data, forKey: key)
store.synchronize()
}
public static func clear(defaults: UserDefaults? = nil) {
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
store.removeObject(forKey: key)
store.synchronize()
}
}