UP | HOME

🔙回到目录 | 🔻 本网页更新于 [2026-09-21 周一 21:48]

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 而非 SCHEDULEDL * * 是 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:

若某个训练日恰逢节假日, + 会向未来找最近的工作日进行补训。

© Published by Emacs 32.0.50 (Org mode 10.0-pre) | RSS 评论