基于Org节点网络的属性探究

基于 Org 节点网络的属性探究

注:这是一篇立足于Org所指属性的,旨在探究的、实验性的记录。


概述

Org mode 为我们提供了一种以树型结构组织文本、数据的方式。基于树型结构,子节点的属性可以累加到父节点,并逐级上升。其应用之一即 Org mode 内置的 clock table 特性——以一种树型累加的方式统计任务耗时。但有时,同一个(子)节点在不同视角/视图/场景下,可能/可以(在逻辑上)同时属于不同的结构。

举个例子,假设有几个任务节点:编码a, 测试a, 分析b, 编码b. 这些任务节点上都带有 Org clock 数据(一种节点属性)。对于这些任务节点,我们有两种分析时间投入的角度:以项目为视角;以日期为视角。

以项目为视角:

* 分析
#+BEGIN: clocktable :maxlevel 3
#+CAPTION: Clock summary at [2025-08-27 Wed 08:50]
| Headline     | Time   |      |      |
|--------------+--------+------+------|
| *Total time* | *9:30* |      |      |
|--------------+--------+------+------|
| 项目          | 9:30   |      |      |
| \_  项目A     |        | 6:00 |      |
| \_    编码a   |        |      | 2:00 |
| \_    测试a   |        |      | 4:00 |
| \_  项目B     |        | 3:30 |      |
| \_    分析b   |        |      | 1:25 |
| \_    编码b   |        |      | 2:05 |
#+END:
* 项目
** 项目A
*** 编码a
:LOGBOOK:
CLOCK: [2025-08-18 Mon 09:30]--[2025-08-18 Mon 11:30] =>  2:00
:END:
*** 测试a
:LOGBOOK:
CLOCK: [2025-08-20 Wed 14:00]--[2025-08-20 Wed 18:00] =>  4:00
:END:
** 项目B
*** 分析b
:LOGBOOK:
CLOCK: [2025-08-22 Fri 10:25]--[2025-08-22 Fri 11:50] =>  1:25
:END:
*** 编码b
:LOGBOOK:
CLOCK: [2025-08-25 Mon 09:45]--[2025-08-25 Mon 11:50] =>  2:05
:END:

以日期为视角:

* 分析
#+BEGIN: clocktable :maxlevel 3
#+CAPTION: Clock summary at [2025-08-27 Wed 08:53]
| Headline     | Time   |      |      |
|--------------+--------+------+------|
| *Total time* | *9:30* |      |      |
|--------------+--------+------+------|
| 日期          | 9:30   |      |      |
| \_  33周投入  |        | 7:25 |      |
| \_    编码a   |        |      | 2:00 |
| \_    测试a   |        |      | 4:00 |
| \_    分析b   |        |      | 1:25 |
| \_  34周投入  |        | 2:05 |      |
| \_    编码b   |        |      | 2:05 |
#+END:
* 日期
** 33周投入
*** 编码a
:LOGBOOK:
CLOCK: [2025-08-18 Mon 09:30]--[2025-08-18 Mon 11:30] =>  2:00
:END:
*** 测试a
:LOGBOOK:
CLOCK: [2025-08-20 Wed 14:00]--[2025-08-20 Wed 18:00] =>  4:00
:END:
*** 分析b
:LOGBOOK:
CLOCK: [2025-08-22 Fri 10:25]--[2025-08-22 Fri 11:50] =>  1:25
:END:
** 34周投入
*** 编码b
:LOGBOOK:
CLOCK: [2025-08-25 Mon 09:45]--[2025-08-25 Mon 11:50] =>  2:05
:END:

两种视角分别对应两种树型结构:

(项目 (项目A (编码a 测试a)) (项目B (分析b 编码b)))

(日期 (33周投入 (编码a 测试a 分析b)) (34周投入 (编码b)))

问题:由于两种视角共同依赖相同的任务节点,如果只使用 Org mode 内置的特性,为实现这两种不同的分析视角,我们将不得不把任务节点分别拷贝至各类视角的节点中。


引用与反向引用

在深入后续之前,我们先定义两个关于 Org entry 的属性:

(org-E.ref &optional node local)

(org-E.backrefs &optional node)

