#+TITLE: Taking Notes with Org
#+AUTHOR: Howard X. Abrams
#+DATE:   2026-07-14
#+FILETAGS: emacs hamacs
#+LASTMOD: [2026-07-14 Tue]

A literate programming file for configuring Emacs to take notes.

#+begin_src emacs-lisp :exports none
  ;;; ha-code-notes --- configuring Emacs to take notes. -*- lexical-binding: t; -*-
  ;;
  ;; © 2026 Howard X. Abrams
  ;;   Licensed under a Creative Commons Attribution 4.0 International License.
  ;;   See http://creativecommons.org/licenses/by/4.0/
  ;;
  ;; Author: Howard X. Abrams <http://gitlab.com/howardabrams>
  ;; Maintainer: Howard X. Abrams
  ;; Created: July 14, 2026
  ;;
  ;; While obvious, GNU Emacs does not include this file or project.
  ;;
  ;; *NB:* Do not edit this file. Instead, edit the original literate file at:
  ;;            /Users/howard.abrams/src/hamacs/ha-code-notes.org
  ;;       And tangle the file to recreate this one.
  ;;
  ;;; Code:
#+end_src

* Introduction
Seems silly to have a full set of instructions for /taking notes/ using Org-mode, because that is what it does, but this is a bit special.

When I [[file:ha-capturing-notes.org][capture notes]] with a link to some source code, the /destination/ is a *clocked in* task. Useful, but seems to require a dedicated task and some forethought. The following project pairs a “source code file” (which can be any file, actually) and a “notes file” with headers based on the function in the original file.

#+begin_src dot :file ha-code-notes-illustration.png :exports file :results file
  digraph G {
    rankdir=LR;
    // Make to icons side-by-side
    code_file [shape=note, label="", fixedsize=true, width=1, height=1.5];
    note_file [shape=note, label="", fixedsize=true, width=1, height=1.5];

    // Create a couple of label to live _below_ the icons:
    code_label [shape=plaintext, label="foobar.py"];
    note_label [shape=plaintext, label=".foobar-note.org"];
    { rank=same; code_file; code_label; }
    { rank=same; note_file; note_label; }
    code_file -> code_label [style=invis];
    note_file -> note_label [style=invis];

    // Connect the code and note icons:
    code_file -> note_file;
  }
#+end_src

#+attr_org: :width 800px
[[file:ha-code-notes-illustration.png]]

This allows me to /wax poetic/ with a parallel, but separate org file.
* Helper Functions
While this project started simple, I’ve expanded the ideas, and this requires some custom helper functions. For instance, I want to add a buffer-level /property/ to an org-mode file, for instance:

#+begin_example
#+TITLE: The title of the file
#+PROPERTY: key1 value1
#+end_example

The tricky bit about this function is that if (in the above example) =key1= exists, we should /replace/ it, not add to it.

