> What about modifying org-lint-search to skip spaces and also | when searching?
Done. What I ended up doing is extracting the code that is used to search headlines from org-link-search. I then used that code to create a new function. In order to avoid false positives, org-link-search calls this function twice. On the first pass, the same regular text search of headlines that was done before my patch is run. If that first search returns nothing, a second search is done where we remove "|"s from headlines before we check to see if a headline matches our search query. Besides that, the behavior of org-link-search has not changed. Le dim. 10 mai 2026 à 04:32, Ihor Radchenko <[email protected]> a écrit : > > Earl Chase <[email protected]> writes: > > > 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. > > What about modifying org-lint-search to skip spaces and also | when searching? > > -- > Ihor Radchenko // yantar92, > Org mode maintainer, > Learn more about Org mode at <https://orgmode.org/>. > Support Org development at <https://liberapay.com/org-mode>, > or support my work at <https://liberapay.com/yantar92>
From 3be520ee78fe1085a45a45607b7158f7d0cbd95a 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/ol.el | 49 +++++++----- lisp/org-clock.el | 42 ++++++---- testing/lisp/test-ol.el | 66 ++++++++++++++++ testing/lisp/test-org-clock.el | 139 ++++++++++++++++++++++++++++++--- 4 files changed, 251 insertions(+), 45 deletions(-) diff --git a/lisp/ol.el b/lisp/ol.el index 73645fb97..10b492db5 100644 --- a/lisp/ol.el +++ b/lisp/ol.el @@ -1733,6 +1733,29 @@ Optional argument ARG is passed to `org-open-file' when S is a s (substring s (1- (org-element-end link))))) (link (org-link-open link arg)))) +(cl-defun org-link--search-headlines (words &optional remove-pipes) + "Search headlines in Org mode buffers. +WORDS is a list of strings. When the value of +REMOVE-PIPES is t, pipe chars are removed from +headlines before we test for equality. Ignore +COMMENT keyword, TODO keywords, priority cookies, +statistics cookies and tags." + (let ((title-re + (format "%s.*\\(?:%s[ \t]\\)?.*%s" + org-outline-regexp-bol + org-comment-string + (regexp-opt words))) + (case-fold-search t)) + (goto-char (point-min)) + (catch :found + (while (re-search-forward title-re nil t) + (when-let* ((heading-content (org-link--normalize-string + (org-get-heading t t t t))) + (heading-parts (split-string (if remove-pipes (string-replace "|" " " heading-content) heading-content))) + (match-found (equal words heading-parts))) + (throw :found t))) + nil))) + (defun org-link-search (s &optional avoid-pos stealth new-heading-container) "Search for a search string S in the accessible part of the buffer. @@ -1832,25 +1855,15 @@ respects buffer narrowing." (forward-line 0) (throw :name-match t)))) nil)))) - ;; Regular text search. Prefer headlines in Org mode buffers. - ;; Ignore COMMENT keyword, TODO keywords, priority cookies, - ;; statistics cookies and tags. + ;; Regular text search of headlines in Org mode buffers ((and (derived-mode-p 'org-mode) - (let ((title-re - (format "%s.*\\(?:%s[ \t]\\)?.*%s" - org-outline-regexp-bol - org-comment-string - (mapconcat #'regexp-quote words ".+")))) - (goto-char (point-min)) - (catch :found - (while (re-search-forward title-re nil t) - (when (equal (mapcar #'upcase words) - (mapcar #'upcase - (split-string - (org-link--normalize-string - (org-get-heading t t t t))))) - (throw :found t))) - nil))) + (org-link--search-headlines words)) + (forward-line 0) + (setq type 'dedicated)) + ;; Second attempt of regular text search of headlines in Org mode buffers + ;; This time we remove pipes from headlines + ((and (derived-mode-p 'org-mode) + (org-link--search-headlines words 't)) (forward-line 0) (setq type 'dedicated)) ;; Offer to create non-existent headline depending on diff --git a/lisp/org-clock.el b/lisp/org-clock.el index 53d326e58..e6604aabd 100644 --- a/lisp/org-clock.el +++ b/lisp/org-clock.el @@ -62,6 +62,7 @@ (defvar org-state) (defvar org-link-bracket-re) + (defgroup org-clock nil "Options concerning clocking working time in Org mode." :tag "Org Clock" @@ -3120,6 +3121,31 @@ 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--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)) + (link (cond + ((and file-name (not headline-contains-pipe)) (format "file:%s::%s" file-name (org-link-heading-search-string headline))) + ((and file-name headline-contains-pipe) (format "file:%s::%s" file-name (string-replace "|" " " (org-link-heading-search-string headline)))) + ((and (not file-name) headline-contains-pipe) (string-replace "|" " " (org-link-heading-search-string headline))) + ((not headline-contains-pipe) (org-link-heading-search-string headline))))) + (org-link-make-string link description))) + (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 +3232,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-ol.el b/testing/lisp/test-ol.el index 371bf1e0f..457e86f5a 100644 --- a/testing/lisp/test-ol.el +++ b/testing/lisp/test-ol.el @@ -886,5 +886,71 @@ API in `org-link-parameters'. Used in test (org-insert-link nil nil "altered description")) (should (equal (buffer-string) "[[file:file.org][altered description]]")))) +(defun test-regular-org-link-heading-search-string (heading) + "Helper function for `test-org-link-search'. +Inserts HEADING into an org +buffer and then returns the heading value +as a org-link search string." + (org-test-with-temp-text heading + (org-link-heading-search-string))) + +(defun test-org-link-heading-search-string-remove-pipes (heading) + "Helper function for `test-org-link-search'. +Inserts HEADING into an org +buffer and then returns the heading value +as a org-link search string. +Replaces pipe chars with spaces +in order to simulate the way +org-clock removes spaces from +headings when it creates links +for a clocktable." + (org-test-with-temp-text heading + (string-replace "|" " " (org-link-heading-search-string)))) + +(cl-defun test-org-link-search (search-string-creator) + "Returns a closure that can be used to test `org-link-search'. +SEARCH-STRING-CREATOR should be a function +that returns an org-link search string." + (lambda (buffer-text headline-to-find) + (let ((org-link-search-must-match-exact-headline nil) + (org-todo-regexp "TODO")) + (org-test-with-temp-text buffer-text + (org-link-search (funcall search-string-creator headline-to-find)) + (should (string-equal (buffer-substring-no-properties (point) (line-end-position)) headline-to-find)))))) + +(defalias 'test-org-link-search-basic (test-org-link-search #'test-regular-org-link-heading-search-string)) + +(defalias 'test-org-link-search-replace-pipe-chars (test-org-link-search #'test-org-link-heading-search-string-remove-pipes)) + +(ert-deftest test-org-link/search-first-pass () + "First test for `org-link-search'. +Confirm that we can find an exact match for +a given heading search string." + (test-org-link-search-basic "* Head1\n* Head2\n* Head3\n* [[Head2]]" "* Head2") + (test-org-link-search-basic "* Test 1 2 3\n** Test 1 2\n* [[*Test 1 2]]" "* [[*Test 1 2]]") + (let ((first-line + "*** TODO [#A] [/] Test [1/2] [33%] 1 \t 2 [%] :work:urgent: ")) + (test-org-link-search-basic + (concat "* Foo Bar\n** [[*Test 1 2]]\n" first-line) first-line) + (test-org-link-search-basic + (concat "* Foo Bar\n** [[*Test 1 2]]\n" first-line) "** [[*Test 1 2]]"))) + +(ert-deftest test-org-link/search-second-pass () + "Second test for `org-link-search'. +Confirm that we can find a match for +a heading when the heading search string does +not contain pipe chars even though +the original heading does." + (test-org-link-search-replace-pipe-chars "* Head1\n* Head2\n* | Head3\n* [[Head2]]" "* | Head3") + (test-org-link-search-replace-pipe-chars "* Test 1 2 3\n** Test 1 | 2 |\n* [[*Test 1 2]]" "** Test 1 | 2 |") + (test-org-link-search-replace-pipe-chars "* DONE task \n* WAITING another task \n* [[file:/home/binarin/test.org::*A|B][A|B]]" "* [[file:/home/binarin/test.org::*A|B][A|B]]") + (let ((first-line + "*** TODO [#A] [/] Test [1/2] [33%] 1 | 2 [%] :work:urgent: ")) + (test-org-link-search-replace-pipe-chars + (concat "* Foo Bar\n** [[*Test 1 |2]]\n" first-line) first-line) + (test-org-link-search-replace-pipe-chars + (concat "* Foo Bar\n** [[*Test 1 2 |]]\n" first-line) "** [[*Test 1 2 |]]"))) + + (provide 'test-ol) ;;; test-ol.el ends here diff --git a/testing/lisp/test-org-clock.el b/testing/lisp/test-org-clock.el index 4d5cb055e..bde81b52a 100644 --- a/testing/lisp/test-org-clock.el +++ b/testing/lisp/test-org-clock.el @@ -861,10 +861,59 @@ 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 ;; the headline. + (should (string-match-p "| +\\[\\[\\*Foo]\\[Foo]] +| 26:00 +|" (org-test-with-temp-text @@ -876,13 +925,13 @@ CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (string-match-p "| \\[\\[file:filename::\\*Foo]\\[Foo]] +| 26:00 +|" (org-test-with-temp-text - (org-test-with-temp-text-in-file - "* Foo + (org-test-with-temp-text-in-file + "* Foo CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" - (let ((file (buffer-file-name))) + (let ((file (buffer-file-name))) (replace-regexp-in-string (regexp-quote file) "filename" - (test-org-clock-clocktable-contents ":link t :lang en")))) + p (test-org-clock-clocktable-contents ":link t :lang en")))) (org-table-align) (buffer-substring-no-properties (point-min) (point-max))))) ;; Ignore TODO keyword, priority cookie, COMMENT and tags in @@ -891,28 +940,28 @@ CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (string-match-p "| \\[\\[\\*Foo]\\[Foo]] +| 26:00 +|" (org-test-with-temp-text - "* TODO Foo + "* TODO Foo CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (test-org-clock-clocktable-contents ":link t :lang en")))) (should (string-match-p "| \\[\\[\\*Foo]\\[Foo]] +| 26:00 +|" (org-test-with-temp-text - "* [#A] Foo + "* [#A] Foo CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (test-org-clock-clocktable-contents ":link t :lang en")))) (should (string-match-p "| \\[\\[\\*Foo]\\[Foo]] +| 26:00 +|" (org-test-with-temp-text - "* COMMENT Foo + "* COMMENT Foo CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (test-org-clock-clocktable-contents ":link t")))) (should (string-match-p "| \\[\\[\\*Foo]\\[Foo]] +| 26:00 +|" (org-test-with-temp-text - "* Foo :tag: + "* Foo :tag: CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (test-org-clock-clocktable-contents ":link t :lang en")))) ;; Remove statistics cookie from headline description. @@ -920,14 +969,14 @@ CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (string-match-p "| \\[\\[\\*Foo]\\[Foo]] +| 26:00 +|" (org-test-with-temp-text - "* Foo [50%] + "* Foo [50%] CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (test-org-clock-clocktable-contents ":link t :lang en")))) (should (string-match-p "| \\[\\[\\*Foo]\\[Foo]] +| 26:00 +|" (org-test-with-temp-text - "* Foo [1/2] + "* Foo [1/2] CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (test-org-clock-clocktable-contents ":link t :lang en")))) ;; Replace links with their description, or turn them into plain @@ -936,16 +985,80 @@ CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (string-match-p "| \\[\\[\\*Foo \\\\\\[\\\\\\[https://orgmode\\.org\\\\]\\\\\\[Org mode\\\\]\\\\]]\\[Foo Org mode]] +| 26:00 +|" (org-test-with-temp-text - "* Foo [[https://orgmode.org][Org mode]] + "* Foo [[https://orgmode.org][Org mode]] CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" (test-org-clock-clocktable-contents ":link t :lang en")))) (should (string-match-p "| \\[\\[\\*Foo \\\\\\[\\\\\\[https://orgmode\\.org\\\\]\\\\]]\\[Foo https://orgmode\\.org]] +| 26:00 +|" (org-test-with-temp-text - "* Foo [[https://orgmode.org]] + "* 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")))) + ;; remove pipe characters before creating lnks + (should + (string-match-p + "| \\[\\[\\*Foo Bar]\\[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 ":link t :lang en")))) + ;; Works even when the heading has a link. + (should + (string-match-p + "| \\[\\[\\*Foo <file:foo\\.org::\\*Heading with inside>]\\[Foo <file:foo\\.org::\\*Heading with\\.\\.\\.]] | 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 ":link t :lang en")))) + (should + (string-match-p + "| \\[\\[\\*\\\\\\[\\\\\\[file:/home/binarin/test\\.org::\\*A B\\\\]\\\\\\[A B\\\\]\\\\]]\\[A B]] +| 26:00 |" + (org-test-with-temp-text + "* [[file:/home/binarin/test.org::*A | B][A | B]] +CLOCK: [2016-12-27 Wed 13:09]--[2016-12-28 Wed 15:09] => 26:00" + (test-org-clock-clocktable-contents ":link t :lang en")))) + ;; Works in files as as well. + (should + (string-match-p + "| \\[\\[file:filename::\\*Foo Bar]\\[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))))) + (should + (string-match-p + "| \\[\\[file:filename::\\*Foo <file:foo\\.org::\\*Heading with inside>]\\[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 + "| \\[\\[file:filename::\\*\\\\\\[\\\\\\[file:/home/binarin/test\\.org::\\*A B\\\\]\\\\\\[A B\\\\]\\\\]]\\[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
