0a60006d2d
Ship user-defined Shortcut skills, companion Events/Navigate recipes, shared skill/style card chrome, and bump the build to 69.
280 lines
10 KiB
Swift
280 lines
10 KiB
Swift
// AIEventExtraction.swift
|
||
// OSGKeyboard · Shared
|
||
//
|
||
// Parses the LLM's extract-events reply into Shortcut lines. Fail closed:
|
||
// empty / NONE / lines without a parseable start never become a run.
|
||
// Canonical line: start|end|title|location
|
||
// All-day uses end token ALLDAY so the companion Shortcut can branch.
|
||
|
||
import Foundation
|
||
|
||
public enum AIEventExtraction: Sendable {
|
||
public static let maximumItems = 20
|
||
public static let defaultDuration: TimeInterval = 3600
|
||
/// End-field sentinel for all-day events (Shortcut If equals this).
|
||
public static let allDaySentinel = "ALLDAY"
|
||
|
||
private static let emptyTokens: Set<String> = [
|
||
"none", "no", "n/a", "na", "nil", "null",
|
||
"无", "没有", "没有日程", "没有事件", "无日程",
|
||
"没有日期", "没有时间", "没有日期或时间", "没有日期和时间",
|
||
"no events", "no event", "no calendar events",
|
||
"no date", "no time", "no date or time", "no date and time",
|
||
]
|
||
|
||
/// Lines to send to the companion Shortcut. Empty → do not run it.
|
||
public static func lines(
|
||
from raw: String,
|
||
sourceClipboard: String? = nil,
|
||
now: Date = Date(),
|
||
calendar: Calendar = .current
|
||
) -> [String] {
|
||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return [] }
|
||
if isEmptyToken(trimmed) { return [] }
|
||
|
||
var seen = Set<String>()
|
||
var items: [String] = []
|
||
for line in trimmed.components(separatedBy: .newlines) {
|
||
guard let encoded = encodeLine(
|
||
line,
|
||
now: now,
|
||
calendar: calendar
|
||
) else { continue }
|
||
let key = encoded.lowercased()
|
||
guard seen.insert(key).inserted else { continue }
|
||
items.append(encoded)
|
||
if items.count == maximumItems { break }
|
||
}
|
||
|
||
if items.count == 1, isWholeClipboardEcho(items[0], source: sourceClipboard) {
|
||
return []
|
||
}
|
||
return items
|
||
}
|
||
|
||
// MARK: - Line
|
||
|
||
private static func encodeLine(
|
||
_ line: String,
|
||
now: Date,
|
||
calendar: Calendar
|
||
) -> String? {
|
||
let fields = splitFields(line)
|
||
guard fields.count >= 2 else { return nil }
|
||
|
||
let startRaw = fields[0]
|
||
let endRaw: String
|
||
let titleRaw: String
|
||
let locationRaw: String
|
||
switch fields.count {
|
||
case 2:
|
||
endRaw = ""
|
||
titleRaw = fields[1]
|
||
locationRaw = ""
|
||
case 3:
|
||
// 3 fields: start|end|title if segment 2 is a time/empty;
|
||
// otherwise start|title|location.
|
||
if fields[1].isEmpty || parseInstant(fields[1], on: now, calendar: calendar) != nil {
|
||
endRaw = fields[1]
|
||
titleRaw = fields[2]
|
||
locationRaw = ""
|
||
} else {
|
||
endRaw = ""
|
||
titleRaw = fields[1]
|
||
locationRaw = fields[2]
|
||
}
|
||
default:
|
||
endRaw = fields[1]
|
||
titleRaw = fields[2]
|
||
locationRaw = fields[3]
|
||
}
|
||
|
||
let title = stripBullet(titleRaw)
|
||
guard !title.isEmpty, !isEmptyToken(title) else { return nil }
|
||
let location = locationRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
guard let start = parseInstant(startRaw, on: now, calendar: calendar) else {
|
||
return nil
|
||
}
|
||
|
||
switch start {
|
||
case .allDay(let day):
|
||
return encode(
|
||
start: formatDay(day, calendar: calendar),
|
||
end: allDaySentinel,
|
||
title: title,
|
||
location: location
|
||
)
|
||
case .timed(let startDate, _):
|
||
let endDate = resolveEnd(
|
||
endRaw,
|
||
start: startDate,
|
||
calendar: calendar
|
||
)
|
||
return encode(
|
||
start: formatMinute(startDate, calendar: calendar),
|
||
end: formatMinute(endDate, calendar: calendar),
|
||
title: title,
|
||
location: location
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Split on `|` and keep at most 4 fields; extra segments join into location.
|
||
private static func splitFields(_ line: String) -> [String] {
|
||
let parts = line
|
||
.split(separator: "|", omittingEmptySubsequences: false)
|
||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||
guard parts.count > 4 else { return parts }
|
||
let location = parts[3...].joined(separator: "|")
|
||
return [parts[0], parts[1], parts[2], location]
|
||
}
|
||
|
||
private static func encode(start: String, end: String, title: String, location: String) -> String {
|
||
"\(start)|\(end)|\(title)|\(location)"
|
||
}
|
||
|
||
// MARK: - Time
|
||
|
||
private enum Instant {
|
||
case allDay(Date)
|
||
case timed(Date, timeOnly: Bool)
|
||
}
|
||
|
||
private static func parseInstant(
|
||
_ raw: String,
|
||
on day: Date,
|
||
calendar: Calendar
|
||
) -> Instant? {
|
||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !text.isEmpty else { return nil }
|
||
if text.caseInsensitiveCompare(allDaySentinel) == .orderedSame {
|
||
return .allDay(calendar.startOfDay(for: day))
|
||
}
|
||
|
||
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm"] {
|
||
if let date = date(from: text, format: format, calendar: calendar) {
|
||
return .timed(date, timeOnly: false)
|
||
}
|
||
}
|
||
if let date = date(from: text, format: "yyyy-MM-dd", calendar: calendar) {
|
||
return .allDay(calendar.startOfDay(for: date))
|
||
}
|
||
for format in ["HH:mm:ss", "HH:mm", "H:mm"] {
|
||
if let parsed = date(from: text, format: format, calendar: calendar) {
|
||
let parts = calendar.dateComponents([.hour, .minute, .second], from: parsed)
|
||
guard let combined = calendar.date(
|
||
bySettingHour: parts.hour ?? 0,
|
||
minute: parts.minute ?? 0,
|
||
second: 0,
|
||
of: day
|
||
) else { return nil }
|
||
return .timed(combined, timeOnly: true)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private static func resolveEnd(
|
||
_ raw: String,
|
||
start: Date,
|
||
calendar: Calendar
|
||
) -> Date {
|
||
let fallback = start.addingTimeInterval(defaultDuration)
|
||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty,
|
||
trimmed.caseInsensitiveCompare(allDaySentinel) != .orderedSame else {
|
||
return fallback
|
||
}
|
||
guard let parsed = parseInstant(trimmed, on: start, calendar: calendar) else {
|
||
return fallback
|
||
}
|
||
let end: Date
|
||
switch parsed {
|
||
case .allDay(let day):
|
||
// Date-only end with a timed start: use that calendar day at 23:59.
|
||
end = calendar.date(bySettingHour: 23, minute: 59, second: 0, of: day) ?? fallback
|
||
case .timed(let date, let timeOnly):
|
||
if timeOnly, date <= start {
|
||
end = calendar.date(byAdding: .day, value: 1, to: date) ?? fallback
|
||
} else {
|
||
end = date
|
||
}
|
||
}
|
||
return end <= start ? fallback : end
|
||
}
|
||
|
||
private static func date(from text: String, format: String, calendar: Calendar) -> Date? {
|
||
let formatter = DateFormatter()
|
||
formatter.calendar = calendar
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.timeZone = calendar.timeZone
|
||
formatter.isLenient = false
|
||
formatter.dateFormat = format
|
||
return formatter.date(from: text)
|
||
}
|
||
|
||
private static func formatDay(_ date: Date, calendar: Calendar) -> String {
|
||
format(date, "yyyy-MM-dd", calendar: calendar)
|
||
}
|
||
|
||
private static func formatMinute(_ date: Date, calendar: Calendar) -> String {
|
||
format(date, "yyyy-MM-dd HH:mm", calendar: calendar)
|
||
}
|
||
|
||
private static func format(_ date: Date, _ format: String, calendar: Calendar) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.calendar = calendar
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.timeZone = calendar.timeZone
|
||
formatter.dateFormat = format
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
// MARK: - Tokens
|
||
|
||
private static func isEmptyToken(_ text: String) -> Bool {
|
||
let folded = text
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "。.!!"))
|
||
.lowercased()
|
||
return emptyTokens.contains(folded)
|
||
}
|
||
|
||
private static func stripBullet(_ line: String) -> String {
|
||
var text = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let prefixes = ["- ", "* ", "• ", "、"]
|
||
for prefix in prefixes where text.hasPrefix(prefix) {
|
||
text = String(text.dropFirst(prefix.count))
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
if let dotted = text.range(of: #"^\d+[\.\)、]\s*"#, options: .regularExpression) {
|
||
text = String(text[dotted.upperBound...])
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
return text
|
||
}
|
||
|
||
/// One long title that is essentially the clipboard body is not an event.
|
||
private static func isWholeClipboardEcho(_ item: String, source: String?) -> Bool {
|
||
guard let source, source.count > 80 else { return false }
|
||
let fields = item.split(separator: "|", omittingEmptySubsequences: false)
|
||
guard fields.count >= 3 else { return false }
|
||
let title = String(fields[2])
|
||
guard title.count > 80 else { return false }
|
||
let a = collapse(title)
|
||
let b = collapse(source)
|
||
guard !a.isEmpty, !b.isEmpty else { return false }
|
||
if a == b { return true }
|
||
return a.contains(b) || b.contains(a)
|
||
}
|
||
|
||
private static func collapse(_ text: String) -> String {
|
||
text.components(separatedBy: .whitespacesAndNewlines)
|
||
.filter { !$0.isEmpty }
|
||
.joined(separator: " ")
|
||
.lowercased()
|
||
}
|
||
}
|