#+BEGIN_SRC emacs-lisp
  (defun ha-org-set-buffer-property (key value)
    "Set a buffer-level property at the top of an Org file.
  If KEY already refers to a #+PROPERTY, replace it.
  Otherwise, insert it at the end of the Org header lines."
    (let ((property-line (format "#+PROPERTY: %s %s" key value))
          (magic-re (rx  (group line-start "#+PROPERTY:"
                                    space (literal key) space
                                    (zero-or-more any)
                                    line-end))))
      (defun process-line ()
        "Helper function returns non-nil if more to process.
      This works because `replace-match' and `insert' return nil,
      while `forward-line' returns a non-nil value."
        (cond
         ((looking-at magic-re) (replace-match property-line))
         ((looking-at "#")      (forward-line))
         (t                     (insert property-line ?\n))))

      (save-excursion
        (goto-char (point-min))
        (while (process-line)))))
#+END_SRC

And we need the ability to read it. The Org API doesn’t have a function to read it without parsing the entire structure, so we use the =org-collect-keywords= for the =PROPERTY= value, and then parse the results.

#+BEGIN_SRC emacs-lisp
  (defun ha-org-get-buffer-property (key)
    "Get the value of a buffer-level #+PROPERTY matching KEY."
    (let* ((properties (thread-last "PROPERTY"
                                    (list)   ; Requires a list of keywords
                                    (org-collect-keywords)
                                    (car)))  ; A list of lists? Get first entry
           ;; Each entry in properties is a string with the key and value:
           (entry (seq-find (lambda (k) (s-starts-with? key k)) properties)))
      (substring entry (1+ (s-index-of " " entry)))))
#+END_SRC

They work like:
#+BEGIN_SRC emacs-lisp :tangle no
  (ha-org-set-buffer-property "foor" "bar")
  (ha-org-get-buffer-property "foo")  ; ⟹ "bar"
#+END_SRC

IMenu has a /mode-agnostic/ approach to jumping to /sections/. This could work whether the file is an formatted in org-mode, markdown, or even source code. This function allows me to jump to a particular header in either Org, Markdown, or other files that define the =defun= interface differently.

#+BEGIN_SRC emacs-lisp
  (defun imenu-goto (header)
    "Jump to the first `imenu' entry whose name contains HEADER.
  Like `consult-imenu', this matches HEADER as a case-insensitive
  substring rather than requiring an exact name, and jumps straight
  to the first hit instead of prompting.

  Note that this won't work if IMenu is stale and requires refreshing."
    ;; Overshadowing imenu's lookup function seems overly sneaky!
    (let ((imenu-name-lookup-function
           (lambda (str name)
             (let ((case-fold-search t))
               (string-match-p (regexp-quote str) name)))))
      ;; Calling `imenu' programmatically is a pain!
      (when-let ((item (imenu--in-alist header (imenu--make-index-alist))))
        (imenu item))))
#+END_SRC

* Code Notes
This function defines what the notes filename should look like, loads it in a side window, and adds a /back reference/ in the form of an org-mode =property=. Next it needs to either find the section that matches the function in the code we are writing about, or jumps to the bottom and creates it.

#+BEGIN_SRC emacs-lisp
  (defun ha-code-notes ()
    "Open an Org file based on the current opened file.
  The pattern for choosing the name of the org file is:

     foobar.py --> .foobar-notes.org

  The Org header will be the name of the function in the original source
  code file. This means you have one note section per function, which
  should be fine in practice because functions are small and succinct,
  right?"
    (interactive)
    (let* ((orig-file   (buffer-file-name))
           (line-num    (line-number-at-pos))
           (orig-parent (file-name-directory orig-file))
           (orig-base   (file-name-base orig-file))

           ;; Keep in mind the `orig-parent' has a final slash, so the
           ;; initial . here marks it as hidden:
           (note-file   (format "%s.%s-notes.org"
                                orig-parent orig-base))
           (header      (which-function)))

      ;; With the above local variables defined, we can open the file in
      ;; a window (pane) to the side:
      (find-file-other-window note-file)
      (ha-org-set-buffer-property "XREF" orig-file)

      (goto-char (point-min))  ; jump to start of file

      ;; The `condition-case' is Elisp's way of a try..catch:
      (condition-case nil
          ;; Find the first Org header that matches `header':
          (re-search-forward (rx line-start
                                 (one-or-more "*")
                                 (one-or-more space)
                                 (optional (or "=" "~"))
                                 (literal header)
                                 (optional (or "=" "~"))))
        (error
         ;; We didn't find a header matching the function, so we jump to
         ;; the end of the file and create a new heading:
         (goto-char (point-max))
         (org-insert-heading)
         (insert (format "%s" header))
         (org-insert-property-drawer)
         (org-set-property "XREF_LINE" (number-to-string line-num))
         ;; In case we want to change the section header name, we store
         ;; the name of the function that led us here as a property:
         (org-set-property "XREF_NAME" header)

         (when-let ((buf (find-buffer-visiting orig-file)))
           (with-current-buffer buf
             (ha-code--fringe-notes)))))

      ;; We are somewhere in the file, so go to the end of the block:
      (org-end-of-subtree)

      ;; If the subtree ends mid-line, insert a newline:
      (unless (eq (line-beginning-position) (point))
        (end-of-line)
        (insert "\n"))))
#+END_SRC

Use the builtin autoinsert feature to inject a basic template at the beginning of the notes file when we first create the notes file:

#+BEGIN_SRC emacs-lisp
    (use-package autoinsert
      :config
      (define-auto-insert
        (cons (rx "/." (one-or-more (not "/")) "-notes.org" string-end) "Org Notes Template")
         '("Short description: "
           "#+TITLE: "
           (s-titleized-words (s-replace-regexp (rx (any "-" "_")) " "
                                               (file-name-base (buffer-file-name))))
           \n
           "#+DATE:"   (format-time-string "%Y-%m-%d %a")
           \n
           "#+LASTMOD:" (format-time-string "[%Y-%m-%d %a]")
           \n \n)))
#+END_SRC

If we are /inside/ one of these note files, let’s have a quick way to return back to the original “code” file:

#+BEGIN_SRC emacs-lisp
  (defun ha-code-notes-return ()
    "Return to the code referenced in the notes.
  Essentially pretends we have a backlink without a database."
    (interactive)
    (let* ((filename (ha-org-get-buffer-property "XREF"))
           (line-num (car (org-property-values "XREF_LINE")))
           (function (or (car (org-property-values "XREF_NAME")) (which-function))))

      ;; If the property is set, load that file (which jumps to the
      ;; buffer if it is displayed), otherwise, we assume the previous
      ;; buffer contains it:
      (if filename
          (find-file-other-window filename)
        (switch-to-prev-buffer))
      (if line-num
          (goto-line (string-to-number line-num)))

      ;; If the line number got out of sync so that the point is no
      ;; longer in the correct function, use `find-function' to
      ;; reposition the point:
      (unless (equal (which-function) function)
        (if (derived-mode-p 'prog-mode)
            (xref-find-definitions function)
          (imenu-goto function)))))
#+END_SRC

And give us keybinding that will either go to the notes (if we are in some code) or return to the source code (if we are in our notes):

#+BEGIN_SRC emacs-lisp
  (defun ha-code-notes-dwim ()
    "Open the notes buffer, or return to the code."
    (interactive)
    (when (buffer-file-name)
      (if (string-match (rx "/." ; A hidden file
                            (one-or-more (not "/"))
                            "-notes.org" string-end)
                        (buffer-file-name))
          (ha-code-notes-return)
        (ha-code-notes))))

  (ha-leader "n c" '("code notes" . ha-code-notes-dwim))
#+END_SRC

** Fringe Indicators for Notes

A code file with an associated notes file is easy to forget about. Let's mark, in the fringe, every line that has a note, so we notice it as we scroll past:

#+BEGIN_SRC emacs-lisp
  (define-fringe-bitmap 'ha-code-notes-bitmap
    [#b00001100
     #b00010110
     #b00010111
     #b00101110
     #b00101110
     #b01011100
     #b01011100
     #b10110000
     #b10010000
     #b11100000]
    nil nil 'center)

  (defface ha-code-notes-face
    '((t :foreground "yellow"))
    "Face for the fringe marker indicating a line has an associated note.")

  (defvar-local ha-code-notes-fringe-overlays nil
    "Overlays marking lines in this buffer that have notes in the paired notes file.")

  (defun ha-code-notes--fringe ()
    "Mark, in the fringe, every line in this buffer that has a note.
  Notes live in the paired `.BASE-notes.org' file (see
  `ha-code-notes') as headings whose XREF/XREF_LINE
  properties point back to a line in this file."
    (mapc #'delete-overlay ha-code-notes--fringe-overlays)
    (setq ha-code--notes--fringe-overlays nil)
    (let* ((orig-file (buffer-file-name))
           (note-file (and orig-file
                           (format "%s.%s-notes.org"
                                   (file-name-directory orig-file)
                                   (file-name-base orig-file))))
           lines)
      (when (and note-file (file-exists-p note-file))
        (with-temp-buffer
          (insert-file-contents note-file)
          (org-mode)
          (org-map-entries
           (lambda ()
             (when (equal (ha-org-get-buffer-property "XREF") orig-file)
               (push (string-to-number (org-entry-get nil "XREF_LINE")) lines)))))
        (dolist (line lines)
          (save-excursion
            (goto-char (point-min))
            (forward-line (1- line))
            (let ((ov (make-overlay (point) (point))))
              (overlay-put ov 'before-string
                           (propertize "x" 'display
                                       '(left-fringe ha-code-notes-bitmap
                                                     ha-code-notes-face)))
              (push ov ha-code-notes--fringe-overlays)))))))

  (add-hook 'find-file-hook #'ha-code-notes--fringe)
#+END_SRC

New notes and edited notes should refresh the markers too, so we hook into saving a notes file, and refresh right after inserting a new XREF property drawer:

#+BEGIN_SRC emacs-lisp
  (defun ha-code-notes--fringe-notes-refresh-all ()
    "Refresh fringe note markers in every buffer after saving a notes file."
    (when (string-match (rx "-notes.org" string-end) (buffer-file-name))
      (dolist (buf (buffer-list))
        (with-current-buffer buf
          (when buffer-file-name
            (ha-code--fringe-notes))))))

  (add-hook 'after-save-hook #'ha-code-notes--fringe-notes-refresh-all)
#+END_SRC

* Technical Artifacts                                :noexport:

Let's =provide= a name so we can =require= this file:

#+begin_src emacs-lisp :exports none
  (provide 'ha-code-notes)
  ;;; ha-code-notes.el ends here
#+end_src

#+DESCRIPTION: configuring Emacs to take notes.

#+PROPERTY:    header-args:sh :tangle no
#+PROPERTY:    header-args:emacs-lisp  :tangle yes
#+PROPERTY:    header-args    :results none :eval no-export :comments no mkdirp yes

#+OPTIONS:     num:nil toc:nil todo:nil tasks:nil tags:nil date:nil
#+OPTIONS:     skip:nil author:nil email:nil creator:nil timestamp:nil
#+INFOJS_OPT:  view:nil toc:nil ltoc:t mouse:underline buttons:0 path:http://orgmode.org/org-info.js
