UP | HOME

▼ 本文更新于 [2025-08-15 周五 11:57]

emacs-使用cron风格设置任务重复

1. 前言

org-mode的任务管理功能一直为人所称道,特别是 SCHEDULEDDEADLINETIMESTAMP/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_INTERVALRESCHEDULE_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

© Published by Emacs 31.0.50 (Org mode 9.8-pre) | RSS 评论