技术

让 Obsidian 工作任务在 Mac 上反复提醒:用 EventKit 双向同步提醒事项

把 Obsidian 工作任务投影到 Apple 提醒事项:用 Templater 固化格式、EventKit 双向合并完成状态,再由 launchd 为未完成任务持续催办。

发布
阅读约 8 分钟
让 Obsidian 工作任务在 Mac 上反复提醒:用 EventKit 双向同步提醒事项
章节索引页面导航1 / 14
先确定边界:谁才是任务的事实源1 / 14

上一篇 Mac 下用 Git 提交自动生成 Obsidian 工作日报 解决了「做过什么没有留下记录」的问题:Git 提交自动进入每日工作项,人工栏目再补上目标、进展、风险和非代码产出。

但任务写进 Obsidian,并不代表我会按时看到它。文件不会主动弹窗,Obsidian 关闭后更不会持续催办。真正需要处理的问题变成了:如何继续让 Vault 管任务,同时借用 Apple 提醒事项负责系统级通知?

我的最终方案不是把两套任务系统完全对等地同步,而是把 Apple 提醒事项当作 Obsidian 的通知投影:标题、时间和优先级以 Vault 为准,完成状态允许两边合并。


先确定边界:谁才是任务的事实源

双向同步最危险的地方,是两边都能随意修改所有字段。标题冲突、时间覆盖和删除传播很快就会变成无法解释的状态。

所以这套方案先规定字段所有权:

字段或动作权威来源同步规则
任务标题Obsidian每次同步覆盖提醒事项中的标题
提醒时间Obsidian每次同步覆盖到期时间和闹钟
优先级ObsidianP0P1P2 映射到高、中、低
完成状态两端任一端完成,另一端也完成
重新打开不自动处理已完成任务不会被另一端重新打开
删除不自动传播源任务消失时不会自动删除提醒事项

Vault 保存任务语义,Apple 提醒事项负责提供 macOS 系统通知;只有提醒列表属于 iCloud 账户时,任务才会继续同步到登录同一账户的 iPhone 等设备。

脚本也不会扫描整个 Vault。它只读取两个明确位置:

  1. 目标日报 ## 今日目标 中带 #提醒 的任务;定时任务会把范围扩展为当天及此前 7 天;
  2. work/近期规划/近期重点.md## 重点任务 中带 #提醒 的任务。

工作进展、风险和 Git 提交正文不会被复制到提醒事项。但任务标题、优先级、到期时间、Vault 名称、源文件路径、行号和 Obsidian URL 会写入提醒或备注;如果列表位于 iCloud,这些元数据也会进入 iCloud。


一条可同步的任务长什么样

推荐格式如下:

- [ ] P1 完成接口联调 #提醒 2026-08-06 09:30 ^rem-a81f
markdown

这一行包含三个同步标记:

稳定 ID 比任务标题更重要。任务可能改名,也可能被 $work-daily 迁移到新日报;只要 ID 保持不变,EventKit 助手就会更新原提醒,而不是重复创建一条。

优先级映射如下:

Obsidian 标记Apple 提醒事项
P0
P1
P2
P3 或不写

旧格式 #提醒 [remind:: 2026-08-06T09:30:00+08:00] ^rem-a81f 仍然兼容,但新格式更容易直接阅读,也不容易把 Dataview 内联字段写坏。


不要手写稳定 ID:交给 Templater

真正容易出错的不是 #提醒,而是它后面的时间和块 ID。少一个空格、日期不存在或误写其他块 ID,都会让同步行为变得不确定。

因此我增加了一个 Templater 命令:Add or Update Reminder

使用方式:

  1. 把光标放在日报「今日目标」或近期重点「重点任务」的非空任务行上;
  2. 打开命令面板,运行 Templater: Add or Update Reminder
  3. 输入 明天 09:002026-08-06 09:00
  4. 模板写入绝对时间,并创建或复用 ^rem-... 稳定 ID。

模板会拒绝错误文件、错误区块、空任务、非法时间以及已存在其他块 ID 的任务。已有提醒再次运行命令时,只更新时间,不会更换 ID。

核心逻辑可以概括为:

const existingId = line.match(
  /(?:^|\s)\^(rem-[A-Za-z0-9_-]+)(?=\s|$)/,
)?.[1];

const reminderId = existingId || generateReminderId();
const normalizedTime = parseReminderTime(input);

editor.setLine(
  cursor.line,
  `${task} #提醒 ${normalizedTime} ^${reminderId}`,
);
javascript

手工输入 #提醒 明天 09:00 也能工作,但默认 dry-run 会报告缺少稳定 ID。第一次实际执行 --sync 时,脚本会先把相对日期固化为绝对日期并补齐 ID,再继续校验和调用 EventKit。

这意味着,即使后续校验、权限请求或 EventKit 写入失败,前面的格式规范化仍可能已经写回 Vault。更稳妥的做法是始终先用 Templater 生成完整格式,让 dry-run 在任何外部写入前通过。


