Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ let package = Package(
])
]
),
.testTarget(
name: "WeChatBridgeDeliveryTests",
dependencies: ["WeChatBridgeApp", "WeChatBridgeCore"],
path: "Tests",
exclude: ["WeChatBridgeCoreTests"],
sources: ["DeliveryNotifierTests.swift", "KnowledgeDeliveryTests.swift"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
.testTarget(
name: "WeChatBridgeCoreTests",
dependencies: ["WeChatBridgeCore"],
Expand Down
10 changes: 10 additions & 0 deletions Resources/Localizations/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"发给 WeSight" = "Send to WeSight";
"发给 DeepSeek Harness" = "Send to DeepSeek Harness";
"沉淀到 Obsidian" = "Save to Obsidian";
"沉淀到文件夹" = "Save to Folder";
"添加应用…" = "Add an App…";

/* Forward targets */
Expand All @@ -47,8 +48,13 @@
"豆包" = "Doubao";
"WeSight" = "WeSight";
"Obsidian" = "Obsidian";
"文件夹" = "the folder";
"剪贴板" = "the clipboard";

/* Folder delivery — lands quietly, so its success gets said */
"已保存到 %@" = "Saved to %@";
"在访达中显示" = "Reveal in Finder";

/* Share extension — no interface; only a failure still speaks */
"系统没有返回文件。" = "The system returned no file.";
"没能接住这次转发:%@" = "Couldn't receive that share: %@";
Expand Down Expand Up @@ -125,6 +131,7 @@
"激活 WeSight 并直接粘贴到输入框。" = "Brings WeSight forward and pastes straight into its input.";
"激活 DeepSeek Harness 并直接粘贴到输入框。" = "Brings DeepSeek Harness forward and pastes straight into its input.";
"把聊天记录转成 Markdown,写入选定的 Obsidian 知识库。" = "Turns the chat archive into Markdown and writes it into the selected Obsidian vault.";
"把聊天记录转成 Markdown 和附件,写入选定的文件夹。" = "Turns the chat into Markdown and attachments in the folder you choose.";
"只放进剪贴板,去哪儿按 ⌘V 由你决定。" = "Puts them on the clipboard and leaves the ⌘V to you.";
"转发时从你自己的清单里挑一个 App,激活它并粘贴。" = "Pick an app from your own list when you forward; WeChatBridge brings it forward and pastes.";
"未注册" = "Not registered";
Expand All @@ -141,10 +148,13 @@
"只粘贴文件路径" = "Paste path only";
"给终端类应用用:粘贴的是带引号的文件路径,跟把文件拖进终端一样。" = "For terminal-style apps: pastes the quoted file path, exactly as dropping the file on a terminal would.";
"Obsidian 沉淀" = "Obsidian";
"文件夹沉淀" = "Folder";
"知识库文件夹" = "Vault folder";
"目标文件夹" = "Destination folder";
"尚未选择" = "Not selected yet";
"选择文件夹…" = "Choose Folder…";
"选择知识库" = "Choose Vault";
"选择目标文件夹" = "Choose Destination Folder";
"子文件夹" = "Subfolder";
"聊天 Markdown 与原始 ZIP 会写入这个目录。" = "Chat Markdown and the original ZIP are written under this folder.";

Expand Down
10 changes: 10 additions & 0 deletions Resources/Localizations/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"发给 WeSight" = "发给 WeSight";
"发给 DeepSeek Harness" = "发给 DeepSeek Harness";
"沉淀到 Obsidian" = "沉淀到 Obsidian";
"沉淀到文件夹" = "沉淀到文件夹";
"添加应用…" = "添加应用…";

/* 转发目标 */
Expand All @@ -47,8 +48,13 @@
"豆包" = "豆包";
"WeSight" = "WeSight";
"Obsidian" = "Obsidian";
"文件夹" = "文件夹";
"剪贴板" = "剪贴板";

/* 文件夹投递 · 安静落盘,成功要说一声 */
"已保存到 %@" = "已保存到 %@";
"在访达中显示" = "在访达中显示";

/* 共享扩展 · 无界面,只有失败还会说话 */
"系统没有返回文件。" = "系统没有返回文件。";
"没能接住这次转发:%@" = "没能接住这次转发:%@";
Expand Down Expand Up @@ -125,6 +131,7 @@
"激活 WeSight 并直接粘贴到输入框。" = "激活 WeSight 并直接粘贴到输入框。";
"激活 DeepSeek Harness 并直接粘贴到输入框。" = "激活 DeepSeek Harness 并直接粘贴到输入框。";
"把聊天记录转成 Markdown,写入选定的 Obsidian 知识库。" = "把聊天记录转成 Markdown,写入选定的 Obsidian 知识库。";
"把聊天记录转成 Markdown 和附件,写入选定的文件夹。" = "把聊天记录转成 Markdown 和附件,写入选定的文件夹。";
"只放进剪贴板,去哪儿按 ⌘V 由你决定。" = "只放进剪贴板,去哪儿按 ⌘V 由你决定。";
"转发时从你自己的清单里挑一个 App,激活它并粘贴。" = "转发时从你自己的清单里挑一个 App,激活它并粘贴。";
"未注册" = "未注册";
Expand All @@ -141,10 +148,13 @@
"只粘贴文件路径" = "只粘贴文件路径";
"给终端类应用用:粘贴的是带引号的文件路径,跟把文件拖进终端一样。" = "给终端类应用用:粘贴的是带引号的文件路径,跟把文件拖进终端一样。";
"Obsidian 沉淀" = "Obsidian 沉淀";
"文件夹沉淀" = "文件夹沉淀";
"知识库文件夹" = "知识库文件夹";
"目标文件夹" = "目标文件夹";
"尚未选择" = "尚未选择";
"选择文件夹…" = "选择文件夹…";
"选择知识库" = "选择知识库";
"选择目标文件夹" = "选择目标文件夹";
"子文件夹" = "子文件夹";
"聊天 Markdown 与原始 ZIP 会写入这个目录。" = "聊天 Markdown 与原始 ZIP 会写入这个目录。";

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"CFBundleDisplayName" = "Save to Folder";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"CFBundleDisplayName" = "沉淀到文件夹";
4 changes: 2 additions & 2 deletions Scripts/check-release-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ plutil -lint "$APP_INFO" "$SHARE_INFO" "$APP_ENTITLEMENTS" "$SHARE_ENTITLEMENTS"
plutil -lint "$ROOT"/Resources/Localizations/*/*.strings "$ROOT"/Resources/ShareLocalizations/*/*/*.strings >/dev/null
python3 -m json.tool "$ROOT/Resources/Skills/catalog.json" >/dev/null

if [ "${#SHARE_SLOTS[@]}" -ne 10 ]; then
echo "expected 10 Share-menu entries, found ${#SHARE_SLOTS[@]}" >&2
if [ "${#SHARE_SLOTS[@]}" -ne 11 ]; then
echo "expected 11 Share-menu entries, found ${#SHARE_SLOTS[@]}" >&2
exit 1
fi

Expand Down
7 changes: 4 additions & 3 deletions Scripts/share-slots.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# same pkd election System Settings → General → Login Items & Extensions →
# Sharing does.
#
# All nine run the same executable and tell themselves apart by DKShareAction,
# All eleven run the same executable and tell themselves apart by DKShareAction,
# so adding an entry costs a row here and a pair of InfoPlist.strings — not a
# second copy of the import code.
#
Expand All @@ -26,12 +26,13 @@ SHARE_SLOTS=(
"WeSight|WeChatBridgeShareWeSight|ShareWeSight|weSight|发给 WeSight"
"DeepSeekHarness|WeChatBridgeShareDeepSeekHarness|ShareDeepSeekHarness|deepSeekHarness|发给 DeepSeek Harness"
"Obsidian|WeChatBridgeShareObsidian|ShareObsidian|obsidian|沉淀到 Obsidian"
"Folder|WeChatBridgeShareFolder|ShareFolder|folder|沉淀到文件夹"
"Clipboard|WeChatBridgeShareClipboard|ShareClipboard|clipboard|复制到剪贴板"
"Custom|WeChatBridgeShareCustom|ShareCustom|custom|发送到自定义"
)

# Artwork copied into each extension that targets another app. Clipboard and
# Custom have no destination app logo to borrow.
# Artwork copied into each extension that targets another app. Clipboard,
# Custom and Folder have no destination app logo to borrow.
share_logo_name() {
case "$1" in
Codex) printf '%s' "04-chatgpt.png" ;;
Expand Down
59 changes: 54 additions & 5 deletions Sources/WeChatBridgeApp/ActionRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ import Foundation
/// files and records the request; this runs it, in the app, where a failure has
/// somewhere to be reported.
///
/// Success is silent. Every destination ends with the result in front of the
/// user — the share sheet's own 「已复制」 or the pasted files in the target app
/// — so the only thing left to report is a failure.
/// Success is silent. Every paste destination ends with the result in front of
/// the user — the share sheet's own 「已复制」 or the pasted files in the target
/// app — so the only thing left to report is a failure. 「沉淀到文件夹」 is the
/// one exception: nothing appears on screen when a note lands in a folder, so
/// it gets the one success report — a system notification, which shows even
/// while this app sits in the background where a self-drawn capsule cannot
/// (see `DeliveryNotifier`).
@MainActor
final class ActionRunner {
/// Opens 设置 → 入口, which is the only place the 「发送到自定义」 list can be
Expand All @@ -31,6 +35,7 @@ final class ActionRunner {
private let sceneCoordinator: SceneCoordinator
private let skills: SkillLibrary
private let toast = ToastPresenter()
private let notifier = DeliveryNotifier()
private let picker = TargetPickerPanel()
/// The forward currently in flight, so the next one waits for it.
///
Expand Down Expand Up @@ -66,6 +71,10 @@ final class ActionRunner {
pending = Task { await previous?.value; await operation() }
}

func configureNotifications() {
notifier.configure()
}

func handle(_ arrival: ArrivedBatch) {
switch arrival.action {
case .clipboard:
Expand All @@ -79,7 +88,7 @@ final class ActionRunner {
// Nothing is shown: the share sheet said 「已复制到剪贴板」 a moment
// ago and is still on screen. A second capsule saying it again is
// WeChatBridge talking over the system.
case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness, .obsidian, .custom:
case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness, .obsidian, .folder, .custom:
// Shares and WeChat captures share the same clipboard queue.
//
// Read here rather than where the panel opens. This forward may wait
Expand All @@ -102,8 +111,10 @@ final class ActionRunner {
self.model.recordExpired(urls: arrival.urls)
return
}
// The knowledge destinations paste nothing, so there is no
// prompt to attach and no scene to pick.
let context = await self.sceneCoordinator.prepare(
enabled: arrival.action != .obsidian
enabled: arrival.action != .obsidian && arrival.action != .folder
&& !self.preferences.scenes.enabledScenes.isEmpty,
groupName: nil,
captureTitle: arrival.capturesGroupName,
Expand Down Expand Up @@ -144,6 +155,10 @@ final class ActionRunner {
await deliverToObsidian(arrival, context: context)
return
}
if arrival.action == .folder {
await deliverToFolder(arrival, context: context)
return
}
guard arrival.action == .custom, arrival.target == nil else {
await forward(arrival, context: context)
return
Expand Down Expand Up @@ -250,6 +265,40 @@ final class ActionRunner {
}
}

/// 「沉淀到文件夹」 is not a paste destination either: the archive becomes a
/// note in the folder the user picked. Unlike every other destination,
/// nothing appears on screen when it works, so this is the one delivery
/// that reports success — a notification with a way to the file (Issue
/// #19). A notification rather than a capsule because the share extension
/// puts this app in the background for every delivery, where a self-drawn
/// window never appears.
private func deliverToFolder(
_ arrival: ArrivedBatch,
context: SceneCoordinator.Selection
) async {
do {
let notes = try KnowledgeDelivery.deliver(
urls: arrival.urls,
folderPath: preferences.folderDeliveryPath,
subfolder: preferences.folderDeliverySubfolder,
chatName: context.groupName,
sceneName: context.scene?.name
)
model.recordDelivery(urls: arrival.urls, action: .folder)
sceneCoordinator.advance(context)
let folder = URL(
fileURLWithPath: preferences.folderDeliveryPath,
isDirectory: true
)
await notifier.notify(savedTo: folder.lastPathComponent, revealing: notes)
} catch {
fallBack(
arrival,
message: (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
)
}
}

private func openInObsidian(_ notes: [URL], vaultPath: String) {
let vault = URL(fileURLWithPath: vaultPath, isDirectory: true).standardizedFileURL
let prefix = vault.path.hasSuffix("/") ? vault.path : vault.path + "/"
Expand Down
1 change: 1 addition & 0 deletions Sources/WeChatBridgeApp/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
runner.openEntries = { [weak self] in self?.openMainWindow(.entries) }
runner.openSkills = { [weak self] in self?.openMainWindow(.skills) }
actionRunner = runner
runner.configureNotifications()
sceneShortcuts = SceneShortcutController(preferences: preferences)

// One place decides what an arriving batch means.
Expand Down
2 changes: 1 addition & 1 deletion Sources/WeChatBridgeApp/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ final class AppModel: ObservableObject {
// Already copied, by the extension, and already recorded as
// such by `BatchState.initial`. Nothing left to do.
return false
case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness, .obsidian, .custom:
case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness, .obsidian, .folder, .custom:
// A forward whose one-shot request is already gone: the file was
// consumed by a run that then died before it could act, or the
// extension never managed to write it. Either way nothing can be
Expand Down
Loading
Loading