灵感来源还有代码修改自
具体的使用说明参见我的中文README。 使用样例请参见我后面回帖贴出的两篇文章。
灵感来源还有代码修改自
具体的使用说明参见我的中文README。 使用样例请参见我后面回帖贴出的两篇文章。
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包登场了。
[2025-09-09 二 20:08] : 我修改了该包,移除了python依赖并进行了一些小改动,重新发布在org-repeat-by-cron这里。不需要下文的安装依赖即可使用。
该包依赖Python的 croniter 包,需要安装。
Windows上:直接打开终端,输入 pip install croniter 即可。
MacOS或Linux上: 在终端中输入 pip3 install croniter --break-system-packages 即可。
将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 ,明确重复的间隔。
监听 Org 任务状态变化,当任务被标记为 DONE 时触发。
检查当前任务是否定义了 RESCHEDULE_INTERVAL 或 RESCHEDULE_CRON 属性,无则跳过
首先需要确定计算的起点(基准时间)。会优先使用 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) #报错
最后会将计算出的新日期同时更新到任务的 SCHEDULED 时间戳和 RESCHEDULE_ANCHOR 属性中,并将任务状态重置为 TODO 。
:RESCHEDULE_CRON: * * 5#3,L5
:RESCHEDULE_CRON: * * L1
L 表示「当月最后一个」, 1 指一周的第一天(周一/Mon)
:RESCHEDULE_CRON: 0 9 * * Mon
:RESCHEDULE_CRON: * * Thu#3
:RESCHEDULE_CRON: * * Mon,Fri
:RESCHEDULE_INTERVAL: 2d
:RESCHEDULE_ANCHOR: 2025-08-03 Sun
:RESCHEDULE_CRON: * Jan,Apr,Jul,Oct Mon#1
经常使用任务管理软件的朋友都知道,我们有的任务并无具体的截止日期,而是一个笼统的区间,比如年度、季度、月度、周度任务。org-mode中基于 SCHEDULED 和 DEADLINE 的传统方式,难以满足这种任务的查看与重复需求。
经过一番思索,我找到了利用Org-QL和org-repeat-by-cron实现相关任务查看重复的方法,分享与此。
提示:org-repeat-by-cron 是我修改自 https://github.com/Raemi/org-reschedule-by-rule 的包,略微修改之处在此按下不表。
在需要被追踪的任务中,添加名为 PERIOD 的属性。
由于Emacs Org-Mode 重复任务在按月重复情况下无法定位于「某月的最后一天」,因此后面的季度、月度任务需要用到 cron 来帮助重复定位。
* TODO 这是一个年度任务
DEADLINE: <2025-12-31 周三 ++1y>
:PROPERTIES:
:PERIOD: year
:END:
* TODO 这是一个季度任务
DEADLINE: <2025-09-30 周二>
:PROPERTIES:
:REPEAT_CRON: L 3,6,9,12 *
:PERIOD: season
:REPEAT_DEADLINE: t
:END:
* TODO 这是一个月度任务
DEADLINE: <2025-09-30 周二>
:PROPERTIES:
:REPEAT_CRON: L * *
:PERIOD: month
:REPEAT_DEADLINE: t
:END:
* TODO 这是一个周度任务
DEADLINE: <2025-09-12 周五 ++1w>
:PROPERTIES:
:PERIOD: week
:END:
这里我们定义了两个相关函数。第一个函数 my/org-ql-ts-period 会根据输入的周期,返回当前时间所在周期的起止日期。
如在2025年9月9日执行 (my/org-ql-ts-period 's) ,则会返回 ("2025-07-01" . "2025-09-30")
(defun my/org-ql-ts-period (period)
"根据输入的 PERIOD ('y', 's', 'm', 'w') 返回当前年、季度、月或周的起止日期。
格式为 'YYYY-MM-DD'。
季节 (Season) 定义:
- 季度1: 1月 - 3月
- 季度2: 4月 - 6月
- 季度3: 7月 - 9月
- 季度4: 10月 - 12月
星期一被视为一周的开始。
参数:
PERIOD: 一个表示时间段的符号,可以是 'y', 's', 'm', 或 'w'。
返回:
一个包含起止日期的字符串,格式为 'YYYY-MM-DD - YYYY-MM-DD',
如果输入无效则返回错误信息。"
(let* ((now (current-time))
(decoded-time (decode-time now))
(sec (nth 0 decoded-time))
(min (nth 1 decoded-time))
(hour (nth 2 decoded-time))
(day (nth 3 decoded-time))
(month (nth 4 decoded-time))
(year (nth 5 decoded-time))
start-date
end-date)
(cond
;; 年 (Year)
((eq period 'y)
(setq start-date (format-time-string "%Y-01-01"))
(setq end-date (format-time-string "%Y-12-31")))
;; 季 (Season)
((eq period 's)
(let* ((start-month (cond ((<= month 3) 1)
((<= month 6) 4)
((<= month 9) 7)
(t 10)))
(end-month (+ start-month 2))
(end-day (calendar-last-day-of-month end-month year)))
(setq start-date (format-time-string "%Y-%m-01" (encode-time 0 0 0 1 start-month year)))
(setq end-date (format-time-string (format "%%Y-%%m-%d" end-day) (encode-time 0 0 0 end-day end-month year)))))
;; 月 (Month)
((eq period 'm)
(setq start-date (format-time-string "%Y-%m-01"))
(let* ((last-day (calendar-last-day-of-month month year)))
(setq end-date (format-time-string (format "%%Y-%%m-%d" last-day)))))
;; 周 (Week)
((eq period 'w)
(let* ((day-of-week (string-to-number (format-time-string "%u"))) ; 星期一为1,星期日为7
(start-offset (- day-of-week 1))
(end-offset (- 7 day-of-week))
(start-time (time-subtract now (seconds-to-time (* start-offset 24 60 60))))
(end-time (time-add now (seconds-to-time (* end-offset 24 60 60)))))
(setq start-date (format-time-string "%Y-%m-%d" start-time))
(setq end-date (format-time-string "%Y-%m-%d" end-time))))
;; 无效输入
(t (error "无效的参数,请输入 'y', 's', 'm', 或 'w'")))
(cons start-date end-date)))
第二个函数 my/org-ql-block-period 用来简化 org-agenda-custom-commands 中可能出现的重复代码。根据输入的周期,构建不同的 org-ql-block 。
(defun my/org-ql-block-period (period)
(let ((property-string nil)
(header-string nil))
(cond
;; 年 (Year)
((eq period 'y)
(setq property-string "year"
header-string "年"))
;; 季 (Season)
((eq period 's)
(setq property-string "season"
header-string "季"))
;; 月 (Month)
((eq period 'm)
(setq property-string "month"
header-string "月"))
;; 周 (Week)
((eq period 'w)
(setq property-string "week"
header-string "周"))
;; 无效输入
(t (error "无效的参数,请输入 'y', 's', 'm', 或 'w'")))
(org-ql-block `(and (todo "TODO" "HOLD") (property "PERIOD" property-string)(ts-active :from ,(car (my/org-ql-ts-period period)) :to ,(cdr (my/org-ql-ts-period period))))
((org-ql-block-header ,(concat "🔄周期--" header-string "🔄"))))))
;; 参考设置
;; (my/org-ql-block-period 'y)
最后,我们需要自定义 org-agenda-custom-commands ,加入上述块函数:
(setq org-agenda-custom-commands
'(("d" "Daily Agenda"
;; 上略……
;; 周期任务拿出来
(my/org-ql-block-period 'y)
(my/org-ql-block-period 's)
(my/org-ql-block-period 'm)
(my/org-ql-block-period 'w)
;; 下略…
)))
这样一来,执行 (org-agenda nil "d") 之后,就能在Agenda 区域中看见
──────────────────────────────────────────────────────────────
🔄周期--年🔄
TODO 这是一个年度任务
───────────────────────────────────────────────────────────────
🔄周期--季🔄
TODO 这是一个季度任务
───────────────────────────────────────────────────────────────
🔄周期--月🔄
TODO 这是一个月度任务
───────────────────────────────────────────────────────────────
🔄周期--周🔄
TODO 这是一个周度任务
org-ql-block 函数可以将搜索结果展示为agenda中的一个block。这里我们搜索的内容为 (and (todo "TODO" "HOLD") (property "PERIOD" property-string)(ts-active :from ,(car (my/org-ql-ts-period period)) :to ,(cdr (my/org-ql-ts-period period)))) ,其实是满足以下三个要求的任务:
(todo "TODO" "HOLD") :TODO或者HOLD这种处于TODO状态的HEADING(property "PERIOD" property-string) :根据上面的要求,搜索 PERIOD 属性为 year season month week 的对应任务(ts-active :from ,(car (my/org-ql-ts-period period)) :to ,(cdr (my/org-ql-ts-period period))) :活跃时间戳,且时间为 year season month week 对应的当前日期所处起始年月日里。由 my/org-ql-ts-period 函数生成。这里我们用 DEADLINE 标记,就是为了腾出空间留给 SCHEDULED ,以便我们计划这类任务应该开始完成的时间。如果一个任务被标记完成,那么它会自动标记 DEADLINE 在符合条件的下个周期最后一天,也就会从我们统计的Agenda-View里移除了(因为时间戳超过了期限)。
在org-todo标记为完成后,因为我们用的 DEADLINE ,则会将 SCHEDULED 清空,并重新设定 DEADLINE 时间戳。