diff --git a/AGENTS.md b/AGENTS.md index 45a767c..599f6e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ writing commit messages that will ship to users. ### Version format -The current source-of-truth version is **1.7.5 (build 69)**. Releases use stable SemVer: +The current source-of-truth version is **1.7.5 (build 71)**. Releases use stable SemVer: | Field | File | Rule | |-------|------|------| diff --git a/CHANGELOG.md b/CHANGELOG.md index 902cb69..2253082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,19 +8,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Overlapping key presses**: the typing grid tracks multiple fingers, so the next key can go down before the previous lifts. Pending letters commit in press order (not release order); Shift can be held with one finger while another types. / **叠指连打**:打字网格跟踪多指,上一键未松开也可按下下一键。未提交的字母按按下顺序出字(而非抬手顺序);一只手指按住 Shift 时另一只可打字。 +- **Period shortcut**: in English, a second Space shortly after a Space that follows a word becomes `. ` and arms sentence Shift, matching the system "." Shortcut. / **句号快捷**:英文下,在单词后的空格上短时间内再按一次空格会变成 `. ` 并点亮句首 Shift,对齐系统「句号快捷」。 +- **Return key labels**: Go / Search / Send / Done / Next / Join and the other `UIReturnKeyType` values show their system captions on the green action key instead of collapsing to Send or a return arrow. / **回车键文案**:前往 / 搜索 / 发送 / 完成 / 下一项 / 加入等 `UIReturnKeyType` 在绿色动作键上显示系统对应文案,不再一律变成「发送」或换行箭头。 - **Skills tab**: Home dock and iPad sidebar add Skills between Home and Styles; cards list default Reply / Summarize / Translate plus installable export skills (max 8 enabled, long-press drag to reorder). / **技能 Tab**:首页 Dock 与 iPad 侧栏在「首页」和「风格」之间新增「技能」;卡片列出默认的回复 / 总结 / 翻译以及可安装的出口技能(最多启用 8 个,长按拖动排序)。 -- **Extract tasks skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取待办` on the system Add page (split lines → Reminders, default list). After you tap Add, copying text and tapping Tasks asks the LLM for to-dos and silently adds them; no tasks stays in the current app with a keyboard tip. / **提取待办技能**:技能页会打开已做好的配套捷径 `OSG · 提取待办` 的系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。 -- **Extract events skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取日程` on the system Add page (multiple events into Calendar; date-only → all-day, time-only → today, optional end time and location). After you tap Add, copying text and tapping Events asks the LLM for events and silently adds them; no date or time stays in the current app with a keyboard tip. / **提取日程技能**:技能页会打开已做好的配套捷径 `OSG · 提取日程` 的系统添加页(可多条写入日历;只有日期为全天;只有时刻用当天;可带结束时间与地点)。点添加后,复制文字并点「日程」会让模型抽取日程并静默写入;没有日期或时间则留在当前 App,键盘上给出提示。 -- **Navigate skill**: the Skills tab opens a companion Shortcut named `OSG · 导航`. After you add it, copying text and tapping Navigate asks the LLM for one address (or origin → destination) and opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No address stays in the current app with a keyboard tip. / **导航技能**:技能页打开配套捷径 `OSG · 导航`。添加后,复制文字并点「导航」会抽取一条地址(或起点→终点)并开始驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。没有地址则留在当前 App,键盘上给出提示。 +- **Extract tasks skill**: the Skills tab opens the bundled companion Shortcut `OSGExtractTodos` on the system Add page (split lines → Reminders, default list). After you tap Add, copying text and tapping Tasks asks the LLM for to-dos and silently adds them; no tasks stays in the current app with a keyboard tip. / **提取待办技能**:技能页会打开 App 内置的配套捷径 `OSGExtractTodos` 系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。 +- **Extract events skill**: the Skills tab opens the bundled companion Shortcut `OSGExtractEvents` on the system Add page (multiple events into Calendar; date-only → all-day, time-only → today, optional end time and location). After you tap Add, copying text and tapping Events asks the LLM for events and silently adds them; no date or time stays in the current app with a keyboard tip. / **提取日程技能**:技能页会打开 App 内置的配套捷径 `OSGExtractEvents` 系统添加页(可多条写入日历;只有日期为全天;只有时刻用当天;可带结束时间与地点)。点添加后,复制文字并点「日程」会让模型抽取日程并静默写入;没有日期或时间则留在当前 App,键盘上给出提示。 +- **Navigate skill**: copying text and tapping Navigate asks the LLM for one address (or origin → destination) and the host app opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No companion Shortcut. No address stays in the current app with a keyboard tip. / **导航技能**:复制文字并点「导航」会抽取一条地址(或起点→终点),由 App 直接打开驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。不需要配套捷径。没有地址则留在当前 App,键盘上给出提示。 +- **Save to Notes skill**: the Skills tab opens a ready-made companion Shortcut named `OSGSaveToNotes` on the system Add page (one new Apple Note with an explicit title and body). After you tap Add, copying text and tapping Notes asks the LLM for a short title from the time and content; the body stays the original clipboard. / **存入备忘录技能**:技能页会打开已做好的配套捷径 `OSGSaveToNotes` 的系统添加页(新建一条带标题和正文的苹果备忘录)。点添加后,复制文字并点「备忘录」会按时间和内容生成短标题;正文保持剪贴板原文。 - **Skills clipboard guide**: when Clipboard History is off, the Skills tab shows a card that jumps to in-app Clipboard settings and to iOS Settings for paste authorization. / **技能页剪贴板指引**:未开启剪贴板历史时,技能页展示可点击卡片,分别跳转 App 内剪贴板设置和系统设置以完成粘贴授权。 - **Custom skills**: Skills tab `+` adds a user skill (name, about, SF Symbol, prompt, required iCloud Shortcut link with name lookup, independent Shortcut name, thinking off by default). Built-in thinking stays off and disabled. No cap on how many custom skills you can save; the keyboard still holds at most 8. / **自定义技能**:技能页右上角 `+` 可添加用户技能(名称、介绍、SF Symbol、提示词、必填 iCloud 捷径链接并自动读取名称、可与技能名分开的捷径名、思考默认关)。内置技能思考固定关闭且不可开。自定义数量不设上限,键盘仍最多启用 8 个。 ### Fixed +- **Save to Notes Shortcut**: bind the combined first-line title and clipboard body through iPhone Create Note’s real `WFCreateNoteInput` field. The previous `contents` binding was ignored and left an enter-content sheet or a title-only note. / **存入备忘录捷径**:通过 iPhone「创建备忘录」真正的 `WFCreateNoteInput` 字段绑定首行标题与剪贴板正文;旧版 `contents` 绑定会被忽略,导致弹出内容填写框或只生成标题。 - **Extract tasks Shortcut**: receive Shortcut Input as Text, then split lines and add each title to Reminders — the previous recipe could finish successfully without creating items. / **提取待办捷径**:先把快捷指令输入收成文本,再按行写入提醒;旧配方会成功跑完但不创建条目。 - **Skill reorder feedback**: long-press lifts a skill card and the grid slides live under the finger, matching Home Screen rearrange. / **技能拖动排序**:长按拎起技能卡片,网格随手指实时让位,接近主屏幕图标重排。 - **Skill drag preview corners**: the lift preview clips to the card’s continuous rounded rect so square white corners no longer show. / **技能拖动圆角**:拖起预览按卡片连续圆角裁剪,去掉圆角外的直角白底。 ### Changed +- **Bundled Shortcut names**: Tasks and Events now install their signed in-app resources as `OSGExtractTodos` and `OSGExtractEvents`, matching `OSGSaveToNotes`; existing Chinese-named copies must be replaced from the Skills tab. / **内置捷径名称**:待办与日程改为安装 App 内签名资源 `OSGExtractTodos` 和 `OSGExtractEvents`,与 `OSGSaveToNotes` 保持一致;已有中文名称版本需从技能页重新安装。 +- **Navigation icons**: Skills uses a wand; Styles uses a dial. The phone dock and iPad sidebar (except Home) use outline when idle and fill when selected. Mac Styles / Settings follow the same pairing. iPad and Mac Home stay the house icon. / **导航图标**:技能改为魔杖,风格改为旋钮。手机 Dock 与 iPad 侧栏(除首页外)未选中描边、选中填充;Mac 的风格 / 设置同样切换。iPad / Mac 首页仍用房子图标。 +- **Phone dock size**: slightly smaller — 22 pt icons, 46 pt rows, 8 pt glass padding. / **手机 Dock 尺寸**:略缩小,图标 22 pt、行高 46 pt、玻璃内边距 8 pt。 - **Skill and style cards**: every card uses a top-right edit pencil and a bottom-right selected check; skill Done sits on the right like style details; tapping a skill card toggles the keyboard slot (unconfirmed Shortcut skills still open the install sheet). / **技能与风格卡片**:右上为编辑铅笔、右下为选中勾选;技能详情「完成」改到右侧;点技能卡片切换是否上键盘(未确认捷径的仍打开安装页)。 - **Clipboard skill grid**: idle skill chips stay as one centered block, wrapping to two rows of up to four instead of scrolling sideways after four. / **剪贴板技能网格**:空闲技能按钮作为一整块居中,超过四个改为两行(每行最多四个),不再横向滚动。 - **Skill sheet buttons**: Add, Turn off, Add Shortcut, I’ve added it, and Reinstall are solid capsules — primary actions use the brand green (`#3AA05A`), secondary actions use an elevated fill. / **技能弹窗按钮**:添加、关闭、添加捷径、我已添加、重新安装改为实心胶囊;主操作为品牌绿(`#3AA05A`),次要为抬升底。 diff --git a/OSGKeyboard/Resources/Shortcuts/OSG · 提取待办.shortcut b/OSGKeyboard/Resources/Shortcuts/OSG · 存入备忘录.shortcut similarity index 52% rename from OSGKeyboard/Resources/Shortcuts/OSG · 提取待办.shortcut rename to OSGKeyboard/Resources/Shortcuts/OSG · 存入备忘录.shortcut index 0286763..c605178 100644 Binary files a/OSGKeyboard/Resources/Shortcuts/OSG · 提取待办.shortcut and b/OSGKeyboard/Resources/Shortcuts/OSG · 存入备忘录.shortcut differ diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut index e1f601f..1483444 100644 Binary files a/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut and b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut differ diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut index a467a09..ceb1d7f 100644 Binary files a/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut and b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut differ diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut index 0286763..de38bd3 100644 Binary files a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut and b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut differ diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut index 25f2bb2..084cc3c 100644 Binary files a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut and b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut differ diff --git a/OSGKeyboard/Resources/Shortcuts/OSGNavigate.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGNavigate.unsigned.shortcut deleted file mode 100644 index 4a2f8a8..0000000 Binary files a/OSGKeyboard/Resources/Shortcuts/OSGNavigate.unsigned.shortcut and /dev/null differ diff --git a/OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut similarity index 52% rename from OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut rename to OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut index 75d2304..97ec20c 100644 Binary files a/OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut and b/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut differ diff --git a/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.unsigned.shortcut new file mode 100644 index 0000000..f25cddf Binary files /dev/null and b/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.unsigned.shortcut differ diff --git a/OSGKeyboard/Services/AIAgentShortcutRunner.swift b/OSGKeyboard/Services/AIAgentShortcutRunner.swift index 5d64b06..260d93b 100644 --- a/OSGKeyboard/Services/AIAgentShortcutRunner.swift +++ b/OSGKeyboard/Services/AIAgentShortcutRunner.swift @@ -1,9 +1,8 @@ // AIAgentShortcutRunner.swift // OSGKeyboard · Main App // -// Consumes the keyboard's pending export-skill payload and opens the -// companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add -// x-callback URLs so Console can record success / error / cancel. +// Consumes the keyboard's pending export-skill payload. Navigate opens a +// map URL in the host. Other exports open the companion Shortcut. import UIKit import OSGKeyboardShared @@ -14,19 +13,23 @@ enum AIAgentShortcutRunner { AIAgentShortcutRun.trace("host.runPending begin") guard let payload = AppGroupStore().consumePendingShortcutRun() else { return } let catalog = AppGroupStore().agentUserSkillCatalog - guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog), - let name = skill.shortcutName else { + guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog) else { AIAgentShortcutRun.trace( "host.runPending skip unknownSkill=\(payload.skillID)" ) return } - guard let text = shortcutText(for: skill, payload: payload) else { + if skill.id == AIClipboardSkillCatalog.navigateID { + openMap(for: payload) + return + } + guard let name = skill.shortcutName else { AIAgentShortcutRun.trace( - "host.runPending skip textBuildFailed skill=\(payload.skillID)" + "host.runPending skip missingShortcut skill=\(payload.skillID)" ) return } + let text = payload.joinedTitles AIAgentShortcutRun.traceBody("host.titlesToShortcut", text) guard let url = shortcutsURL(name: name, text: text) else { AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)") @@ -62,19 +65,22 @@ enum AIAgentShortcutRunner { ) } - /// Navigate: pick 高德 → 百度 → Apple Maps, pass that URL to the Shortcut. - /// Other export skills send the parsed lines unchanged. - private static func shortcutText( - for skill: AIClipboardSkill, - payload: AIAgentShortcutRunPayload - ) -> String? { - if skill.id == AIClipboardSkillCatalog.navigateID { - return AIMapNavigation.shortcutInput( - from: payload.joinedTitles, - canOpen: { UIApplication.shared.canOpenURL($0) } - ) + /// 高德 → 百度 → Apple Maps. Do not bounce through Shortcuts. + private static func openMap(for payload: AIAgentShortcutRunPayload) { + guard let urlString = AIMapNavigation.shortcutInput( + from: payload.joinedTitles, + canOpen: { UIApplication.shared.canOpenURL($0) } + ), let url = URL(string: urlString) else { + AIAgentShortcutRun.trace("host.runPending skip navigateURLBuildFailed") + return + } + AIAgentShortcutRun.trace( + "host.openMap scheme=\(url.scheme ?? "") host=\(url.host ?? "")" + ) + AIAgentShortcutRun.traceBody("host.mapURL", urlString) + UIApplication.shared.open(url) { success in + AIAgentShortcutRun.trace("host.openMap result success=\(success)") } - return payload.joinedTitles } private static func shortcutsURL(name: String, text: String) -> URL? { diff --git a/OSGKeyboard/Views/Components/MinimalTabBar.swift b/OSGKeyboard/Views/Components/MinimalTabBar.swift index 5b72805..c699c65 100644 --- a/OSGKeyboard/Views/Components/MinimalTabBar.swift +++ b/OSGKeyboard/Views/Components/MinimalTabBar.swift @@ -15,23 +15,6 @@ enum AppTab: Int, CaseIterable { case styles case settings - var icon: MaterialIconName { - switch self { - case .keyboard: return .keyboard - case .skills, .styles: return .menuBook // unused — these tabs use SF Symbols - case .settings: return .settings - } - } - - /// SF Symbol overrides shared with the Mac and iPad sidebars. - var sfSymbol: String? { - switch self { - case .skills: return "sparkles" - case .styles: return "text.badge.star" - default: return nil - } - } - var accessibilityKey: LocalizedStringKey { switch self { case .keyboard: return "tab.keyboard" @@ -41,15 +24,28 @@ enum AppTab: Int, CaseIterable { } } + /// Phone dock: outline when idle, fill when selected (system tab-bar + /// convention). `wand.and.sparkles` has no `.fill` pair. + func dockSystemImage(selected: Bool) -> String { + switch self { + case .keyboard: return selected ? "keyboard.fill" : "keyboard" + case .skills: return "wand.and.sparkles" + case .styles: return selected ? "dial.high.fill" : "dial.high" + case .settings: return selected ? "gearshape.fill" : "gearshape" + } + } + /// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title). var sidebarTitle: LocalizedStringKey { accessibilityKey } - var sidebarSystemImage: String { + /// iPad sidebar. Home stays `house` in both states; other tabs follow + /// the same outline / fill pairing as the phone dock. + func sidebarSystemImage(selected: Bool) -> String { switch self { case .keyboard: return "house" - case .skills: return "sparkles" - case .styles: return "text.badge.star" - case .settings: return "gearshape" + case .skills: return "wand.and.sparkles" + case .styles: return selected ? "dial.high.fill" : "dial.high" + case .settings: return selected ? "gearshape.fill" : "gearshape" } } } @@ -69,14 +65,8 @@ struct MinimalTabBar: View { } } label: { VStack(spacing: 2) { - Group { - if let sfSymbol = tab.sfSymbol { - Image(systemName: sfSymbol) - .font(.system(size: TabBarDockMetrics.iconSize, weight: .regular)) - } else { - MaterialIcon(name: tab.icon, size: TabBarDockMetrics.iconSize) - } - } + Image(systemName: tab.dockSystemImage(selected: selection == tab)) + .font(.system(size: TabBarDockMetrics.iconSize, weight: .regular)) Text(tab.accessibilityKey) .font(TypeStyle.caption2) .lineLimit(1) diff --git a/OSGKeyboard/Views/Components/TabBarVisibility.swift b/OSGKeyboard/Views/Components/TabBarVisibility.swift index 4b3cb16..c44af5d 100644 --- a/OSGKeyboard/Views/Components/TabBarVisibility.swift +++ b/OSGKeyboard/Views/Components/TabBarVisibility.swift @@ -52,13 +52,13 @@ extension View { } enum TabBarDockMetrics { - static let itemHeight: CGFloat = 52 - static let iconSize: CGFloat = 24 + static let itemHeight: CGFloat = 46 + static let iconSize: CGFloat = 22 /// Horizontal glass pad is 0: the selected capsule's `selectionInset` /// is the only side gap. `Spacing.md` (16) plus that 5 pt was ~3–4× /// the 5 pt top/bottom gap. static let dockInsetHorizontal: CGFloat = 0 - static let dockInsetVertical: CGFloat = Spacing.sm + static let dockInsetVertical: CGFloat = Spacing.xs /// Gap between the selected fill and the glass dock / neighbouring tabs. static let selectionInset: CGFloat = 5 static let bottomPadding: CGFloat = Spacing.xs diff --git a/OSGKeyboard/Views/MainSplitView.swift b/OSGKeyboard/Views/MainSplitView.swift index 728cba6..4c7d36b 100644 --- a/OSGKeyboard/Views/MainSplitView.swift +++ b/OSGKeyboard/Views/MainSplitView.swift @@ -110,7 +110,7 @@ private struct WideSidebarRow: View { var body: some View { Button(action: action) { - Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage) + Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage(selected: isSelected)) .labelStyle(SidebarIconColumnLabelStyle()) // Slightly larger label + taller vertical padding so each // sidebar row hits the Apple HIG 44pt touch target on iPad @@ -144,7 +144,7 @@ private struct WideSidebarRow: View { /// (`house`) — enough to push titles apart by ~5pt. `List` reserves that column /// automatically; this hand-rolled `VStack` sidebar has to do it itself. private struct SidebarIconColumnLabelStyle: LabelStyle { - /// Wider than the widest symbol in `AppTab.sidebarSystemImage`. + /// Wider than the widest symbol in `AppTab.sidebarSystemImage(selected:)`. private static let iconColumnWidth: CGFloat = 22 func makeBody(configuration: Configuration) -> some View { diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index cb7d991..d684e4b 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -457,6 +457,8 @@ "skills.extractTodos.description" = "Extract to-dos from copied text into Reminders."; "skills.extractEvents.name" = "Extract events"; "skills.extractEvents.description" = "Extract events from copied text into Calendar."; +"skills.saveToNotes.name" = "Save to Notes"; +"skills.saveToNotes.description" = "Create a new note from the copied text. The title comes from the time and content; the body stays as copied."; "skills.navigate.name" = "Navigate"; "skills.navigate.description" = "Find an address in the copied text and start driving directions. Uses Amap if installed, then Baidu Maps, then Apple Maps."; "skills.install.lead" = "Add it, then confirm. Don’t rename it."; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 87a21f0..122ed85 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -456,6 +456,8 @@ "skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。"; "skills.extractEvents.name" = "提取日程"; "skills.extractEvents.description" = "从复制内容提取日程,写入日历。"; +"skills.saveToNotes.name" = "存入备忘录"; +"skills.saveToNotes.description" = "根据复制内容和当前时间生成标题,写入一条新的备忘录;正文保持原文。"; "skills.navigate.name" = "导航"; "skills.navigate.description" = "从复制内容识别地址并开始驾车导航。优先高德,其次百度,最后 Apple 地图。"; "skills.install.lead" = "添加后点「我已添加」,请勿改名。"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 9d13838..d833072 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -773,10 +773,30 @@ public final class KeyboardViewController: UIInputViewController { private func returnKeyRole(for returnKeyType: UIReturnKeyType) -> State.ReturnKeyRole { switch returnKeyType { - case .send, .go, .search, .join, .route, .google, .yahoo, .continue, .emergencyCall: - return .send - case .default, .next, .done: + case .default: return .newline + case .go: + return .go + case .google: + return .google + case .join: + return .join + case .next: + return .next + case .route: + return .route + case .search: + return .search + case .send: + return .send + case .yahoo: + return .yahoo + case .done: + return .done + case .emergencyCall: + return .emergencyCall + case .continue: + return .continue @unknown default: return .newline } diff --git a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift index b0ea8e9..2b23b86 100644 --- a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift +++ b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift @@ -70,13 +70,18 @@ final class AIKeyboardCoordinator { func submitClipboardSkill(_ skill: AIClipboardSkill) { guard canAcceptIdleSubmit else { return } enterIfNeeded() - state.pendingClipboardSkillID = skill.kind == .export ? skill.id : nil + let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text + if skill.kind == .export { + state.pendingClipboardSkillID = skill.id + state.pendingClipboardSkillSource = material + } else { + clearPendingExportSkill() + } let instruction = AIClipboardSkillCatalog.instruction( for: skill, locale: AIHintLocaleResolver.packLocale(), translationTargetLocaleId: state.translationTargetLocaleId ) - let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)") if let material { AIAgentShortcutRun.traceBody("keyboard.clipboard", material) @@ -120,7 +125,7 @@ final class AIKeyboardCoordinator { ) { guard case .ready(let prompt) = resolution else { // The clipboard window closed between rendering and this tap. - state.pendingClipboardSkillID = nil + clearPendingExportSkill() state.aiSession.fail( ExtL10n.string("keyboard.ai.error.clipboardUnavailable"), utteranceID: nil @@ -128,7 +133,7 @@ final class AIKeyboardCoordinator { return } guard let conversationID = state.aiSession.conversationID else { - state.pendingClipboardSkillID = nil + clearPendingExportSkill() return } let disposition = flow.submitAIQuestion( @@ -137,14 +142,14 @@ final class AIKeyboardCoordinator { thinkingEnabled: thinkingEnabled ) if case .rejected(let rejection) = disposition { - state.pendingClipboardSkillID = nil + clearPendingExportSkill() state.aiSession.fail(message(for: rejection), utteranceID: nil) } } func cancel() { guard state.aiSession.isBusy else { return } - state.pendingClipboardSkillID = nil + clearPendingExportSkill() flow.cancelAIRecording() state.aiSession.cancelCurrentWork() } @@ -153,7 +158,7 @@ final class AIKeyboardCoordinator { if state.aiSession.canInsert, let answer = state.aiSession.answer { guard insertAnswer(answer) else { return } state.aiSession.markAnswerInserted( - offersSend: state.returnKeyRole == .send + offersSend: state.returnKeyRole.usesActionFill ) } else if state.aiSession.canSend { state.aiSession.markAnswerSent() @@ -228,16 +233,21 @@ final class AIKeyboardCoordinator { } func fail(_ message: String, utteranceID: UUID?) { - state.pendingClipboardSkillID = nil + clearPendingExportSkill() state.aiSession.fail(message, utteranceID: utteranceID) } private func endConversationIfNeeded() { - state.pendingClipboardSkillID = nil + clearPendingExportSkill() guard let conversationID = state.aiSession.conversationID else { return } flow.endAIConversation(conversationID) } + private func clearPendingExportSkill() { + state.pendingClipboardSkillID = nil + state.pendingClipboardSkillSource = nil + } + private var isPendingExportSkill: Bool { guard let id = state.pendingClipboardSkillID else { return false } return resolvedSkill(id: id)?.kind == .export @@ -251,9 +261,10 @@ final class AIKeyboardCoordinator { } /// Parse an export skill. Empty → in-keyboard tip, stay in the host app. - /// Lines → hand off to the host to run the companion Shortcut. + /// Lines → hand off to the host (Shortcut, Maps, or Didi). private func finishExportSkill(answer: String) { - let source = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text + let source = state.pendingClipboardSkillSource + ?? ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text let skillID = state.pendingClipboardSkillID let items: [String] let emptyTipKey: String @@ -267,6 +278,13 @@ final class AIKeyboardCoordinator { case AIClipboardSkillCatalog.navigateID: items = AIAddressExtraction.lines(from: answer, sourceClipboard: source) emptyTipKey = "keyboard.ai.skill.noAddress" + case AIClipboardSkillCatalog.saveToNotesID: + items = AINoteExport.items( + from: answer, + sourceClipboard: source, + locale: AIHintLocaleResolver.packLocale() + ) + emptyTipKey = "keyboard.ai.skill.noNote" default: items = AIGenericSkillExport.items(from: answer) emptyTipKey = "keyboard.ai.skill.noExportItems" @@ -280,7 +298,7 @@ final class AIKeyboardCoordinator { AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n")) } #endif - state.pendingClipboardSkillID = nil + clearPendingExportSkill() if state.aiSession.isBusy { state.aiSession.cancelCurrentWork() } @@ -292,14 +310,25 @@ final class AIKeyboardCoordinator { state.skillTipText = ExtL10n.string(emptyTipKey) return } - guard let skillID, - resolvedSkill(id: skillID)?.shortcutName != nil else { - AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")") + guard let skillID, let skill = resolvedSkill(id: skillID) else { + AIAgentShortcutRun.trace("keyboard.parse missingSkill") + state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing") + return + } + if skill.requiresShortcut, skill.shortcutName == nil { + AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID)") state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing") return } AIAgentShortcutRun.trace("keyboard.handoffToHost skill=\(skillID) items=\(items.count)") - state.skillTipText = ExtL10n.string("keyboard.ai.skill.runningShortcut") + let tipKey: String + switch skillID { + case AIClipboardSkillCatalog.navigateID: + tipKey = "keyboard.ai.skill.openingMaps" + default: + tipKey = "keyboard.ai.skill.runningShortcut" + } + state.skillTipText = ExtL10n.string(tipKey) state.runClipboardExportSkill(skillID, items) } diff --git a/OSGKeyboardExt/Typing/TypingKeyTouchPad.swift b/OSGKeyboardExt/Typing/TypingKeyTouchPad.swift index 976efe0..1e854a2 100644 --- a/OSGKeyboardExt/Typing/TypingKeyTouchPad.swift +++ b/OSGKeyboardExt/Typing/TypingKeyTouchPad.swift @@ -3,7 +3,8 @@ // // Grid-level UIKit touch tracking for the typing surface: // Down highlight → Move reselect → Up commit (letters / space / return), -// delete repeats on down, Shift holds while the gesture owns it. +// overlapping fingers commit in press order, delete repeats on down, +// Shift holds while that finger owns it. import SwiftUI import UIKit @@ -12,7 +13,7 @@ import OSGKeyboardShared struct TypingKeyTouchPad: UIViewRepresentable { var layout: TypingKeyLayout var hapticIntensity: KeyboardHapticIntensity - var onHighlightChange: (String?) -> Void + var onHighlightChange: (Set) -> Void var onCommit: (TypingKeyHitTarget) -> Void var onDeleteFire: () -> Void var onShiftBegan: () -> Void @@ -36,8 +37,7 @@ struct TypingKeyTouchPad: UIViewRepresentable { @MainActor final class Coordinator { var parent: TypingKeyTouchPad - private var activeKeyID: String? - private var gestureOwnsShift = false + private let tracker = TypingTouchTracker() private var deleteRepeatTask: Task? private var deleteRepeatStartedAt: Date? private var isDeleteRepeating = false @@ -46,72 +46,24 @@ struct TypingKeyTouchPad: UIViewRepresentable { self.parent = parent } - func handleBegan(at point: CGPoint) { - resetDeleteRepeat() - gestureOwnsShift = false - guard let key = hit(at: point) else { - setHighlight(nil) - return - } - activate(key) + func handleBegan(id: ObjectIdentifier, at point: CGPoint) { + apply(tracker.began(id: id, key: hit(at: point))) } - func handleMoved(at point: CGPoint) { - let key = hit(at: point) - if key?.id == activeKeyID { return } - - if activeBehavior == .deleteRepeat { - stopDeleteRepeat() - } - - if let key { - activate(key) - } else { - // Outside plane: clear highlight; Shift stays held until ended. - setHighlight(nil) - activeKeyID = nil - } + func handleMoved(id: ObjectIdentifier, at point: CGPoint) { + apply(tracker.moved(id: id, key: hit(at: point))) } - func handleEnded(at point: CGPoint) { - // Outside the key plane → cancel (no commit), per accuracy plan. - let key = hit(at: point) - stopDeleteRepeat() - - defer { - setHighlight(nil) - activeKeyID = nil - finishShiftIfNeeded() - } - - guard let key else { return } - - switch key.behavior { - case .commitOnRelease: - parent.onCommit(key) - case .deleteRepeat: - // Already fired on down / while held. - break - case .shiftHold: - // endShiftHold decides tap vs hold-with-type. - break - } + func handleEnded(id: ObjectIdentifier, at point: CGPoint) { + apply(tracker.ended(id: id, key: hit(at: point))) } - func handleCancelled() { - stopDeleteRepeat() - setHighlight(nil) - activeKeyID = nil - finishShiftIfNeeded() + func handleCancelled(id: ObjectIdentifier) { + apply(tracker.cancelled(id: id)) } // MARK: - Internals - private var activeBehavior: TypingKeyTouchBehavior? { - guard let activeKeyID else { return nil } - return parent.layout.key(id: activeKeyID)?.behavior - } - private func hit(at point: CGPoint) -> TypingKeyHitTarget? { let layout = parent.layout return KeyHitTesting.hitTarget( @@ -124,27 +76,29 @@ struct TypingKeyTouchPad: UIViewRepresentable { ) } - private func activate(_ key: TypingKeyHitTarget) { - activeKeyID = key.id - setHighlight(key.id) - playFeedback(for: key) - - switch key.behavior { - case .commitOnRelease: - break - case .deleteRepeat: - parent.onDeleteFire() - startDeleteRepeat() - case .shiftHold: - if !gestureOwnsShift { - gestureOwnsShift = true - parent.onShiftBegan() - } + private func apply(_ effects: TypingTouchEffects) { + if effects.stopDeleteRepeat { + stopDeleteRepeat() } - } - - private func setHighlight(_ id: String?) { - parent.onHighlightChange(id) + for key in effects.commits { + parent.onCommit(key) + } + if let key = effects.playFeedback { + playFeedback(for: key) + } + if effects.deleteFire { + parent.onDeleteFire() + } + if effects.startDeleteRepeat { + startDeleteRepeat() + } + if effects.beginShift { + parent.onShiftBegan() + } + if effects.endShift { + parent.onShiftEnded() + } + parent.onHighlightChange(tracker.highlightedKeyIDs) } private func playFeedback(for key: TypingKeyHitTarget) { @@ -207,16 +161,6 @@ struct TypingKeyTouchPad: UIViewRepresentable { deleteRepeatTask?.cancel() deleteRepeatTask = nil } - - private func resetDeleteRepeat() { - stopDeleteRepeat() - } - - private func finishShiftIfNeeded() { - guard gestureOwnsShift else { return } - gestureOwnsShift = false - parent.onShiftEnded() - } } } @@ -239,7 +183,7 @@ final class TypingKeyTouchPadUIView: UIView { override init(frame: CGRect) { super.init(frame: frame) backgroundColor = Self.padTint - isMultipleTouchEnabled = false + isMultipleTouchEnabled = true isExclusiveTouch = true isUserInteractionEnabled = true } @@ -250,21 +194,30 @@ final class TypingKeyTouchPadUIView: UIView { } override func touchesBegan(_ touches: Set, with event: UIEvent?) { - guard let touch = touches.first else { return } - coordinator?.handleBegan(at: touch.location(in: self)) + for touch in Self.sorted(touches) { + coordinator?.handleBegan(id: ObjectIdentifier(touch), at: touch.location(in: self)) + } } override func touchesMoved(_ touches: Set, with event: UIEvent?) { - guard let touch = touches.first else { return } - coordinator?.handleMoved(at: touch.location(in: self)) + for touch in Self.sorted(touches) { + coordinator?.handleMoved(id: ObjectIdentifier(touch), at: touch.location(in: self)) + } } override func touchesEnded(_ touches: Set, with event: UIEvent?) { - guard let touch = touches.first else { return } - coordinator?.handleEnded(at: touch.location(in: self)) + for touch in Self.sorted(touches) { + coordinator?.handleEnded(id: ObjectIdentifier(touch), at: touch.location(in: self)) + } } override func touchesCancelled(_ touches: Set, with event: UIEvent?) { - coordinator?.handleCancelled() + for touch in Self.sorted(touches) { + coordinator?.handleCancelled(id: ObjectIdentifier(touch)) + } + } + + private static func sorted(_ touches: Set) -> [UITouch] { + touches.sorted { $0.timestamp < $1.timestamp } } } diff --git a/OSGKeyboardExt/Typing/TypingRootView.swift b/OSGKeyboardExt/Typing/TypingRootView.swift index 6675a8e..4309bbb 100644 --- a/OSGKeyboardExt/Typing/TypingRootView.swift +++ b/OSGKeyboardExt/Typing/TypingRootView.swift @@ -47,8 +47,8 @@ struct TypingRootView: View { /// After the first expand, keep the panel tree mounted and only toggle /// opacity so subsequent ▼/▲ taps stay cheap. @State private var candidatePanelMounted = false - /// Key currently under the finger (grid-level touch pad). - @State private var highlightedKeyID: String? + /// Keys currently under a finger (grid-level touch pad, multi-touch). + @State private var highlightedKeyIDs: Set = [] static func totalHeight(isIPad: Bool = false, width: CGFloat = 0) -> CGFloat { TypingLayoutMetrics.contentHeight(isIPad: isIPad, width: width) @@ -398,7 +398,7 @@ struct TypingRootView: View { TypingKeyTouchPad( layout: layout, hapticIntensity: state.keyboardHapticIntensity, - onHighlightChange: { highlightedKeyID = $0 }, + onHighlightChange: { highlightedKeyIDs = $0 }, onCommit: { commitTypingKey($0) }, onDeleteFire: { apply(typing.handleKey("⌫")) }, onShiftBegan: { typing.beginShiftHold() }, @@ -448,10 +448,10 @@ struct TypingRootView: View { let pageLabel = typing.page == .letters ? "123" : "ABC" let spaceLabel = typing.language == .chinese ? "空格" : "space" let returnLabel: String = { - switch state.returnKeyRole { - case .newline: return "return" - case .send: return ExtL10n.string(state.returnKeyRole.titleKey) + if state.returnKeyRole.usesActionFill { + return ExtL10n.string(state.returnKeyRole.titleKey) } + return "return" }() // iPad spends its extra width on comma / period like the system @@ -491,7 +491,7 @@ struct TypingRootView: View { @ViewBuilder private func visualTypingKey(_ key: TypingKeyHitTarget) -> some View { - let pressed = highlightedKeyID == key.id + let pressed = highlightedKeyIDs.contains(key.id) let isBottom = key.id.hasPrefix("bottom.") let isShift = key.label == "⇧" let shiftLit = isShift && typing.isShiftEnabled @@ -624,13 +624,14 @@ struct TypingRootView: View { @ViewBuilder private var returnKeyLabel: some View { - switch state.returnKeyRole { - case .newline: - Image(systemName: "arrow.turn.down.left") - .font(.system(size: 21, weight: .medium)) - case .send: + if state.returnKeyRole.usesActionFill { ExtL10n.text(state.returnKeyRole.titleKey) .font(.system(size: 15, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } else { + Image(systemName: "arrow.turn.down.left") + .font(.system(size: 21, weight: .medium)) } } @@ -684,42 +685,29 @@ struct TypingRootView: View { } private var returnKeyFill: Color { - switch state.returnKeyRole { - case .newline: return keyFill - case .send: return sendKeyFill - } + state.returnKeyRole.usesActionFill ? sendKeyFill : keyFill } private var returnKeyPressedFill: Color { - switch state.returnKeyRole { - case .newline: return keyPressedFill - case .send: return sendKeyPressedFill - } + state.returnKeyRole.usesActionFill ? sendKeyPressedFill : keyPressedFill } private var returnKeyBorder: Color { - switch state.returnKeyRole { - case .newline: - return palette.divider - case .send: + if state.returnKeyRole.usesActionFill { return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08) } + return palette.divider } private var returnKeyAccessibilityLabel: String { - switch state.returnKeyRole { - case .newline: return "return" - case .send: return ExtL10n.string(state.returnKeyRole.titleKey) + if state.returnKeyRole.usesActionFill { + return ExtL10n.string(state.returnKeyRole.titleKey) } + return "return" } private var returnKeyTextColor: Color { - switch state.returnKeyRole { - case .newline: - return keyTextColor - case .send: - return .white - } + state.returnKeyRole.usesActionFill ? .white : keyTextColor } /// The send key stays recognizable in both appearances without becoming neon. diff --git a/OSGKeyboardExt/Views/AIKeyboardView.swift b/OSGKeyboardExt/Views/AIKeyboardView.swift index 6faa324..d4850e7 100644 --- a/OSGKeyboardExt/Views/AIKeyboardView.swift +++ b/OSGKeyboardExt/Views/AIKeyboardView.swift @@ -518,6 +518,9 @@ struct AIKeyboardView: View { if state.pendingClipboardSkillID == AIClipboardSkillCatalog.navigateID { return ExtL10n.string("keyboard.ai.skill.extractingAddress") } + if state.pendingClipboardSkillID == AIClipboardSkillCatalog.saveToNotesID { + return ExtL10n.string("keyboard.ai.skill.namingNote") + } if let draft = state.aiSession.draftAnswerText, !draft.isEmpty { return ExtL10n.string("keyboard.ai.generating") } diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 3c59241..0dd8362 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -484,7 +484,7 @@ public struct KeyboardRootView: View { title: title, label: title, disabled: disabled, - isSend: state.returnKeyRole == .send, + isSend: state.returnKeyRole.usesActionFill, hapticIntensity: state.keyboardHapticIntensity ) { state.insertNewline() diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 75129b8..94a3cb1 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -26,6 +26,14 @@ "common.space" = "Space"; "common.newline" = "Return"; "common.send" = "Send"; +"keyboard.return.go" = "Go"; +"keyboard.return.search" = "Search"; +"keyboard.return.join" = "Join"; +"keyboard.return.next" = "Next"; +"keyboard.return.route" = "Route"; +"keyboard.return.google" = "Google"; +"keyboard.return.yahoo" = "Yahoo"; +"keyboard.return.emergencyCall" = "Emergency Call"; /* Privacy footnote (onboarding welcome page) */ "privacy.audio.title" = "On-device transcription"; @@ -299,6 +307,7 @@ "keyboard.ai.skill.translate" = "Translate"; "keyboard.ai.skill.extractTodos" = "Tasks"; "keyboard.ai.skill.extractEvents" = "Events"; +"keyboard.ai.skill.saveToNotes" = "Notes"; "keyboard.ai.skill.navigate" = "Navigate"; "keyboard.ai.skill.previewPolish" = "Polish"; "keyboard.ai.skill.previewIdeas" = "Ideas"; @@ -306,10 +315,13 @@ "keyboard.ai.skill.noTodos" = "No tasks in the clipboard"; "keyboard.ai.skill.noEvents" = "No date or time in the clipboard"; "keyboard.ai.skill.noAddress" = "No address in the clipboard"; +"keyboard.ai.skill.noNote" = "Nothing to save to Notes"; "keyboard.ai.skill.noExportItems" = "Nothing to send to the Shortcut"; "keyboard.ai.skill.extracting" = "Finding tasks…"; "keyboard.ai.skill.extractingEvents" = "Finding events…"; "keyboard.ai.skill.extractingAddress" = "Finding an address…"; +"keyboard.ai.skill.namingNote" = "Naming the note…"; "keyboard.ai.skill.runningShortcut" = "Running Shortcut…"; +"keyboard.ai.skill.openingMaps" = "Opening Maps…"; "keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills"; "keyboard.ai.skill.handoffFailed" = "Couldn’t open the app to run the Shortcut"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index f40f409..6f00205 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -26,6 +26,14 @@ "common.space" = "空格"; "common.newline" = "回车"; "common.send" = "发送"; +"keyboard.return.go" = "前往"; +"keyboard.return.search" = "搜索"; +"keyboard.return.join" = "加入"; +"keyboard.return.next" = "下一项"; +"keyboard.return.route" = "路线"; +"keyboard.return.google" = "Google"; +"keyboard.return.yahoo" = "Yahoo"; +"keyboard.return.emergencyCall" = "紧急电话"; /* Privacy footnote (onboarding welcome page) */ "privacy.audio.title" = "支持本地转写"; @@ -299,6 +307,7 @@ "keyboard.ai.skill.translate" = "翻译"; "keyboard.ai.skill.extractTodos" = "待办"; "keyboard.ai.skill.extractEvents" = "日程"; +"keyboard.ai.skill.saveToNotes" = "备忘录"; "keyboard.ai.skill.navigate" = "导航"; "keyboard.ai.skill.previewPolish" = "润色"; "keyboard.ai.skill.previewIdeas" = "点子"; @@ -306,10 +315,13 @@ "keyboard.ai.skill.noTodos" = "剪贴板内容没有待办事项"; "keyboard.ai.skill.noEvents" = "剪贴板内容没有日期或时间"; "keyboard.ai.skill.noAddress" = "剪贴板内容没有可导航的地址"; +"keyboard.ai.skill.noNote" = "剪贴板没有可保存的内容"; "keyboard.ai.skill.noExportItems" = "没有可发给捷径的内容"; "keyboard.ai.skill.extracting" = "正在提取待办…"; "keyboard.ai.skill.extractingEvents" = "正在提取日程…"; "keyboard.ai.skill.extractingAddress" = "正在识别地址…"; +"keyboard.ai.skill.namingNote" = "正在生成标题…"; "keyboard.ai.skill.runningShortcut" = "正在运行捷径…"; +"keyboard.ai.skill.openingMaps" = "正在打开地图…"; "keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装"; "keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径"; diff --git a/OSGKeyboardExtTests/EnglishTypingTests.swift b/OSGKeyboardExtTests/EnglishTypingTests.swift index 6f86fba..fbb9c2c 100644 --- a/OSGKeyboardExtTests/EnglishTypingTests.swift +++ b/OSGKeyboardExtTests/EnglishTypingTests.swift @@ -300,4 +300,81 @@ final class EnglishTypingTests: XCTestCase { XCTAssertEqual(undone.deleteCount, spaced.text.count) } } + + @MainActor + func testPeriodShortcutReplacesDoubleSpace() { + let typing = TypingSessionController() + _ = typing.setLanguage(.english) + typing.enterTypingMode() + + for character in ["h", "e", "l", "l", "o"] { + apply(typing, typing.handleKey(character)) + } + let first = typing.handleSpace() + XCTAssertTrue(first.text.hasSuffix(" "), "first Space should insert a space") + apply(typing, first) + + let second = typing.handleSpace() + XCTAssertEqual(second, .replace(deleteCount: 1, with: ". ")) + apply(typing, second) + XCTAssertTrue(typing.shiftActive, "period+space must arm sentence Shift") + } + + @MainActor + func testPeriodShortcutDoesNotFireAfterAnotherLetter() { + let typing = TypingSessionController() + _ = typing.setLanguage(.english) + typing.enterTypingMode() + + apply(typing, typing.handleKey("h")) + apply(typing, typing.handleSpace()) + apply(typing, typing.handleKey("i")) + let second = typing.handleSpace() + XCTAssertEqual(second, .insert(" ")) + } + + @MainActor + func testPeriodShortcutDoesNotFireAfterSentenceTerminator() { + let typing = TypingSessionController() + _ = typing.setLanguage(.english) + typing.precedingTextProvider = { "Hello." } + typing.enterTypingMode() + typing.syncAutocapitalization() + + apply(typing, typing.handleSpace()) + let second = typing.handleSpace() + XCTAssertEqual(second, .insert(" ")) + } + + @MainActor + func testChineseSpaceDoesNotUsePeriodShortcut() { + let typing = TypingSessionController() + _ = typing.setLanguage(.chinese) + let first = typing.handleSpace() + let second = typing.handleSpace() + XCTAssertEqual(first.text, " ") + XCTAssertEqual(second.text, " ") + XCTAssertEqual(second.deleteCount, 0) + } + + func testPeriodShortcutPredicate() { + XCTAssertTrue(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello ")) + XCTAssertTrue(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "你 ")) + XCTAssertTrue(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "v2 ")) + XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello. ")) + XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello ")) + XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello")) + XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: " ")) + XCTAssertTrue(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello")) + XCTAssertFalse(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello.")) + XCTAssertFalse(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello ")) + } + + @MainActor + private func apply(_ typing: TypingSessionController, _ output: TypingOutput) { + typing.syncAutocapitalization( + accountingForInsert: output.text, + deleteCount: output.deleteCount + ) + } } diff --git a/OSGKeyboardExtTests/KeyboardStateTests.swift b/OSGKeyboardExtTests/KeyboardStateTests.swift index 5fd8ff4..54428a5 100644 --- a/OSGKeyboardExtTests/KeyboardStateTests.swift +++ b/OSGKeyboardExtTests/KeyboardStateTests.swift @@ -143,4 +143,16 @@ final class KeyboardStateTests: XCTestCase { XCTAssertNil(state.clipboardSuggestionText) XCTAssertEqual(state.clipboardOverlay, .none) } + + func testReturnKeyRoleActionFillAndTitles() { + XCTAssertFalse(KeyboardState.ReturnKeyRole.newline.usesActionFill) + XCTAssertTrue(KeyboardState.ReturnKeyRole.send.usesActionFill) + XCTAssertTrue(KeyboardState.ReturnKeyRole.search.usesActionFill) + XCTAssertTrue(KeyboardState.ReturnKeyRole.done.usesActionFill) + XCTAssertTrue(KeyboardState.ReturnKeyRole.next.usesActionFill) + XCTAssertEqual(KeyboardState.ReturnKeyRole.search.titleKey, "keyboard.return.search") + XCTAssertEqual(KeyboardState.ReturnKeyRole.next.titleKey, "keyboard.return.next") + XCTAssertEqual(KeyboardState.ReturnKeyRole.done.titleKey, "common.done") + XCTAssertEqual(KeyboardState.ReturnKeyRole.go.titleKey, "keyboard.return.go") + } } diff --git a/OSGKeyboardExtTests/TypingTouchTrackerTests.swift b/OSGKeyboardExtTests/TypingTouchTrackerTests.swift new file mode 100644 index 0000000..7b04f1e --- /dev/null +++ b/OSGKeyboardExtTests/TypingTouchTrackerTests.swift @@ -0,0 +1,150 @@ +// TypingTouchTrackerTests.swift +// OSGKeyboard · Ext unit tests +// +// Multi-finger overlap: press order commits, Shift hold + type, slide reselect. + +import XCTest +@testable import OSGKeyboardShared + +final class TypingTouchTrackerTests: XCTestCase { + private final class Token {} + + private func letter(_ id: String) -> TypingKeyHitTarget { + TypingKeyHitTarget( + id: id, + label: id, + visualFrame: .zero, + behavior: .commitOnRelease + ) + } + + private func shiftKey() -> TypingKeyHitTarget { + TypingKeyHitTarget( + id: "shift", + label: "⇧", + visualFrame: .zero, + behavior: .shiftHold + ) + } + + private func deleteKey() -> TypingKeyHitTarget { + TypingKeyHitTarget( + id: "delete", + label: "⌫", + visualFrame: .zero, + behavior: .deleteRepeat + ) + } + + func testSecondFingerDownCommitsFirstInPressOrder() { + let tracker = TypingTouchTracker() + let a = Token() + let b = Token() + let keyA = letter("a") + let keyB = letter("b") + + let downA = tracker.began(id: ObjectIdentifier(a), key: keyA) + XCTAssertTrue(downA.commits.isEmpty) + XCTAssertEqual(tracker.highlightedKeyIDs, ["a"]) + + let downB = tracker.began(id: ObjectIdentifier(b), key: keyB) + XCTAssertEqual(downB.commits.map(\.id), ["a"]) + XCTAssertEqual(tracker.highlightedKeyIDs, ["b"]) + + let upA = tracker.ended(id: ObjectIdentifier(a), key: keyA) + XCTAssertTrue(upA.commits.isEmpty) + + let upB = tracker.ended(id: ObjectIdentifier(b), key: keyB) + XCTAssertEqual(upB.commits.map(\.id), ["b"]) + XCTAssertTrue(tracker.highlightedKeyIDs.isEmpty) + } + + func testShiftHoldWithOtherFingerDoesNotCommitShift() { + let tracker = TypingTouchTracker() + let shiftFinger = Token() + let letterFinger = Token() + let shift = shiftKey() + let keyA = letter("a") + + let downShift = tracker.began(id: ObjectIdentifier(shiftFinger), key: shift) + XCTAssertTrue(downShift.beginShift) + XCTAssertTrue(downShift.commits.isEmpty) + + let downA = tracker.began(id: ObjectIdentifier(letterFinger), key: keyA) + XCTAssertTrue(downA.commits.isEmpty) + XCTAssertFalse(downA.beginShift) + + let upA = tracker.ended(id: ObjectIdentifier(letterFinger), key: keyA) + XCTAssertEqual(upA.commits.map(\.id), ["a"]) + XCTAssertFalse(upA.endShift) + + let upShift = tracker.ended(id: ObjectIdentifier(shiftFinger), key: shift) + XCTAssertTrue(upShift.endShift) + XCTAssertTrue(upShift.commits.isEmpty) + } + + func testSlideReselectsWithoutCommittingPreviousKey() { + let tracker = TypingTouchTracker() + let finger = Token() + let keyA = letter("a") + let keyS = letter("s") + + _ = tracker.began(id: ObjectIdentifier(finger), key: keyA) + let moved = tracker.moved(id: ObjectIdentifier(finger), key: keyS) + XCTAssertTrue(moved.commits.isEmpty) + XCTAssertEqual(moved.playFeedback?.id, "s") + XCTAssertEqual(tracker.highlightedKeyIDs, ["s"]) + + let ended = tracker.ended(id: ObjectIdentifier(finger), key: keyS) + XCTAssertEqual(ended.commits.map(\.id), ["s"]) + } + + func testLiftOutsideCancelsPendingKey() { + let tracker = TypingTouchTracker() + let finger = Token() + _ = tracker.began(id: ObjectIdentifier(finger), key: letter("a")) + let ended = tracker.ended(id: ObjectIdentifier(finger), key: nil) + XCTAssertTrue(ended.commits.isEmpty) + } + + func testCancelDoesNotCommit() { + let tracker = TypingTouchTracker() + let finger = Token() + _ = tracker.began(id: ObjectIdentifier(finger), key: letter("a")) + let cancelled = tracker.cancelled(id: ObjectIdentifier(finger)) + XCTAssertTrue(cancelled.commits.isEmpty) + XCTAssertTrue(tracker.highlightedKeyIDs.isEmpty) + } + + func testLetterWhileDeletingStopsRepeat() { + let tracker = TypingTouchTracker() + let delFinger = Token() + let letterFinger = Token() + + let downDelete = tracker.began(id: ObjectIdentifier(delFinger), key: deleteKey()) + XCTAssertTrue(downDelete.deleteFire) + XCTAssertTrue(downDelete.startDeleteRepeat) + + let downLetter = tracker.began(id: ObjectIdentifier(letterFinger), key: letter("a")) + XCTAssertTrue(downLetter.stopDeleteRepeat) + XCTAssertFalse(downLetter.deleteFire) + XCTAssertTrue(downLetter.commits.isEmpty) + } + + func testSecondFingerOnSameKeyIsIgnored() { + let tracker = TypingTouchTracker() + let first = Token() + let second = Token() + let keyA = letter("a") + + _ = tracker.began(id: ObjectIdentifier(first), key: keyA) + let duplicate = tracker.began(id: ObjectIdentifier(second), key: keyA) + XCTAssertTrue(duplicate.commits.isEmpty) + XCTAssertNil(duplicate.playFeedback) + + let upFirst = tracker.ended(id: ObjectIdentifier(first), key: keyA) + XCTAssertEqual(upFirst.commits.map(\.id), ["a"]) + let upSecond = tracker.ended(id: ObjectIdentifier(second), key: keyA) + XCTAssertTrue(upSecond.commits.isEmpty) + } +} diff --git a/OSGKeyboardMac/MacDictationViewModel.swift b/OSGKeyboardMac/MacDictationViewModel.swift index fb50bd4..d451504 100644 --- a/OSGKeyboardMac/MacDictationViewModel.swift +++ b/OSGKeyboardMac/MacDictationViewModel.swift @@ -28,13 +28,14 @@ enum MacSection: String, CaseIterable, Identifiable { } } - var systemImage: String { + /// Outline when idle, fill when selected — except Home, which stays `house`. + func systemImage(selected: Bool) -> String { switch self { case .dashboard: return "house" case .history: return "clock.arrow.circlepath" case .dictionary: return "character.book.closed" - case .styles: return "text.badge.star" - case .settings: return "gearshape" + case .styles: return selected ? "dial.high.fill" : "dial.high" + case .settings: return selected ? "gearshape.fill" : "gearshape" } } } diff --git a/OSGKeyboardMac/MacRootView.swift b/OSGKeyboardMac/MacRootView.swift index 23a9031..f8590f7 100644 --- a/OSGKeyboardMac/MacRootView.swift +++ b/OSGKeyboardMac/MacRootView.swift @@ -123,7 +123,7 @@ private struct MacSidebarRow: View { var body: some View { Button(action: action) { - Label(section.title(language: language), systemImage: section.systemImage) + Label(section.title(language: language), systemImage: section.systemImage(selected: isSelected)) .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) .foregroundStyle(isSelected ? palette.accent : palette.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/OSGKeyboardShared/Services/AIAgentShortcutRun.swift b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift index 206e4c8..0e5b556 100644 --- a/OSGKeyboardShared/Services/AIAgentShortcutRun.swift +++ b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift @@ -25,6 +25,8 @@ public struct AIAgentShortcutRunPayload: Codable, Equatable, Sendable { public enum AIAgentShortcutRun { public static let pendingKey = "config.aiAgentSkills.pendingRun.v1" + /// Last skill-handoff lines, readable from the App Group plist on device. + public static let recentTracesKey = "diag.skills.recent.v1" /// Drop payloads older than this; a leftover write must not fire later. public static let payloadTTL: TimeInterval = 60 @@ -65,6 +67,19 @@ public enum AIAgentShortcutRun { /// Xcode / Console search: `OSGDiag/skills`. DEBUG builds include bodies. public static func trace(_ message: String) { OSGDiag.log(message, category: "skills") + persistTrace(message) + } + + /// Keep a short ring so we can copy traces off the phone without root `log collect`. + private static func persistTrace(_ message: String) { + guard let defaults = AppGroup.defaultsIfAvailable else { return } + var lines = defaults.stringArray(forKey: recentTracesKey) ?? [] + let stamp = ISO8601DateFormatter().string(from: Date()) + lines.append("\(stamp) \(message)") + if lines.count > 60 { + lines = Array(lines.suffix(60)) + } + defaults.set(lines, forKey: recentTracesKey) } /// Single-line preview so Console keeps the format (`\\n` for newlines). diff --git a/OSGKeyboardShared/Services/AIClipboardSkill.swift b/OSGKeyboardShared/Services/AIClipboardSkill.swift index bbf1764..1ddb5fb 100644 --- a/OSGKeyboardShared/Services/AIClipboardSkill.swift +++ b/OSGKeyboardShared/Services/AIClipboardSkill.swift @@ -4,7 +4,7 @@ // Built-in clipboard actions for AI idle. The catalog is an ordered list so // Settings / the Skills tab can persist a subset or permutation without // changing the view. Transform skills insert into the current field; -// export skills hand off to a companion Shortcut after the model runs. +// export skills hand off to the host after the model runs (Shortcut, Maps, or Didi). import Foundation @@ -39,7 +39,9 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable { /// Built-in skills are always false. Custom skills default off. public let thinkingEnabled: Bool - public var requiresShortcut: Bool { kind == .export } + /// Reminders, Calendar, and Notes exports need a companion Shortcut. + /// Navigate and Ride hand off to the host (Maps or Didi). No Shortcut. + public var requiresShortcut: Bool { kind == .export && shortcutName != nil } public var isUserCreated: Bool { id.hasPrefix("user.") } public init( @@ -80,22 +82,18 @@ public enum AIClipboardSkillCatalog: Sendable { public static let summarizeID = "summarize" public static let translateID = "translate" public static let extractTodosID = "extractTodos" - public static let extractTodosShortcutName = "OSG · 提取待办" - public static let extractTodosShortcutICloudURL = URL( - string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44" - )! + public static let extractTodosShortcutName = "OSGExtractTodos" public static let extractTodosResourceName = "OSGExtractTodos" public static let extractEventsID = "extractEvents" - public static let extractEventsShortcutName = "OSG · 提取日程" - public static let extractEventsShortcutICloudURL = URL( - string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf" - )! + public static let extractEventsShortcutName = "OSGExtractEvents" public static let extractEventsResourceName = "OSGExtractEvents" + public static let saveToNotesID = "saveToNotes" + public static let saveToNotesShortcutName = "OSGSaveToNotes" + public static let saveToNotesResourceName = "OSGSaveToNotes" + public static let navigateID = "navigate" - public static let navigateShortcutName = "OSG · 导航" - public static let navigateResourceName = "OSGNavigate" /// Full built-in catalog, in a stable display order for the Skills tab. public static let catalog: [AIClipboardSkill] = [ @@ -135,7 +133,6 @@ public enum AIClipboardSkillCatalog: Sendable { kind: .export, isDefault: false, shortcutName: extractTodosShortcutName, - shortcutICloudURL: extractTodosShortcutICloudURL, shortcutResourceName: extractTodosResourceName ), AIClipboardSkill( @@ -147,9 +144,19 @@ public enum AIClipboardSkillCatalog: Sendable { kind: .export, isDefault: false, shortcutName: extractEventsShortcutName, - shortcutICloudURL: extractEventsShortcutICloudURL, shortcutResourceName: extractEventsResourceName ), + AIClipboardSkill( + id: saveToNotesID, + systemImage: "note.text", + titleKey: "keyboard.ai.skill.saveToNotes", + cardTitleKey: "skills.saveToNotes.name", + descriptionKey: "skills.saveToNotes.description", + kind: .export, + isDefault: false, + shortcutName: saveToNotesShortcutName, + shortcutResourceName: saveToNotesResourceName + ), AIClipboardSkill( id: navigateID, systemImage: "arrow.triangle.turn.up.right.diamond.fill", @@ -157,9 +164,7 @@ public enum AIClipboardSkillCatalog: Sendable { cardTitleKey: "skills.navigate.name", descriptionKey: "skills.navigate.description", kind: .export, - isDefault: false, - shortcutName: navigateShortcutName, - shortcutResourceName: navigateResourceName + isDefault: false ), ] @@ -263,6 +268,8 @@ public enum AIClipboardSkillCatalog: Sendable { """ case extractEventsID: return eventInstruction(zh: zh, now: now) + case saveToNotesID: + return noteInstruction(zh: zh, now: now) case navigateID: return navigateInstruction(zh: zh) default: @@ -272,6 +279,26 @@ public enum AIClipboardSkillCatalog: Sendable { } } + /// Title only. The original clipboard is the note body; do not ask the + /// model to rewrite it. + private static func noteInstruction(zh: Bool, now: Date) -> String { + let clock = clockContext(now: now, zh: zh) + if zh { + return """ + \(clock) + 请根据剪贴板正文写一个简短备忘录标题。只要一行标题,不要输出正文,不要编号、不要引号、不要解释。标题中不要出现换行或 |。最多 40 个字。 + 标题应能让人在列表里认出这篇笔记,可结合今天的日期或时间(例如「8月13日周会纪要」)。不要改写或重复正文。 + 即使原文很短也要给一个标题。不要输出 NONE。 + """ + } + return """ + \(clock) + Write a short Notes title from the clipboard. One line only; do not output the body. No numbering, quotes, or commentary. No newlines or | in the title. Maximum 40 characters. + The title should identify the note in a list and may include today's date or time (for example "13 Aug standup notes"). Do not rewrite or repeat the body. + Always return a title, even when the clipboard is short. Do not output NONE. + """ + } + private static func navigateInstruction(zh: Bool) -> String { if zh { return """ diff --git a/OSGKeyboardShared/Services/AIGenericSkillExport.swift b/OSGKeyboardShared/Services/AIGenericSkillExport.swift index f246c20..97b5c8d 100644 --- a/OSGKeyboardShared/Services/AIGenericSkillExport.swift +++ b/OSGKeyboardShared/Services/AIGenericSkillExport.swift @@ -2,7 +2,7 @@ // OSGKeyboard · Shared // // Line-oriented Shortcut input for user-created export skills. Built-in -// extract-todos / extract-events keep their dedicated parsers. +// extract-todos / extract-events / save-to-notes keep their dedicated parsers. import Foundation diff --git a/OSGKeyboardShared/Services/AIMapNavigation.swift b/OSGKeyboardShared/Services/AIMapNavigation.swift index 03a434b..fac9c9a 100644 --- a/OSGKeyboardShared/Services/AIMapNavigation.swift +++ b/OSGKeyboardShared/Services/AIMapNavigation.swift @@ -2,8 +2,7 @@ // OSGKeyboard · Shared // // Builds one turn-by-turn URL. Provider order is Amap (高德) → Baidu → -// Apple Maps. Shortcuts cannot call `canOpenURL`, so the host injects that -// check and passes the resulting URL to the companion Shortcut. +// Apple Maps. The host calls `canOpenURL` and opens the URL itself. import Foundation diff --git a/OSGKeyboardShared/Services/AINoteExport.swift b/OSGKeyboardShared/Services/AINoteExport.swift new file mode 100644 index 0000000..bfe8366 --- /dev/null +++ b/OSGKeyboardShared/Services/AINoteExport.swift @@ -0,0 +1,170 @@ +// 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 = [ + "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[.. 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() + } +} diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 6e923ee..6cdb59f 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -144,6 +144,9 @@ public final class KeyboardState: ObservableObject { @Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs /// Export skill currently waiting on the LLM. Nil for transform skills. @Published public var pendingClipboardSkillID: String? + /// Clipboard captured when that export skill was tapped, so the body + /// still exists after the 30-second hint window closes. + public var pendingClipboardSkillSource: String? /// In-keyboard toast (e.g. no todos). Does not leave the host app. @Published public var skillTipText: String? /// Host field is a password / secure entry — never read pasteboard. @@ -219,13 +222,38 @@ public final class KeyboardState: ObservableObject { public enum ReturnKeyRole: Equatable { case newline case send + case go + case search + case join + case done + case next + case `continue` + case route + case google + case yahoo + case emergencyCall public var titleKey: String { switch self { case .newline: return "common.newline" - case .send: return "common.send" + case .send: return "common.send" + case .go: return "keyboard.return.go" + case .search: return "keyboard.return.search" + case .join: return "keyboard.return.join" + case .done: return "common.done" + case .next: return "keyboard.return.next" + case .continue: return "common.continue" + case .route: return "keyboard.return.route" + case .google: return "keyboard.return.google" + case .yahoo: return "keyboard.return.yahoo" + case .emergencyCall: return "keyboard.return.emergencyCall" } } + + /// Green action chrome (system uses blue for Go / Search / Send / Done). + public var usesActionFill: Bool { + self != .newline + } } // Action hooks — injected by the view controller at install time. diff --git a/OSGKeyboardShared/Typing/PeriodShortcut.swift b/OSGKeyboardShared/Typing/PeriodShortcut.swift new file mode 100644 index 0000000..7ad0f6a --- /dev/null +++ b/OSGKeyboardShared/Typing/PeriodShortcut.swift @@ -0,0 +1,28 @@ +// PeriodShortcut.swift +// OSGKeyboard · Shared +// +// iOS "." Shortcut: a second Space shortly after a Space that follows a +// word character becomes ". " and arms sentence Shift. + +import Foundation + +public enum PeriodShortcut: Sendable { + /// Window for the second Space tap. Slow consecutive spaces stay spaces. + public static let doubleTapInterval: TimeInterval = 0.45 + + /// Whether `precedingText` (already including the first space) can take + /// the shortcut: `…X ` where X is a letter or number, not a terminator. + public static func shouldReplacePreviousSpace(precedingText: String) -> Bool { + guard precedingText.last == " " else { return false } + guard let previous = precedingText.dropLast().last else { return false } + if previous.isWhitespace || previous.isNewline { return false } + return previous.isLetter || previous.isNumber + } + + /// After inserting a space, arm only when that space followed a word char. + public static func shouldArm(afterSpaceFollowing precedingBeforeSpace: String) -> Bool { + guard let last = precedingBeforeSpace.last else { return false } + if last.isWhitespace || last.isNewline { return false } + return last.isLetter || last.isNumber + } +} diff --git a/OSGKeyboardShared/Typing/TypingSessionController.swift b/OSGKeyboardShared/Typing/TypingSessionController.swift index e194786..2156d2c 100644 --- a/OSGKeyboardShared/Typing/TypingSessionController.swift +++ b/OSGKeyboardShared/Typing/TypingSessionController.swift @@ -79,6 +79,9 @@ public final class TypingSessionController: ObservableObject { /// (common in Notes). Capped; reseeds from the proxy when it looks fresh. private var precedingShadow = "" private static let precedingShadowLimit = 400 + /// iOS "." Shortcut: second Space shortly after a Space that followed a word. + private var periodShortcutArmed = false + private var lastSpaceAt: Date? public init( engine: (@MainActor () -> RimeEngineBridging)? = nil, @@ -166,6 +169,7 @@ public final class TypingSessionController: ObservableObject { page = .letters resetShiftState() clearEnglishWordState(keepPrevious: false) + clearPeriodShortcut() // Drop English lexicon pages when leaving typing (jetsam recovery). EnglishLexicon.shared.unload() englishStorage = nil @@ -202,6 +206,7 @@ public final class TypingSessionController: ObservableObject { engine.setLanguage(newLanguage) page = .letters isCandidatePanelExpanded = false + clearPeriodShortcut() if newLanguage == .english { clearEnglishWordState(keepPrevious: false) refreshPersonalTerms() @@ -235,6 +240,7 @@ public final class TypingSessionController: ObservableObject { public func setPage(_ page: TypingKeyPage) { self.page = page resetShiftState() + clearPeriodShortcut() if page == .letters { syncAutocapitalization() } @@ -264,6 +270,7 @@ public final class TypingSessionController: ObservableObject { /// Handle a visible key label. public func handleKey(_ label: String) -> TypingOutput { + clearPeriodShortcut() switch label { case "⇧": // Tests / non-gesture callers: same as a completed Shift tap. @@ -317,8 +324,9 @@ public final class TypingSessionController: ObservableObject { public func handleSpace() -> TypingOutput { if language == .english { - return commitEnglishWord(suffix: " ") + return handleEnglishSpace() } + clearPeriodShortcut() let text = engine.processSpace() ?? " " composition = engine.composition syncCandidatePanelVisibility() @@ -326,6 +334,7 @@ public final class TypingSessionController: ObservableObject { } public func handleReturn() -> TypingOutput { + clearPeriodShortcut() if language == .english { return commitEnglishWord(suffix: "\n") } @@ -336,6 +345,7 @@ public final class TypingSessionController: ObservableObject { } public func selectCandidate(at index: Int) -> TypingOutput { + clearPeriodShortcut() if language == .english { return selectEnglishCandidate(at: index) } @@ -352,6 +362,38 @@ public final class TypingSessionController: ObservableObject { // MARK: - English + private func handleEnglishSpace() -> TypingOutput { + let preceding = precedingTextForShortcut() + if periodShortcutArmed, + let stamped = lastSpaceAt, + Date().timeIntervalSince(stamped) <= PeriodShortcut.doubleTapInterval, + PeriodShortcut.shouldReplacePreviousSpace(precedingText: preceding) { + clearPeriodShortcut() + let wordOut = commitEnglishWord(suffix: "") + let deleteCount = wordOut.deleteCount + 1 + return TypingOutput(deleteCount: deleteCount, text: wordOut.text + ". ") + } + + let output = commitEnglishWord(suffix: " ") + if PeriodShortcut.shouldArm(afterSpaceFollowing: preceding) { + periodShortcutArmed = true + lastSpaceAt = Date() + } else { + clearPeriodShortcut() + } + return output + } + + private func precedingTextForShortcut() -> String { + if !precedingShadow.isEmpty { return precedingShadow } + return precedingTextProvider?() ?? "" + } + + private func clearPeriodShortcut() { + periodShortcutArmed = false + lastSpaceAt = nil + } + private func handleEnglishCharacter(_ ch: Character) -> TypingOutput { pendingAutocorrection = nil if ch.isLetter { @@ -546,6 +588,9 @@ public final class TypingSessionController: ObservableObject { /// briefly reports the immediately preceding edit (common in Notes). public func synchronizeEnglishDocumentContext(caretMoved: Bool = false) { guard language == .english else { return } + if caretMoved { + clearPeriodShortcut() + } guard suggestionsEnabled else { clearEnglishWordState(keepPrevious: false) composition = .empty diff --git a/OSGKeyboardShared/Typing/TypingTouchTracker.swift b/OSGKeyboardShared/Typing/TypingTouchTracker.swift new file mode 100644 index 0000000..74b7cd0 --- /dev/null +++ b/OSGKeyboardShared/Typing/TypingTouchTracker.swift @@ -0,0 +1,181 @@ +// TypingTouchTracker.swift +// OSGKeyboard · Shared +// +// Multi-finger typing contract (system-keyboard overlap): +// each finger is independent; a new key-down commits any other pending +// character/space/return so press order, not release order, wins. +// Shift can be held with one finger while another types. + +import Foundation + +/// Per-step side effects for the UIKit touch pad to apply. +public struct TypingTouchEffects: Equatable, Sendable { + public var commits: [TypingKeyHitTarget] = [] + public var playFeedback: TypingKeyHitTarget? + public var deleteFire = false + public var startDeleteRepeat = false + public var stopDeleteRepeat = false + public var beginShift = false + public var endShift = false + + public init() {} +} + +/// Pure multi-touch state machine. IDs are `ObjectIdentifier` of `UITouch` +/// in the extension, or any unique object in tests. +public final class TypingTouchTracker { + private struct Finger { + let id: ObjectIdentifier + var key: TypingKeyHitTarget? + var committed: Bool + var ownsShift: Bool + var ownsDeleteRepeat: Bool + let order: UInt64 + } + + private var fingers: [ObjectIdentifier: Finger] = [:] + private var nextOrder: UInt64 = 0 + + public init() {} + + public var highlightedKeyIDs: Set { + Set( + fingers.values.compactMap { finger in + guard !finger.committed else { return nil } + return finger.key?.id + } + ) + } + + public func began(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects { + var effects = TypingTouchEffects() + + if let key, hasUncommittedFinger(on: key.id) { + return effects + } + + if key != nil { + commitPendingCharacterKeys(into: &effects) + stopForeignDeleteRepeats(into: &effects) + } + + var finger = Finger( + id: id, + key: key, + committed: false, + ownsShift: false, + ownsDeleteRepeat: false, + order: nextOrder + ) + nextOrder += 1 + + if let key { + effects.playFeedback = key + activate(key, on: &finger, effects: &effects) + } + fingers[id] = finger + return effects + } + + public func moved(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects { + guard var finger = fingers[id], !finger.committed else { + return TypingTouchEffects() + } + if finger.key?.id == key?.id { + return TypingTouchEffects() + } + + var effects = TypingTouchEffects() + if finger.ownsDeleteRepeat { + effects.stopDeleteRepeat = true + finger.ownsDeleteRepeat = false + } + + finger.key = key + if let key { + effects.playFeedback = key + activate(key, on: &finger, effects: &effects) + } + fingers[id] = finger + return effects + } + + public func ended(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects { + guard let finger = fingers.removeValue(forKey: id) else { + return TypingTouchEffects() + } + return finish(finger, hit: key, commitIfNeeded: true) + } + + public func cancelled(id: ObjectIdentifier) -> TypingTouchEffects { + guard let finger = fingers.removeValue(forKey: id) else { + return TypingTouchEffects() + } + return finish(finger, hit: nil, commitIfNeeded: false) + } + + // MARK: - Internals + + private func activate( + _ key: TypingKeyHitTarget, + on finger: inout Finger, + effects: inout TypingTouchEffects + ) { + switch key.behavior { + case .commitOnRelease: + break + case .deleteRepeat: + effects.deleteFire = true + effects.startDeleteRepeat = true + finger.ownsDeleteRepeat = true + case .shiftHold: + if !finger.ownsShift, !fingers.values.contains(where: { $0.ownsShift }) { + effects.beginShift = true + finger.ownsShift = true + } + } + } + + /// Press order = typing order: flush other uncommitted character keys. + private func commitPendingCharacterKeys(into effects: inout TypingTouchEffects) { + let pending = fingers.values + .filter { !$0.committed && $0.key?.behavior == .commitOnRelease } + .sorted { $0.order < $1.order } + for finger in pending { + if let key = finger.key { + effects.commits.append(key) + } + fingers[finger.id]?.committed = true + } + } + + /// Holding delete + tapping a letter must stop the repeat. + private func stopForeignDeleteRepeats(into effects: inout TypingTouchEffects) { + for (id, finger) in fingers where finger.ownsDeleteRepeat { + effects.stopDeleteRepeat = true + fingers[id]?.ownsDeleteRepeat = false + } + } + + private func hasUncommittedFinger(on keyID: String) -> Bool { + fingers.values.contains { !$0.committed && $0.key?.id == keyID } + } + + private func finish( + _ finger: Finger, + hit: TypingKeyHitTarget?, + commitIfNeeded: Bool + ) -> TypingTouchEffects { + var effects = TypingTouchEffects() + if finger.ownsDeleteRepeat { + effects.stopDeleteRepeat = true + } + if finger.ownsShift { + effects.endShift = true + } + if commitIfNeeded, !finger.committed, let hit, hit.behavior == .commitOnRelease { + effects.commits.append(hit) + } + return effects + } +} diff --git a/OSGKeyboardTests/AIAgentSkillLayoutTests.swift b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift index 7115402..aa01e59 100644 --- a/OSGKeyboardTests/AIAgentSkillLayoutTests.swift +++ b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift @@ -163,12 +163,24 @@ final class AIAgentSkillLayoutTests: XCTestCase { XCTAssertNil(AIAgentShortcutRun.decode(data)) } - func testShortcutsRunURLEncodesNameAndText() { - let url = AIAgentShortcutRun.shortcutsRunURL(name: "OSG · 提取待办", text: "买牛奶\n回邮件") + func testShortcutsRunURLPreservesNotesFieldSeparator() { + let text = "周会纪要\(AINoteExport.fieldSeparator)第一项\n第二项" + let url = AIAgentShortcutRun.shortcutsRunURL(name: "OSGSaveToNotes", text: text) + let raw = url?.absoluteString ?? "" + XCTAssertFalse(raw.contains("<"), "angle brackets in the URL get stripped by Shortcuts") + XCTAssertFalse(raw.contains(">")) + let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(items.first { $0.name == "text" }?.value, text) + XCTAssertTrue(text.contains("||OSG_NOTE||")) + } + + func testShortcutsRunURLIncludesNameAndText() { + let shortcutName = AIClipboardSkillCatalog.extractTodosShortcutName + let url = AIAgentShortcutRun.shortcutsRunURL(name: shortcutName, text: "买牛奶\n回邮件") XCTAssertEqual(url?.scheme, "shortcuts") XCTAssertEqual(url?.host, "run-shortcut") let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] - XCTAssertEqual(items.first { $0.name == "name" }?.value, "OSG · 提取待办") + XCTAssertEqual(items.first { $0.name == "name" }?.value, "OSGExtractTodos") XCTAssertEqual(items.first { $0.name == "input" }?.value, "text") XCTAssertEqual(items.first { $0.name == "text" }?.value, "买牛奶\n回邮件") XCTAssertNil(items.first { $0.name == "x-success" }) @@ -176,7 +188,7 @@ final class AIAgentSkillLayoutTests: XCTestCase { func testXCallbackRunURLUsesCallbackHost() { let url = AIAgentShortcutRun.shortcutsRunURL( - name: "OSG · 提取待办", + name: AIClipboardSkillCatalog.extractTodosShortcutName, text: "买牛奶", xSuccess: "osgkeyboard://skill/shortcut-result?status=success", xError: "osgkeyboard://skill/shortcut-result?status=error", @@ -197,51 +209,67 @@ final class AIAgentSkillLayoutTests: XCTestCase { XCTAssertEqual(AIAgentShortcutRun.preview("买牛奶\n回邮件"), "买牛奶\\n回邮件") } - func testExtractTodosUsesICloudShareLink() { + func testExtractTodosUsesBundledShortcut() { let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractTodosID) - XCTAssertEqual( - skill?.shortcutICloudURL, - AIClipboardSkillCatalog.extractTodosShortcutICloudURL - ) - XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com") - XCTAssertEqual(skill?.shortcutName, "OSG · 提取待办") + XCTAssertNil(skill?.shortcutICloudURL) + XCTAssertEqual(skill?.shortcutName, "OSGExtractTodos") XCTAssertEqual(skill?.shortcutResourceName, "OSGExtractTodos") } - func testExtractEventsUsesICloudShareLink() { + func testExtractEventsUsesBundledShortcut() { let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractEventsID) - XCTAssertEqual( - skill?.shortcutICloudURL, - AIClipboardSkillCatalog.extractEventsShortcutICloudURL - ) - XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com") - XCTAssertEqual(skill?.shortcutName, "OSG · 提取日程") + XCTAssertNil(skill?.shortcutICloudURL) + XCTAssertEqual(skill?.shortcutName, "OSGExtractEvents") XCTAssertEqual( skill?.shortcutResourceName, "OSGExtractEvents" ) XCTAssertEqual(skill?.systemImage, "calendar") XCTAssertFalse(skill?.isDefault ?? true) - XCTAssertEqual( - AIAgentShortcutRun.iCloudShareToken(from: skill!.shortcutICloudURL!), - "1f4afcf7ee22400cbf84e319d969aadf" - ) } - func testNavigateUsesBundledShortcut() { + func testNavigateDoesNotRequireShortcut() { let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.navigateID) + XCTAssertNil(skill?.shortcutName) XCTAssertNil(skill?.shortcutICloudURL) - XCTAssertEqual(skill?.shortcutName, "OSG · 导航") - XCTAssertEqual(skill?.shortcutResourceName, "OSGNavigate") + XCTAssertNil(skill?.shortcutResourceName) XCTAssertEqual( skill?.systemImage, "arrow.triangle.turn.up.right.diamond.fill" ) XCTAssertFalse(skill?.isDefault ?? true) XCTAssertEqual(skill?.kind, .export) + XCTAssertFalse(skill?.requiresShortcut ?? true) + } + + func testNavigateEnablesWithoutShortcutConfirmation() { + let store = AIAgentSkillLayoutStore(defaults: makeDefaults()) + XCTAssertEqual( + store.enable(AIClipboardSkillCatalog.navigateID), + .enabled + ) + XCTAssertTrue(store.layout.isEnabled(AIClipboardSkillCatalog.navigateID)) + } + + func testSaveToNotesUsesBundledShortcutUntilICloudShareExists() { + let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.saveToNotesID) + XCTAssertNil(skill?.shortcutICloudURL) + XCTAssertEqual(skill?.shortcutName, "OSGSaveToNotes") + XCTAssertEqual(skill?.shortcutResourceName, "OSGSaveToNotes") + XCTAssertEqual(skill?.systemImage, "note.text") + XCTAssertFalse(skill?.isDefault ?? true) XCTAssertTrue(skill?.requiresShortcut ?? false) } + func testCannotEnableSaveToNotesBeforeShortcutConfirmation() { + let store = AIAgentSkillLayoutStore(defaults: makeDefaults()) + XCTAssertEqual( + store.enable(AIClipboardSkillCatalog.saveToNotesID), + .needsShortcut + ) + XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.saveToNotesID)) + } + func testICloudShareLinkMapsToShortcutsInstallURL() { let share = URL(string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44")! let url = AIAgentShortcutRun.shortcutsInstallURL(from: share) diff --git a/OSGKeyboardTests/AIHintKeywordExtractorTests.swift b/OSGKeyboardTests/AIHintKeywordExtractorTests.swift index a27bada..ee148bb 100644 --- a/OSGKeyboardTests/AIHintKeywordExtractorTests.swift +++ b/OSGKeyboardTests/AIHintKeywordExtractorTests.swift @@ -265,4 +265,15 @@ final class AIClipboardSkillTests: XCTestCase { XCTAssertTrue(prompt.contains("NONE")) XCTAssertTrue(prompt.contains("起点|终点")) } + + func testSaveToNotesAsksForTitleNotBody() { + let prompt = AIClipboardSkillCatalog.instruction( + skillID: AIClipboardSkillCatalog.saveToNotesID, + locale: "zh", + translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId + ) + XCTAssertTrue(prompt.contains("标题")) + XCTAssertTrue(prompt.contains("不要输出正文")) + XCTAssertTrue(prompt.contains("不要输出 NONE")) + } } diff --git a/OSGKeyboardTests/AINoteExportTests.swift b/OSGKeyboardTests/AINoteExportTests.swift new file mode 100644 index 0000000..a98515b --- /dev/null +++ b/OSGKeyboardTests/AINoteExportTests.swift @@ -0,0 +1,150 @@ +// AINoteExportTests.swift +// OSGKeyboardTests + +import XCTest +@testable import OSGKeyboardShared + +final class AINoteExportTests: XCTestCase { + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! + return calendar + } + + private var now: Date { + date(2026, 8, 13, 23, 56) + } + + func testEmptyClipboardProducesNoItems() { + XCTAssertEqual(items(from: "周会纪要", clipboard: nil), []) + XCTAssertEqual(items(from: "周会纪要", clipboard: " \n "), []) + } + + func testUsesModelTitleAndKeepsOriginalBody() { + XCTAssertEqual( + items(from: "8月13日周会纪要", clipboard: "第一项\n第二项"), + ["8月13日周会纪要\(AINoteExport.fieldSeparator)第一项\n第二项"] + ) + } + + func testIgnoresModelBodyAfterFirstLine() { + let answer = """ + 购物清单 + 这是模型改写过的正文,不应写入备忘录。 + """ + XCTAssertEqual( + items(from: answer, clipboard: "买牛奶\n买鸡蛋"), + ["购物清单\(AINoteExport.fieldSeparator)买牛奶\n买鸡蛋"] + ) + } + + func testTakesTitleBeforePipe() { + XCTAssertEqual( + items(from: "会议纪要|请忽略这段模型正文", clipboard: "原文"), + ["会议纪要\(AINoteExport.fieldSeparator)原文"] + ) + } + + func testNONEFallsBackToDatedSnippet() { + XCTAssertEqual( + items(from: "NONE", clipboard: "买牛奶"), + ["8月13日 · 买牛奶\(AINoteExport.fieldSeparator)买牛奶"] + ) + XCTAssertEqual( + items(from: "没有标题", clipboard: "买牛奶"), + ["8月13日 · 买牛奶\(AINoteExport.fieldSeparator)买牛奶"] + ) + } + + func testEnglishFallbackUsesMonthDay() { + XCTAssertEqual( + items(from: "NONE", clipboard: "Buy milk", locale: "en"), + ["13 Aug · Buy milk\(AINoteExport.fieldSeparator)Buy milk"] + ) + } + + func testStripsQuotesAndBullets() { + XCTAssertEqual( + items(from: "「周会纪要」", clipboard: "纪要正文"), + ["周会纪要\(AINoteExport.fieldSeparator)纪要正文"] + ) + XCTAssertEqual( + items(from: "- 周会纪要", clipboard: "纪要正文"), + ["周会纪要\(AINoteExport.fieldSeparator)纪要正文"] + ) + } + + func testKeepsTitleAndBodyWhenTheyMatch() { + XCTAssertEqual( + items(from: "买牛奶", clipboard: "买牛奶"), + ["买牛奶\(AINoteExport.fieldSeparator)买牛奶"] + ) + } + + func testTruncatesLongTitle() { + let title = String(repeating: "纪", count: 50) + let items = items(from: title, clipboard: "正文") + XCTAssertEqual(items.count, 1) + let fields = items[0].components(separatedBy: AINoteExport.fieldSeparator) + XCTAssertEqual(fields.count, 2) + XCTAssertEqual(fields[0].count, AINoteExport.maximumTitleLength) + XCTAssertEqual(fields[1], "正文") + } + + func testWholeClipboardEchoFallsBack() { + let source = String(repeating: "这是一段很长的会议纪要内容,包含许多句子。", count: 4) + let items = items(from: source, clipboard: source) + XCTAssertEqual(items.count, 1) + XCTAssertTrue(items[0].hasPrefix("8月13日 · ")) + XCTAssertTrue(items[0].hasSuffix("\(AINoteExport.fieldSeparator)\(source)")) + } + + func testPromptAsksForTitleOnly() { + let prompt = AIClipboardSkillCatalog.instruction( + skillID: AIClipboardSkillCatalog.saveToNotesID, + locale: "zh", + translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId, + now: now + ) + XCTAssertTrue(prompt.contains("标题")) + XCTAssertTrue(prompt.contains("不要输出正文")) + XCTAssertTrue(prompt.contains("不要改写")) + } + + func testEnglishPromptAsksForTitleOnly() { + let prompt = AIClipboardSkillCatalog.instruction( + skillID: AIClipboardSkillCatalog.saveToNotesID, + locale: "en", + translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId, + now: now + ) + XCTAssertTrue(prompt.contains("title")) + XCTAssertTrue(prompt.contains("do not output the body")) + } + + private func items( + from answer: String, + clipboard: String?, + locale: String = "zh" + ) -> [String] { + AINoteExport.items( + from: answer, + sourceClipboard: clipboard, + now: now, + locale: locale, + calendar: calendar + ) + } + + private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date { + calendar.date( + from: DateComponents( + year: year, + month: month, + day: day, + hour: hour, + minute: minute + ) + )! + } +} diff --git a/Scripts/generate_extract_events_shortcut.py b/Scripts/generate_extract_events_shortcut.py index b6141e3..7abafa1 100644 --- a/Scripts/generate_extract_events_shortcut.py +++ b/Scripts/generate_extract_events_shortcut.py @@ -26,6 +26,7 @@ ROOT = Path(__file__).resolve().parents[1] OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts" UNSIGNED = OUT_DIR / "OSGExtractEvents.unsigned.shortcut" SIGNED = OUT_DIR / "OSGExtractEvents.shortcut" +SHORTCUT_NAME = "OSGExtractEvents" OBJECT_REPLACEMENT = "\ufffc" @@ -156,7 +157,7 @@ def workflow() -> dict: "WFWorkflowClientRelease": "26.0", "WFWorkflowMinimumClientVersion": 900, "WFWorkflowMinimumClientVersionString": "900", - "WFWorkflowName": "OSG · 提取日程", + "WFWorkflowName": SHORTCUT_NAME, "WFWorkflowHasShortcutInputVariables": True, "WFWorkflowHasOutputFallback": False, "WFWorkflowImportQuestions": [], diff --git a/Scripts/generate_extract_todos_shortcut.py b/Scripts/generate_extract_todos_shortcut.py index 33ae6ab..089e7d3 100644 --- a/Scripts/generate_extract_todos_shortcut.py +++ b/Scripts/generate_extract_todos_shortcut.py @@ -21,6 +21,7 @@ ROOT = Path(__file__).resolve().parents[1] OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts" UNSIGNED = OUT_DIR / "OSGExtractTodos.unsigned.shortcut" SIGNED = OUT_DIR / "OSGExtractTodos.shortcut" +SHORTCUT_NAME = "OSGExtractTodos" OBJECT_REPLACEMENT = "\ufffc" @@ -77,7 +78,7 @@ def workflow() -> dict: "WFWorkflowClientRelease": "26.0", "WFWorkflowMinimumClientVersion": 900, "WFWorkflowMinimumClientVersionString": "900", - "WFWorkflowName": "OSG · 提取待办", + "WFWorkflowName": SHORTCUT_NAME, "WFWorkflowHasShortcutInputVariables": True, "WFWorkflowHasOutputFallback": False, "WFWorkflowImportQuestions": [], diff --git a/Scripts/generate_navigate_shortcut.py b/Scripts/generate_navigate_shortcut.py deleted file mode 100644 index 55567f5..0000000 --- a/Scripts/generate_navigate_shortcut.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -"""Build the companion Shortcut for the Navigate skill. - -Writes an unsigned binary plist that `shortcuts sign` can notarize. - -The host already picked 高德 → 百度 → Apple Maps and passes one URL. -This recipe only materializes Shortcut Input as Text and opens it: - - Text (Shortcut Input) - → Open URLs -""" - -from __future__ import annotations - -import plistlib -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts" -UNSIGNED = OUT_DIR / "OSGNavigate.unsigned.shortcut" -SIGNED = OUT_DIR / "OSGNavigate.shortcut" - -OBJECT_REPLACEMENT = "\ufffc" - -TEXT_UUID = "C1D2E3F0-1111-4E00-8A5A-FD7E1B0C396F" -OPEN_UUID = "C1D2E3F1-2222-4F11-9A6B-0E8F2C1D4A70" - - -def token_string(attachment_value: dict) -> dict: - return { - "Value": { - "attachmentsByRange": {"{0, 1}": attachment_value}, - "string": OBJECT_REPLACEMENT, - }, - "WFSerializationType": "WFTextTokenString", - } - - -def shortcut_input_text() -> dict: - return token_string({"Type": "ExtensionInput"}) - - -def action_output_text(output_name: str, output_uuid: str) -> dict: - return token_string( - { - "Aggrandizements": [], - "OutputName": output_name, - "OutputUUID": output_uuid, - "Type": "ActionOutput", - } - ) - - -def workflow() -> dict: - return { - "WFWorkflowClientVersion": "3600.0.4", - "WFWorkflowClientRelease": "26.0", - "WFWorkflowMinimumClientVersion": 900, - "WFWorkflowMinimumClientVersionString": "900", - "WFWorkflowName": "OSG · 导航", - "WFWorkflowHasShortcutInputVariables": True, - "WFWorkflowHasOutputFallback": False, - "WFWorkflowImportQuestions": [], - "WFWorkflowTypes": [], - "WFWorkflowInputContentItemClasses": [ - "WFStringContentItem", - "WFURLContentItem", - "WFRichTextContentItem", - ], - "WFWorkflowOutputContentItemClasses": [], - "WFWorkflowNoInputBehavior": { - "Name": "WFWorkflowNoInputBehaviorAskForInput", - "Parameters": { - "ItemClass": "WFStringContentItem", - }, - }, - "WFWorkflowIcon": { - "WFWorkflowIconGlyphNumber": 59723, - "WFWorkflowIconStartColor": 4278222847, - }, - "WFWorkflowActions": [ - { - "WFWorkflowActionIdentifier": "is.workflow.actions.gettext", - "WFWorkflowActionParameters": { - "UUID": TEXT_UUID, - "CustomOutputName": "快捷指令文本", - "WFTextActionText": shortcut_input_text(), - }, - }, - { - "WFWorkflowActionIdentifier": "is.workflow.actions.openurl", - "WFWorkflowActionParameters": { - "UUID": OPEN_UUID, - "WFURL": action_output_text("快捷指令文本", TEXT_UUID), - "WFShowWhenRun": False, - }, - }, - ], - } - - -def main() -> None: - OUT_DIR.mkdir(parents=True, exist_ok=True) - UNSIGNED.write_bytes(plistlib.dumps(workflow(), fmt=plistlib.FMT_BINARY)) - print(f"wrote {UNSIGNED}") - print( - "sign with: shortcuts sign --mode anyone " - f"--input {UNSIGNED} --output {SIGNED}" - ) - - -if __name__ == "__main__": - main() diff --git a/Scripts/generate_save_to_notes_shortcut.py b/Scripts/generate_save_to_notes_shortcut.py new file mode 100644 index 0000000..a0c61c1 --- /dev/null +++ b/Scripts/generate_save_to_notes_shortcut.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Build the companion Shortcut for the Save to Notes skill. + +Writes an unsigned binary plist that `shortcuts sign` can notarize. + +On iPhone, `com.apple.mobilenotes.SharingExtension` binds note content through +`WFCreateNoteInput`. The similarly named `contents` App Intent parameter is +ignored by this legacy action and leaves an unresolved Content placeholder. + + Text (Shortcut Input) + → Split Text (`||OSG_NOTE||`) + → Get item 1 (title) and item 2 (body) + → Text (title + blank line + body) + → Create Note (WFCreateNoteInput = Text output, silent) +""" + +from __future__ import annotations + +import plistlib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts" +UNSIGNED = OUT_DIR / "OSGSaveToNotes.unsigned.shortcut" +SIGNED = OUT_DIR / "OSGSaveToNotes.shortcut" + +OBJECT_REPLACEMENT = "\ufffc" +FIELD_SEPARATOR = "||OSG_NOTE||" + +TEXT_UUID = "B2C3D4E0-1111-4E00-8A5A-FD7E1B0C396F" +SPLIT_UUID = "B2C3D4E2-3333-4F11-9A6B-0E8F2C1D4A70" +TITLE_UUID = "B2C3D4E3-4444-4014-B5E6-13B2A8C9D034" +BODY_UUID = "B2C3D4E4-5555-4125-C6F7-24C3B9DAE145" +NOTE_TEXT_UUID = "B2C3D4E5-6666-4236-D7A8-35D4CAEBF256" +NOTE_UUID = "B2C3D4E1-2222-4F11-9A6B-0E8F2C1D4A70" + + +def token_string(attachment_value: dict) -> dict: + return { + "Value": { + "attachmentsByRange": {"{0, 1}": attachment_value}, + "string": OBJECT_REPLACEMENT, + }, + "WFSerializationType": "WFTextTokenString", + } + + +def shortcut_input_text() -> dict: + return token_string({"Type": "ExtensionInput"}) + + +def action_output_text(output_name: str, output_uuid: str) -> dict: + return token_string( + { + "Aggrandizements": [], + "OutputName": output_name, + "OutputUUID": output_uuid, + "Type": "ActionOutput", + } + ) + + +def action_output_attachment(output_name: str, output_uuid: str) -> dict: + return { + "Value": { + "Aggrandizements": [], + "OutputName": output_name, + "OutputUUID": output_uuid, + "Type": "ActionOutput", + }, + "WFSerializationType": "WFTextTokenAttachment", + } + + +def get_item(uuid: str, name: str, index: int) -> dict: + return { + "WFWorkflowActionIdentifier": "is.workflow.actions.getitemfromlist", + "WFWorkflowActionParameters": { + "UUID": uuid, + "CustomOutputName": name, + "WFInput": action_output_attachment("字段", SPLIT_UUID), + "WFItemSpecifier": "Item At Index", + "WFItemIndex": index, + }, + } + + +def note_text() -> dict: + return { + "Value": { + "attachmentsByRange": { + "{0, 1}": { + "Aggrandizements": [], + "OutputName": "标题", + "OutputUUID": TITLE_UUID, + "Type": "ActionOutput", + }, + "{3, 1}": { + "Aggrandizements": [], + "OutputName": "正文", + "OutputUUID": BODY_UUID, + "Type": "ActionOutput", + }, + }, + "string": f"{OBJECT_REPLACEMENT}\n\n{OBJECT_REPLACEMENT}", + }, + "WFSerializationType": "WFTextTokenString", + } + + +def create_note_action() -> dict: + # The Create Note editor must show the `备忘录文本` magic-variable pill. + # A generic `内容` placeholder means this key or value was not recognized. + return { + "WFWorkflowActionIdentifier": "com.apple.mobilenotes.SharingExtension", + "WFWorkflowActionParameters": { + "UUID": NOTE_UUID, + "CustomOutputName": "备忘录", + "IntentAppIdentifier": "com.apple.mobilenotes", + "WFCreateNoteInput": action_output_text("备忘录文本", NOTE_TEXT_UUID), + "ShowWhenRun": False, + "WFShowWhenRun": False, + }, + } + + +def workflow() -> dict: + return { + "WFWorkflowClientVersion": "3600.0.4", + "WFWorkflowClientRelease": "26.0", + "WFWorkflowMinimumClientVersion": 900, + "WFWorkflowMinimumClientVersionString": "900", + "WFWorkflowName": "OSGSaveToNotes", + "WFWorkflowHasShortcutInputVariables": True, + "WFWorkflowHasOutputFallback": False, + "WFWorkflowImportQuestions": [], + "WFWorkflowTypes": [], + "WFWorkflowInputContentItemClasses": [ + "WFStringContentItem", + "WFRichTextContentItem", + ], + "WFWorkflowOutputContentItemClasses": [], + "WFWorkflowNoInputBehavior": { + "Name": "WFWorkflowNoInputBehaviorAskForInput", + "Parameters": { + "ItemClass": "WFStringContentItem", + }, + }, + "WFWorkflowIcon": { + "WFWorkflowIconGlyphNumber": 61440, + "WFWorkflowIconStartColor": 2555137535, + }, + "WFWorkflowActions": [ + { + "WFWorkflowActionIdentifier": "is.workflow.actions.gettext", + "WFWorkflowActionParameters": { + "UUID": TEXT_UUID, + "CustomOutputName": "快捷指令文本", + "WFTextActionText": shortcut_input_text(), + }, + }, + { + "WFWorkflowActionIdentifier": "is.workflow.actions.text.split", + "WFWorkflowActionParameters": { + "UUID": SPLIT_UUID, + "CustomOutputName": "字段", + "text": action_output_text("快捷指令文本", TEXT_UUID), + "WFTextSeparator": "Custom", + "WFTextCustomSeparator": FIELD_SEPARATOR, + }, + }, + get_item(TITLE_UUID, "标题", 1), + get_item(BODY_UUID, "正文", 2), + { + "WFWorkflowActionIdentifier": "is.workflow.actions.gettext", + "WFWorkflowActionParameters": { + "UUID": NOTE_TEXT_UUID, + "CustomOutputName": "备忘录文本", + "WFTextActionText": note_text(), + }, + }, + create_note_action(), + ], + } + + +def validate(data: dict) -> None: + """Reject bindings that make Create Note prompt for missing content.""" + actions = data["WFWorkflowActions"] + create_note = actions[-1] + parameters = create_note["WFWorkflowActionParameters"] + note_input = parameters.get("WFCreateNoteInput") + if create_note["WFWorkflowActionIdentifier"] != "com.apple.mobilenotes.SharingExtension": + raise ValueError("last action must be Create Note") + if "contents" in parameters or not note_input: + raise ValueError("Create Note must use WFCreateNoteInput, not contents") + if note_input.get("WFSerializationType") != "WFTextTokenString": + raise ValueError("WFCreateNoteInput must be a Text magic-variable token") + attachments = note_input["Value"]["attachmentsByRange"].values() + if not any(item.get("OutputUUID") == NOTE_TEXT_UUID for item in attachments): + raise ValueError("WFCreateNoteInput must reference the combined note text") + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + data = workflow() + validate(data) + UNSIGNED.write_bytes(plistlib.dumps(data, fmt=plistlib.FMT_BINARY)) + print(f"wrote {UNSIGNED}") + print( + "sign with: shortcuts sign --mode anyone " + f"--input {UNSIGNED} --output {SIGNED}" + ) + + +if __name__ == "__main__": + main() diff --git a/project.yml b/project.yml index 4671e18..b2ca4f1 100644 --- a/project.yml +++ b/project.yml @@ -49,7 +49,7 @@ settings: STRING_CATALOG_GENERATE_SYMBOLS: YES CLANG_CXX_LANGUAGE_STANDARD: c++17 MARKETING_VERSION: "1.7.5" - CURRENT_PROJECT_VERSION: "69" + CURRENT_PROJECT_VERSION: "71" # 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖) # 项目级签名 xcconfig,适用于所有 target @@ -96,7 +96,7 @@ targets: - path: OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut type: file buildPhase: resources - - path: OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut + - path: OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut type: file buildPhase: resources entitlements: