feat(polish): add configurable style packs

Add shared prompt composition, custom style management, iCloud sync, and native iOS/macOS selection interfaces so users can keep a consistent writing voice across devices.
This commit is contained in:
Rocky
2026-07-26 12:34:43 +08:00
parent e6f99d2744
commit 937ce33f05
29 changed files with 2388 additions and 87 deletions
@@ -0,0 +1,95 @@
// PolishStylePack+Merging.swift
// OSGKeyboard · Shared
//
// Deterministic iCloud merge rules for user-created polish style packs.
import Foundation
extension PolishStyleCatalog {
public static let kvsKeyV2 = "polishStyles.v2"
public static let tombstoneRetention: TimeInterval = 365 * 24 * 60 * 60
public static let maxTombstones = 100
public static func merge(
local: PolishStyleCatalog,
remote: PolishStyleCatalog
) -> PolishStyleCatalog {
let clearedAt = later(local.clearedAt, remote.clearedAt)
var tombstones = local.deletedEntryIDs
for (id, date) in remote.deletedEntryIDs {
tombstones[id] = max(tombstones[id] ?? .distantPast, date)
}
tombstones = prune(tombstones, clearedAt: clearedAt)
var byID: [String: PolishStylePack] = [:]
for candidate in local.entries + remote.entries {
guard candidate.kind == .user else { continue }
guard !candidate.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue }
let prompt = candidate.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !prompt.isEmpty, prompt.count <= PolishStyleLimits.maximumPromptCharacters else { continue }
guard tombstones[candidate.id] == nil else { continue }
if let clearedAt, candidate.createdAt <= clearedAt { continue }
if let existing = byID[candidate.id] {
byID[candidate.id] = candidate.updatedAt >= existing.updatedAt ? candidate : existing
} else {
byID[candidate.id] = candidate
}
}
let entries = byID.values
.sorted {
if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt }
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
.prefix(PolishStyleLimits.maximumUserPacks)
return PolishStyleCatalog(
entries: Array(entries),
version: max(local.version, remote.version) + 1,
lastSyncedAt: [local.lastSyncedAt, remote.lastSyncedAt].compactMap { $0 }.max(),
deletedEntryIDs: tombstones,
clearedAt: clearedAt
)
}
public mutating func recordClearAll(at date: Date = Date()) {
entries.removeAll()
clearedAt = date
version += 1
}
public mutating func pruneTombstonesIfNeeded() {
deletedEntryIDs = Self.prune(deletedEntryIDs, clearedAt: clearedAt)
}
private static func prune(
_ tombstones: [String: Date],
clearedAt: Date?
) -> [String: Date] {
let cutoff = Date().addingTimeInterval(-tombstoneRetention)
var kept = tombstones.filter { _, date in
guard date >= cutoff else { return false }
guard let clearedAt else { return true }
return date > clearedAt
}
if kept.count > maxTombstones {
kept = Dictionary(
uniqueKeysWithValues: kept
.sorted { $0.value > $1.value }
.prefix(maxTombstones)
.map { ($0.key, $0.value) }
)
}
return kept
}
private static func later(_ lhs: Date?, _ rhs: Date?) -> Date? {
switch (lhs, rhs) {
case let (left?, right?): max(left, right)
case (nil, let right?): right
case (let left?, nil): left
case (nil, nil): nil
}
}
}