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包登场了。
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