emacs-为重复任务添加自定义工作日判定
有的时候,你只想让有的待办事项计划在工作日,而且不是简单的固定期限的工作日,可能是工作第一日/工作最后一日,甚至在这个基础上还要再偏移一下,变成工作前一日。
1. 设置节假日
排除掉周六、周日以及法定节假日(也许还有自定义的假日)之后,就是工作日。在 org-agenda-files 中任一文件加入以下内容:
;; 2025中国法定节假日
%%(diary-date 1 1 2025) 🏮元旦🏮
%%(diary-block 1 28 2025 2 4 2025) 🏮春节🏮
%%(diary-date 1 26 2025) 💼春节-上班💼
%%(diary-date 2 8 2025) 💼春节-上班💼
%%(diary-block 4 4 2025 4 6 2025) 🏮清明🏮
%%(diary-block 5 1 2025 5 5 2025) 🏮劳动节🏮
%%(diary-date 4 27 2025) 💼劳动节-上班💼
%%(diary-block 5 31 2025 6 2 2025) 🏮端午🏮
%%(diary-block 10 1 2025 10 8 2025) 🏮国庆、中秋🏮
%%(diary-date 9 28 2025) 💼国庆中秋-上班💼
%%(diary-date 10 11 2025) 💼国庆中秋-上班💼
即可在org-agenda中生成sexp表达式的日历条目,后续会用到。
2. 工作日判定函数
(defun my/datestr-is-workday-p (date &optional offset)
"判断日期是否为工作日(包含调休)。DATE格式为 'YYYY-MM-DD'。
OFFSET 为相对 DATE 的天数偏移,正数向未来,负数向过去。"
(let* ((d (date-to-time date))
(target-time (time-add d (days-to-time (or offset 0))))
(greg (calendar-gregorian-from-absolute (time-to-days target-time)))
(entries (org-agenda-get-day-entries (concat my/org-dir1 "/cal.org") greg :sexp))
(is-holiday (and entries (string-match-p "🏮" (car entries))))
(is-workday (and entries (string-match-p "💼" (car entries))))
(day-of-week (calendar-day-of-week greg)))
(cond
(is-workday t)
(is-holiday nil)
(t (not (member day-of-week org-agenda-weekend-days))))))
这个函数会根据周末及 emoji 包含的情况,判定某个日期加上偏移 offset 之后的日期是否为工作日。如果是,则返回 t ,否则返回 nil 。
注意,这里我将所有的 sexp 日程条目放在了 (concat my/org-dir1 "/cal.org") 路径下,如果你有其他的放置需求,需要结合自己情况进行修改,比如这样:
(defun my/datestr-is-workday-p (date &optional offset)
"判断日期是否为工作日(包含调休)。DATE 格式为 'YYYY-MM-DD'。
OFFSET 为相对 DATE 的天数偏移,正数向未来,负数向过去。
在 `org-agenda-files' 中所有文件里查找 sexp 条目:
- 命中 💼 视为工作日(优先)
- 命中 🏮 视为节假日
- 都没有命中则按周末判断"
(let* ((d (date-to-time date))
(target-time (time-add d (days-to-time (or offset 0))))
(greg (calendar-gregorian-from-absolute (time-to-days target-time)))
(files (org-agenda-files))
(is-holiday nil)
(day-of-week (calendar-day-of-week greg)))
(if (catch 'workday
(dolist (file files)
(let ((entries (org-agenda-get-day-entries file greg :sexp)))
(dolist (entry entries)
(when (string-match-p "💼" entry)
(throw 'workday t))
(when (string-match-p "🏮" entry)
(setq is-holiday t)))))
nil)
t
(if is-holiday
nil
(not (member day-of-week org-agenda-weekend-days))))))
3. 处理函数
(defun my/org-repeat-by-cron--workday-date (date-str &optional direction offset)
"返回从 DATE-STR 出发按 DIRECTION 找到的最近工作日,再偏移 OFFSET 天。
DATE-STR 为 `YYYY-MM-DD'。
DIRECTION 为字符串,默认 \"-\":
- 方向:\"-\" 向过去(默认),\"+\" 向未来。
- 边界:可包含 \"start\" 或 \"end\"。
\"start\" 表示找连续工作日的起始日(当天为工作日,前一天不是);
\"end\" 表示找连续工作日的结束日(当天为工作日,第二天不是);
不含边界词则只找最近的工作日。
组合示例:\"-\"、\"+\"、\"-start\"、\"+end\"、\"-end\"、\"+start\"。
当 DATE-STR 当天本身是工作日时,若含 \"start\" 或 \"end\",
则忽略 -/+ 方向,直接在当前连续工作日区间内定位:
- start → 当前区间的起始日(向过去找)
- end → 当前区间的结束日(向未来找)
OFFSET 为整数,默认 0;正数向未来偏移,负数向过去偏移。
最多搜索 30 天,找不到返回 nil。"
(let* ((dir-str (if (stringp direction) (downcase (string-trim direction)) ""))
(explicit-step (if (string-match-p "\\+" dir-str) 1 -1))
(boundary (cond ((string-match-p "start" dir-str) 'start)
((string-match-p "end" dir-str) 'end)
(t nil)))
(offset (cond ((numberp offset) offset)
((and (stringp offset)
(not (string-empty-p (string-trim offset))))
(string-to-number (string-trim offset)))
(t 0)))
(base-time (date-to-time date-str))
(today-workday (my/datestr-is-workday-p date-str 0))
;; 当日在工作日且有 start/end 时,忽略 -/+ 方向,
;; 强制在当前连续工作日区间内定位
(step (cond
((and today-workday (eq boundary 'start)) -1)
((and today-workday (eq boundary 'end)) 1)
(t explicit-step)))
(limit 30)
(found-offset nil)
(i 0))
(while (and (<= i limit) (not found-offset))
(let* ((candidate-offset (* step i))
(is-workday (my/datestr-is-workday-p date-str candidate-offset)))
(when is-workday
(cond
((null boundary)
(setq found-offset candidate-offset))
((eq boundary 'start)
(unless (my/datestr-is-workday-p date-str (1- candidate-offset))
(setq found-offset candidate-offset)))
((eq boundary 'end)
(unless (my/datestr-is-workday-p date-str (1+ candidate-offset))
(setq found-offset candidate-offset))))))
(setq i (1+ i)))
(when found-offset
(format-time-string
"%Y-%m-%d"
(time-add base-time (days-to-time (+ found-offset offset)))))))
(defun my/org-repeat-by-cron--snap-timestamp-to-workday (type &optional direction offset)
"把当前 heading 的 TYPE 时间戳按 DIRECTION/OFFSET 调整到工作日附近。
TYPE 为 \"SCHEDULED\" 或 \"DEADLINE\"。时间戳里若带 HH:MM 则保留。
DIRECTION 默认向过去;OFFSET 默认 0。"
(let ((ts-str (org-entry-get nil type)))
(when (and ts-str (not (string-empty-p (string-trim ts-str))))
(let* ((time (org-time-string-to-time ts-str))
(date-str (format-time-string "%Y-%m-%d" time))
(has-time (string-match-p "[0-9]\\{1,2\\}:[0-9]\\{2\\}" ts-str))
(new-date (my/org-repeat-by-cron--workday-date date-str direction offset)))
(when (and new-date (not (string= new-date date-str)))
(let ((new-str (if has-time
(format "%s %s" new-date
(format-time-string "%H:%M" time))
new-date)))
(pcase type
("SCHEDULED" (org-schedule nil new-str))
("DEADLINE" (org-deadline nil new-str)))))))))
(defun my/org-repeat-by-cron-snap-to-workday ()
"Cron 重复完成后,若当前 heading 带有 \"工作\" 标签,则按属性调整到工作日附近。
启用条件:当前 heading 的标签中包含 \"工作\"。
方向:WORKDAY_DIRECTION 为 past/future,默认 past。
偏移:WORKDAY_OFFSET 为整数,默认 0;正数向未来,负数向过去。
处理哪些时间戳与 `org-repeat-by-cron-on-done' 保持一致:
- REPEAT_DEADLINE = t → 只处理 DEADLINE
- REPEAT_DEADLINE = cron 表达式 → 同时处理 DEADLINE 与 SCHEDULED
- 其它 → 只处理 SCHEDULED
用法:把该函数加入 `org-repeat-by-cron-after-repeat-functions':
(add-hook 'org-repeat-by-cron-after-repeat-functions
#'my/org-repeat-by-cron-snap-to-workday)"
(when (derived-mode-p 'org-mode)
(save-excursion
(org-back-to-heading t)
(when (member "工作" (org-get-tags))
(let* ((direction (org-entry-get nil "WORKDAY_DIRECTION"))
(offset-str (org-entry-get nil "WORKDAY_OFFSET"))
(offset (if (and offset-str
(not (string-empty-p (string-trim offset-str))))
(string-to-number (string-trim offset-str))
0))
(deadline-prop (org-entry-get nil org-repeat-by-cron-deadline-prop))
(process-deadline nil)
(process-schedule nil))
(cond
((and deadline-prop (string= deadline-prop "t"))
(setq process-deadline t))
((and deadline-prop
(org-repeat-by-cron--cron-rule-arity deadline-prop))
(setq process-deadline t)
(setq process-schedule t))
(t
(setq process-schedule t)))
(when process-schedule
(my/org-repeat-by-cron--snap-timestamp-to-workday
"SCHEDULED" direction offset))
(when process-deadline
(my/org-repeat-by-cron--snap-timestamp-to-workday
"DEADLINE" direction offset)))))))
(add-hook 'org-repeat-by-cron-after-repeat-functions
#'my/org-repeat-by-cron-snap-to-workday)
以上函数均有丰富的 docstring ,这里就不在赘述。
4. 使用例
以上函数我特意设计为配合 org-repeat-by-cron 包使用,不过普通使用也可以,更改一下挂载的 hook 即可。
4.1. 示例:每周五的周报,自动吸附到最近工作日
假设每周五 17:00 提交周报,但如果周五是节假日,希望提前到最近的工作日:
* TODO 提交周报
SCHEDULED: <2026-09-18 Fri 17:00>
:PROPERTIES:
:REPEAT_CRON: 0 17 * * FRI
:WORKDAY_DIRECTION: -
:END:
标记 DONE 后, org-repeat-by-cron 先将时间戳推进到下一个周五;随后你的吸附函数检查该周五是否为工作日( cal.org 中无 🏮 标记且非周末),若不是,则向过去找到最近的工作日,保留 17:00 。
4.2. 示例:每月最后一天的账单,自动吸附到最近工作日
账单在每月最后一天到期,但如果最后一天是周末/节假日,希望提前到最近的工作日:
* TODO 缴纳信用卡账单
DEADLINE: <2026-09-30 Wed>
:PROPERTIES:
:REPEAT_CRON: L * *
:REPEAT_DEADLINE: t
:WORKDAY_DIRECTION: -
:END:
REPEAT_DEADLINE: t 让 cron 规则更新 DEADLINE 而非 SCHEDULED 。 L * * 是 3 字段简写,表示每月最后一天,生成纯日期时间戳。
4.3. 示例:季度维护,取连续工作日的第一天
每季度第一个周一做服务器维护,但如果该周一是假期,希望找到 假期结束后连续工作日的第一天 开始维护:
* TODO 服务器季度维护
SCHEDULED: <2026-10-05 Mon>
:PROPERTIES:
:REPEAT_CRON: * JAN,APR,JUL,OCT MON#1
:WORKDAY_DIRECTION: +start
:END:
* JAN,APR,JUL,OCT MON#1:每季度第一个月的第一个周一。+start:向未来找到连续工作日的起始日(若该周一落在长假中,会找到假期结束后的第一个工作日)。
4.4. 示例:与 org-habit 共存
org-repeat-by-cron 完全兼容 org-habit ,只要保留一个 repeater cookie。你可以在习惯任务上同时使用 cron 重复和工作日吸附:
* TODO 力量训练
SCHEDULED: <2026-09-21 Mon .+1d/2d>
:PROPERTIES:
:STYLE: habit
:REPEAT_CRON: * * MON,WED,FRI
:WORKDAY_DIRECTION: +
:WORKDAY_OFFSET: 0
:END:
若某个训练日恰逢节假日, + 会向未来找最近的工作日进行补训。