protesilaos / denote

Simple notes for Emacs with an efficient file-naming scheme
https://protesilaos.com/emacs/denote
GNU General Public License v3.0
533 stars 55 forks source link

Making file links compatible between Denote and Obsidian #475

Open hyperfocus1337 opened 1 week ago

hyperfocus1337 commented 1 week ago

Denote takes care for my note taking needs on desktop, Obsidian helps me make notes on my smartphone.

Using my Obsidian templater template, I'm able to create markdown notes in Obsidian (both desktop and app) that contain the default Denote file type and front matter.

What is missing, however, is compatibility between the way Obsidian and Denote create (markdown) links.

To achieve this compatibility, the following is required:

This is how Obsidian creates links:

Wikilink: [[file-name]]​
Normal markdown link: [file-name](file-name.md)

This is how Denote creates links:

Normal markdown link: [Note title](denote:20241105T084137)

Whether the function should be:

Can be up for debate!

protesilaos commented 6 days ago

Hello @hyperfocus1337!

Since we already have denote-md-extras.el, I think we can have this functionality there. There will not be any Obsidian-specific code in it, anyway, but a mere conversion of links from/to the expected formats.

We do not do anything with Wikilinks for now, but this seems easy to do. Let's focus on the other scenario:

Normal markdown link: [file-name](file-name.md)

Can you try this?

(defun denote-md-extras-convert-links-to-obsidian-type ()
  "Convert denote: links to Obsidian-style file paths.
Ignore all other link types.  Also ignore links that do not
resolve to a file in the variable `denote-directory'."
  (interactive nil markdown-mode)
  (if (derived-mode-p 'markdown-mode)
      (save-excursion
        (let ((count 0))
          (goto-char (point-min))
          (while (re-search-forward (denote-md-extras--get-regexp 'obsidian) nil :no-error)
            (when-let* ((id (match-string-no-properties 1))
                        (path (save-match-data (denote-get-relative-path-by-id id)))
                        (name (file-name-sans-extension path)))
              (replace-match (format "[%s](%s)" name path) :fixed-case :literal)
              (setq count (1+ count))))
          (message "Converted %d `denote:' links to Obsidian-style format" count)))
    (user-error "The current file is not using Markdown mode")))

If it works for you, then we can check how to make the conversion back to denote: links, like we do now with denote-md-extras-convert-links-to-denote-type.

protesilaos commented 6 days ago

Sorry, I just realised you are missing this helper function first:

(defun denote-md-extras--get-regexp (type)
  "Return regular expression to match link TYPE.
TYPE is a symbol among `denote', `file', `obsidian', and `reverse-obsidian'."
  (pcase type
    ('denote "(denote:\\(?1:.*?\\))")
    ('file (format "(.*?\\(?1:%s\\).*?)" denote-id-regexp))
    ('obsidian "\\(?2:\\[.*?\\]\\)(denote:\\(?1:.*?\\))")
    ('reverse-obsidian (format "\\(?2:\\[.*?\\(?:%s\\).*?\\]\\)(\\(?1:.*?\\(?:%s\\).*?\\))" denote-id-regexp denote-id-regexp))
    (_ (error "`%s' is an unknown type of link" type))))

So putting it all together:

(defun denote-md-extras-convert-links-to-obsidian-type ()
  "Convert denote: links to Obsidian-style file paths.
Ignore all other link types.  Also ignore links that do not
resolve to a file in the variable `denote-directory'."
  (interactive nil markdown-mode)
  (if (derived-mode-p 'markdown-mode)
      (save-excursion
        (let ((count 0))
          (goto-char (point-min))
          (while (re-search-forward (denote-md-extras--get-regexp 'obsidian) nil :no-error)
            (when-let* ((id (match-string-no-properties 1))
                        (path (save-match-data (denote-get-relative-path-by-id id)))
                        (name (file-name-sans-extension path)))
              (replace-match (format "[%s](%s)" name path) :fixed-case :literal)
              (setq count (1+ count))))
          (message "Converted %d `denote:' links to Obsidian-style format" count)))
    (user-error "The current file is not using Markdown mode")))

And then this:

(defun denote-md-extras-convert-obsidian-links-to-denote-type ()
  "Convert Obsidian-style links to denote: links in the current Markdown buffer.
Ignore all other link types.  Also ignore file links that do not point
to a file with a Denote file name.

Also see `denote-md-extras-convert-links-to-denote-type'."
  (interactive nil markdown-mode)
  (if (derived-mode-p 'markdown-mode)
      (save-excursion
        (let ((count 0))
          (goto-char (point-min))
          (while (re-search-forward (denote-md-extras--get-regexp 'reverse-obsidian) nil :no-error)
            (let ((file nil)
                  (id nil)
                  (description nil))
              (save-match-data
                (setq file (expand-file-name (match-string-no-properties 1) (denote-directory))
                      id (denote-retrieve-filename-identifier file)
                      description (denote-get-link-description file)))
              (when id
                (replace-match (format "[%s](denote:%s)" description id) :fixed-case :literal)
                (setq count (1+ count)))))
          (message "Converted %d Obsidian-style links to `denote:' links" count)))
    (user-error "The current file is not using Markdown mode")))

I will still need to review the documentation, but this should be good for now.

hyperfocus1337 commented 6 days ago

Thank you Prot! I reviewed it, and it works perfectly. I also managed to generate a personal function to create Obsidian style links, could you review this one for me?

(defun my/denote-obsidian-link (file file-type description &optional id-only)
  "Create Obsidian-style link to FILE note in variable `denote-directory'.

Similar to `denote-link' but creates links in Obsidian's format: [filename](filename.md).
FILE-TYPE is ignored as Obsidian uses the same format regardless of file type.
DESCRIPTION and ID-ONLY are ignored as this format always uses the filename."
  (interactive
   (let* ((file (denote-file-prompt nil "Link to FILE"))
          (file-type (denote-filetype-heuristics buffer-file-name))
          (description (when (file-exists-p file)
                        (denote-get-link-description file))))
     (list file file-type description current-prefix-arg)))
  (unless (file-exists-p file)
    (user-error "The linked file does not exist"))
  (let* ((relative-path (denote-get-relative-path-by-id
                        (denote-retrieve-filename-identifier file)))
         (name (file-name-sans-extension relative-path)))
    (denote--delete-active-region-content)
    (insert (format "[%s](%s)" name relative-path))))

(defalias 'my/denote-insert-obsidian-link 'my/denote-obsidian-link
  "Alias for `denote-obsidian-link' command.")

Thanks!! My note taking setup is close to perfect!!

protesilaos commented 6 days ago

About your function, here are some small tweaks. The only one you really need is the use of denote-get-file-name-relative-to-denote-directory.

(defun my/denote-obsidian-link (file file-type description &optional id-only)
  "Create Obsidian-style link to FILE note in variable `denote-directory'.

Similar to `denote-link' but creates links in Obsidian's format: [filename](filename.md).
FILE-TYPE is ignored as Obsidian uses the same format regardless of file type.
DESCRIPTION and ID-ONLY are ignored as this format always uses the filename."
  (interactive
   (let* ((file (denote-file-prompt "\.md" "Link to FILE")) ; Note the use of .md to only show markdown files
          (file-type 'markdown-yaml) ; Only markdown
          (description (when (file-exists-p file)
                         (denote-get-link-description file))))
     (list file file-type description current-prefix-arg)))
  (unless (file-exists-p file)
    (user-error "The linked file does not exist"))
  (let* ((relative-path (denote-get-file-name-relative-to-denote-directory file)) ; We have fn to get rel path
         (name (file-name-sans-extension relative-path)))
    (denote--delete-active-region-content)
    (insert (format "[%s](%s)" name relative-path))))
protesilaos commented 6 days ago

Thank you Prot!

You are welcome!

I reviewed it, and it works perfectly.

Very well! I will push the changes today. Do you have any idea on how to call these types of links beside "Obsidian" or derivatives? If there is some generic name, I prefer to use that (of course, I can come up with a term, but maybe there is one already).

hyperfocus1337 commented 5 days ago

Do you have any idea on how to call these types of links beside "Obsidian" or derivatives?

I would say it makes it easier to find these function names using M-x if you keep Obsidian in the name. It would also make it more relatable for other Denote users that might be searching for similar functionality. These functions might more easily disappear by obscurity with a generic name.

Given that Obsidian is one of the most popular note taking tools out there (alongside Notion, but it's a SaaS), I would say it makes sense to make a small exception for a few functions in a extras file.

Finally, I would say there is not really a good reason to use these style of links if you're not looking to achieve compatibility with Obsidian, because if you decide to change the title or keywords of the link at a later point, the link would break.

That being said the most generic and fitting title I could think of would be denote-md-extras-convert-denote-links-to-file-name-type and denote-md-extras-convert-file-name-links-to-denote-type. I guess this also sounds pretty good. Works for me too.

protesilaos commented 5 days ago

Thank you for the feedback! Those are all good points. I kept the "Obsidian" name and pushed the changes. Let me know if we need something else.