OmniStacksStaticModules 2.0.0
Loading...
Searching...
No Matches
ReminderSync.cppm
Go to the documentation of this file.
1
15module;
16
17#include <fcntl.h>
18#include <spawn.h>
19#include <sys/wait.h>
20#include <unistd.h>
21
22#include <array>
23#include <cerrno>
24#include <cstdio>
25#include <cstdlib>
26#include <cstring>
27#include <string>
28#include <string_view>
29#include <vector>
30
31extern char** environ;
32
33export module ReminderSync;
34
35import std;
36import InterfaceLog;
37
38export namespace ReminderSync {
39
40// ---- 公共输入/输出模型 ----------------------------------------------------
41
48
51 std::string task_id;
52 std::string title;
53 std::string description;
54 std::string due_date;
55 bool done = false;
56};
57
60 bool dry_run = false;
61 std::string reminders_list = "Stacks";
62 std::string omnifocus_bundle_id = "com.omnigroup.OmniFocus4";
63};
64
66struct SyncResult {
67 bool reminders_ok = false;
68 bool omnifocus_ok = false;
69 std::string reminders_output;
70 std::string omnifocus_output;
71 std::string marker;
72};
73
84
92std::expected<SyncResult, SyncError> sync_task(const TaskReminder& task, Provider provider,
93 const SyncOptions& options = {});
94
96std::string build_reminders_script();
97
99std::string build_omnifocus_script();
100
102std::string provider_name(Provider provider) noexcept;
103
105std::string_view sync_error_message(SyncError error) noexcept;
106
107} // namespace ReminderSync
108
109namespace ReminderSync {
110
111namespace {
112
115 int exit_code = -1;
116 std::string output;
117 std::string error;
118};
119
121bool valid_task_id(std::string_view value) {
122 return !value.empty() && std::ranges::all_of(value, [](char character) {
123 return std::isalnum(static_cast<unsigned char>(character)) || character == '-' ||
124 character == '_';
125 });
126}
127
129std::optional<std::array<int, 3>> split_date(std::string_view value) {
130 if (value.size() != 10 || value[4] != '-' || value[7] != '-') {
131 return std::nullopt;
132 }
133 try {
134 const int year = std::stoi(std::string(value.substr(0, 4)));
135 const int month = std::stoi(std::string(value.substr(5, 2)));
136 const int day = std::stoi(std::string(value.substr(8, 2)));
137 const std::chrono::year_month_day date{
138 std::chrono::year{year},
139 std::chrono::month{static_cast<unsigned>(month)},
140 std::chrono::day{static_cast<unsigned>(day)},
141 };
142 if (!date.ok()) {
143 return std::nullopt;
144 }
145 return std::array{year, month, day};
146 } catch (const std::exception&) {
147 return std::nullopt;
148 }
149}
150
155std::string read_descriptor(int descriptor) {
156 std::string result;
157 std::array<char, 4096> buffer{};
158 while (true) {
159 const ssize_t count = ::read(descriptor, buffer.data(), buffer.size());
160 if (count > 0) {
161 result.append(buffer.data(), static_cast<std::size_t>(count));
162 } else if (count == 0) {
163 break;
164 } else if (errno != EINTR) {
165 break;
166 }
167 }
168 return result;
169}
170
175std::expected<ProcessResult, SyncError> run_osascript(std::string_view script,
176 const std::vector<std::string>& arguments) {
177 // 先显式检查路径,便于区分“系统没有 osascript”和普通脚本失败。
178 if (!std::filesystem::exists("/usr/bin/osascript")) {
179 return std::unexpected(SyncError::osascript_unavailable);
180 }
181
182 int stdout_pipe[2]{};
183 int stderr_pipe[2]{};
184 // 两个管道分别捕获应用结果与 AppleScript 诊断。
185 if (::pipe(stdout_pipe) != 0 || ::pipe(stderr_pipe) != 0) {
186 return std::unexpected(SyncError::process_failure);
187 }
188
189 posix_spawn_file_actions_t actions;
190 posix_spawn_file_actions_init(&actions);
191
192 // 子进程把管道写端映射到 stdout/stderr,并关闭所有不需要的管道端。
193 posix_spawn_file_actions_adddup2(&actions, stdout_pipe[1], STDOUT_FILENO);
194 posix_spawn_file_actions_adddup2(&actions, stderr_pipe[1], STDERR_FILENO);
195 posix_spawn_file_actions_addclose(&actions, stdout_pipe[0]);
196 posix_spawn_file_actions_addclose(&actions, stderr_pipe[0]);
197 posix_spawn_file_actions_addclose(&actions, stdout_pipe[1]);
198 posix_spawn_file_actions_addclose(&actions, stderr_pipe[1]);
199
200 std::vector<std::string> owned_arguments;
201 owned_arguments.reserve(arguments.size() + 3);
202
203 // `--` 后所有用户值都是独立 argv,不经过 shell word splitting。
204 owned_arguments.emplace_back("/usr/bin/osascript");
205 owned_arguments.emplace_back("-e");
206 owned_arguments.emplace_back(script);
207 owned_arguments.emplace_back("--");
208 owned_arguments.insert(owned_arguments.end(), arguments.begin(), arguments.end());
209 std::vector<char*> argv;
210 argv.reserve(owned_arguments.size() + 1);
211 for (auto& argument : owned_arguments) {
212 argv.push_back(argument.data());
213 }
214 argv.push_back(nullptr);
215
216 // 优先使用 posix_spawn,避免 fork 大量 std 状态和全局 logger。
217 pid_t child = -1;
218 const int spawn_result =
219 ::posix_spawn(&child, "/usr/bin/osascript", &actions, nullptr, argv.data(), environ);
220 posix_spawn_file_actions_destroy(&actions);
221
222 // 父进程只保留读端,否则子进程写入永远不会收到 EOF。
223 ::close(stdout_pipe[1]);
224 ::close(stderr_pipe[1]);
225 if (spawn_result != 0) {
226 ::close(stdout_pipe[0]);
227 ::close(stderr_pipe[0]);
228 return std::unexpected(SyncError::process_failure);
229 }
230
231 ProcessResult result;
232 // 顺序读取两个管道,适合个人任务同步的小输出量。
233 result.output = read_descriptor(stdout_pipe[0]);
234 result.error = read_descriptor(stderr_pipe[0]);
235 ::close(stdout_pipe[0]);
236 ::close(stderr_pipe[0]);
237
238 int status = 0;
239 // waitpid 可能被信号中断;EINTR 时继续等待同一个子进程。
240 while (::waitpid(child, &status, 0) < 0 && errno == EINTR) {
241 }
242 if (WIFEXITED(status)) {
243 result.exit_code = WEXITSTATUS(status);
244 } else if (WIFSIGNALED(status)) {
245 result.exit_code = 128 + WTERMSIG(status);
246 }
247 return result;
248}
249
251std::vector<std::string> common_arguments(const TaskReminder& task, std::string_view marker) {
252 const auto date = split_date(task.due_date);
253 if (!date) {
254 return {};
255 }
256 return {
257 task.title,
258 task.description,
259 marker.data(),
260 std::to_string((*date)[0]),
261 std::to_string((*date)[1]),
262 std::to_string((*date)[2]),
263 task.done ? "true" : "false",
264 };
265}
266
267} // namespace
268
270 // 脚本只按 argv 下标取值;用户内容不插入脚本文本本身。
271 return R"APPLESCRIPT(
272on run argv
273 set taskName to item 1 of argv
274 set taskBody to item 2 of argv
275 set marker to item 3 of argv
276 set dueYearText to item 4 of argv
277 set dueMonthText to item 5 of argv
278 set dueDayText to item 6 of argv
279 set completedValue to (item 7 of argv) is "true"
280 set listName to item 8 of argv
281
282 set dueDate to date (dueYearText & "-" & dueMonthText & "-" & dueDayText)
283 set time of dueDate to 86340
284
285 tell application "Reminders"
286 -- 目标列表可配置;不存在时创建一次。
287 set targetList to missing value
288 repeat with candidate in lists
289 if name of candidate is listName then
290 set targetList to candidate
291 exit repeat
292 end if
293 end repeat
294 if targetList is missing value then
295 set targetList to make new list with properties {name:listName}
296 end if
297
298 -- 隐藏标记承担 upsert 主键;重复同步只更新,不新增副本。
299 set matches to every reminder of targetList whose body contains marker
300 if (count of matches) > 0 then
301 set targetReminder to item 1 of matches
302 set name of targetReminder to taskName
303 set body of targetReminder to taskBody
304 set due date of targetReminder to dueDate
305 else
306 set targetReminder to make new reminder at end of targetList with properties {name:taskName, body:taskBody, due date:dueDate}
307 end if
308 set completed of targetReminder to completedValue
309 return "Reminders:" & (id of targetReminder)
310 end tell
311end run
312)APPLESCRIPT";
313}
314
315std::string build_omnifocus_script() {
316 // OmniFocus 在 default document 的 flattened tasks 中按 note 标记查找。
317 return R"APPLESCRIPT(
318on run argv
319 set taskName to item 1 of argv
320 set taskBody to item 2 of argv
321 set marker to item 3 of argv
322 set dueYearText to item 4 of argv
323 set dueMonthText to item 5 of argv
324 set dueDayText to item 6 of argv
325 set completedValue to (item 7 of argv) is "true"
326 set dueDate to date (dueYearText & "-" & dueMonthText & "-" & dueDayText)
327 set time of dueDate to 86340
328
329 tell application "OmniFocus"
330 set targetDocument to default document
331 tell targetDocument
332 -- 只更新 Inbox/项目中的现有任务;首次同步创建 Inbox task。
333 set targetTask to missing value
334 repeat with candidate in flattened tasks
335 if (note of candidate contains marker) then
336 set targetTask to candidate
337 exit repeat
338 end if
339 end repeat
340 if targetTask is missing value then
341 set targetTask to make new inbox task with properties {name:taskName, note:taskBody, due date:dueDate}
342 else
343 set name of targetTask to taskName
344 set note of targetTask to taskBody
345 set due date of targetTask to dueDate
346 end if
347 if completedValue then
348 mark complete targetTask
349 else
350 mark incomplete targetTask
351 end if
352 return "OmniFocus:" & (id of targetTask)
353 end tell
354 end tell
355end run
356)APPLESCRIPT";
357}
359std::expected<SyncResult, SyncError> sync_task(const TaskReminder& task, Provider provider,
360 const SyncOptions& options) {
361 // 入参日志不含 description 全文,只保留排障所需的任务标识和同步模式。
362 const std::string input =
363 std::format("task_id={} title={} due={} provider={} dry_run={}", task.task_id, task.title,
364 task.due_date, provider_name(provider), options.dry_run);
365 const auto operation = [&]() -> std::expected<SyncResult, SyncError> {
366 // 在启动任何外部进程前完成全部领域校验。
367 if (!valid_task_id(task.task_id)) {
368 return std::unexpected(SyncError::invalid_task_id);
369 }
370 if (task.due_date.empty()) {
371 return std::unexpected(SyncError::due_date_required);
372 }
373 if (!split_date(task.due_date)) {
374 return std::unexpected(SyncError::invalid_due_date);
375 }
376
377 SyncResult result;
378 // 去重标记写入描述末尾,保持在普通 Stacks/Markdown 文本中不可见干扰最小。
379 result.marker = std::format("[Stacks-ID:{}]", task.task_id);
380 const std::string body = std::format("{}\n\n{}", task.description, result.marker);
381 TaskReminder script_task = task;
382 script_task.description = body;
383 const bool use_reminders = provider == Provider::reminders || provider == Provider::both;
384 const bool use_omnifocus = provider == Provider::omnifocus || provider == Provider::both;
385
386 if (options.dry_run) {
387 // dry-run 不依赖 macOS 自动化权限,也不需要应用正在运行。
388 result.reminders_ok = use_reminders;
389 result.omnifocus_ok = use_omnifocus;
390 result.reminders_output = use_reminders ? "dry-run: reminders script ready" : "";
391 result.omnifocus_output = use_omnifocus ? "dry-run: OmniFocus script ready" : "";
392 return result;
393 }
394
395 if (use_reminders) {
396 // 第八个 argv 是目标列表名;脚本自身负责创建缺失列表。
397 auto arguments = common_arguments(script_task, result.marker);
398 if (arguments.empty()) {
399 return std::unexpected(SyncError::invalid_due_date);
400 }
401 arguments.push_back(options.reminders_list);
402 const auto process = run_osascript(build_reminders_script(), arguments);
403 if (!process || process->exit_code != 0) {
404 return std::unexpected(SyncError::reminders_failed);
405 }
406 result.reminders_ok = true;
407 result.reminders_output = process->output;
408 }
409
410 if (use_omnifocus) {
411 // OmniFocus 脚本当前通过应用名/默认文档操作;bundle 配置保留给扩展。
412 auto arguments = common_arguments(script_task, result.marker);
413 if (arguments.empty()) {
414 return std::unexpected(SyncError::invalid_due_date);
415 }
416 arguments.push_back(options.omnifocus_bundle_id);
417 const auto process = run_osascript(build_omnifocus_script(), arguments);
418 if (!process || process->exit_code != 0) {
419 return std::unexpected(SyncError::omnifocus_failed);
420 }
421 result.omnifocus_ok = true;
422 result.omnifocus_output = process->output;
423 }
424 return result;
425 }();
426
427 // 成功只记录两个后端状态;失败记录稳定错误分类,不泄露 osascript stderr。
428 const bool ok = operation.has_value();
430 interface_log::ApiName("ReminderSync::sync_task"), interface_log::LogText(input),
431 interface_log::LogText(ok ? std::format("reminders={} omnifocus={}", operation->reminders_ok,
432 operation->omnifocus_ok)
433 : std::string(sync_error_message(operation.error()))),
435 return operation;
436}
437
438std::string provider_name(Provider provider) noexcept {
439 // token 名称与 Alfred action 协议保持一致。
440 switch (provider) {
442 return "reminders";
444 return "omnifocus";
445 case Provider::both:
446 return "both";
447 }
448 return "unknown";
449}
450
451std::string_view sync_error_message(SyncError error) noexcept {
452 // UI 和 CLI 共用错误映射,避免两处文案漂移。
453 switch (error) {
455 return "task id is invalid";
457 return "task has no due date";
459 return "task due date is invalid";
461 return "osascript is unavailable";
463 return "Apple Reminders sync failed";
465 return "OmniFocus sync failed";
467 return "failed to launch osascript";
468 }
469 return "unknown reminder sync error";
470}
471
472} // namespace ReminderSync
char ** environ
接口输入/输出日志模块(STATIC 模块库)。
将带截止日期的 Stacks 任务同步到 Apple Reminders / OmniFocus。
std::string read_descriptor(int descriptor)
读到 EOF 的管道读取器。
std::expected< ProcessResult, SyncError > run_osascript(std::string_view script, const std::vector< std::string > &arguments)
启动 osascript,并通过独立 argv 传递用户字段。
bool valid_task_id(std::string_view value)
任务 ID 只允许字母、数字、-、_,防止注入 action/AppleScript 参数。
std::optional< std::array< int, 3 > > split_date(std::string_view value)
严格拆分并校验 YYYY-MM-DD;用于 AppleScript 日期构造前的领域校验。
std::vector< std::string > common_arguments(const TaskReminder &task, std::string_view marker)
构造两个 AppleScript 共用的位置参数序列。
SyncError
可恢复同步错误分类。
@ process_failure
管道或进程创建失败。
@ osascript_unavailable
系统缺少 osascript。
@ due_date_required
缺少截止日期。
@ invalid_due_date
日期格式或日历值非法。
@ reminders_failed
Reminders 脚本非零退出。
@ invalid_task_id
任务 ID 含 token 不安全字符。
@ omnifocus_failed
OmniFocus 脚本非零退出。
Provider
提醒事项提供方;both 表示两个目标都执行。
@ reminders
Apple Reminders。
@ both
Reminders + OmniFocus。
std::string provider_name(Provider provider) noexcept
Provider 到 CLI/日志稳定名称。
std::string build_reminders_script()
返回可在 osacompile 中独立编译的 Reminders AppleScript 源。
std::expected< SyncResult, SyncError > sync_task(const TaskReminder &task, Provider provider, const SyncOptions &options={})
将一个任务同步到指定外部应用。
std::string build_omnifocus_script()
返回可在 osacompile 中独立编译的 OmniFocus AppleScript 源。
std::string_view sync_error_message(SyncError error) noexcept
SyncError 到稳定可读文本。
std::vector< std::string > arguments(int argc, char *argv[], int start)
从 argv[start..] 复制额外参数;start 之后的参数所有权仍属于进程。
Definition main.cpp:345
void record(ApiName api, LogText input, LogText output, ApiStatus status)
记录一次接口调用。
@ failed
调用失败(错误已记录在 output 文本中)
外部应用同步选项。
std::string omnifocus_bundle_id
OmniFocus 应用 ID。
std::string reminders_list
Reminders 目标列表。
bool dry_run
只验证,不调用 osascript。
同步结果;每个后端独立报告成败,便于 CLI 精确展示。
std::string marker
[Stacks-ID:<id>] 去重标记。
std::string omnifocus_output
OmniFocus 脚本输出。
std::string reminders_output
Reminders 脚本输出。
bool omnifocus_ok
OmniFocus 是否完成。
bool reminders_ok
Reminders 是否完成。
与外部应用同步所需的最小任务快照。
std::string task_id
用于去重标记和格式校验。
std::string description
提醒正文;同步时追加去重标记。
std::string title
提醒标题。
std::string due_date
YYYY-MM-DD。
bool done
是否同步完成状态。
osascript 子进程的退出码与双管道输出。
int exit_code
正常退出 0;信号退出为 128 + signal。