借助 #'org-E.ref, 我们可以生成任意 Org entry 的引用,其返回的可能是一条带中括号的 Org 链接,也可能是个 marker. 总之,我们暂且称它为 REF. 通过 #'org-E.ref 返回的 REF, 我们可以定位 REF 所指的 Org entry; 而 #'org-E.backrefs 返回的是一个 REF 集合。

注:后续为 #'org-E.ref#'org-E.backrefs 的实现细节,可跳过直转下一节。
注:本文提及的 Org entry 特指 org-element-type 为 headline 的 Org 元素。
注:此处关于 Org entry 引用的定义并非唯一,只是为了演示后续的功能而定义。
注: REF 所指的 Org节点 的类型为 Org entry, 而不是 Org Source Block, 或其他什么东西。

Org entry 引用 (#'org-E.ref):

#+name: 2025-08-29-20-08
#+header: :eval yes
#+begin_src emacs-lisp :lexical t :results silent
(!def 'org-E.ref
 (!let (org-E.ref store-ref full-ref local-ref)
  (!def org-E.ref
   (lambda (&optional node local)
     ;; 因为我们可能返回 marker, 相比于 LINK, marker 是一
     ;; 种不稳定的引用,因此 ‘org-E.ref’ 不使用
     ;; ‘org-referent-get’ 的缓存特性。
     (org-referent-get node
       (lambda nil (store-ref local))
       :no-cache t)))

  (!def store-ref
   ;; 我们统一使用加中括号的 LINK, 且不要 DESC.
   (lambda (local)
     (let* ((link (or ;; 优先使用 ID 链接。
                   ;; ‘org-id-store-link-maybe’ 返回的
                   ;; 链接格式为 id:x.
                   (org-id-store-link-maybe)
                   ;; ‘org-store-link’ 返的 FILE LINK
                   ;; 格式为 [[file:xx]].
                   (org-store-link nil nil)
                   ;; 一些无关联文件且无 ID 的 entry 中,
                   (when local
                     (and-let*
                         ((cid (org-entry-get
                                nil "CUSTOM_ID"))
                          (cid (format
                                "[[#%s]]" cid))))))))
       (cond
        ;; 如果有 LINK 可引用当前节点,返回 LINK,
        ((and
          link
          ;; LINK::*headline 类链接属于非法 ref, 因为此
          ;; 类链接的定位可能不唯一,所以我们不用此类链接。
          (length= (string-split link "::*" t) 1)
          (setq link
                (with-temp-buffer
                  (save-excursion
                    (insert link))
                  (org-element-link-parser))))
         (if local (local-ref link) (full-ref link)))
        ;; 否则使用 marker 引用当前节点。
        ;; (global) ref 可能是 LINK 或 marker; 但对于
        ;; local ref 而言,marker 不算 local, 所以,当
        ;; LINK 不可用时,只有 not local 时才返回 marker.
        ((not local)
         (save-excursion
           ;; 如其名中 E 所暗示, ‘org-E.ref’ 只是对 Org
           ;; entry, 或者说 headline, 的引用。
           (org-back-to-heading-or-point-min)
           ;; 如果当前节点非 Org entry, 直接抛异常。
           (unless (org-element-type-p
                    (org-element-at-point)
                    '(headline))
             (error "Not a valid entry at %S %s"
                    (current-buffer) (point)))
           (point-marker)))))))

  ;; 全局引用。如:
  ;; [[id:ID::SEARCH-OPTION]],
  ;; [[file:path::SEARCH-OPTION]].
  (!def full-ref
   ;; link: org-element link
   (lambda (link)
     (format "[[%s]]"
             (org-element-property
              :raw-link link))))

  ;; 局部引用。如:
  ;; [[#CUSTOM-ID]], [[#SEARCH-OPTION]].
  (!def local-ref
   ;; link: org-element link
   (lambda (link)
     (pcase (org-element-property :type link)
       ("file"
        (and-let*
            ((opt (org-element-property
                   :search-option link))
             (_ (format "[[%s]]" opt)))))
       ("id"
        (and-let*
            ((rl (org-element-property
                  :raw-link link))
             (opt (string-split rl "::" t))
             (_ (length> 1 opt))
             (opt (car (last opt)))
             (_ (format "[[%s]]" opt)))))
       ("custom-id"
        (format "[[%s]]" (org-element-property
                          :raw-link link))))))

  org-E.ref))
#+end_src

Org entry 反向引用 (#'org-E.backrefs):

#+name: 2025-08-29-20-09
#+header: :eval yes
#+begin_src emacs-lisp :lexical t :results silent
(!def 'org-E.backrefs
 (!let (org-E.backrefs
        backrefs search ref-re)
  (!def org-E.backrefs
   (lambda (&optional node)
     ;; 因为我们可能返回 marker, 相比于 LINK, marker 是一
     ;; 种不稳定的引用,因此 ‘org-E.ref’ 不使用
     ;; ‘org-referent-get’ 的缓存特性。
     (org-referent-get node
       backrefs :no-cache t)))

  (!def backrefs
   (lambda nil
     (when-let*
         ((ref (org-E.ref))
          ;; org-E.ref 返回的 LINK 总带中括号。
          (ref (if (not (stringp ref)) ref
                 (string-trim
                  ref "\\[\\[" "\\]\\]")))
          (lref (or (org-E.ref nil t) ""))
          (lref (string-trim
                 lref "\\[\\[" "\\]\\]"))
          ;; 反向引用查找的范围:所有与文件相关的
          ;; Org buffers.
          (bufs
           (seq-filter
            (lambda (b)
              (with-current-buffer b
                (and (derived-mode-p 'org-mode)
                     (buffer-file-name))))
            (buffer-list))))
       (seq-uniq
        (append
         ;; 对于带 option 型链接,我们需特殊处理。
         (when (length> lref 0)
           (search lref))
         ;; external backrefs
         (when (stringp ref)
           (mapcan
            (lambda nil (search ref)) bufs)))))))

  (!def search
   ;; 根据 re 查找 buf 中的引用。
   (lambda (ref &optional buf)
     (let ((m (make-marker))
           (re (ref-re ref)) R
           (buf (or buf (current-buffer))))
       (org-with-point-at (set-marker m 1 buf)
         (save-match-data
           (while (re-search-forward re nil t)
             (and-let*
                 ((r (org-E.ref))
                  ;; 注意: org-E.ref 可能返回 marker.
                  (_ (not (equal r ref)))
                  (_ (not (member r R))))
               (push r R)))))
       R)))

  (!def ref-re
   (lambda (ref)
     (concat
      "\\[" "\\[" ref "\\]"
      "\\(\\[.*?\\]\\)?" "\\]")))

  org-E.backrefs))
#+end_src

结构类节点属性

仔细考察 Org clock table 特性会发现其中涉及到一种节点之间的父子关系,一种借文本大纲的组织形式天然定义的父子关系。然而现实中,节点之间具有任意且无数的关系,比如,常见的关系有:节点与其链接到的节点之间的关系;节点与其后链节点之间的关系。

抽象的说,这些关系均是一种从节点到节点集合的映射。基于此,我们完全可以定义任意的节点关系,比如,下面定义的一些属性。

属性: #'org-T.peers.tree, Org tree 的节点集。

#+header: :eval yes
#+begin_src emacs-lisp :lexical t :results silent
(!def 'org-T.peers.tree
 (lambda (&optional node)
   (org-referent-get node
     (lambda nil
       (org-map-entries
        #'org-E.ref nil 'tree))
     :no-cache t)))
#+end_src

属性: #'org-T.peers.subtree, Org tree 的子节点集。

#+header: :eval yes
#+begin_src emacs-lisp :lexical t :results silent
(!def 'org-T.peers.subtree
 (lambda (&optional node)
   (cdr (org-T.peers.tree node))))
#+end_src

属性: #'org-T.peers.children, Org tree 的直接子节点集。

#+name: 2025-08-29-20-12
#+header: :eval yes
#+begin_src emacs-lisp :lexical t :results silent
(!def 'org-T.peers.children
 (lambda (&optional node)
   (org-referent-get node
     (lambda nil
       (org-map-entries
        #'org-E.ref
        (format "+LEVEL=%d"
                (1+ (org-current-level)))
        'tree))
     :no-cache t)))
#+end_src

数据类节点属性

有了各种节点到节点集的映射后,我们便可以通过另一些偏向于数据类的节点属性抓取整个结构的数据,比如下面的 clock-sum 属性等:

属性: #'org-T.clock-sum, Org tree 的 clock sum.

#+header: :eval yes
#+begin_src emacs-lisp :lexical t :results silent
(org-N-defprop clock-sum
  (lambda nil
    (org-with-point-at nil
      (narrow-to-region
       (point)
       (org-end-of-subtree))
      (org-clock-sum)))
  :type T)
#+end_src

属性: #'org-E.clock-sum, Org entry 的 clock sum.

#+header: :eval yes
#+begin_src emacs-lisp :lexical t :results silent
(org-N-defprop clock-sum
  (lambda nil
    (org-with-point-at nil
      (narrow-to-region
       (point)
       (or (outline-next-heading)
           (org-end-of-subtree)))
      (org-clock-sum)))
  :type E)
#+end_src

属性: #'org-E.title, #'org-E.level, Org entry 的标题与等级。

#+header: :eval yes
#+begin_src emacs-lisp :lexical t :results silent
(org-N-defprop title
  (lambda nil
    (let ((T (org-entry-get nil "ITEM")))
      (with-temp-buffer
        (org-mode)
        (insert T)
        (substring-no-properties
         (car
          (org-element-map
              (org-element-parse-buffer)
              '(plain-text)
            #'identity))))))
  :type E)
(org-N-defprop level #'org-current-level :type E)
#+end_src

数据与结构的结合

以下面的 Org 片段为例(项目视角):

* 项目
** 项目A
*** 编码a
:LOGBOOK:
CLOCK: [2025-08-18 Mon 09:30]--[2025-08-18 Mon 11:30] =>  2:00
:END:
*** 测试a
:LOGBOOK:
CLOCK: [2025-08-20 Wed 14:00]--[2025-08-20 Wed 18:00] =>  4:00
:END:
** 项目B
*** 分析b
:LOGBOOK:
CLOCK: [2025-08-22 Fri 10:25]--[2025-08-22 Fri 11:50] =>  1:25
:END:
*** 编码b
:LOGBOOK:
CLOCK: [2025-08-25 Mon 09:45]--[2025-08-25 Mon 11:50] =>  2:05
:END:

通过前几节定义的属性配合求得各子节点的 clock sum:

#+header: :lexical t :eval no
#+begin_src emacs-lisp
(let* ((n "[[*项目]]")
       (formatter
        (lambda (mins)
          (format "%d:%02d"
                  (/ mins 60)
                  (% mins 60))))
       (N (org-T.peers.tree n))
       (i (mapcar #'org-E.title N))
       (c (mapcar #'org-T.clock-sum N))
       (c (mapcar formatter c)))
  (seq-mapn #'list i c))
#+end_src

#+RESULTS:
| 项目  | 9:30 |
| 项目A | 6:00 |
| 编码a | 2:00 |
| 测试a | 4:00 |
| 项目B | 3:30 |
| 分析b | 1:25 |
| 编码b | 2:05 |

链接子树

回到我们开头遇到的问题,为避免将节点拷贝到不同视图的节点中,我们需要定义一种超越大纲结构的关系。一种方案如下:

节点的链接子树:

* 节点A
:PROPERTIES:
:ID: xxx
:END:
* 节点B
** [[id:xxx][节点A]]

如上述 Org 片段所示,节点A 以链接的形式作为 节点B 的子树。借此定义, B 的子节点除了物理意义上存于其下的 Org entry 外,还包括这些——被链接所指的 Org entries——链接子树:

属性: #'org-T.peers.linktree, 链接子树。

#+name: 2025-08-29-20-18
#+header: :lexical t :eval yes :results silent
#+begin_src emacs-lisp
(!def 'org-T.peers.linktree
 (!let (org-T.peers.linktree linktree linknode)
  (!def org-T.peers.linktree
   (lambda (&optional node)
     (org-referent-get node linktree :no-cache t)))

  (!def linktree
   (lambda nil
     (remq nil (org-map-entries
                linknode nil 'tree))))

  (!def linknode
   (lambda nil
     (and-let*
         ((title (org-entry-get nil "ITEM"))
          (link (with-temp-buffer
                  (save-excursion (insert title))
                  (org-element-link-parser)))
          (_ (org-element-type-p link '(link)))
          (link (org-element-property :raw-link link))
          (_ (format "[[%s]]" link))))))

  org-T.peers.linktree))
#+end_src

在上述定义的链接子树关系的基础上,我们将先前的任务节点重新组织成如下的 Org 片段。任务节点物理上依旧存储于“项目”节点中,并以链接子树的形式,在逻辑上作为“日期”节点的子节点:

* 项目
** 项目A
*** 编码a
:LOGBOOK:
CLOCK: [2025-08-18 Mon 09:30]--[2025-08-18 Mon 11:30] =>  2:00
:END:
*** 测试a
:LOGBOOK:
CLOCK: [2025-08-20 Wed 14:00]--[2025-08-20 Wed 18:00] =>  4:00
:END:
** 项目B
*** 分析b
:LOGBOOK:
CLOCK: [2025-08-22 Fri 10:25]--[2025-08-22 Fri 11:50] =>  1:25
:END:
*** 编码b
:LOGBOOK:
CLOCK: [2025-08-25 Mon 09:45]--[2025-08-25 Mon 11:50] =>  2:05
:END:
* 日期
** 32周投入
*** [[*编码a][编码a]]
*** [[*测试a][测试a]]
*** [[*分析b][分析b]]
** 33周投入
*** [[*编码b][编码b]]

基于上述 Org 片段,我们可通过如下代码生成不同视角的报表:

#+name: 2025-08-29-20-19
#+header: :lexical t :eval no
#+begin_src emacs-lisp :var node="[[*项目]]"
(let* ((n node)
       ;; 一种新的 clock-sum 属性。
       (clock-sum
        (lambda (n)
          (+
           ;; 除了整棵 tree 的 clock 数据外,
           (org-T.clock-sum n)
           ;; 还包括所有 linktree 的 clock 数据。
           (apply
            #'+
            (mapcar
             #'org-T.clock-sum
             (org-T.peers.linktree n))))))
       (formatter
        (lambda (mins)
          (format "%d:%02d"
                  (/ mins 60) (% mins 60))))
       (N (org-T.peers.tree n))
       (item (seq-mapn
              (lambda (i l)
                (if (= l 1) i
                  (concat
                   "\\_"
                   (make-string (* 2 (1- l)) ? ) i
                   (make-string (1- l) ?|))))
              (mapcar #'org-E.title N)
              (mapcar #'org-E.level N)))
       (clock (mapcar clock-sum N))
       (clock (mapcar formatter clock)))
  (seq-mapn #'list item clock))
#+end_src

项目视角:

#+call: 2025-08-29-20-19(node="[[*项目]]") :eval no

#+RESULTS:
| 项目        | 9:30 |      |      |
| \_  项目A   |      | 6:00 |      |
| \_    编码a |      |      | 2:00 |
| \_    测试a |      |      | 4:00 |
| \_  项目B   |      | 3:30 |      |
| \_    分析b |      |      | 1:25 |
| \_    编码b |      |      | 2:05 |

日期视角:

#+call: 2025-08-29-20-19(node="[[*日期]]") :eval no

#+RESULTS:
| 日期         | 9:30 |      |      |
| \_  32周投入 |      | 7:25 |      |
| \_    编码a  |      |      | 2:00 |
| \_    测试a  |      |      | 4:00 |
| \_    分析b  |      |      | 1:25 |
| \_  33周投入 |      | 2:05 |      |
| \_    编码b  |      |      | 2:05 |

节点属性表

在深入后续之前,我们引入一个用于生成节点属性表的工具函数:

#+name: 2025-08-29-20-22
#+begin: elisp-docstring

Org entry 节点属性表生成工具。

N: 节点,下文记为 n;
C: n->C, 节点到子节点映射;
P: 属性集; p: 属性; v: 属性值; V: 属性值集;
p 若 function, p: n->v;
p 若 list, p: (p.v p.+); p.v: n->v; p.+: V->v.

属性表的行结构: (l n v1 v2 …), l: level.

#+end:

#+name: 2025-08-29-20-23
#+begin_src emacs-lisp :eval no
(!let ((p.v (lambda (p) (or (car-safe p) p)))
       (p.+ (lambda (p) (if (listp p) (cadr p))))
       N)
 (lambda (n C &rest P)
   "Org Node Properties Table Generator.

<<@([[id:org-N::doc:org-E-gen-table]])>>"
   (unless (numberp (car P))
     (setq P (cons 0 P)) ; 借 P[0] 存递归深度。
     (setq N nil))
   (push n N) ; mark n visited.
   (!let ((l (1+ (car P))) (P (cdr P)) (C C) I D)
    ;; 子孙节点
    (!def D
     (mapcan
      (lambda (n)
        (unless (member n N)
          (let ((D (apply #'org-E-gen-table
                          n C l P)))
            ;; 直接子节点, Immediate child node.
            (push (car D) I)
            D)))
      (C n)))
    ;; 当前节点
    (!def n
     (append
      `(,l ,n)
      (seq-map-indexed
       (lambda (p i)
         (!let ((+ (p.+ p)) (p (p.v p))
                (ith (lambda (n) (nth (+ i 2) n))))
          (if (null +) (p n)
            (apply + (p n) (mapcar ith I)))))
       P)))
    `(,n ,@D))))
#+end_src

赋名为 org-E-gen-table:

#+header: :lexical t :noweb yes :results silent
#+begin_src emacs-lisp :eval yes
(!def 'org-E-gen-table
 (progn
   (put 'org-E-gen-table 'lisp-indent-function 2)
   <<2025-08-29-20-23>>))
#+end_src

用例:

给定如下树:

* 项目
** 项目A
*** 编码a
*** 测试a
** 项目B
*** 分析b
*** 编码b

用如下代码

(mapcar
 #'cddr
 (org-E-gen-table
  "[[*项目]]" #'org-T.peers.children
  #'org-E.level #'org-E.title))

生成如下表:

| 1 | 项目  |
| 2 | 项目A |
| 3 | 编码a |
| 3 | 测试a |
| 2 | 项目B |
| 3 | 分析b |
| 3 | 编码b |

链接标签

除了链接子树外,我们还有一种解决问题的方式——链接标签,一种通过节点链接筛选出特定节点集合的方法。

考虑这样的场景:

对于某作品,其可能存在不同的出版形式,比如小说、漫画、动漫、影视等。用作品名作为标签时常常难以区分此类细节。

也许有人会提议:“可以同时使用作品名和出版形式作为标签,比如:X:小说:。”

但这种方法存在一定的问题:假设某节点要同时被标记为:X:小说::Y:漫画:。如果单按 Org 标签的方式,节点的 headline 将被:X:小说:Y:漫画:标记。此时,四个标签的结合额外多出两个我们非期望的组合::X:漫画::Y:小说:

一种更为合理的方法是:为某作品的不同出版形式分别创建各自的节点,并以这些节点本身作为标签,将其链接插入到需要被标记的节点中。如下所示:

* 标签A
:PROPERTIES:
:ID: xxx
:END:
* 被链接标签A标记的节点
:PROPERTIES:
:LINK_TAG: [[id:xxx][标签A]]
:END:

之后通过反向筛选出此类基于链接的标签的匹配项。

基于 反向引用 的定义,我们将 Org entry 的链接标签(子节点)属性定义成如下形式:

属性: #'org-E.peers.linktag, 链接标签子节点。

#+name: 2025-08-29-20-32
#+header: :lexical t :eval yes :results silent
#+begin_src emacs-lisp
(!def 'org-E.peers.linktag
 (!let (org-E.peers.linktag linktag)
  (!def org-E.peers.linktag
   (lambda (&optional node)
     (org-referent-get node linktag :no-cache t)))

  (!def linktag
   (lambda nil
     "返回被当前链接标签选中的所有节点。"
     (and-let*
         ((ref (org-E.ref))
          ;; org-E.ref 返回的 LINK 总带中括号。
          (ref (if (not (stringp ref)) ref
                 (string-trim
                  ref "\\[\\[" "\\]\\]")))
          (lref (or (org-E.ref nil t) ""))
          (lref (string-trim
                 lref "\\[\\[" "\\]\\]"))
          (backrefs (org-E.backrefs)))
       (seq-uniq
        (append
         ;; local backrefs
         (when (length> lref 0)
           (org-id-select
            :id-set backrefs
            :match (format "+LINK_TAG={%s}" lref)
            :ref-mode t))
         ;; global backrefs
         (when (stringp ref)
           (org-id-select
            :id-set backrefs
            :match (format "+LINK_TAG={%s}" ref)
            :ref-mode t)))))))

  org-E.peers.linktag))
#+end_src

将先前的任务节点以链接标签的形式重新组织如下,可以发现,在此方案下,链接标签节点与具体的任务节点不存在耦合。

* 项目
** 项目A
*** 编码a
:PROPERTIES:
:LINK_TAG: [[#2025-08-28-20-36][32周投入]]
:END:
:LOGBOOK:
CLOCK: [2025-08-18 Mon 09:30]--[2025-08-18 Mon 11:30] =>  2:00
:END:
*** 测试a
:PROPERTIES:
:LINK_TAG: [[#2025-08-28-20-36][32周投入]]
:END:
:LOGBOOK:
CLOCK: [2025-08-20 Wed 14:00]--[2025-08-20 Wed 18:00] =>  4:00
:END:
** 项目B
*** 分析b
:PROPERTIES:
:LINK_TAG: [[#2025-08-28-20-36][32周投入]]
:END:
:LOGBOOK:
CLOCK: [2025-08-22 Fri 10:25]--[2025-08-22 Fri 11:50] =>  1:25
:END:
*** 编码b
:PROPERTIES:
:LINK_TAG: [[#2025-08-28-20-37][33周投入]]
:END:
:LOGBOOK:
CLOCK: [2025-08-25 Mon 09:45]--[2025-08-25 Mon 11:50] =>  2:05
:END:
* 日期
** 32周投入
:PROPERTIES:
:CUSTOM_ID: 2025-08-28-20-36
:END:
** 33周投入
:PROPERTIES:
:CUSTOM_ID: 2025-08-28-20-37
:END:

给定上述 Org 片段,我们重新定义基于链接标签的节点关系与属性,并以这些属性构造报表。

使用基于链接标签的节点属性生成报表:

#+name: 2025-08-29-20-33
#+header: :lexical t :noweb yes
#+header: :var node="[[*项目]]"
#+begin_src emacs-lisp :eval no
(!let ((node node))
 (mapcar
  <<2025-08-29-20-34>>
  (org-E-gen-table
   node
   ;; 基于链接标签的子节点集。
   (lambda (n)
     ;; 节点 n 的子节点
     (seq-uniq
      `(;; 除 Org tree 所有直接子节点外,
        ,@(org-T.peers.children n)
        ;; 还包括所有 linktag 节点。
        ,@(org-E.peers.linktag n))))
   ;; 需提取的属性集
   #'org-E.title `(org-E.clock-sum +))))
#+end_src

格式化 clock 数据的工具:

#+name: 2025-08-29-20-34
#+begin_src emacs-lisp :eval no
;; row: (level node title clock)
(lambda (row)
  (let ((i (nth 2 row))
        (l (nth 0 row))
        (c (nth 3 row)))
    (list
     (if (= l 1) i
       (concat
        "\\_"
        (make-string (* 2 (1- l)) ? ) i
        (make-string (1- l) ?|)))
     (format "%d:%02d"
             (/ c 60) (% c 60)))))
#+end_src

项目视角:

#+call: 2025-08-29-20-33(node="[[*项目]]") :eval no

#+RESULTS:
| 项目        | 9:30 |      |      |
| \_  项目A   |      | 6:00 |      |
| \_    编码a |      |      | 2:00 |
| \_    测试a |      |      | 4:00 |
| \_  项目B   |      | 3:30 |      |
| \_    分析b |      |      | 1:25 |
| \_    编码b |      |      | 2:05 |

日期视角:

#+call: 2025-08-29-20-33(node="[[*日期]]") :eval no

#+RESULTS:
| 日期         | 9:30 |      |      |
| \_  32周投入 |      | 7:25 |      |
| \_    分析b  |      |      | 1:25 |
| \_    测试a  |      |      | 4:00 |
| \_    编码a  |      |      | 2:00 |
| \_  33周投入 |      | 2:05 |      |
| \_    编码b  |      |      | 2:05 |

时间戳集

考虑这样的场景:

你需要了解某段时间范围内执行过的任务,并且以类似甘特图的方式呈现结果,比如:

#+begin_example
123456789|0123456789|0123456789|01
aaa      |        aa|aaaaaaaa  |
bb       |        bb|bbbbbb    |
 cc      |          | cccc c   |
   d     |          |          |
         | ee e     |          |
         |   f      |          |
         |      hhh |          |
         |        j |      jj  |
         |        k |kkkkkk    |
         |          | llll  lll| l
#+end_example
|a|41.94%|[[*Task01]]|
|b|32.26%|[[*Task02]]|
|c|22.58%|[[*Task03]]|
|d|03.23%|[[*Task04]]|
|e|09.68%|[[*Task05]]|
|f|03.23%|[[*Task06]]|
|h|09.68%|[[*Task07]]|
|j|09.68%|[[*Task08]]|
|k|22.58%|[[*Task09]]|
|l|25.81%|[[*Task10]]|

为此,你需要以某种方式指定任务的投入时间,或者说,给定某个任务节点,你能通过某种方式得到一个非连续的时间区间. Org mode 的 clock table 特性一定程度上能实现这种要求,但其以分钟为单位的时间管理粒度有时反而容易因为计时不准、补漏计时等问题演变成“为了跟踪时间而跟踪时间”的心智负担,过分地占据了本该专注于任务的注意力。如果你的需求只是以天为单位跟踪事项,而且恰好你会在任务节点中不时地以 Org时间戳 记录事项相关的日志,你便可以定义一个专门收集时间戳的 Org entry 属性:

属性: #'org-E.timestamps, Org entry 的时间戳集。

#+name: 2025-08-29-20-40
#+header: :lexical t :eval yes :results silent
#+begin_src emacs-lisp
(org-N-defprop timestamps
  (lambda nil
    "Org entry 时间戳。"
    ;; ‘org-element-parser-buffer’ 不会解析
    ;; property-drawer 中的时间戳。我们通过
    ;; props 指定哪些属性的时间戳需要被收集。
    (!let* ((props '("CLOSED" "CLOCK" "CREATE_TIME"))
            (clock (member "CLOCK" props))
            (end (save-excursion
                   (or (outline-next-heading)
                       (org-end-of-subtree))))
            (bs (buffer-substring (point) end))
            (pt (with-temp-buffer
                  (org-mode) (insert bs)
                  (org-element-parse-buffer)))
            ;; Org entry 与 LOGBOOK 中的时间戳
            (get-ts
             (lambda (ts)
               (substring-no-properties
                (org-element-property
                 :raw-value ts))))
            (get-clock-ts
             (lambda (c)
               (get-ts
                (org-element-property :value c))))
            (ts `(,@(org-element-map
                        pt '(timestamp) get-ts)
                  ,@(org-element-map
                        pt (if clock '(clock))
                      get-clock-ts)))
            ;; PROPERTIES 中的时间戳
            (ts `(,@(mapcar
                     (lambda (p)
                       (org-entry-get nil p))
                     (remove "CLOCK" props))
                  ,@ts))
            (ts (remq nil ts))
            (parse-ts
             (lambda (ts)
               (mapcar
                (lambda (it)
                  (float-time
                   (encode-time
                    (org-parse-time-string it))))
                (string-split ts "--"))))
            ;; 对于具备起始时间和结束时间的时间戳,我们
            ;; 以天为单位,填充其中漏掉的日期。
            (fill-range
             (lambda (ts &optional ret)
               (while (time-less-p
                       (car ts) (cadr ts))
                 (push (car ts) ret)
                 (setf (car ts)
                       (time-add
                        (car ts) (* 24 60 60))))
               (push (cadr ts) ret)
               (nreverse ret)))
            (ts (mapcan
                 (lambda (it)
                   (let* ((ts (parse-ts it))
                          (ts (remq nil ts)))
                     (if (length= ts 1) ts
                       (fill-range ts))))
                 ts))
            (ts (remq nil ts)))
     ts))
  :type E)
#+end_src

借助上述定义的时间戳属性,和先前定义的各种结构属性,你可以收集某个节点网络中所有的时间戳:

#+header: :lexical t :results raw :cache yes
#+begin_src emacs-lisp :eval yes
(let* ((id 'f6d4da17-4bcf-4a82-8f3c-12372369e111)
       (node (format "id:%s" id)) table)
  (!def table
   (org-E-gen-table node
       ;; 节点 n 的子节点
       (lambda (n)
         (seq-uniq
          `(;; 除 Org tree 所有直接子节点外,
            ,@(org-T.peers.children n)
            ;; 还包括所有 linktag 节点和
            ,@(org-E.peers.linktag n)
            ;; linktree 节点。
            ,@(org-T.peers.linktree n))))
     #'org-E.timestamps))

  (seq-sort
   #'< (seq-uniq
        (flatten-list
         (mapcan #'last table)))))
#+end_src
#+RESULTS[248ce1fb0219a7c5466769b1bcc0c12444df97f9]:
(946656000.0 1701678300.0 1702015020.0 ...)
1 个赞