整体数据流

flowchart LR
  O[Obsidian 今日目标与近期重点]
  T[Templater 规范格式]
  P[Python 解析与校验]
  S[Swift EventKit 助手]
  R[Apple 提醒事项]
  N[macOS 通知]
  I[iCloud 列表才会跨设备]
  L[launchd 每 15 分钟]

  O --> T --> P --> S --> R --> N
  R -.条件满足.-> I
  R -->|完成状态| S -->|只回写复选框| O
  L --> P
mermaid

Python 脚本负责读取 Markdown、限制允许的区块、解析时间、清理标题、检查重复 ID,并生成交给 Swift 助手的 JSON。

Swift 助手使用 EventKit 读写提醒事项。Apple 的 EventKit 提供日历和提醒事项的数据访问能力;这里使用 EKReminder 保存任务,并用绝对时间的 EKAlarm 安排通知。EventKit 概览EKAlarm.absoluteDate

这条链路不依赖快捷指令,也不要求 Obsidian 一直打开。真正执行系统写入的是按需编译的本机 Swift 程序。


运行前准备

下面所有相对路径都以 Vault 根目录为起点。先进入自己的 Vault,并确认基础工具可用:

cd "/path/to/Obsidian Vault"

python3 --version
xcrun --find swiftc
codesign --version
bash

Python 至少需要 3.9,因为脚本使用 zoneinfo 和现代类型语法。Swift 助手需要 swiftc、EventKit SDK 与系统自带的 codesign;如果 xcrun --find swiftc 失败,需要先安装 Xcode Command Line Tools 或完整 Xcode。

提醒事项完整访问 API 还要求当前 macOS 和编译 SDK 支持 requestFullAccessToReminders。本文描述的是当前这台 Mac 已验证通过的环境,不是面向旧版 macOS 的兼容实现。


为什么要用 EventKit 助手

脚本目录包含 eventkit_helper.swift。首次使用时,它会编译到被 Git 忽略的缓存目录,并使用固定标识 com.frevia.obsidian-reminders 做本机 ad-hoc 签名。

先构建并查询权限状态:

python3 system/scripts/reminders/sync_reminders.py --build-helper
python3 system/scripts/reminders/sync_reminders.py --status
bash

常见状态包括 notDeterminedfullAccessdenied。只有第一次实际同步才会触发 macOS 权限请求;--status 不请求权限,也不写提醒事项,但助手缺失或过期时可能先在缓存目录重新编译并签名。

由于脚本既要写入任务,也要读取用户是否在提醒事项中完成了任务,所以需要提醒事项的完整访问权限。Apple 提供的 requestFullAccessToReminders 正是用于请求提醒事项读写访问。Apple API 文档

EventKit 助手会创建或复用名为 Obsidian 工作 的列表。若列表不存在,它会沿用系统“新提醒的默认列表”所属账户创建,而不是强制选择 iCloud;若多个账户存在同名列表,当前实现会复用 EventKit 返回的第一项。

因此,第一次同步后应在提醒事项中确认 Obsidian 工作 位于预期账户。若需要跨设备通知,应先把系统默认提醒列表设为 iCloud,避免不同账户出现同名列表,再创建或迁移这张专用列表。

每条提醒的备注中都保存:

obsidian-task-id:rem-a81f
source:work/每日工作项/2026/08/2026-08-06.md:12
obsidian://open?...
text

obsidian-task-id 用于幂等更新,Obsidian URL 则可以直接跳回源任务。脚本不会依赖 Apple 内部生成的标题或列表顺序来匹配任务。


先 dry-run,再允许写入

默认命令只打印 JSON,不会访问或修改 Apple 提醒事项:

python3 system/scripts/reminders/sync_reminders.py
python3 system/scripts/reminders/sync_reminders.py --date 2026-08-06
bash

如果只想检查指定日报,不读取近期重点:

python3 system/scripts/reminders/sync_reminders.py --no-include-focus
bash

存在缺少时间、缺少 ID、重复 ID 或目标区块不存在时,dry-run 会以退出码 2 结束。先让这一步通过,比直接在提醒事项里清理重复任务安全得多。

--sync 不是纯粹的“校验通过后再写入” 它会先规范化相对日期并补稳定 ID,再重新解析并调用 EventKit。后续步骤失败时,这部分 Vault 修改不会自动回滚;优先使用 Templater,让 dry-run 先得到完整、可验证的任务。

确认 JSON 后再实际同步:

python3 system/scripts/reminders/sync_reminders.py --sync
bash

--apply--sync 当前等价,都会写入 Apple 提醒事项并合并完成状态。使用 --sync 只是让命令意图更清楚。


双向同步只合并完成状态

同步时,助手会比较 Vault 复选框和 Apple 提醒事项的完成状态:

let resolvedCompleted = item.completed || reminder.isCompleted
swift

只要任一端已经完成,结果就是完成。若完成发生在 Apple 提醒事项,Python 脚本只会在允许的源区块中定位同一 ^rem-...,把 [ ] 改为 [x]

