320dc777c9
Add save-to-Notes export and direct map handoff while aligning multi-touch typing, period shortcuts, return actions, and navigation visuals with system behavior.
171 lines
6.1 KiB
Swift
171 lines
6.1 KiB
Swift
// AINoteExport.swift
|
||
// OSGKeyboard · Shared
|
||
//
|
||
// Builds one Notes payload: generated title + original clipboard body,
|
||
// joined by `fieldSeparator` so the companion Shortcut can combine them
|
||
// as first-line title + body. Fail open: a missing or unusable model title
|
||
// falls back to a dated snippet so the paste still lands in Notes.
|
||
|
||
import Foundation
|
||
|
||
public enum AINoteExport: Sendable {
|
||
public static let maximumTitleLength = 40
|
||
public static let maximumSnippetLength = 24
|
||
/// Split token for the companion Shortcut: title, then original body.
|
||
/// Newlines stay inside the body, so do not join with `\n`.
|
||
/// Avoid `<>` — `shortcuts://…&text=` treats angle brackets like tags and
|
||
/// drops the payload (todos/events work because they only use `|` / newlines).
|
||
public static let fieldSeparator = "||OSG_NOTE||"
|
||
|
||
private static let emptyTokens: Set<String> = [
|
||
"none", "no", "n/a", "na", "nil", "null",
|
||
"无", "没有", "没有标题", "无标题",
|
||
"no title", "no note", "no notes",
|
||
]
|
||
|
||
/// One string for Shortcuts: `title||OSG_NOTE||body`. Empty → do not run it.
|
||
public static func items(
|
||
from answer: String,
|
||
sourceClipboard: String?,
|
||
now: Date = Date(),
|
||
locale: String = "zh",
|
||
calendar: Calendar = .current
|
||
) -> [String] {
|
||
let body = sourceClipboard?
|
||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
guard !body.isEmpty else { return [] }
|
||
let title = resolvedTitle(
|
||
from: answer,
|
||
body: body,
|
||
now: now,
|
||
locale: locale,
|
||
calendar: calendar
|
||
)
|
||
return ["\(title)\(fieldSeparator)\(body)"]
|
||
}
|
||
|
||
// MARK: - Title
|
||
|
||
private static func resolvedTitle(
|
||
from answer: String,
|
||
body: String,
|
||
now: Date,
|
||
locale: String,
|
||
calendar: Calendar
|
||
) -> String {
|
||
if let title = parsedTitle(answer),
|
||
!isWholeClipboardEcho(title, source: body) {
|
||
return truncate(title, maximumTitleLength)
|
||
}
|
||
return fallbackTitle(body: body, now: now, locale: locale, calendar: calendar)
|
||
}
|
||
|
||
private static func parsedTitle(_ raw: String) -> String? {
|
||
let first = raw
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.components(separatedBy: .newlines)
|
||
.first ?? ""
|
||
var title = stripBullet(first)
|
||
title = stripWrappingQuotes(title)
|
||
if let pipe = title.firstIndex(of: "|") {
|
||
title = String(title[..<pipe])
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
guard !title.isEmpty, !isEmptyToken(title) else { return nil }
|
||
return title
|
||
}
|
||
|
||
private static func fallbackTitle(
|
||
body: String,
|
||
now: Date,
|
||
locale: String,
|
||
calendar: Calendar
|
||
) -> String {
|
||
let stamp = dateStamp(now, locale: locale, calendar: calendar)
|
||
let snippet = truncate(firstLine(body), maximumSnippetLength)
|
||
if snippet.isEmpty { return stamp }
|
||
return "\(stamp) · \(snippet)"
|
||
}
|
||
|
||
private static func dateStamp(_ now: Date, locale: String, calendar: Calendar) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.calendar = calendar
|
||
formatter.timeZone = calendar.timeZone
|
||
formatter.locale = Locale(identifier: locale == "zh" ? "zh_CN" : "en_US_POSIX")
|
||
formatter.dateFormat = locale == "zh" ? "M月d日" : "d MMM"
|
||
return formatter.string(from: now)
|
||
}
|
||
|
||
// MARK: - Text
|
||
|
||
private static func firstLine(_ text: String) -> String {
|
||
text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.components(separatedBy: .newlines)
|
||
.first?
|
||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
}
|
||
|
||
private static func truncate(_ text: String, _ limit: Int) -> String {
|
||
guard text.count > limit else { return text }
|
||
return String(text.prefix(limit))
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
private static func stripWrappingQuotes(_ line: String) -> String {
|
||
let pairs: [(Character, Character)] = [
|
||
("\"", "\""),
|
||
("“", "”"),
|
||
("「", "」"),
|
||
("『", "』"),
|
||
("'", "'"),
|
||
("‘", "’"),
|
||
]
|
||
var text = line
|
||
for (open, close) in pairs where text.count >= 2 {
|
||
if text.first == open, text.last == close {
|
||
text = String(text.dropFirst().dropLast())
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
}
|
||
return text
|
||
}
|
||
|
||
/// A title that is essentially the whole clipboard is not a title.
|
||
private static func isWholeClipboardEcho(_ title: String, source: String) -> Bool {
|
||
guard source.count > 80, 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()
|
||
}
|
||
}
|