写了一个按cron风格重复org任务的包

灵感来源还有代码修改自

具体的使用说明参见我的中文README。 使用样例请参见我后面回帖贴出的两篇文章。

6 个赞

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

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

[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_INTERVALRESCHEDULE_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)

每周一上午 9 点

:RESCHEDULE_CRON: 0 9 * * Mon

每月第三个周四

:RESCHEDULE_CRON: * * Thu#3

下周一或周五

:RESCHEDULE_CRON: * * Mon,Fri

从特定锚点起每 2 天

:RESCHEDULE_INTERVAL: 2d
:RESCHEDULE_ANCHOR: 2025-08-03 Sun

每季度第一个星期一

:RESCHEDULE_CRON: * Jan,Apr,Jul,Oct Mon#1
1 个赞

在org-agenda中展示年度、季度、月度、周度任务

经常使用任务管理软件的朋友都知道,我们有的任务并无具体的截止日期,而是一个笼统的区间,比如年度、季度、月度、周度任务。org-mode中基于 SCHEDULEDDEADLINE 的传统方式,难以满足这种任务的查看与重复需求。

经过一番思索,我找到了利用Org-QLorg-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:

设置Agenda Commands

自定义相关函数

my/org-ql-ts-period

这里我们定义了两个相关函数。第一个函数 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

第二个函数 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

最后,我们需要自定义 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 时间戳。

4 个赞