diff --git a/doc/org-manual.org b/doc/org-manual.org index 2be6c92cd..89592b12d 100644 --- a/doc/org-manual.org +++ b/doc/org-manual.org @@ -3300,10 +3300,6 @@ Here is the full set of built-in link types: File links. File name may be remote, absolute, or relative. - Additionally, you can specify a line number, or a text search. - In Org files, you may link to a headline name, a custom ID, or a - code reference instead. - As a special case, "file" prefix may be omitted if the file name is complete, e.g., it starts with =./=, or =/=. @@ -3367,44 +3363,50 @@ Here is the full set of built-in link types: Execute a shell command upon activation. + +For =file:= and =id:= links, you can additionally specify a line +number, or a text search string, separated by =::=. In Org files, you +may link to a headline name, a custom ID, or a code reference instead. + The following table illustrates the link types above, along with their options: -| Link Type | Example | -|------------+----------------------------------------------------------| -| http | =http://staff.science.uva.nl/c.dominik/= | -| https | =https://orgmode.org/= | -| doi | =doi:10.1000/182= | -| file | =file:/home/dominik/images/jupiter.jpg= | -| | =/home/dominik/images/jupiter.jpg= (same as above) | -| | =file:papers/last.pdf= | -| | =./papers/last.pdf= (same as above) | -| | =file:/ssh:me@some.where:papers/last.pdf= (remote) | -| | =/ssh:me@some.where:papers/last.pdf= (same as above) | -| | =file:sometextfile::NNN= (jump to line number) | -| | =file:projects.org= | -| | =file:projects.org::some words= (text search)[fn:12] | -| | =file:projects.org::*task title= (headline search) | -| | =file:projects.org::#custom-id= (headline search) | -| attachment | =attachment:projects.org= | -| | =attachment:projects.org::some words= (text search) | -| docview | =docview:papers/last.pdf::NNN= | -| id | =id:B7423F4D-2E8A-471B-8810-C40F074717E9= | -| news | =news:comp.emacs= | -| mailto | =mailto:adent@galaxy.net= | -| mhe | =mhe:folder= (folder link) | -| | =mhe:folder#id= (message link) | -| rmail | =rmail:folder= (folder link) | -| | =rmail:folder#id= (message link) | -| gnus | =gnus:group= (group link) | -| | =gnus:group#id= (article link) | -| bbdb | =bbdb:R.*Stallman= (record with regexp) | -| irc | =irc:/irc.com/#emacs/bob= | -| help | =help:org-store-link= | -| info | =info:org#External links= | -| shell | =shell:ls *.org= | -| elisp | =elisp:(find-file "Elisp.org")= (Elisp form to evaluate) | -| | =elisp:org-agenda= (interactive Elisp command) | +| Link Type | Example | +|------------+--------------------------------------------------------------------| +| http | =http://staff.science.uva.nl/c.dominik/= | +| https | =https://orgmode.org/= | +| doi | =doi:10.1000/182= | +| file | =file:/home/dominik/images/jupiter.jpg= | +| | =/home/dominik/images/jupiter.jpg= (same as above) | +| | =file:papers/last.pdf= | +| | =./papers/last.pdf= (same as above) | +| | =file:/ssh:me@some.where:papers/last.pdf= (remote) | +| | =/ssh:me@some.where:papers/last.pdf= (same as above) | +| | =file:sometextfile::NNN= (jump to line number) | +| | =file:projects.org= | +| | =file:projects.org::some words= (text search)[fn:12] | +| | =file:projects.org::*task title= (headline search) | +| | =file:projects.org::#custom-id= (headline search) | +| attachment | =attachment:projects.org= | +| | =attachment:projects.org::some words= (text search) | +| docview | =docview:papers/last.pdf::NNN= | +| id | =id:B7423F4D-2E8A-471B-8810-C40F074717E9= | +| | =id:B7423F4D-2E8A-471B-8810-C40F074717E9::*task= (headline search) | +| news | =news:comp.emacs= | +| mailto | =mailto:adent@galaxy.net= | +| mhe | =mhe:folder= (folder link) | +| | =mhe:folder#id= (message link) | +| rmail | =rmail:folder= (folder link) | +| | =rmail:folder#id= (message link) | +| gnus | =gnus:group= (group link) | +| | =gnus:group#id= (article link) | +| bbdb | =bbdb:R.*Stallman= (record with regexp) | +| irc | =irc:/irc.com/#emacs/bob= | +| help | =help:org-store-link= | +| info | =info:org#External links= | +| shell | =shell:ls *.org= | +| elisp | =elisp:(find-file "Elisp.org")= (Elisp form to evaluate) | +| | =elisp:org-agenda= (interactive Elisp command) | #+cindex: VM links #+cindex: Wanderlust links @@ -3465,8 +3467,9 @@ current buffer: - /Org mode buffers/ :: For Org files, if there is a =<>= at point, the link points - to the target. Otherwise it points to the current headline, which - is also the description. + to the target. If there is a named block (using =#+name:=) at + point, the link points to that name. Otherwise it points to the + current headline, which is also the description. #+vindex: org-id-link-to-org-use-id #+cindex: @samp{CUSTOM_ID}, property @@ -3484,6 +3487,32 @@ current buffer: timestamp, depending on ~org-id-method~. Later, when inserting the link, you need to decide which one to use. + #+vindex: org-id-link-consider-parent-id + #+vindex: org-id-link-use-context + #+vindex: org-link-context-for-files + When ~org-id-link-consider-parent-id~ is ~t~[fn:: Also, + ~org-link-context-for-files~ and ~org-id-link-use-context~ should be + both enabled (which they are, by default).], parent =ID= properties + are considered. This allows linking to specific targets, named + blocks, or headlines (which may not have a globally unique =ID= + themselves) within the context of a parent headline or file which + does. + + For example, given this org file: + + #+begin_src org + ,* Parent + :PROPERTIES: + :ID: abc + :END: + ,** Child 1 + ,** Child 2 + #+end_src + + Storing a link with point at "Child 1" will produce a link + ==, which precisely links to the "Child 1" + headline even though it does not have its own ID. + - /Email/News clients: VM, Rmail, Wanderlust, MH-E, Gnus/ :: #+vindex: org-link-email-description-format @@ -3763,7 +3792,9 @@ the link completion function like this: :ALT_TITLE: Search Options :END: #+cindex: search option in file links +#+cindex: search option in id links #+cindex: file links, searching +#+cindex: id links, searching #+cindex: attachment links, searching File links can contain additional information to make Emacs jump to a @@ -3775,8 +3806,8 @@ example, when the command ~org-store-link~ creates a link (see line as a search string that can be used to find this line back later when following the link with {{{kbd(C-c C-o)}}}. -Note that all search options apply for Attachment links in the same -way that they apply for File links. +Note that all search options apply for Attachment and ID links in the +same way that they apply for File links. Here is the syntax of the different ways to attach a search to a file link, together with explanations for each: @@ -21522,7 +21553,7 @@ The following =ol-man.el= file implements it PATH should be a topic that can be thrown at the man command." (funcall org-man-command path)) -(defun org-man-store-link () +(defun org-man-store-link (&optional _interactive?) "Store a link to a man page." (when (memq major-mode '(Man-mode woman-mode)) ;; This is a man page, we do make this link. @@ -21582,13 +21613,15 @@ A review of =ol-man.el=: For example, ~org-man-store-link~ is responsible for storing a link when ~org-store-link~ (see [[*Handling Links]]) is called from a buffer - displaying a man page. It first checks if the major mode is - appropriate. If check fails, the function returns ~nil~, which - means it isn't responsible for creating a link to the current - buffer. Otherwise the function makes a link string by combining - the =man:= prefix with the man topic. It also provides a default - description. The function ~org-insert-link~ can insert it back - into an Org buffer later on. + displaying a man page. It is passed an argument ~interactive?~ + which this function does not use, but other store functions use to + behave differently when a link is stored interactively by the user. + It first checks if the major mode is appropriate. If check fails, + the function returns ~nil~, which means it isn't responsible for + creating a link to the current buffer. Otherwise the function + makes a link string by combining the =man:= prefix with the man + topic. It also provides a default description. The function + ~org-insert-link~ can insert it back into an Org buffer later on. ** Adding Export Backends :PROPERTIES: diff --git a/etc/ORG-NEWS b/etc/ORG-NEWS index c4e1263bf..b79f275c4 100644 --- a/etc/ORG-NEWS +++ b/etc/ORG-NEWS @@ -460,6 +460,14 @@ timestamp object. Possible values: ~timerange~, ~daterange~, ~nil~. ~org-element-timestamp-interpreter~ takes into account this property and returns an appropriate timestamp string. +**** =org-link= store functions are passed an ~interactive?~ argument + +The ~:store:~ functions set for link types using +~org-link-set-parameters~ are now passed an ~interactive?~ argument, +indicating whether ~org-store-link~ was called interactively. + +Existing store functions will continue to work. + *** ~org-priority=show~ command no longer adjusts for scheduled/deadline In agenda views, ~org-priority=show~ command previously displayed the @@ -538,6 +546,28 @@ The change is breaking when ~org-use-property-inheritance~ is set to ~t~. *** ~org-babel-lilypond-compile-lilyfile~ ignores optional second argument The =TEST= parameter is better served by Emacs debugging tools. + +*** =id:= links support search options; ~org-id-store-link~ adds search option by default + +Adding search option by ~org-id-store-link~ can be disabled by setting +~org-id-link-use-context~ to ~nil~, or toggled for a single call by +passing universal argument. + +When using this feature, IDs should not include =::=, which is used in +links to indicate the start of the search string. For backwards +compability, existing IDs including =::= will still be matched (but +cannot be used together with search option). A new org-lint checker +has been added to warn about this. + +*** ~org-store-link~ behaviour storing additional =CUSTOM_ID= links has changed + +Previously, when storing =id:= link, ~org-store-link~ stored an +additional "human readable" link using a node's =CUSTOM_ID= property. + +This behaviour has been expanded to store an additional =CUSTOM_ID= +link when storing any type of external link type in an Org file, not +just =id:= links. + ** New and changed options *** New option ~org-beamer-frame-environment~ @@ -868,6 +898,35 @@ This option starts the agenda to automatically include archives, propagating the value for this variable to ~org-agenda-archives-mode~. For acceptable values and their meaning, see the value of that variable. +*** New option ~org-id-link-consider-parent-id~ to allow =id:= links to parent headlines + +For =id:= links, when this option is enabled, ~org-store-link~ will +look for ids from parent/ancestor headlines, if the current headline +does not have an id. + +Combined with the new ability for =id:= links to use search options + [fn:: when =org-id-link-use-context= is =t=, which is the default], +this allows linking to specific headlines without requiring every +headline to have an id property, as long as the headline is unique +within a subtree that does have an id property. + +For example, given this org file: + +#+begin_src org +,* Parent +:PROPERTIES: +:ID: abc +:END: +,** Child 1 +,** Child 2 +#+end_src + +Storing a link with point at "Child 1" will produce a link +==, which precisely links to the "Child 1" headline +even though it does not have its own ID. By giving files top-level id +properties, links to headlines in the file can also be made more +robust by using the file id instead of the file path. + ** New features *** =ob-plantuml.el=: Support tikz file format output @@ -1164,6 +1223,19 @@ A numeric value forces a heading at that level to be inserted. For backwards compatibility, non-numeric non-nil values insert level 1 headings as before. +*** New optional argument for ~org-id-get~ + +New optional argument =INHERIT= means inherited ID properties from +parent entries are considered when getting an entry's ID (see +~org-id-link-consider-parent-id~ option). + +*** New optional argument for ~org-link-search~ + +If a missing heading is created to match the search string, the new +optional argument =NEW-HEADING-CONTAINER= specifies where in the +buffer it will be added. If not specified, new headings are created +at level 1 at the end of the accessible part of the buffer, as before. + ** Miscellaneous *** =org-crypt.el= now applies initial visibility settings to decrypted entries diff --git a/lisp/ol-bbdb.el b/lisp/ol-bbdb.el index be3924fc9..6ea060f70 100644 --- a/lisp/ol-bbdb.el +++ b/lisp/ol-bbdb.el @@ -226,7 +226,7 @@ date year)." ;;; Implementation -(defun org-bbdb-store-link () +(defun org-bbdb-store-link (&optional _interactive?) "Store a link to a BBDB database entry." (when (eq major-mode 'bbdb-mode) ;; This is BBDB, we make this link! diff --git a/lisp/ol-bibtex.el b/lisp/ol-bibtex.el index c5a950e2d..38468f32f 100644 --- a/lisp/ol-bibtex.el +++ b/lisp/ol-bibtex.el @@ -507,7 +507,7 @@ ARG, when non-nil, is a universal prefix argument. See `org-open-file' for details." (org-link-open-as-file path arg)) -(defun org-bibtex-store-link () +(defun org-bibtex-store-link (&optional _interactive?) "Store a link to a BibTeX entry." (when (eq major-mode 'bibtex-mode) (let* ((search (org-create-file-search-in-bibtex)) diff --git a/lisp/ol-docview.el b/lisp/ol-docview.el index b31f1ce5e..0907ddee1 100644 --- a/lisp/ol-docview.el +++ b/lisp/ol-docview.el @@ -83,7 +83,7 @@ (error "No such file: %s" path)) (when page (doc-view-goto-page page)))) -(defun org-docview-store-link () +(defun org-docview-store-link (&optional _interactive?) "Store a link to a docview buffer." (when (eq major-mode 'doc-view-mode) ;; This buffer is in doc-view-mode diff --git a/lisp/ol-eshell.el b/lisp/ol-eshell.el index 2c7ec6bef..595dd0ee0 100644 --- a/lisp/ol-eshell.el +++ b/lisp/ol-eshell.el @@ -60,7 +60,7 @@ followed by a colon." (insert command) (eshell-send-input))) -(defun org-eshell-store-link () +(defun org-eshell-store-link (&optional _interactive?) "Store eshell link. When opened, the link switches back to the current eshell buffer and the current working directory." diff --git a/lisp/ol-eww.el b/lisp/ol-eww.el index 40b820d2b..c13dbf339 100644 --- a/lisp/ol-eww.el +++ b/lisp/ol-eww.el @@ -62,7 +62,7 @@ "Open URL with Eww in the current buffer." (eww url)) -(defun org-eww-store-link () +(defun org-eww-store-link (&optional _interactive?) "Store a link to the url of an EWW buffer." (when (eq major-mode 'eww-mode) (org-link-store-props diff --git a/lisp/ol-gnus.el b/lisp/ol-gnus.el index e105fdb2c..b9ee8683f 100644 --- a/lisp/ol-gnus.el +++ b/lisp/ol-gnus.el @@ -123,7 +123,7 @@ If `org-store-link' was called with a prefix arg the meaning of (url-encode-url message-id)) (concat "gnus:" group "#" message-id))) -(defun org-gnus-store-link () +(defun org-gnus-store-link (&optional _interactive?) "Store a link to a Gnus folder or message." (pcase major-mode (`gnus-group-mode diff --git a/lisp/ol-info.el b/lisp/ol-info.el index 0edf9a13f..6062cab34 100644 --- a/lisp/ol-info.el +++ b/lisp/ol-info.el @@ -50,7 +50,7 @@ :insert-description #'org-info-description-as-command) ;; Implementation -(defun org-info-store-link () +(defun org-info-store-link (&optional _interactive?) "Store a link to an Info file and node." (when (eq major-mode 'Info-mode) (let ((link (concat "info:" diff --git a/lisp/ol-irc.el b/lisp/ol-irc.el index 78c4884b0..b263e52db 100644 --- a/lisp/ol-irc.el +++ b/lisp/ol-irc.el @@ -103,7 +103,7 @@ attributes that are found." parts)) ;;;###autoload -(defun org-irc-store-link () +(defun org-irc-store-link (&optional _interactive?) "Dispatch to the appropriate function to store a link to an IRC session." (cond ((eq major-mode 'erc-mode) diff --git a/lisp/ol-man.el b/lisp/ol-man.el index e3f13815e..42aacea81 100644 --- a/lisp/ol-man.el +++ b/lisp/ol-man.el @@ -82,7 +82,7 @@ matched strings in man buffer." (set-window-point window point) (set-window-start window point))))))) -(defun org-man-store-link () +(defun org-man-store-link (&optional _interactive?) "Store a link to a README file." (when (memq major-mode '(Man-mode woman-mode)) ;; This is a man page, we do make this link diff --git a/lisp/ol-mhe.el b/lisp/ol-mhe.el index 106cfedc9..a32481324 100644 --- a/lisp/ol-mhe.el +++ b/lisp/ol-mhe.el @@ -80,7 +80,7 @@ supported by MH-E." (org-link-set-parameters "mhe" :follow #'org-mhe-open :store #'org-mhe-store-link) ;; Implementation -(defun org-mhe-store-link () +(defun org-mhe-store-link (&optional _interactive?) "Store a link to an MH-E folder or message." (when (or (eq major-mode 'mh-folder-mode) (eq major-mode 'mh-show-mode)) diff --git a/lisp/ol-rmail.el b/lisp/ol-rmail.el index f6031ab52..f1f753b6f 100644 --- a/lisp/ol-rmail.el +++ b/lisp/ol-rmail.el @@ -51,7 +51,7 @@ :store #'org-rmail-store-link) ;; Implementation -(defun org-rmail-store-link () +(defun org-rmail-store-link (&optional _interactive?) "Store a link to an Rmail folder or message." (when (or (eq major-mode 'rmail-mode) (eq major-mode 'rmail-summary-mode)) diff --git a/lisp/ol.el b/lisp/ol.el index a680c43f3..22782578c 100644 --- a/lisp/ol.el +++ b/lisp/ol.el @@ -57,13 +57,13 @@ (declare-function org-element-link-parser "org-element" ()) (declare-function org-element-property "org-element-ast" (property node)) (declare-function org-element-begin "org-element" (node)) +(declare-function org-element-end "org-element" (node)) (declare-function org-element-type-p "org-element-ast" (node types)) (declare-function org-element-update-syntax "org-element" ()) (declare-function org-entry-get "org" (pom property &optional inherit literal-nil)) (declare-function org-find-property "org" (property &optional value)) (declare-function org-get-heading "org" (&optional no-tags no-todo no-priority no-comment)) (declare-function org-id-find-id-file "org-id" (id)) -(declare-function org-id-store-link "org-id" ()) (declare-function org-insert-heading "org" (&optional arg invisible-ok top)) (declare-function org-load-modules-maybe "org" (&optional force)) (declare-function org-mark-ring-push "org" (&optional pos buffer)) @@ -818,6 +818,74 @@ spec." (org-with-point-at (car region) (not (org-in-regexp org-link-any-re)))) +(defun org-link--try-link-store-functions (interactive?) + "Try storing external links, prompting if more than one is possible. + +Each function returned by `org-store-link-functions' is called in +turn. If multiple functions return non-nil, prompt for which +link should be stored. + +Argument INTERACTIVE? indicates whether `org-store-link' was +called interactively and is passed to the link store functions. + +Return t when a link has been stored in `org-link-store-props'." + (let ((results-alist nil)) + (dolist (f (org-store-link-functions)) + (when (condition-case nil + (funcall f interactive?) + ;; FIXME: The store function used (< Org 9.7) to accept + ;; no arguments; provide backward compatibility support + ;; for them. + (wrong-number-of-arguments + (funcall f))) + ;; FIXME: return value is not link's plist, so we store the + ;; new value before it is modified. It would be cleaner to + ;; ask store link functions to return the plist instead. + (push (cons f (copy-sequence org-store-link-plist)) + results-alist))) + (pcase results-alist + (`nil nil) + (`((,_ . ,_)) t) ;single choice: nothing to do + (`((,name . ,_) . ,_) + ;; Reinstate link plist associated to the chosen + ;; function. + (apply #'org-link-store-props + (cdr (assoc-string + (completing-read + (format "Store link with (default %s): " name) + (mapcar #'car results-alist) + nil t nil nil (symbol-name name)) + results-alist))) + t)))) + +(defun org-link--add-to-stored-links (link desc) + "Add LINK to `org-stored-links' with description DESC." + (cond + ((not (member (list link desc) org-stored-links)) + (push (list link desc) org-stored-links) + (message "Stored: %s" (or desc link))) + ((equal (list link desc) (car org-stored-links)) + (message "This link has already been stored")) + (t + (setq org-stored-links + (delete (list link desc) org-stored-links)) + (push (list link desc) org-stored-links) + (message "Link moved to front: %s" (or desc link))))) + +(defun org-link--file-link-to-here () + "Return as (LINK . DESC) a file link with search string to here." + (let ((link (concat "file:" + (abbreviate-file-name + (buffer-file-name (buffer-base-buffer))))) + desc) + (when org-link-context-for-files + (pcase (org-link-precise-link-target) + (`nil nil) + (`(,search-string ,search-desc ,_position) + (setq link (format "%s::%s" link search-string)) + (setq desc search-desc)))) + (cons link desc))) + ;;; Public API @@ -1044,7 +1112,9 @@ LINK is escaped with backslashes for inclusion in buffer." "List of functions that are called to create and store a link. The functions are defined in the `:store' property of -`org-link-parameters'. +`org-link-parameters'. Each function should accept an argument +INTERACTIVE? which indicates whether the user has initiated +`org-store-link' interactively. Each function will be called in turn until one returns a non-nil value. Each function should check if it is responsible for @@ -1163,7 +1233,7 @@ Optional argument ARG is passed to `org-open-file' when S is a (`nil (user-error "No valid link in %S" s)) (link (org-link-open link arg)))) -(defun org-link-search (s &optional avoid-pos stealth) +(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. If S starts with \"#\", it triggers a custom ID search. @@ -1183,6 +1253,13 @@ When optional argument STEALTH is non-nil, do not modify visibility around point, thus ignoring `org-show-context-detail' variable. +When optional argument NEW-HEADING-CONTAINER is an element, any +new heading that is created (see +`org-link-search-must-match-exact-headline') will be added as a +subheading of NEW-HEADING-CONTAINER. Otherwise, new headings are +created at level 1 at the end of the accessible part of the +buffer. + Search is case-insensitive and ignores white spaces. Return type of matched result, which is either `dedicated' or `fuzzy'. Search respects buffer narrowing." @@ -1281,11 +1358,24 @@ respects buffer narrowing." ((and (derived-mode-p 'org-mode) (eq org-link-search-must-match-exact-headline 'query-to-create) (yes-or-no-p "No match - create this as a new heading? ")) - (goto-char (point-max)) - (unless (bolp) (newline)) - (org-insert-heading nil t t) - (insert s "\n") - (forward-line -1)) + (let* ((container-ok (and new-heading-container + (org-element-type-p new-heading-container '(headline)))) + (new-heading-position (if container-ok + (- (org-element-end new-heading-container) 1) + (point-max))) + (new-heading-level (if container-ok + (+ 1 (org-element-property :level new-heading-container)) + 1))) + ;; Need to widen when target is outside accessible portion of + ;; buffer, since the we want the user to end up there. + (unless (and (<= (point-min) new-heading-position) + (>= (point-max) new-heading-position)) + (widen)) + (goto-char new-heading-position) + (unless (bolp) (newline)) + (org-insert-heading nil t new-heading-level) + (insert (if starred (substring s 1) s) "\n") + (forward-line -1))) ;; Only headlines are looked after. No need to process ;; further: throw an error. ((and (derived-mode-p 'org-mode) @@ -1335,6 +1425,70 @@ priority cookie or tag." (org-link--normalize-string (or string (org-get-heading t t t t))))) +(defun org-link-precise-link-target () + "Determine search string and description for storing a link. + +If a search string (see `org-link-search') is found, return +list (SEARCH-STRING DESC POSITION). Otherwise, return nil. + +If there is an active region, the contents (or a part of it, see +`org-link-context-for-files') is used as the search string. + +In Org buffers, if point is at a named element (such as a source +block), the name is used for the search string. If at a heading, +its CUSTOM_ID is used to form a search string of the form +\"#id\", if present, otherwise the current heading text is used +in the form \"*Heading\". + +If none of those finds a suitable search string, the current line +is used as the search string. + +The description DESC is nil (meaning the user will be prompted +for a description when inserting the link) for search strings +based on a region or the current line. For other cases, DESC is +a cleaned-up version of the name or heading at point. + +POSITION is the buffer position at which the search string +matches." + (let* ((region (org-link--context-from-region)) + (result + (cond + (region + (list (org-link--normalize-string region t) + nil + (region-beginning))) + + ((derived-mode-p 'org-mode) + (let* ((element (org-element-at-point)) + (name (org-element-property :name element)) + (heading (org-element-lineage element '(headline inlinetask) t)) + (custom-id (org-entry-get heading "CUSTOM_ID"))) + (cond + (name + (list name + name + (org-element-begin element))) + ((org-before-first-heading-p) + (list (org-link--normalize-string (org-current-line-string) t) + nil + (line-beginning-position))) + (heading + (list (if custom-id (concat "#" custom-id) + (org-link-heading-search-string)) + (org-link--normalize-string + (org-get-heading t t t t)) + (org-element-begin heading)))))) + + ;; Not in an org-mode buffer, no region + (t + (list (org-link--normalize-string (org-current-line-string) t) + nil + (line-beginning-position)))))) + + ;; Only use search option if there is some text. + (when (org-string-nw-p (car result)) + result))) + (defun org-link-open-as-file (path in-emacs) "Pretend PATH is a file name and open it. @@ -1407,7 +1561,7 @@ PATH is a symbol name, as a string." ((and (pred boundp) variable) (describe-variable variable)) (name (user-error "Unknown function or variable: %s" name)))) -(defun org-link--store-help () +(defun org-link--store-help (&optional _interactive?) "Store \"help\" type link." (when (eq major-mode 'help-mode) (let ((symbol @@ -1542,7 +1696,12 @@ prefix ARG forces storing a link for each line in the active region. Assume the function is called interactively if INTERACTIVE? is -non-nil." +non-nil. + +In Org buffers, an additional \"human-readable\" simple file link +is stored as an alternative to persistent org-id or other links, +if at a heading with a CUSTOM_ID property or an element with a +NAME." (interactive "P\np") (org-load-modules-maybe) (if (and (equal arg '(64)) (org-region-active-p)) @@ -1557,36 +1716,19 @@ non-nil." (move-beginning-of-line 2) (set-mark (point))))) (setq org-store-link-plist nil) - (let (link cpltxt desc search custom-id agenda-link) ;; description + ;; Negate `org-context-in-file-links' when given a single universal arg. + (let ((org-link-context-for-files (org-xor org-link-context-for-files + (equal arg '(4)))) + link cpltxt desc search agenda-link) ;; description (cond ;; Store a link using an external link type, if any function is - ;; available. If more than one can generate a link from current - ;; location, ask which one to use. + ;; available, unless external link types are skipped for this + ;; call using two universal args. If more than one function + ;; can generate a link from current location, ask the user + ;; which one to use. ((and (not (equal arg '(16))) - (let ((results-alist nil)) - (dolist (f (org-store-link-functions)) - (when (funcall f) - ;; XXX: return value is not link's plist, so we - ;; store the new value before it is modified. It - ;; would be cleaner to ask store link functions to - ;; return the plist instead. - (push (cons f (copy-sequence org-store-link-plist)) - results-alist))) - (pcase results-alist - (`nil nil) - (`((,_ . ,_)) t) ;single choice: nothing to do - (`((,name . ,_) . ,_) - ;; Reinstate link plist associated to the chosen - ;; function. - (apply #'org-link-store-props - (cdr (assoc-string - (completing-read - (format "Store link with (default %s): " name) - (mapcar #'car results-alist) - nil t nil nil (symbol-name name)) - results-alist))) - t)))) - (setq link (plist-get org-store-link-plist :link)) + (org-link--try-link-store-functions interactive?)) + (setq link (plist-get org-store-link-plist :link)) ;; If store function actually set `:description' property, use ;; it, even if it is nil. Otherwise, fallback to nil (ask user). (setq desc (plist-get org-store-link-plist :description))) @@ -1637,6 +1779,7 @@ non-nil." (org-with-point-at m (setq agenda-link (org-store-link nil interactive?)))))) + ;; Calendar mode ((eq major-mode 'calendar-mode) (let ((cd (calendar-cursor-to-date))) (setq link @@ -1645,6 +1788,7 @@ non-nil." (org-encode-time 0 0 0 (nth 1 cd) (nth 0 cd) (nth 2 cd)))) (org-link-store-props :type "calendar" :date cd))) + ;; Image mode ((eq major-mode 'image-mode) (setq cpltxt (concat "file:" (abbreviate-file-name buffer-file-name)) @@ -1662,15 +1806,22 @@ non-nil." (setq cpltxt (concat "file:" file) link cpltxt))) + ;; Try `org-create-file-search-functions`. If any are + ;; successful, create a file link to the current buffer with + ;; the provided search string. (sets `link` and `cpltxt` to + ;; the same thing; it looks like the intention originally was + ;; that cpltxt was a description, which might have been set by + ;; the search-function (removed in switch to lexical binding)). ((setq search (run-hook-with-args-until-success 'org-create-file-search-functions)) (setq link (concat "file:" (abbreviate-file-name buffer-file-name) "::" search)) (setq cpltxt (or link))) ;; description + ;; Main logic for storing built-in link types in org-mode + ;; buffers ((and (buffer-file-name (buffer-base-buffer)) (derived-mode-p 'org-mode)) (org-with-limited-levels - (setq custom-id (org-entry-get nil "CUSTOM_ID")) (cond ;; Store a link using the target at point ((org-in-regexp "[^<]<<\\([^<>]+\\)>>[^>]" 1) @@ -1684,74 +1835,21 @@ non-nil." ;; links. Maybe the case of identical target and ;; description should be handled by `org-insert-link'. cpltxt nil - desc nil - ;; Do not append #CUSTOM_ID link below. - custom-id nil)) - ((and (featurep 'org-id) - (or (eq org-id-link-to-org-use-id t) - (and interactive? - (or (eq org-id-link-to-org-use-id 'create-if-interactive) - (and (eq org-id-link-to-org-use-id - 'create-if-interactive-and-no-custom-id) - (not custom-id)))) - (and org-id-link-to-org-use-id (org-entry-get nil "ID")))) - ;; Store a link using the ID at point - (setq link (condition-case nil - (prog1 (org-id-store-link) - (setq desc (plist-get org-store-link-plist :description))) - (error - ;; Probably before first headline, link only to file - (concat "file:" - (abbreviate-file-name - (buffer-file-name (buffer-base-buffer)))))))) - (t + desc nil)) + (t ;; Just link to current headline. - (setq cpltxt (concat "file:" - (abbreviate-file-name - (buffer-file-name (buffer-base-buffer))))) - ;; Add a context search string. - (when (org-xor org-link-context-for-files (equal arg '(4))) - (let* ((element (org-element-at-point)) - (name (org-element-property :name element)) - (context - (cond - ((let ((region (org-link--context-from-region))) - (and region (org-link--normalize-string region t)))) - (name) - ((org-before-first-heading-p) - (org-link--normalize-string (org-current-line-string) t)) - (t (org-link-heading-search-string))))) - (when (org-string-nw-p context) - (setq cpltxt (format "%s::%s" cpltxt context)) - (setq desc - (or name - ;; Although description is not a search - ;; string, use `org-link--normalize-string' - ;; to prettify it (contiguous white spaces) - ;; and remove volatile contents (statistics - ;; cookies). - (and (not (org-before-first-heading-p)) - (org-link--normalize-string - (org-get-heading t t t t))) - "NONE"))))) - (setq link cpltxt))))) + (let ((here (org-link--file-link-to-here))) + (setq cpltxt (car here)) + (setq desc (cdr here))) + (setq link cpltxt))))) + ;; Buffer linked to file, but not an org-mode buffer. ((buffer-file-name (buffer-base-buffer)) ;; Just link to this file here. - (setq cpltxt (concat "file:" - (abbreviate-file-name - (buffer-file-name (buffer-base-buffer))))) - ;; Add a context search string. - (when (org-xor org-link-context-for-files (equal arg '(4))) - (let ((context (org-link--normalize-string - (or (org-link--context-from-region) - (org-current-line-string)) - t))) - ;; Only use search option if there is some text. - (when (org-string-nw-p context) - (setq cpltxt (format "%s::%s" cpltxt context)) - (setq desc "NONE")))) - (setq link cpltxt)) + (let ((here (org-link--file-link-to-here))) + (setq cpltxt (car here)) + (setq desc (cdr here))) + (setq link cpltxt)) (interactive? (user-error "No method for storing a link from this buffer")) @@ -1767,24 +1865,18 @@ non-nil." ;; Store and return the link (if (not (and interactive? link)) (or agenda-link (and link (org-link-make-string link desc))) - (dotimes (_ (if custom-id 2 1)) ; Store 2 links when CUSTOM-ID is non-nil. - (cond - ((not (member (list link desc) org-stored-links)) - (push (list link desc) org-stored-links) - (message "Stored: %s" (or desc link))) - ((equal (list link desc) (car org-stored-links)) - (message "This link has already been stored")) - (t - (setq org-stored-links - (delete (list link desc) org-stored-links)) - (push (list link desc) org-stored-links) - (message "Link moved to front: %s" (or desc link)))) - (when custom-id - (setq link (concat "file:" - (abbreviate-file-name - (buffer-file-name (buffer-base-buffer))) - "::#" custom-id)))) - (car org-stored-links))))) + (org-link--add-to-stored-links link desc) + ;; In org buffers, store an additional "human-readable" link + ;; using custom id, if available. + (when (and (buffer-file-name (buffer-base-buffer)) + (derived-mode-p 'org-mode) + (org-entry-get nil "CUSTOM_ID")) + (let ((here (org-link--file-link-to-here))) + (setq link (car here)) + (setq desc (cdr here))) + (unless (equal (list link desc) (car org-stored-links)) + (org-link--add-to-stored-links link desc))) + (car org-stored-links))))) ;;;###autoload (defun org-insert-link (&optional complete-file link-location description) diff --git a/lisp/org-id.el b/lisp/org-id.el index 8647a57cc..58d51deca 100644 --- a/lisp/org-id.el +++ b/lisp/org-id.el @@ -129,6 +129,46 @@ nil Never use an ID to make a link, instead link using a text search for (const :tag "Only use existing" use-existing) (const :tag "Do not use ID to create link" nil))) +(defcustom org-id-link-consider-parent-id nil + "Non-nil means storing a link to an Org entry considers inherited IDs. + +When this option is non-nil and `org-id-link-use-context' is +enabled, ID properties inherited from parent entries will be +considered when storing an ID link. If no ID is found in this +way, a new one may be created as normal (see +`org-id-link-to-org-use-id'). + +For example, given this org file: + +* Parent +:PROPERTIES: +:ID: abc +:END: +** Child 1 +** Child 2 + +With `org-id-link-consider-parent-id' and +`org-id-link-use-context' both enabled, storing a link with point +at \"Child 1\" will produce a link \"\". This +allows linking to uniquely-named sub-entries within a parent +entry with an ID, without requiring every sub-entry to have its +own ID." + :group 'org-link-store + :group 'org-id + :package-version '(Org . "9.7") + :type 'boolean) + +(defcustom org-id-link-use-context t + "Non-nil means enables search string context in org-id links. + +Search strings are added by `org-id-store-link' when both the +general option `org-link-context-for-files' and the org-id option +`org-id-link-use-context' are non-nil." + :group 'org-link-store + :group 'org-id + :package-version '(Org . "9.7") + :type 'boolean) + (defcustom org-id-uuid-program "uuidgen" "The uuidgen program." :group 'org-id @@ -280,15 +320,21 @@ This is useful when working with contents in a temporary buffer that will be copied back to the original.") ;;;###autoload -(defun org-id-get (&optional epom create prefix) - "Get the ID property of the entry at EPOM. -EPOM is an element, marker, or buffer position. -If EPOM is nil, refer to the entry at point. -If the entry does not have an ID, the function returns nil. -However, when CREATE is non-nil, create an ID if none is present already. -PREFIX will be passed through to `org-id-new'. -In any case, the ID of the entry is returned." - (let ((id (org-entry-get epom "ID"))) +(defun org-id-get (&optional epom create prefix inherit) + "Get the ID of the entry at EPOM. + +EPOM is an element, marker, or buffer position. If EPOM is nil, +refer to the entry at point. + +If INHERIT is non-nil, ID properties inherited from parent +entries are considered. Otherwise, only ID properties on the +entry itself are considered. + +When CREATE is nil, return the ID of the entry if found, +otherwise nil. When CREATE is non-nil, create an ID if none has +been found, and return the new ID. PREFIX will be passed through +to `org-id-new'." + (let ((id (org-entry-get epom "ID" (and inherit t)))) (cond ((and id (stringp id) (string-match "\\S-" id)) id) @@ -700,21 +746,56 @@ optional argument MARKERP, return the position as a new marker." ;; id link type -;; Calling the following function is hard-coded into `org-store-link', -;; so we do have to add it to `org-store-link-functions'. +(defun org-id--get-id-to-store-link (&optional create) + "Get or create the relevant ID for storing a link. + +Optional argument CREATE is passed to `org-id-get'. + +Inherited IDs are only considered when +`org-id-link-consider-parent-id', `org-id-link-use-context' and +`org-link-context-for-files' are all enabled, since inherited IDs +are confusing without the additional search string context. + +Note that this function resets the +`org-entry-property-inherited-from' marker: it will either point +to nil (if the id was not inherited) or to the point it was +inherited from." + (let* ((inherit-id (and org-id-link-consider-parent-id + org-id-link-use-context + org-link-context-for-files))) + (move-marker org-entry-property-inherited-from nil) + (org-id-get nil create nil inherit-id))) ;;;###autoload (defun org-id-store-link () "Store a link to the current entry, using its ID. -If before first heading store first title-keyword as description -or filename if no title." +The link description is based on the heading, or if before the +first heading, the title keyword if available, or else the +filename. + +When `org-link-context-for-files' and `org-id-link-use-context' +are non-nil, add a search string to the link. The link +description is then based on the search string target. + +When in addition `org-id-link-consider-parent-id' is non-nil, the +ID can be inherited from a parent entry, with the search string +used to still link to the current location." (interactive) - (when (and (buffer-file-name (buffer-base-buffer)) (derived-mode-p 'org-mode)) - (let* ((link (concat "id:" (org-id-get-create))) + (when (and (buffer-file-name (buffer-base-buffer)) + (derived-mode-p 'org-mode)) + ;; Get the precise target first, in case looking for an id causes + ;; a properties drawer to be added at the current location. + (let* ((precise-target (and org-link-context-for-files + org-id-link-use-context + (org-link-precise-link-target))) + (link (concat "id:" (org-id--get-id-to-store-link 'create))) + (id-location (or (and org-entry-property-inherited-from + (marker-position org-entry-property-inherited-from)) + (save-excursion (org-back-to-heading-or-point-min t) (point)))) (case-fold-search nil) (desc (save-excursion - (org-back-to-heading-or-point-min t) + (goto-char id-location) (cond ((org-before-first-heading-p) (let ((keywords (org-collect-keywords '("TITLE")))) (if keywords @@ -726,14 +807,59 @@ or filename if no title." (match-string 4) (match-string 0))) (t link))))) + ;; Precise targets should be after id-location to avoid + ;; duplicating the current headline as a search string + (when (and precise-target + (> (nth 2 precise-target) id-location)) + (setq link (concat link "::" (nth 0 precise-target))) + (setq desc (nth 1 precise-target))) (org-link-store-props :link link :description desc :type "id") link))) -(defun org-id-open (id _) - "Go to the entry with id ID." - (org-mark-ring-push) - (let ((m (org-id-find id 'marker)) - cmd) +;;;###autoload +(defun org-id-store-link-maybe (&optional interactive?) + "Store a link to the current entry using its ID if enabled. + +The value of `org-id-link-to-org-use-id' determines whether an ID +link should be stored, using `org-id-store-link'. + +Assume the function is called interactively if INTERACTIVE? is +non-nil." + (when (and (buffer-file-name (buffer-base-buffer)) + (derived-mode-p 'org-mode) + (or (eq org-id-link-to-org-use-id t) + (and interactive? + (or (eq org-id-link-to-org-use-id 'create-if-interactive) + (and (eq org-id-link-to-org-use-id + 'create-if-interactive-and-no-custom-id) + (not (org-entry-get nil "CUSTOM_ID"))))) + ;; 'use-existing + (and org-id-link-to-org-use-id + (org-id--get-id-to-store-link)))) + (org-id-store-link))) + +(defun org-id-open (link _) + "Go to the entry indicated by id link LINK. + +The link can include a search string after \"::\", which is +passed to `org-link-search'. + +For backwards compatibility with IDs that contain \"::\", if no +match is found for the ID, the full link string including \"::\" +will be tried as an ID." + (let* ((option (and (string-match "::\\(.*\\)\\'" link) + (match-string 1 link))) + (id (if (not option) link + (substring link 0 (match-beginning 0)))) + m cmd) + (org-mark-ring-push) + (setq m (org-id-find id 'marker)) + (when (and (not m) option) + ;; Backwards compatibility: if id is not found, try treating + ;; whole link as an id. + (setq m (org-id-find link 'marker)) + (when m + (setq option nil))) (unless m (error "Cannot find entry with ID \"%s\"" id)) ;; Use a buffer-switching command in analogy to finding files @@ -750,9 +876,17 @@ or filename if no title." (funcall cmd (marker-buffer m))) (goto-char m) (move-marker m nil) + (when option + (save-restriction + (unless (org-before-first-heading-p) + (org-narrow-to-subtree)) + (org-link-search option nil nil + (org-element-lineage (org-element-at-point) 'headline t)))) (org-fold-show-context))) -(org-link-set-parameters "id" :follow #'org-id-open) +(org-link-set-parameters "id" + :follow #'org-id-open + :store #'org-id-store-link-maybe) (provide 'org-id) diff --git a/lisp/org-lint.el b/lisp/org-lint.el index e65f9a7eb..9e9d56253 100644 --- a/lisp/org-lint.el +++ b/lisp/org-lint.el @@ -65,6 +65,7 @@ ;; - special properties in properties drawers, ;; - obsolete syntax for properties drawers, ;; - invalid duration in EFFORT property, +;; - invalid ID property with a double colon, ;; - missing definition for footnote references, ;; - missing reference for footnote definitions, ;; - non-footnote definitions in footnote section, @@ -686,6 +687,16 @@ Use :header-args: instead" (list (org-element-begin p) (format "Invalid effort duration format: %S" value)))))))) +(defun org-lint-invalid-id-property (ast) + (org-element-map ast 'node-property + (lambda (p) + (when (equal "ID" (org-element-property :key p)) + (let ((value (org-element-property :value p))) + (and (org-string-nw-p value) + (string-match-p "::" value) + (list (org-element-begin p) + (format "IDs should not include \"::\": %S" value)))))))) + (defun org-lint-link-to-local-file (ast) (org-element-map ast 'link (lambda (l) @@ -1697,6 +1708,11 @@ AST is the buffer parse tree." #'org-lint-invalid-effort-property :categories '(properties)) +(org-lint-add-checker 'invalid-id-property + "Report search string delimiter \"::\" in ID property" + #'org-lint-invalid-id-property + :categories '(properties)) + (org-lint-add-checker 'undefined-footnote-reference "Report missing definition for footnote references" #'org-lint-undefined-footnote-reference diff --git a/testing/lisp/test-ol.el b/testing/lisp/test-ol.el index e0cec0854..3150b4e2f 100644 --- a/testing/lisp/test-ol.el +++ b/testing/lisp/test-ol.el @@ -381,6 +381,128 @@ See https://github.com/yantar92/org/issues/4." (equal (format "[[file:%s::*foo bar][foo bar]]" file file) (org-store-link nil))))))) +(ert-deftest test-org-link/precise-link-target () + "Test `org-link-precise-link-target` specifications." + (org-test-with-temp-text "* H1\n* H2\n" + (should + (equal '("*H1" "H1" 1) + (org-link-precise-link-target)))) + (org-test-with-temp-text "* H1\n#+name: foo\n#+begin_example\nhi\n#+end_example\n" + (should + (equal '("foo" "foo" 6) + (org-link-precise-link-target)))) + (org-test-with-temp-text "\nText\n* H1\n" + (should + (equal '("Text" nil 2) + (org-link-precise-link-target)))) + (org-test-with-temp-text "\n\n* H1\n" + (should + (equal nil (org-link-precise-link-target))))) + +(defmacro test-ol-stored-link-with-text (text &rest body) + "Return :link and :description from link stored in body." + (declare (indent 1)) + `(let (org-store-link-plist) + (org-test-with-temp-text-in-file ,text + ,@body + (list (plist-get org-store-link-plist :link) + (plist-get org-store-link-plist :description))))) + +(ert-deftest test-org-link/id-store-link () + "Test `org-id-store-link' specifications." + (let ((org-id-link-to-org-use-id nil)) + (should + (equal '(nil nil) + (test-ol-stored-link-with-text "* H1\n:PROPERTIES:\n:ID: abc\n:END:\n" + (org-id-store-link-maybe t))))) + ;; On a headline, link to that headline's ID. Use heading as the + ;; description of the link. + (let ((org-id-link-to-org-use-id t)) + (should + (equal '("id:abc" "H1") + (test-ol-stored-link-with-text "* H1\n:PROPERTIES:\n:ID: abc\n:END:\n" + (org-id-store-link-maybe t))))) + ;; Remove TODO keywords etc from description of the link. + (let ((org-id-link-to-org-use-id t)) + (should + (equal '("id:abc" "H1") + (test-ol-stored-link-with-text "* TODO [#A] H1 :tag:\n:PROPERTIES:\n:ID: abc\n:END:\n" + (org-id-store-link-maybe t))))) + ;; create-if-interactive + (let ((org-id-link-to-org-use-id 'create-if-interactive)) + (should + (equal '("id:abc" "H1") + (cl-letf (((symbol-function 'org-id-new) + (lambda (&rest _rest) "abc"))) + (test-ol-stored-link-with-text "* H1\n" + (org-id-store-link-maybe t))))) + (should + (equal '(nil nil) + (test-ol-stored-link-with-text "* H1\n" + (org-id-store-link-maybe nil))))) + ;; create-if-interactive-and-no-custom-id + (let ((org-id-link-to-org-use-id 'create-if-interactive-and-no-custom-id)) + (should + (equal '("id:abc" "H1") + (cl-letf (((symbol-function 'org-id-new) + (lambda (&rest _rest) "abc"))) + (test-ol-stored-link-with-text "* H1\n" + (org-id-store-link-maybe t))))) + (should + (equal '(nil nil) + (test-ol-stored-link-with-text "* H1\n:PROPERTIES:\n:CUSTOM_ID: xyz\n:END:\n" + (org-id-store-link-maybe t)))) + (should + (equal '(nil nil) + (test-ol-stored-link-with-text "* H1\n" + (org-id-store-link-maybe nil))))) + ;; use-context should have no effect when on the headline with an id + (let ((org-id-link-to-org-use-id t) + (org-id-link-use-context t)) + (should + (equal '("id:abc" "H2") + (test-ol-stored-link-with-text "* H1\n** H2\n:PROPERTIES:\n:ID: abc\n:END:\n" + ;; simulate previously getting an inherited value + (move-marker org-entry-property-inherited-from 1) + (org-id-store-link-maybe t)))))) + +(ert-deftest test-org-link/id-store-link-using-parent () + "Test `org-id-store-link' specifications with `org-id-link-consider-parent-id` set." + ;; when using context to still find specific heading + (let ((org-id-link-to-org-use-id t) + (org-id-link-consider-parent-id t) + (org-id-link-use-context t)) + (should + (equal '("id:abc::*H2" "H2") + (test-ol-stored-link-with-text "* H1\n:PROPERTIES:\n:ID: abc\n:END:\n** H2\n" + (org-id-store-link)))) + (should + (equal '("id:abc::name" "name") + (test-ol-stored-link-with-text "* H1\n:PROPERTIES:\n:ID: abc\n:END:\n\n#+name: name\n#+begin_example\nhi\n#+end_example\n" + (org-id-store-link)))) + (should + (equal '("id:abc" "H1") + (test-ol-stored-link-with-text "* H1\n:PROPERTIES:\n:ID: abc\n:END:\n** H2\n" + (org-id-store-link)))) + ;; should not use newly added ids as search string, e.g. in an empty file + (should + (let (name result) + (setq result + (cl-letf (((symbol-function 'org-id-new) + (lambda (&rest _rest) "abc"))) + (test-ol-stored-link-with-text "" + (setq name (buffer-name)) + (org-id-store-link)))) + (equal `("id:abc" ,name) result)))) + ;; should not find targets in the next section + (let ((org-id-link-to-org-use-id 'use-existing) + (org-id-link-consider-parent-id t) + (org-id-link-use-context t)) + (should + (equal '(nil nil) + (test-ol-stored-link-with-text "* H1\n:PROPERTIES:\n:ID: abc\n:END:\n* H2\n** Target\n" + (org-id-store-link-maybe t)))))) + ;;; Radio Targets