emacs-使用cron风格设置任务重复
1. 前言
org-mode的任务管理功能一直为人所称道,特别是 SCHEDULED 、 DEADLINE 和 TIMESTAMP/SEXP 之间的微妙差异。一般来说,只要使用基础的 reapter ,如 .+ 和 ++ ,便足以应付绝大部分待办事项重复,更复杂日程则可以通过 % 开头的sexp来表示。但是在一些特定的场景,这一套系统便有了不便。
比如,你每周一会看一部动画片A,所以给动画片A的heading设置了一个重复任务。
* TODO 明天,美食广场见。
SCHEDULED:<2025-08-11 Mon ++1w>
那么,如果你在8月11日没有将这个任务标为 DONE ,则会在你的 Agenda 中持续显示一个过期的待办项 TODO 明天,美食广场见。 。或许对于一些洁癖或细节控来说,一直挂着一个过期任务会让人发狂,但倘若你并不是这种人,可能也无所谓了。
真是这样吗?不妨设想如下的场景:你决定在本周末看这个动画,于是将其重新设置到了周六。
* TODO 明天,美食广场见。
SCHEDULED:<2025-08-16 Sat ++1w>
那么,如果你在周末看完了最新的一集(虽然在现实中该动画只有六集,并不会再有最新的一集了),并将其标记为 DONE ,则会发生如下事情:
* TODO 明天,美食广场见。
SCHEDULED:<2025-08-23 Sat ++1w>
下一次提醒的时间,会从周一转换到了周六。
简而言之,就是如果你频繁依赖 SCHEDULED 来进行日程安排(如 org-agenda/Org-ql-block ),或者接收任务提醒(如 appt/orgzly/beorg ),那么就会遇到一些固定重复的任务,会因重新设定计划日期,导致下次重复日期变动。
这时候,就轮到org-reschedule-by-rule包登场了。
: 我修改了该包,移除了python依赖并进行了一些小改动,重新发布在org-repeat-by-cron这里。不需要下文的安装依赖即可使用。
2. 依赖安装
该包依赖Python的 croniter 包,需要安装。
Windows上:直接打开终端,输入 pip install croniter 即可。
MacOS或Linux上: 在终端中输入 pip3 install croniter --break-system-packages 即可。
3. 使用
将GitHub上的 org-reschedule-by-rule.el 放到 路径A 中,然后配置 use-package 即可。
(use-package org-reschedule-by-rule
:demand t
:load-path "路径A")
以先前的例子为例,设置任务的 RESCHEDULE_CRON 属性即可。
* TODO 明天,美食广场见。
SCHEDULED:<2025-08-23 Sat>
:PROPERTIES:
:RESCHEDULE_CRON: * * Mon
:END:
SCHEDULED 中不应使用repeater,可能造成重复日期的错误。
RESCHEDULE_CRON 中可以使用三字段或五字段,具体的含义如下:
* * * * *
# | | | | |
# | | | | 周几 (0–6) (或者英文简写 Sun Mon Tue Wed Thu Fri Sat)
# | | | 月份 (1–12)
# | | 日期 (1–31)
# | 小时 (0–23)
# 分钟 (0–59)
当我们使用三字段时,则会将前面的小时与分钟默认填为0。即: * * Fri 等价于 0 0 * * Fri 。
这样一来,标记上述任务完成后,会自动将其计划到原 SCHEDULED 日期(这里是8月23日)的下一个周一(8月25日),并增加 RESCHEDULE_ANCHOR 时间为8月25日。
* TODO 明天,美食广场见。
SCHEDULED: <2025-08-25 周一>
:PROPERTIES:
:RESCHEDULE_CRON: * * Mon
:RESCHEDULE_ANCHOR: 2025-08-25 周一
:END:
此时,不管我们怎么更改 SCHEDULED 的日期,标为完成后总会计划到从 RESCHEDULE_ANCHOR 往后的、符合 RESCHEDULE_CRON 规则的日期。
当然,我们也可以手动设置 RESCHEDULE_INTERVAL ,明确重复的间隔。
4. 流程解析
4.1. 监听
监听 Org 任务状态变化,当任务被标记为 DONE 时触发。
4.2. 读取规则
检查当前任务是否定义了 RESCHEDULE_INTERVAL 或 RESCHEDULE_CRON 属性,无则跳过
4.3. 计算下一个调度日期
首先需要确定计算的起点(基准时间)。会优先使用 RESCHEDULE_ANCHOR 的值。如果不存在,则退而求其次使用任务当前的 SCHEDULED 时间戳,如果连这个也没有,就使用当前时间。
然后会调用Python的 croniter 库计算下一个调度日期:
如果定义了 RESCHEDULE_INTERVAL ,则会在基准时间上增加相应的时间间隔,得到一个候选日期。当 RESCHEDULE_CRON 表达式为空,或上述候选日期满足表达式的约束,则会输出一个有效的未来日期。
如果没有定义 RESCHEDULE_INTERVAL ,则会检查是否有 RESCHEDULE_CRON 表达式。如果没有,则报错;如果有,则会输出满足表达式的、最近的未来时间。
代码逻辑:
if interval:
偏移 = 计算偏移(interval) #可选单位为 h d w m y 小时 天 周 月 年
候选时间 = 基准时间 + 偏移
计数 = 0
while True:
计数 += 1
if 计数 > 最大尝试次数:
sys.exit(1) #报错
if 候选时间 > 当前时间 and (cron表达式 is None or croniter.match(cron表达式, 候选时间 )):
输出时间 = 候选时间
break
候选时间 += 偏移
elif cron_表达式:
n = croniter.croniter(cron表达式, 基准时间) #这是个迭代器
输出时间 = n.get_next(datetime) #每次这样调用都会获取到从基准时间开始,满足cron表达式的下一个值
计数 = 0
while 输出时间 <= 当前时间:
计数 += 1
if 计数 > 最大尝试次数:
sys.exit(1) #报错
输出时间 = n.get_next(datetime)
else:
sys.exit(1) #报错
4.4. 更新计划
最后会将计算出的新日期同时更新到任务的 SCHEDULED 时间戳和 RESCHEDULE_ANCHOR 属性中,并将任务状态重置为 TODO 。
5. 更多示例
5.1. 每月第三个和最后一个周五
:RESCHEDULE_CRON: * * 5#3,L5
5.2. 每月最后一个星期一
:RESCHEDULE_CRON: * * L1
L 表示「当月最后一个」, 1 指一周的第一天(周一/Mon)
5.3. 每周一上午 9 点
:RESCHEDULE_CRON: 0 9 * * Mon
5.4. 每月第三个周四
:RESCHEDULE_CRON: * * Thu#3
5.5. 下周一或周五
:RESCHEDULE_CRON: * * Mon,Fri
5.6. 从特定锚点起每 2 天
:RESCHEDULE_INTERVAL: 2d
:RESCHEDULE_ANCHOR: 2025-08-03 Sun
5.7. 每季度第一个星期一
:RESCHEDULE_CRON: * Jan,Apr,Jul,Oct Mon#1