355 lines
14 KiB
Org Mode
355 lines
14 KiB
Org Mode
#+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
|