Org内联元素扩展

Org 内联元素扩展


背景

当前,我们的笔记系统中有许多时间戳日志——一种时间戳打头的文本片段(如下所示)

[​2025-01-01 Tue 22:04]这是一条时间戳日志。[​2025-01-02 Tue 22:04]这是另一条时间戳日志。[​2025-01-01 Tue 22:04]这三条日志均属于同一段落。

注:这些时间戳日志嵌于 Org段落 中,其所占范围以起于时间戳,止于时间戳。

这些时间戳日志散落于各个项目相关的节点中。有时,我们期望以日期视角浏览这些日志,比如:

为此需要一个收集这些日志的工具。又,因为此类时间戳日志属于某种内联元素,考虑到后续可能有新的内联元素类型,为尽可能复用代码,我们将此收集日志的工具以及一些可复用的模块独立出来,同时加入一些共用的交互功能,以整合为 Org内联元素 (org-inline-element).

在深入 org-inline-element 的实现之前,我们先介绍它的两个应用:时间戳日志 (org-timestamp-fragment) 和 内联条目 (org-inline-entry).

1 个赞

时间戳日志


概述

org-timestamp-fragment, 一个收集时间戳日志的工具。

本文中的“时间戳日志”特指一种嵌于 Org段落 中,以 Org时间戳 打头的文本片段,如下所示:

[​1970-01-01 Thu 22:00]这是一条时间戳日志。[​1970-01-02 Fri 22:04]这是另一条时间戳日志。[​1970-01-01 Thu 22:05]这三条日志均属于同一段落。

另外,时间戳日志所在段落的首个子元素必须是时间戳,换句话说:

因为这个段落的首个子元素非时间戳,故[​1970-01-01 Thu 22:00]这个段落中的时间戳日志属于非法定义,将不纳入收集结果。


交互及入口

作为 Emacs 命令:

M-x org-timestamp-fragment o 收集时间戳日志。
M-x org-timestamp-fragment a 返回当前位置的时间戳日志,如存在。
M-x org-timestamp-fragment h 查询 org-timestamp-fragment API 文档。

作为 elisp 接口:

(org-timestamp-fragment 'occur &rest KARGS)
(org-timestamp-fragment 'at-point)

#+name: 2025-09-13-15-55
#+begin_src emacs-lisp
(lambda (op &rest args)
  (interactive
   `(,(pcase (car (read-multiple-choice
                   "" '((?a "at-point")
                        (?h "help")
                        (?o "occur"))))
        (?a at-point) (?h describe) (?o occur))))
  (ignore at-point describe occur)
  (cond
   ((eq this-command #'org-timestamp-fragment)
    (when (commandp op)
      (call-interactively op)))
   ((and-let* ((_ (symbolp op))
               (f org-timestamp-fragment)
               (f (alist-get op (aref f 2)))
               (_ (functionp f)))
      (apply f args)))))
#+end_src

收集时间戳日志

收集指定范围内的所有时间戳日志。

接口

(org-timestamp-fragment 'occur &rest kargs)

#+name: 2025-09-13-15-56
#+begin: elisp-docstring

KARGS

:date 默认 nil. 如 nil, 试从 Property drawer
中获取,取其首个出现的时间戳。输入格式同 ‘org-read-date’ 的
FROM-STRING 参数。

:with-link 默认 t. 如 t, 带上 TIMESTAMP-FRAGMENT
所在的节点的 ID链接。

:as-paragraph 默认 nil. 如 t, 每个 TIMESTAMP-FRAGMENT
独立成段。

:with-context 默认 nil. 如 t, 提取 TIMESTAMP-FRAGMENT
所在的整个段落,段落中未匹配的 FRAGMENT 同样纳入收集结果。

#+end:

示例

给定如下时间戳日志(两个段落):

[1970-01-01 Thu 22:00]这是一条时间戳日志。[1970-01-02 Fri 22:04]这是另一条时间戳日志。[1970-01-01 Thu 22:05]这三条日志均属于同一段落。

[1970-01-03 Sat 22:10]这是一条时间戳日志。[1970-01-02 Fri 22:40]这是另一条时间戳日志。[1970-01-01 Thu 22:11]这三条日志均属于同一段落。

通过 elisp 收集: (org-timestamp-fragment 'occur :date "1970-01-01") 将返回如下文本:

"[​1970-01-01 Thu 22:00]这是一条时间戳日志。[​1970-01-01 Thu 22:05]这三条日志均属于同一段落。\n\n[​1970-01-01 Thu 22:11]这三条日志均属于同一段落。"

通过 Emacs 命令收集: M-x org-timestamp-fragment o 1970-1-1 RET 将弹出带有如下内容的窗口:

[​1970-01-01 Thu 22:00]这是一条时间戳日志。[​1970-01-01 Thu 22:05]这三条日志均属于同一段落。

[​1970-01-01 Thu 22:11]这三条日志均属于同一段落。

通过 Org 动态块收集:

#+begin: ts-text :date "1970-01-01"
[1970-01-01 Thu 22:00]这是一条时间戳日志。[1970-01-01 Thu 22:05]这三条日志均属于同一段落。

[1970-01-01 Thu 22:11]这三条日志均属于同一段落。
#+end:

注:值得注意的是,有些的日志被过滤掉了。换句话说, org-timestamp-fragment 在收集日志的过程中裁剪掉了段落的某些部分。

配置参数对收集结果的影响:

:with-link

#+begin: ts-text :date "1970-01-01" :with-link t
ID-LINK: [1970-01-01 Thu 22:00]这是一条时间戳日志。[1970-01-01 Thu 22:05]这三条日志均属于同一段落。

ID-LINK: [1970-01-01 Thu 22:11]这三条日志均属于同一段落。
#+end:

:as-paragraph

#+begin: ts-text :date "1970-01-01" :as-paragraph t
[1970-01-01 Thu 22:00]这是一条时间戳日志。

[1970-01-01 Thu 22:05]这三条日志均属于同一段落。

[1970-01-01 Thu 22:11]这三条日志均属于同一段落。
#+end:

:with-context

#+begin: ts-text :date "1970-01-01" :with-context t
[1970-01-01 Thu 22:00]这是一条时间戳日志。[1970-01-02 Fri 22:04]这是另一条时间戳日志。[1970-01-01 Thu 22:05]这三条日志均属于同一段落。

[1970-01-03 Sat 22:10]这是一条时间戳日志。[1970-01-02 Fri 22:40]这是另一条时间戳日志。[1970-01-01 Thu 22:11]这三条日志均属于同一段落。
#+end:

实现

借助 (org-inline-element 'occur)

occur

#+name: 2025-09-13-16-09
#+begin_src emacs-lisp :eval no
;; depends: here?
(lambda (&rest kargs)
  "Org timestamp fragment occur.

<<@([[id:org-timestamp-fragment::doc:occur]])>>"
  (interactive (list :date (org-read-date)))
  (let* ((date (plist-get kargs :date))
         (date
          (or (and date (org-read-date
                         nil t date))
              ;; property drawer 中的首个时间戳
              (let ((r (org-get-property-block)))
                (ignore-errors
                  (encode-time
                   (org-parse-time-string
                    (buffer-substring
                     (car r) (cdr r))))))
              (error "Not date found")))
         (date (format-time-string
                "%Y-%m-%d" date)))
    (unless (plist-member kargs :with-link)
      (plist-put kargs :with-link t))
    (apply
     (org-inline-element 'occur)
     (format "\\[%s[^]]*\\]." date)
     here?
     :popup
     (when (or (plist-get kargs :popup)
               (called-interactively-p
                'interactive))
       (format "org-timestamp-fragment:%s" date))
     :key
     (or (plist-get kargs :key)
         (lambda (e)
           (float-time
            (encode-time
             (org-parse-time-string e)))))
     ;; here? 内部用
     :--id (org-entry-get nil "ID" t)
     kargs)))
#+end_src

here?

#+name: 2025-09-13-16-10
#+begin_src emacs-lisp :eval no
;; depends: at-point
(lambda (kargs)
  (!let ((-p (plist-get kargs :as-paragraph))
         (-c (plist-get kargs :with-context))
         (-l (plist-get kargs :with-link))
         (--id (plist-get kargs :--id))
         (--lp (plist-get kargs :--last-par))
         ;; 不匹配那些自动生成于动态块中的文本。
         (exclude '(dynamic-block))
         (id (org-entry-get nil "ID" t))
         (e (org-element-context)) ie par ts item)
   (unless (or (org-element-lineage e exclude)
               ;; external input: entry-id.
               (and --id (equal id --id))
               (not (setq ie (at-point))))
     (setq ts (org-element-property :raw-value e))
     ;; 记录当前段落的范围。
     (setq par (org-element-parent e)
           par `(,(org-element-begin par)
                 ,(org-element-end par)))
     (setq e (if (not -c) ie
               ;; 如 with-context, 返回整个段落。
               (save-restriction
                 (org-narrow-to-element)
                 (org-element-map
                     (org-element-parse-buffer)
                     '(paragraph)
                   #'identity nil t))))
     `(,(org-element-end e)
       ,(let* ((p (org-element-interpret-data e))
               (p (string-trim p)))
          (concat
           ;; ID-LINK
           (cond
            ((equal --lp par) "")
            ((not (and -l id)) "")
            ((let ((item
                    (org-entry-get
                     (org-find-entry-with-id id)
                     "ITEM")))
               (format "[[id:%s::%s][%s]]: "
                       id (substring ts 1 -1)
                       (if (length= item 0)
                           "@" item)))))
           p))
       ,(if -p nil (equal --lp par))
       ,(plist-put kargs :--last-par par)))))
#+end_src

提取时间戳日志

提取当前位置的时间戳日志。

接口

(org-timestamp-fragment 'at-point)

#+name: 2025-09-13-16-20
#+begin: elisp-docstring

如当前位置有 TIMESTAMP-FRAGMENT, 返回该元素,否则返 nil.

#+end:

实现

关于时间戳日志的范围:

(边界元素 边界元素之间的元素…)(边界元素 边界元素之间的元素…)(边界元素…)

括号中的元素属于同一条时间戳日志。界定时间戳日志的范围后, at-point 将 narrow 至该区域,然后用 org-element 解析并返回区域中的 paragraph 元素。

at-point

#+name: 2025-09-13-16-21
#+begin_src emacs-lisp :eval no :noweb yes
(lambda nil
  "Org timestamp fragment at point.

<<@([[id:org-timestamp-fragment::doc:at-point]])>>"
  (interactive)
  (and-let*
      ((e (org-element-context))
       (_ (org-element-type-p e '(timestamp)))
       ;; 边界元素。
       (B '(timestamp export-snippet))
       ;; 判断当前 timestamp-fragment 的父级元素是否
       ;; paragraph. org-data/section/paragraph.
       (p (save-restriction
            (org-narrow-to-element)
            (org-element-parse-buffer)))
       (p (car (org-element-contents p)))
       (p (car (org-element-contents p)))
       (_ (org-element-type-p p '(paragraph)))
       (C (org-element-contents p))
       ;; 段落的首元素为边界元素。
       (_ (org-element-type-p (car C) B))
       ;; 界定当前 timestamp-fragment 范围。
       ;; 这里,我们仅用 begin 界定,更完备的方法是同时
       ;; 结合 end 及 buffer, 但暂无必要。
       (beg #'org-element-begin)
       (e= (lambda (&rest a)
             (apply #'equal (mapcar beg a))))
       (i (seq-position C e e=)))
    ;; 从当前 timestamp 起,到段落尾或边界元素止,之间
    ;; 的元素都属于当前 timestamp-fragment 的组成部分。
    (let* ((C (nthcdr (1+ i) C))
           (end? (lambda (e _)
                   (org-element-type-p e B)))
           (i (seq-position C t end?))
           (beg (org-element-begin e))
           (end (if i (org-element-begin (nth i C))
                  (org-element-contents-end p)))
           (e (save-restriction
                (narrow-to-region beg end)
                (org-element-map
                    (org-element-parse-buffer)
                    '(paragraph)
                  #'identity nil t))))
      (when (called-interactively-p 'any)
        (setq e (org-element-interpret-data e))
        (message "%s" (string-trim e))
        (kill-new e))
      e)))
#+end_src

Org动态块

收集时间戳日志用的 Org动态块 ts-text:

#+name: 2025-09-13-16-22
#+begin_src emacs-lisp :eval no
(!def 'org-dblock-write:ts-text
 (lambda (p)
   (delete-char -1)
   (and-let* ((content (apply occur p)))
     (insert "\n" content))))
(org-dynamic-block-define
 "ts-text"
 (lambda nil (insert "#+begin: ts-text\n#+end:")))
#+end_src

整体结构

#+name: 2025-09-13-16-23
#+begin_src emacs-lisp :eval no :noweb yes
;;; org-timestamp-fragment -*- lexical-binding: t; -*-
(!def 'org-timestamp-fragment
 (!let (org-timestamp-fragment
        describe at-point occur
        here?)

;;;; org-timestamp-fragment
  (!def org-timestamp-fragment
   <<@([[id:org-timestamp-fragment::org-timestamp-fragment]])>>)

;;;; describe
  (!def describe
   (org-inline-element
    'describe "org-timestamp-fragment"
    org-timestamp-fragment))

;;;; at-point
  (!def at-point
   <<@([[id:org-timestamp-fragment::at-point]])>>)

;;;; occur
  (!def occur
   <<@([[id:org-timestamp-fragment::occur]])>>)

;;;; here?
  (!def here?
   <<@([[id:org-timestamp-fragment::here?]])>>)

;;;; dblock: ts-text
  <<@([[id:org-timestamp-fragment::dblock]])>>

;;; end
  ;; 以时间戳定位需要配置此参数。
  (setq org-link-search-must-match-exact-headline nil)

  (org-inline-element 'add ?t 'org-timestamp-fragment)

  org-timestamp-fragment))
#+end_src

构建:时间戳日志

占位。


Org内联条目


概述

org-inline-entry, 一个收集内联条目的工具。

内联条目的定义

内联条目 (inline entry), 一种嵌于 Org段落 中的 Org entry, 其实现原理是将表示 Org entry 的 sexp 与 Org export-snippet 相结合:

@​@entry:ENTRY-SEXP@@

或简写为

@​@-:ENTRY-SEXP@@

其中,表示 Org entry 的 ENTRY-SEXP 定义为:

ENTRY-SEXP := ITEM
           := (ITEM PROPERTIES)
           := (ITEM CONTENT)
           := (ITEM PROPERTIES CONTENT)
PROPERTIES := (P V P V ...)
P|V        := STRING|SYMBOL ...
CONTENT    := STRING|SYMBOL ...

另外, @​@-:ENTRY-SEXP@@ 后跟的所有非内联条目的 Org元素 也属于内联条目的内容,即:

@​@-:(E1 这是一个内联条目。)@@这里也属于 E1 的内容。@​@-:(E2 这是另一个内联条目。)@@这里也属于 E2 的内容。@​@-:(E3 这又是一个内联条目。)@@这里也属于 E3 的内容。@​@-:(E4 这还是一个内联条目。)@@这里也属于 E4 的内容。

一些 Org inline entry 所对应的 Org entry 的例子:

(!let ((+ (lambda (&rest s)
            (string-join s "\n")))
       (ie->e ;; inline entry 转 entry
        (lambda (s)
          (org-inline-entry
           'interpret-as-entry
           (with-temp-buffer
             (org-mode)
             (save-excursion
               (insert s))
             (org-inline-entry
              'at-point)))))
       ie&e)
 ;; Org inline entry 与 Org entry 的对应
 (!def ie&e
  `(;; test case 1
    (,(+ "@@entry:TITLE@@")
     ,(+ "* TITLE"))
    ;; test case 2
    (,(+ "@@entry:T@@CONTENT")
     ,(+ "* T"
         "CONTENT"))
    ;; test case 3
    (,(+ "@@-:(T C1 C2)@@C3 C4")
     ,(+ "* T"
         "C1C2C3 C4"))
    ;; test case 4
    (,(+ "@@-:(T(P1 V1 P2 V2)C1)@@C2")
     ,(+ "* T"
         ":PROPERTIES:"
         ":P1: V1"
         ":P2: V2"
         ":END:"
         "C1C2"))
    ;; test case 4
    (,(+ "@@-:(T(TAGS a:b:c)1 2)@@3")
     ,(+ "* T :a:b:c:"
         "123"))
    ;; test case 5
    (,(+ "@@-::a:b:c:@@")
     ,(+ "* :a:b:c:"))))
 (seq-every-p
  #'identity
  (seq-mapn
   #'equal
   (seq-map ie->e (seq-map #'car ie&e))
   (seq-map #'cadr ie&e))))
;; => t

衍生应用:内联标签

通过 org-inline-entry, 你可以结合 Org Match String 提取、收集匹配到的文本片段,以实现某种“内联标签”功能。比如给定如下文本:

@​@-::t1:@@被 t1 标记的文本。@​@-::t2:@@被 t2 标记的文本。@​@-::t3:@@被 t3 标记的文本。@​@-::t4:@@被 t4 标记的文本。@​@-:@@[​2025-01-01 Wed]时戳文本。

通过如下代码提取“被 t2 标记”或“带时间戳”的文本片段:

(let ((m "t2|TIMESTAMP_IA={2025-01}"))
  (with-temp-buffer
    (org-mode)
    (insert
     "@@-::t1:@@被 t1 标记的文本。"
     "@@-::t2:@@被 t2 标记的文本。"
     "@@-::t3:@@被 t3 标记的文本。"
     "@@-::t4:@@被 t3 标记的文本。"
     "@@-:@@[2025-01-01 Wed]时戳文本。")
    (org-element-map
        (org-element-parse-buffer)
        '(export-snippet)
      (lambda (e)
        (org-with-point-at
            (org-element-begin e)
          (and-let*
              ((ie (org-inline-entry
                    'match? m)))
            (substring-no-properties
             (string-trim
              (org-element-interpret-data
               ie)))))))))
;; =>
;; ("@@-::t2:@@被 t2 标记的文本。"
;;  "@@-:@@[2025-01-01 Wed]时戳文本。")

交互及接口

作为 Emacs 命令:

M-x org-inline-entry o 收集内联条目。
M-x org-inline-entry i 插入内联条目。
M-x org-inline-entry a 返回当前位置的内联条目,如存在。
M-x org-inline-entry h 查询 org-inline-entry API 文档。

作为 elisp 接口:

(org-inline-entry 'occur &rest KARGS)
(org-inline-entry 'at-point)
(org-inline-entry 'match? MATCH)
(org-inline-entry 'interpret-as-entry INLINE-ENTRY)

#+name: 2025-09-13-16-28
#+begin_src emacs-lisp :eval no
(lambda (op &rest args)
  (interactive
   `(,(pcase (car (read-multiple-choice
                   "" '((?o "occur")
                        (?i "insert")
                        (?a "at-point")
                        (?h "help"))))
        (?o occur) (?i insert)
        (?a at-point) (?h describe))))
  (ignore at-point insert describe occur
          interpret-as-entry match?)
  (cond
   ((and (eq this-command #'org-inline-entry)
         (commandp op))
    (let ((this-command op))
      (call-interactively op)))
   ((and-let* ((_ (symbolp op))
               (f org-inline-entry)
               (f (alist-get op (aref f 2)))
               (_ (functionp f)))
      (apply f args)))))
#+end_src

收集内联条目

接口

(org-inline-entry 'occur &rest kargs)

#+name: 2025-09-13-16-29
#+begin: elisp-docstring

KARGS

:match 默认 nil. Org Match String.

:as-paragraph 默认 nil. 如 t, 每个 INLINE-ENTRY 独立
成段。

:as-entry 默认 nil. 如 t, 将 INLINE-ENTRY 格式化
为 Org entry.

:with-meta-data 默认 t. 如 nil, 移除 INLINE-ENTRY 的元
数据部分,即:被 export-snippet 界定的部分。

#+end:

使用示例

给定如下段落:

@@-:(E1 这是一个内联条目。)@@这里也属于 E1 的内容。@@-:(E2 这是另一个内联条目。)@@这里也属于 E2 的内容。@@-:(E3 这又是一个内联条目。)@@这里也属于 E3 的内容。@@-:(E4 这还是一个内联条目。)@@这里也属于 E4 的内容。

通过 elisp 收集: (org-inline-entry 'occur :match "+ITEM={E2}|+ITEM={E4}") 将返回如下文本:

"@​@-:(E2 这是另一个内联条目。)@@这里也属于 E2 的内容。@​@-:(E4 这还是一个内联条目。)@@这里也属于 E4 的内容。"

通过 Emacs 命令收集: M-x org-inline-entry o +ITEM={E2}|+ITEM={E4} n RET 将弹出带有如下内容的窗口:

@​@-:(E2 这是另一个内联条目。)@@这里也属于 E2 的内容。@​@-:(E4 这还是一个内联条目。)@@这里也属于 E4 的内容。

通过 Org 动态块收集:

#+begin: i-entry :match "+ITEM={E2}|+ITEM={E4}"
@​@-:(E2 这是另一个内联条目。)@@这里也属于 E2 的内容。@​@-:(E4 这还是一个内联条目。)@@这里也属于 E4 的内容。
#+end:

配置参数对收集结果的影响:

:as-paragraph

#+begin: i-entry :match "+ITEM={E2}|+ITEM={E4}" :as-paragraph t
@​@-:(E2 这是另一个内联条目。)@@这里也属于 E2 的内容。

@​@-:(E4 这还是一个内联条目。)@@这里也属于 E4 的内容。
#+end:

:as-entry

#+begin: i-entry :match "+ITEM={E2}|+ITEM={E4}" :as-entry t
​* E2
这是另一个内联条目。这里也属于 E2 的内容。

​* E4
这还是一个内联条目。这里也属于 E4 的内容。
#+end:

:with-meta-data

#+begin: i-entry :match "+ITEM={E2}|+ITEM={E4}" :with-meta-data nil
这里也属于 E2 的内容。这里也属于 E4 的内容。
#+end:

实现

occur

#+name: 2025-09-13-16-31
#+begin_src emacs-lisp :eval no
;; depends: here?
(lambda (&rest kargs)
  "Org inline entry occur.

<<@([[id:org-inline-entry::doc:occur]])>>"
  (interactive
   (list :match (read-string "Match: ")
         :as-entry (y-or-n-p "As Org entry: ")))
  (unless (plist-member kargs :with-meta-data)
    (setf (plist-get kargs :with-meta-data) t))
  (apply
   (org-inline-element 'occur)
   "@@\\(?:entry\\|-\\):.*?@@."
   here?
   :popup
   (when (or (plist-get kargs :popup)
             (called-interactively-p
              'interactive))
     (format "org-inline-entry:%s"
             (plist-get kargs :match)))
   kargs))
#+end_src

here?

#+name: 2025-09-13-16-32
#+begin_src emacs-lisp :eval no
;; depends: match?
(lambda (kargs)
  (let ((-p (plist-get kargs :as-paragraph))
        (-e (plist-get kargs :as-entry))
        (-m (plist-get kargs :with-meta-data))
        (--lp (plist-get kargs :--lp))
        (match (plist-get kargs :match))
        ;; 不匹配那些自动生成于动态块中的文本。
        (exclude '(dynamic-block))
        (e (org-element-context)) ie par)
    (unless (or (org-element-lineage e exclude)
                (not (setq ie (match? match))))
      (setq par (org-element-parent e)
            par `(,(org-element-begin par)
                  ,(org-element-end par)))
      `(,(org-element-end ie)
        ,(cond
          (-e (interpret-as-entry ie))
          (-m (org-element-interpret-data ie))
          ((org-element-interpret-data
            (cdr (org-element-contents ie)))))
        ,(if (or -p -e) nil (equal --lp par))
        ,(plist-put kargs :--lp par)))))
#+end_src

match?

#+name: 2025-09-13-16-33
#+begin: elisp-docstring

判断当前位置的元素是否为被 MATCH 所匹配中的 INLINE-ENTRY,
是则返回该 INLINE-ENTRY, 否则返 nil.

#+end:

#+name: 2025-09-13-16-34
#+begin_src emacs-lisp :eval no :noweb yes
;; depends: at-point, interpret-as-entry
(lambda (match)
  "Org inline entry match.

<<@([[id:org-inline-entry::doc:match?]])>>"
  (and-let*
      ((e (at-point))
       (_ (with-temp-buffer
            (org-mode)
            (save-excursion
              (!let ((insert nil))
               (insert (interpret-as-entry e))))
            (org-map-entries t match)))
       (_ e))))
#+end_src

interpret-as-entry

#+name: 2025-09-13-16-35
#+begin: elisp-docstring

将 INILNE-ENTRY 格式化为 Org entry.

INLINE-ENTRY 由 (org-inline-entry 'at-point) 提取。

#+end:

#+name: 2025-09-13-16-36
#+begin_src emacs-lisp :eval no :noweb yes
(lambda (inline-entry)
  "Org inline entry interpret as Org entry.

<<@([[id:org-inline-entry::doc:interpret-as-entry]])>>"
  (let* ((e inline-entry)
         (C (org-element-contents e))
         (e (car C))
         (e (org-element-property :value e))
         (e (or (ignore-errors (read e)) ""))
         (C (org-element-interpret-data (cdr C)))
         (C (string-trim C))
         (e (if (listp e) e `(,e)))
         (entry `(,@e ,C))
         (item (car entry))
         (entry (if (listp item) entry
                  (cdr entry)))
         (item (unless (listp item) item))
         (item (if item (format "%s" item) ""))
         (properties (car entry))
         (properties (if (listp properties)
                         properties))
         (entry (if properties (cdr entry) entry))
         (content
          (string-join
           (mapcar
            (lambda (content)
              (cond
               ((stringp content) content)
               ((format "%s" content))
               ("")))
            entry)))
         (tags (plist-get properties 'TAGS))
         (tags (if tags
                   (format
                    ":%s:"
                    (string-join
                     (string-split
                      (format "%s" tags) "[:-]" t)
                     ":"))
                 ""))
         (properties
          (org-plist-delete properties 'TAGS))
         (properties
          (if properties
              (string-join
               `(":PROPERTIES:"
                 ,@(mapcar
                    (lambda (kv)
                      (apply
                       #'format ":%S: %s" kv))
                    (seq-partition properties 2))
                 ":END:")
               "\n")
            "")))
    (when (length> tags 0)
      (setq item (concat item " ")))
    (substring-no-properties
     (string-trim
      (string-join
       (seq-remove
        #'string-empty-p
        `(,(format "* %s%s" item tags)
          ,properties ,content))
       "\n")))))
#+end_src

插入内联条目

为免去手动录入 @@ 等标记字符, org-inline-entry 提供如下接口与命令,在当前位置插入内联条目。

接口

(org-inline-entry 'insert "INLINE-ENTRY")

使用示例

插入标签: M-x org-inline-entry i t a:b RET

=> @​@-::a:b:@@

插入条目: M-x org-inline-entry i e Item1 RET

=> @​@-:Item1@@

实现

insert

#+name: 2025-09-13-16-41
#+begin_src emacs-lisp :eval no
(lambda (s)
  "Org inline entry insert at point."
  (interactive
   `(,(pcase (car (read-multiple-choice
                   "" '((?e "entry" "")
                        (?t "tags" ""))))
        (?e (read-string "Entry: "))
        (?t (format
             ":%s:" (string-join
                     (string-split
                      (read-string "Tags: ")
                      "[ \t-::,,]" t)
                     ":"))))))
  (!let (insert) (insert (format "@@-:%s@@" s))))
#+end_src

提取内联条目

提取当前位置的内联条目。

接口

(org-inline-entry 'at-point)

#+name: 2025-09-13-16-43
#+begin: elisp-docstring

如当前位置有 INLINE-ENTRY, 返回该元素,否则返 nil.

#+end:

实现

at-point

#+name: 2025-09-13-16-44
#+begin_src emacs-lisp :eval no
(lambda nil
  "Org inline entry at point.

<<@([[id:org-inline-entry::doc:at-point]])>>"
  (interactive)
  (and-let*
      ((e (org-element-context))
       (_ (inline-entry? e))
       ;; 判断当前 inline-entry 的父级元素是否为
       ;; paragraph. org-data/section/paragraph.
       (p (save-restriction
            (org-narrow-to-element)
            (org-element-parse-buffer)))
       (p (car (org-element-contents p)))
       (p (car (org-element-contents p)))
       (_ (org-element-type-p p '(paragraph)))
       ;; 界定当前 inline-entry 范围。
       ;; 这里,我们仅用 begin 界定,更完备的方法是同
       ;; 时结合 end 及 buffer, 但暂无必要。
       (C (org-element-contents p))
       (beg #'org-element-begin)
       (e= (lambda (&rest a)
             (apply #'equal (mapcar beg a))))
       (i (seq-position C e e=)))
    ;; 从当前 inline-entry 起,到下个 inline-entry
    ;; 或段落尾止,之间的元素都属于当前 内联条目 的组成
    ;; 部分。
    (let* ((C (nthcdr (1+ i) C))
           (i (seq-position C t inline-entry?))
           (beg (org-element-begin e))
           (end (if i (org-element-begin (nth i C))
                  (org-element-contents-end p)))
           (e (save-restriction
                (narrow-to-region beg end)
                (org-element-map
                    (org-element-parse-buffer)
                    '(paragraph)
                  #'identity nil t))))
      (when (called-interactively-p 'any)
        (setq e (org-element-interpret-data e))
        (message "%s" (string-trim e))
        (kill-new e))
      e)))
#+end_src

inline-entry?

#+name: 2025-09-13-16-45
#+begin_src emacs-lisp :eval no
(lambda (e &optional _)
  (and (org-element-type-p e '(export-snippet))
       (member (org-element-property :back-end e)
               '("entry" "-"))))
#+end_src

Org动态块

收集内联条目用的 Org动态块 i-entry:

#+name: 2025-09-13-16-46
#+begin_src emacs-lisp :eval no
(!def 'org-dblock-write:i-entry
 (lambda (p)
   (delete-char -1)
   (when-let* ((content (apply occur p)))
     (!let (insert) (insert "\n" content)))))
(org-dynamic-block-define
 "i-entry"
 (lambda nil
   (!let (insert)
    (insert "#+begin: i-entry\n#+end:"))))
#+end_src

整体结构

#+name: 2025-09-13-16-47
#+begin_src emacs-lisp :eval no :noweb yes
;;; org-inline-entry  -*- lexical-binding: t; -*-
;; @@entry:(TITLE(P1 V1 P2 V2 ...)"CONTENT" ...)@@
;; @@entry:TITLE@@
;; @@entry:(TITLE(P1 V1 TAGS a:b:c)CONTENT "ff")@@
;; @@entry:((TAGS a-b-c))@@
;; @@entry:(:a:b:c: january)@@
;; @@entry:(:a:b:c:)@@
;; @@entry:(TITLE(P1 V1 P2 V2 TAGS a:b:c)CONTENT)@@
;; @@-::a:b:c:@@
(!def 'org-inline-entry
 (!let (org-inline-entry
        at-point describe occur insert
        interpret-as-entry match?
        inline-entry? here?)

;;;; org-inline-entry
  (!def org-inline-entry
   <<@([[id:org-inline-entry::org-inline-entry]])>>)

;;;; describe
  (!def describe
   (org-inline-element
    'describe "org-inline-entry" org-inline-entry))

;;;; at-point
  (!def at-point
   <<@([[id:org-inline-entry::at-point]])>>)

;;;; insert
  (!def insert
   <<@([[id:org-inline-entry::insert]])>>)

;;;; occur
  (!def occur
   <<@([[id:org-inline-entry::occur]])>>)

;;;; here?
  (!def here?
   <<@([[id:org-inline-entry::here?]])>>)

;;;; dblock: i-entry
  <<@([[id:org-inline-entry::dblock]])>>

;;;; interpret-as-entry
  (!def interpret-as-entry
   <<@([[id:org-inline-entry::interpret-as-entry]])>>)

;;;; match?
  (!def match?
   <<@([[id:org-inline-entry::match?]])>>)

;;;; inline-entry?
  ;; @@entry:...@@ 或 @@-:...@
  (!def inline-entry?
   <<@([[id:org-inline-entry::inline-entry?]])>>)

;;;; end
  (org-inline-element 'add ?e 'org-inline-entry)

  org-inline-entry))
#+end_src

构建:内联条目

占位。


Org内联元素


概述

org-inline-element, 一个收集内联元素的工具。

本文中的“内联元素”特指一种嵌于 Org段落 中的文本/元素,非 org-element 中的 inline-call, inline-src-block.


交互及接口

作为 Emacs 命令:

org-inline-element h 查询 org-inline-element API 文档。
org-inline-element Key 触发具体的内联元素子命令。

作为 elisp 接口:

(org-inline-element 'add KEY ELEMENT)
(org-inline-element 'occur &optional HERE? &rest KARGS)
(org-inline-element 'describe PKGNAME FN SYMS)

作为与所有内联元素交互的入口, org-inline-element 需要将控制流转发给具体的内联元素。控制流的转发逻辑由其内部的 dispatch 实现。

dispatch

#+name: 2025-09-13-16-50
#+begin_src emacs-lisp :eval no
;; depends: dispatch, elements
(lambda (op &rest args)
  (interactive
   `(,(let* ((E elements)
             (E (mapcar
                 (lambda (k) (assoc k E))
                 (seq-uniq (mapcar #'car E))))
             (C (seq-mapn
                 #'list
                 (mapcar #'car E)
                 (mapcar
                  (lambda (cmd)
                    (string-trim
                     (format "%s" (cdr cmd))
                     "org-"))
                  E)))
             (c (read-multiple-choice "" C)))
        (alist-get (car c) E))))
  (cond
   ((and (eq this-command dispatch)
         (commandp op))
    (let ((this-command op))
      (call-interactively op)))
   ((functionp op) (apply op args))))
#+end_src

其中, 变量 elements 将 具体的内联元素 与 某个按键 相关联。


添加内联元素

通过 (org-inline-element 'add KEY ELEMENT) 可以将新的内联元素 ELEMENT 添加到 elements 中,并将之与 KEY 绑定,如:

(org-inline-element 'add ?e #'org-inline-entry)

绑定后, M-x org-inline-element e 将调用 #'org-inline-entry.

(org-inline-element 'add KEY ELEMENT)

#+name: 2025-09-13-16-51
#+begin: elisp-docstring

新增 INLINE-ELEMENT, 将 ELEMENT 绑定到 KEY 上。

KEY: 字符,如 ?e.
ELEMENT: 代表 INLINE-ELEMENT 的符号。

如:

(org-inline-element 'add ?e #'org-inline-entry).

ELEMENT 为 具体的 INLINE-ELEMENT 的实现,其本身同时是一
个 Emacs 命令, 可通过 M-x org-inline-element KEY 触发。

如 KEY 已有关联,ELEMENT 将覆盖旧关联。

KEY ?h 保留内部用。

#+end:

add

#+name: 2025-09-13-16-52
#+begin_src emacs-lisp :eval no :noweb yes
;; depends: elements
(lambda (key element)
  "Org inline element add.

<<@([[id:org-inline-element::doc:add]])>>"
  ;; ?h 保留内部用
  (when (or (not (equal key ?h))
            (null (alist-get ?h elements)))
    (setf (alist-get key elements)
          element)))
#+end_src

通过正则收集

org-inline-element 的原始目的在于收集时间戳日志,它内部的函数 occur 借助正则在多个 region 中定位、收集内联元素。

注:在无歧义的情况下,后文中出现的 occur 均指 org-inline-element occur.

occur 并非一个 Emacs 命令,而是一个为收集具体的内联元素的文本而设计的共用函数,具体的内联元素可通过 occur 实现其文本收集功能。通常而言,具体的内联元素,比如 org-timestamp-fragment, 有其自身的 occur 命令/接口,比如:

(org-timestamp-fragment 'occur &rest KARGS)

org-timestamp-fragment.occur 将借助 org-inline-element.occur 收集时间戳日志。

(org-inline-element 'occur RE &optional HERE? &rest KARGS)

#+name: 2025-09-13-16-55
#+begin: elisp-docstring

INLINE-ELEMENT 收集工具。

RE: INLINE-ELEMENT 正则表达式。
HERE?: 判断当前位置是否为目标 INLINE-ELEMENT 的函数。
KARGS: 控制参数。

HERE? 输入 KARGS, 输出 (next-pos context-text merge?)
或 nil, 其实现由具体的内联元素定义。其中,

next-pos 表示下个查找位置; context-text 表示匹配到的文本上
下文; merge? 表示是否将当前文本上下文与上次匹配的文本上下文合
并,可选。

如当前位置非目标 INLINE-ELEMENT, HERE? 应返 nil. 此外,除
‘org-inline-element’ 所保留的关键字参数外(见下文), 其他
的 KARGS 将传递给 HERE?.

HERE? 可 nil, nil 时将收集 RE 所匹配到的字符串。

KARGS 包括 :key :sep :scope :popup.

:key 文本片段排序用,默认 nil.

:sep 文本片段的分隔符,默认 “\n\n”.

:scope 查找范围,默认 nil. nil 时查找当前 buffer; 'all
时查找所有 Org buffer. '(id ID …) 查找 ID 指定的 Org
entry.

:popup 将查找结果以弹窗形式呈现,默认 nil. 可 t 可 string,
string 时表示弹窗的 buffer 名。

返回内联元素的收集结果,以字符串形式,:popup 为 nil 时。

#+end:

occur

#+name: 2025-09-13-16-56
#+begin_src emacs-lisp :eval no :noweb yes
;; depends: gen-regions, popup
(lambda (re &optional here? &rest kargs)
  "Org inline element occur.

<<@([[id:org-inline-element::doc:occur]])>>"
  (unless (plist-get kargs :scope)
    (pcase current-prefix-arg
      ('(4)
       (setf (plist-get kargs :scope) 'all))))
  (!let* ((key (plist-get kargs :key))
          (popup? (plist-get kargs :popup))
          (\n (or (plist-get kargs :sep) "\n\n"))
          ;; 根据配置参数生成 regions.
          (R (gen-regions kargs))
          ;; 删除保留的关键字参数。
          (kargs (org-plist-delete kargs :key))
          (kargs (org-plist-delete kargs :popup))
          (kargs (org-plist-delete kargs :sep))
          (kargs (org-plist-delete kargs :scope))
          (m (make-marker)) (here? here?) p P e)
   (dolist (r R)
     (org-with-point-at
         (set-marker m (cadr r) (car r))
       (setq e (caddr r))
       (while (and (< (point) e)
                   (re-search-forward re e t))
         (org-with-point-at (match-beginning 0)
           ;; p: (next-pos text merge?)
           (cond
            ((null here?)
             (setq p `(,(match-end 0)
                       ,(match-string 0)))
             (push (string-trim (cadr p)) P))
            ((not (setq p (here? kargs))))
            ;; merge?
            ((and (length> p 2) (nth 2 p))
             (push
              (concat
               (pop P) (string-trim (cadr p)))
              P))
            ((push (string-trim (cadr p)) P))))
         (when p (goto-char (car p))))))
   (cond
    ((length= P 0) nil)
    ((ignore
      (setq P (nreverse P))
      (setq P (if key (sort P :key key) P))
      (setq p (string-join P \n))))
    ((not popup?) p)
    ((popup p popup? (length R) (length P))))))
#+end_src

生成区域集

根据配置参数生成一个区域集, occur 将在这些区域中查找内联元素。

gen-regions

#+name: 2025-09-13-16-57
#+begin_src emacs-lisp :eval no
(lambda (kargs)
  ;; R: list of (buffer start end).
  (let ((scope (plist-get kargs :scope)) R)
    (!def R
     (cond
      ((eq (car-safe scope) 'id)
       (mapcar
        (lambda (id)
          (when-let*
              ((m (org-id-find id t)) (e t))
            (org-with-point-at m
              (setq e (org-element-at-point))
              `(,(current-buffer)
                ,(org-element-begin e)
                ,(org-element-end e)))))
        (cdr scope)))
      ((eq scope 'all)
       (mapcar
          (lambda (b)
            (with-current-buffer b
              (when (and
                     (derived-mode-p 'org-mode)
                     (buffer-file-name))
                `(,b 1 ,(buffer-end 1)))))
          (buffer-list)))
      (`((,(current-buffer)
          1 ,(buffer-end 1))))))
    (delq nil R)))
#+end_src

收集结果弹窗

occur 通常会返回一个字符串,作为内联元素收集的结果;不过有时候需要将 occur 收集的结果以弹窗形式呈现,类似于 multi-occur, 为此, occur 中包含如下工具函数:

popup

#+name: 2025-09-13-16-58
#+begin_src emacs-lisp :eval no
(lambda (text buf Rn Pn)
  (setq buf (or buf "org-inline-element occur"))
  (with-current-buffer (get-buffer-create buf)
    (erase-buffer)
    (insert text)
    (org-mode)
    (message "%s matched in %d regions." Pn Rn)
    (let ((wcnf (current-window-configuration)))
      (pop-to-buffer (current-buffer))
      (keymap-local-set
       "q" (lambda nil (interactive)
             (kill-buffer buf)
             (set-window-configuration wcnf))))
    nil))
#+end_src

文档工具

org-inline-element 的 API 文档可通过

M-x org-inline-element h TAB Symbol

查询。其他 具体的内联元素 也具备类似的机制。

为实现此特性, org-inline-element 提供了如下的公共函数:

describe

#+name: 2025-09-13-16-59
#+begin_src emacs-lisp :eval no
(lambda (pkgname fn &optional syms)
  ;; (assert (and (interpreted-function-p fn)
  ;;              (closurep fn)))
  (setq syms (or syms (mapcar #'car (aref fn 2))))
  (lambda (sym)
    "Describe symbol SYM."
    (interactive
     `(,(intern (completing-read
                 "Symbol: " syms))))
    (and-let*
        ((f (alist-get sym (aref fn 2)))
         (s (format "%s#%s" pkgname sym))
         (s (make-symbol s)))
      (describe-function (!def s f)))))
#+end_src

describe 将返回一个签名为 (lambda (sym) …) 的函数。

调用用例:

(org-inline-element
 'describe
 "pkgname"
 #'interpreted-closure-symbol)

入口

#+name: 2025-09-13-17-00
#+begin: elisp-docstring

Org 内联元素公共库及命令入口。

与 INLINE-ELEMENT 交互的公共入口;提供 occur, describe
等实现 INLINE-ELEMENT 所需的公共函数。

#+end:

#+name: 2025-09-13-17-01
#+begin_src emacs-lisp :eval no :noweb yes
;; depends: org-inline-element,
;;          dispatch, describe, occur, add
(lambda (op &rest args)
  "Org inline element.

<<@([[id:org-inline-element::doc:org-inline-element]])>>"
  ;; M-x org-inline-element 时,由 dispatch 处理。
  (interactive `(,dispatch))
  (ignore describe occur add)
  (cond
   ((and (eq this-command #'org-inline-element)
         (commandp op))
    (let ((this-command op))
      (call-interactively op)))
   ((and-let* ((f org-inline-element)
               (f (alist-get op (aref f 2)))
               (a t))
      (cond
       ((not (functionp f)))
       ((ignore (setq a (func-arity f))))
       ;; 如无参调用需参 f, 返回 f 本身,以便在某些
       ;; 特殊场景下, 如 (apply f), 使用。
       ((and (length= args 0) (> (car a) 0)) f)
       ((apply f args)))))))
#+end_src

整体结构

#+name: 2025-09-13-17-02
#+begin_src emacs-lisp :eval no :noweb yes
;;; org-inline-element  -*- lexical-binding: t; -*-
(!def 'org-inline-element
 (!let (org-inline-element
;;;; private
        describe occur
        ;; elements: 一个 (char . command) 列表,其
        ;; 元素通过 add 添加。
        elements add dispatch)

;;;; org-inline-element
  (!def org-inline-element
   <<@([[id:org-inline-element::org-inline-element]])>>)

;;;; describe
  (!def describe
   <<@([[id:org-inline-element::describe]])>>)

;;;; dispatch
  ;; 将控制流转发给注册为 elements 的命令。
  (!def dispatch
   <<@([[id:org-inline-element::dispatch]])>>)

;;;; add
  (!def add
   <<@([[id:org-inline-element::add]])>>)
  (add ?h (!def (make-symbol "help")
           (describe "org-inline-element"
                     org-inline-element)))

;;;; occur
  (!def occur
   (!let (occur popup gen-regions)
    (!def occur
     <<@([[id:org-inline-element::occur]])>>)

    (!def gen-regions
     <<@([[id:org-inline-element::occur::gen-regions]])>>)

    (!def popup
     <<@([[id:org-inline-element::occur::popup]])>>)

    occur))

;;;; end
  org-inline-element))
#+end_src

构建:Org内联元素

占位。