它不会反向修改标题、时间或优先级,也不会触碰 Git 自动区块。任务完成后,后续催办闹钟会被移除。

这个合并规则刻意不支持自动重新打开。否则某台设备上的旧状态可能把已经完成的任务重新变成待办。确实需要重做时,我会新建任务,或者手动在两边处理。


怎样让未完成任务反复通知

Apple 提醒事项通常只在设定时间提醒一次,但我的主要目标是:任务没完成,就继续催办。

每次同步会为未完成任务安排一组绝对时间闹钟:

临时调整频率和次数:

python3 system/scripts/reminders/sync_reminders.py \
  --sync \
  --nag-interval 15 \
  --nag-count 12
bash

后台同步每 15 分钟执行一次,但不会在每次运行后简单地从「现在」重新计时。逾期任务始终沿原到期时间的固定间隔计算,因此刷新频率不会把通知频率意外翻倍。

如果提醒已经存在却没有弹出系统通知,还要检查 macOS 对「提醒事项」的通知权限、专注模式和勿扰设置。数据同步成功与系统是否展示横幅是两层不同的问题。


用 launchd 每 15 分钟同步

受版本管理的 LaunchAgent 使用 Label com.frevia.obsidian-reminders,登录后启动,并每 900 秒运行一次:

python3 sync_reminders.py \
  --sync \
  --lookback-days 7 \
  --nag-interval 30 \
  --nag-count 8
text

之所以扫描最近 7 天日报,是因为任务可能跨日迁移。同一个稳定 ID 出现在多日日报时,脚本采用最新日期中的任务,完成状态也只回写最新位置。

安装或恢复 LaunchAgent:

./system/scripts/reminders/configure-launch-agent.sh
bash

检查运行状态和日志:

launchctl print "gui/$(id -u)/com.frevia.obsidian-reminders"
tail -f system/cache/reminders/launchd.out.log
tail -f system/cache/reminders/launchd.err.log
bash

卸载:

./system/scripts/reminders/configure-launch-agent.sh --uninstall
bash

当前安装脚本和 plist 包含 Vault 的本机绝对路径。换电脑或分享给别人时,必须先替换 Vault 根目录、LaunchAgents 目标路径和日志路径,再加载任务。


一天怎样使用

  1. 早上通过 $work-daily 创建工作记录,确认「今日目标」。
  2. 对真正需要系统通知的任务运行 Add or Update Reminder
  3. 先用 dry-run 检查解析结果。
  4. LaunchAgent 每 15 分钟同步提醒与完成状态。
  5. 在 Obsidian 或 Apple 提醒事项任一端完成任务。
  6. 下次同步后,两端都会变成完成,剩余通知也会被移除。

并不是所有任务都要加 #提醒。如果每条任务都反复通知,通知很快会变成新的背景噪音。我只给有明确时间窗口、等待反馈或容易遗忘的任务加提醒。


故障排查

现象优先检查
dry-run 报缺少时间使用 Templater 命令重新生成提醒格式
dry-run 报缺少稳定 ID运行 Templater 命令,或首次执行实际 --sync 自动补齐
--statusdenied在 macOS 隐私设置中重新允许提醒事项访问
提醒事项中出现重复任务检查重复块 ID,以及是否手工复制后修改过 ID
修改标题后出现新提醒检查迁移或改名时是否保留原 ^rem-...
Apple 端完成后 Vault 没变化查看同步日志,确认源任务仍在允许区块且 ID 唯一
同步成功但没有通知检查提醒事项通知权限、专注模式与闹钟时间
LaunchAgent 没运行launchctl print 检查状态,再看两份 launchd 日志
删除 #提醒 后 Apple 端仍存在当前不自动传播删除,需要手工删除已有提醒

验证脚本、Swift 助手和权限:

python3 -m unittest discover -s system/scripts/reminders/tests -v
python3 system/scripts/reminders/sync_reminders.py --build-helper
python3 system/scripts/reminders/sync_reminders.py --status
python3 system/scripts/reminders/sync_reminders.py --date 2026-08-06
bash

和 Git 日报自动化组合起来

Git 自动化回答「今天实际提交了什么」,提醒事项同步回答「哪些任务到了时间还没有完成」。两者都写进工作系统,但职责完全不同:

自动化输入输出不负责什么
git-work-log白名单仓库中的 Git 提交日报 Git 区块、仓库动态不判断优先级和完成结论
reminders sync显式标记的工作任务Apple 提醒事项、完成状态回写不复制 Git 正文和工作上下文

两条链路共同遵守一个原则:Obsidian Vault 是事实源,外部系统只是有边界的投影。

配套文章


正在加载留言…


下一篇
理解PKI(五):CA 如何签发证书,KMC 如何托管加密密钥

搜索文章...

⌘K / Ctrl K

使用上下方向键选择结果,按 Enter 打开,按 Esc 关闭

搜索结果