I have attached to this email a patch that will fix the issues caused by headings with pipe chars in clocktables. I tried to keep this patch simple but that was not really possible as there is a bunch of interlinked things that I needed to take into account:
When the value of link is nil: - Pipe chars in headlines need to be removed in order to avoid breaking clocktables. - Links should be removed and replaced with their description or a plain link if it has no description. Pipe chars must then also be removed from this new value. When the value of link is t,the above two points apply. Additionally: - Links with pipe chars can not be used for search strings even if the pipe character is replaced with a space and fuzzy link searching is enabled. - Escaping pipe chars would also create broken links. So, removing pipe chars when the value of link is nil was simple. When the value of link is t, the simplest solution that I found was to create an id link for a headline if the headline contained a pipe character. Obviously this only works if the headline is in a file. For headlines with pipe characters that are not in files, there currently isn't a solution. Replacing a pipe char with a space or escaping it just creates a broken link. org-colview is actually a good example of escaped pipe chars creating broken links (I will submit a bug report for this separately). Obviously if I leave the pipe character in, the clocktable itself gets broken. For now, a warning will be created whenever a user tries to create a clocktable with a link set to t when they are not in a file. Then, the pipe chars are removed from the heading and the heading is returned itself, instead of a link.
From 24b10f71cd2f6e49776f340f58bf775f5e85ea34 Mon Sep 17 00:00:00 2001 From: ApollonDeParnasse <[email protected]> Date: Wed, 6 May 2026 14:20:02 -0500 Subject: [PATCH] fix: Pipe char (|) in headings breaks clockreport --- lisp/org-clock.el | 51 ++++++++++++++----- testing/lisp/test-org-clock.el | 93 +++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 15 deletions(-) diff --git a/lisp/org-clock.el b/lisp/org-clock.el index 53d326e58..55e99240e 100644 --- a/lisp/org-clock.el +++ b/lisp/org-clock.el @@ -49,6 +49,7 @@ (declare-function org-link-display-format "ol" (s)) (declare-function org-link-heading-search-string "ol" (&optional string)) (declare-function org-link-make-string "ol" (link &optional description)) +(declare-function org-id-store-link-maybe "ol" (&optional interactive?)) (declare-function org-table-goto-line "org-table" (n)) (declare-function w32-notification-notify "w32fns.c" (&rest params)) (declare-function w32-notification-close "w32fns.c" (&rest params)) @@ -61,6 +62,7 @@ (defvar org-frame-title-format-backup nil) (defvar org-state) (defvar org-link-bracket-re) +(defvar org-id-link-to-org-use-id) (defgroup org-clock nil "Options concerning clocking working time in Org mode." @@ -3120,6 +3122,39 @@ a number of clock tables." (setq start next)) (end-of-line 0)))) + +(defun org-clock--create-clean-headline (headline) + "Clean HEADLINE for a clocktable. +Prune statistics cookies. Replace links with their description, +or a plain link if there is none." + (thread-last headline (substring-no-properties) (replace-regexp-in-string + "\\[[0-9]*\\(?:%\\|/[0-9]*\\)\\]" "") + (org-link-display-format) + (string-replace "|" " ") + (org-trim))) + +(defun org-clock--org-id-store-link-maybe () + "Create a link for the current entry." + (let ((org-id-link-to-org-use-id t)) + (org-id-store-link-maybe))) + +(defun org-clock--create-link-for-headline (headline) + "Convert HEADLINE into a link for a clocktable. +Prune statistics cookies. Replace links with their description, +or a plain link if there is none." + (let* ((file-name (buffer-file-name)) + (headline-contains-pipe (numberp (string-match-p (regexp-quote "|") headline))) + (description (org-clock--create-clean-headline headline))) + (cond + ((and file-name (not headline-contains-pipe)) (org-link-make-string (format "file:%s::%s" file-name (org-link-heading-search-string headline)) description)) + ((not headline-contains-pipe) (org-link-make-string (org-link-heading-search-string headline) description)) + ((and file-name headline-contains-pipe) (org-link-make-string (org-clock--org-id-store-link-maybe) description)) + ((and (not file-name) headline-contains-pipe) (progn (org-display-warning + (format + "The following headline has a pipe character: %s. Headlines with pipe characters can not be converted into a link unless they are in a file." + headline)) + (string-replace "|" " " (org-link-heading-search-string headline))))))) + (defun org-clock-get-table-data (file params) "Get the clocktable data for file FILE, with parameters PARAMS. FILE is only for identification - this function assumes that @@ -3206,20 +3241,8 @@ PROPERTIES: The list properties specified in the `:properties' parameter (when (<= level maxlevel) (let* ((headline (org-get-heading t t t t)) (hdl - (if (not link) headline - (let ((search - (org-link-heading-search-string headline))) - (org-link-make-string - (if (not (buffer-file-name)) search - (format "file:%s::%s" (buffer-file-name) search)) - ;; Prune statistics cookies. Replace - ;; links with their description, or - ;; a plain link if there is none. - (org-trim - (org-link-display-format - (replace-regexp-in-string - "\\[[0-9]*\\(?:%\\|/[0-9]*\\)\\]" "" - headline))))))) + (if (not link) (org-clock--create-clean-headline headline) + (org-clock--create-link-for-headline headline))) (tgs (and tags (org-get-tags))) (tsp (and timestamp diff --git a/testing/lisp/test-org-clock.el b/testing/lisp/test-org-clock.el index 4d5cb055e..cc4e0bcfa 100644 --- a/testing/lisp/test-org-clock.el +++ b/testing/lisp/test-org-clock.el @@ -861,6 +861,53 @@ CLOCK: [2016-12-28 Wed 13:09]--[2016-12-28 Wed 15:09] => 2:00" CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (test-org-clock-clocktable-contents ":maxlevel 1 :lang foo"))))) +(ert-deftest test-org-clock/clocktable/remove-pipe-chars () + "Confirm pipe chars are removed from headings before they are added to the Clock Table." + (should + (string-match-p "| Foo Bar +| 26:00 |" + (org-test-with-temp-text + "* Foo | Bar +CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" + (test-org-clock-clocktable-contents ":block untilnow :indent nil")))) + (should + (string-match-p "| Foo Bar Baz | 26:00 |" + (org-test-with-temp-text + "* Foo | Bar | Baz +CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" + (test-org-clock-clocktable-contents ":block untilnow :indent nil"))))) + +(ert-deftest test-org-clock/clocktable/remove-links () + "Confirm links are replaced with their description or a plain link before they are added to the Clock Table." + (should + (string-match-p + "| Foo https://example\\.com | 26:00 +|" + (org-test-with-temp-text + "* Foo [[https://example.com]] +CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" + (test-org-clock-clocktable-contents ":lang en")))) + (should + (string-match-p + "| Foo A link to a site | 26:00 +|" + (org-test-with-temp-text + "* Foo [[https://example.com][A link to a site]] +CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" + (test-org-clock-clocktable-contents ":lang en")))) + ;; works even with pipe characters in links + (should + (string-match-p + "| Foo file:foo\\.org::\\*Heading with inside | 26:00 +|" + (org-test-with-temp-text + "* Foo [[file:foo.org::*Heading with | inside]] +CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" + (test-org-clock-clocktable-contents ":lang en")))) + (should + (string-match-p + "| Bar A B +| 26:00 +|" + (org-test-with-temp-text + "* Bar [[file:/home/binarin/test.org][A | B]] +CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" + (test-org-clock-clocktable-contents ":lang en"))))) + (ert-deftest test-org-clock/clocktable/link () "Test \":link\" parameter in Clock table." ;; If there is no file attached to the document, link directly to @@ -945,7 +992,51 @@ CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (org-test-with-temp-text "* Foo [[https://orgmode.org]] CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" - (test-org-clock-clocktable-contents ":link t :lang en"))))) + (test-org-clock-clocktable-contents ":link t :lang en")))) + (cl-letf (((symbol-function 'org-id-new) + (lambda (&rest _rest) (and (buffer-file-name) "abc")))) + (should + (string-match-p + "| \\[\\[id:abc]\\[Foo Bar]] +| 26:00 +|" + (org-test-with-temp-text + (org-test-with-temp-text-in-file + "* Foo | Bar +CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" + (let ((file (buffer-file-name))) + (replace-regexp-in-string + (regexp-quote file) "filename" + (test-org-clock-clocktable-contents ":link t :lang en")))) + (org-table-align) + (buffer-substring-no-properties (point-min) (point-max))))) + ;; Works even when the heading has a link. + (should + (string-match-p + "| \\[\\[id:abc]\\[Foo <file:foo\\.org::\\*Heading with\\.\\.\\.]] | 26:00 |" + (org-test-with-temp-text + (org-test-with-temp-text-in-file + "* Foo <file:foo.org::*Heading with | inside> +CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" + (let ((file (buffer-file-name))) + (replace-regexp-in-string + (regexp-quote file) "filename" + (test-org-clock-clocktable-contents ":link t :lang en")))) + (org-table-align) + (buffer-substring-no-properties (point-min) (point-max))))) + (should + (string-match-p + "| \\[\\[id:abc]\\[A B]] +| 26:00 +|" + (org-test-with-temp-text + (org-test-with-temp-text-in-file + "* [[file:/home/binarin/test.org::*A | B][A | B]] +CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" + (let ((file (buffer-file-name))) + (replace-regexp-in-string + (regexp-quote file) "filename" + (test-org-clock-clocktable-contents ":link t :lang en")))) + (org-table-align) + (buffer-substring-no-properties (point-min) (point-max))))))) + + (ert-deftest test-org-clock/clocktable/compact () "Test \":compact\" parameter in Clock table." -- 2.54.0
