diff options
| author | Craig Jennings <c@cjennings.net> | 2025-11-21 08:43:19 -0800 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-11-21 08:43:19 -0800 |
| commit | 1752c6e58906721a47afc56e357b7fb1c0c8a5a1 (patch) | |
| tree | d7ea54fa3831c198c96d85eb4a17de7b41d30f75 | |
| parent | 42e3fd284600c84c4158bee6bb0092eba99bece5 (diff) | |
feat(org-noter): implement custom org-noter workflow
Implemented custom org-noter workflow with F6 keybinding:
- Creates notes files as org-roam nodes in org-roam-directory
- Title prompt with pre-slugified default, notes-on-{slug}.org format
- F6 toggles notes window visibility when session active
- Preserves PDF fit setting on toggle
- Deferred org-roam integration to prevent PDF open hang
Also fixed: quick-sdcv quit binding, calendar-sync sentinel buffer error
Added 30 ERT tests for title-to-slug and template generation functions
| -rw-r--r-- | init.el | 2 | ||||
| -rw-r--r-- | modules/org-noter-config.el | 254 | ||||
| -rw-r--r-- | tests/test-org-noter--generate-notes-template.el | 109 | ||||
| -rw-r--r-- | tests/test-org-noter--title-to-slug.el | 100 |
4 files changed, 431 insertions, 34 deletions
@@ -123,7 +123,7 @@ (require 'org-refile-config) ;; refile org-branches (require 'org-roam-config) ;; personal knowledge management in org mode (require 'org-webclipper) ;; "instapaper" to org-roam workflow -;; (require 'org-noter-config) ;; wip +(require 'org-noter-config) ;; -------------------------- AI Integration And Tools ------------------------- diff --git a/modules/org-noter-config.el b/modules/org-noter-config.el index 9a2afe9a..fc578085 100644 --- a/modules/org-noter-config.el +++ b/modules/org-noter-config.el @@ -1,62 +1,250 @@ -;;; org-noter-config.el --- -*- coding: utf-8; lexical-binding: t; -*- +;;; org-noter-config.el --- Org-noter configuration -*- coding: utf-8; lexical-binding: t; -*- ;;; Commentary: -;; Org-noter configuration for taking notes on PDF and DjVu documents. Workflow: -;; open a PDF/DjVu file in Emacs, press F6 to start org-noter session, frame -;; splits with document on one side and notes on the other, notes are saved to -;; ~/sync/org-noter/reading-notes.org by default, and position is automatically -;; saved when closing session. Features include integration with pdf-tools and -;; djvu, org-roam integration for linking notes, automatic session resumption at -;; last position, inserting highlighted text into notes, notes following -;; TASK: Aborted Commentary +;; +;; Org-noter configuration for taking notes on PDF and EPUB documents. +;; +;; Workflow: +;; 1. Open a PDF (pdf-view-mode) or EPUB (nov-mode) in Emacs +;; 2. Press F6 to start org-noter session +;; 3. If new book: prompted for title, creates notes file as org-roam node +;; 4. If existing book: finds and opens associated notes file +;; 5. Window splits with document on left (2/3) and notes on right (1/3) +;; 6. Use 'i' to insert notes at current location +;; 7. Notes are saved as org-roam nodes in org-roam-directory +;; +;; Can also start from notes file: open notes via org-roam, press F6 to open document. +;; +;; See docs/org-noter-workflow-spec.org for full specification. ;;; Code: +(require 'cl-lib) + +;; Forward declarations +(declare-function org-id-uuid "org-id") +(declare-function nov-mode "ext:nov") +(declare-function pdf-view-mode "ext:pdf-view") +(defvar nov-file-name) +(defvar org-roam-directory) +(defvar org-dir) + +;;; Configuration Variables + +(defvar cj/org-noter-notes-directory + (if (boundp 'org-roam-directory) + org-roam-directory + (expand-file-name "~/sync/org/roam/")) + "Directory where org-noter notes files are stored. +Defaults to `org-roam-directory' so notes are indexed by org-roam.") + +(defvar cj/org-noter-keybinding (kbd "<f6>") + "Keybinding to start org-noter session.") + +(defvar cj/org-noter-split-direction 'horizontal + "Direction to split window for notes. +`vertical' puts notes on the right (side-by-side). +`horizontal' puts notes on the bottom (stacked).") + +(defvar cj/org-noter-split-fraction 0.67 + "Fraction of window for document (notes get the remainder). +Default 0.67 means document gets 2/3, notes get 1/3.") + +;;; Helper Functions + +(defun cj/org-noter--title-to-slug (title) + "Convert TITLE to lowercase hyphenated slug for filename. +Example: \"The Pragmatic Programmer\" -> \"the-pragmatic-programmer\"" + (let ((slug (downcase title))) + (setq slug (replace-regexp-in-string "[^a-z0-9]+" "-" slug)) + (setq slug (replace-regexp-in-string "^-\\|-$" "" slug)) + slug)) + +(defun cj/org-noter--generate-notes-template (title doc-path) + "Generate org-roam notes template for TITLE and DOC-PATH." + (format ":PROPERTIES: +:ID: %s +:ROAM_REFS: %s +:NOTER_DOCUMENT: %s +:END: +#+title: Notes on %s +#+FILETAGS: :ReadingNotes: +#+CATEGORY: %s + +* Notes +" + (org-id-uuid) + doc-path + doc-path + title + title)) + +(defun cj/org-noter--in-document-p () + "Return non-nil if current buffer is a PDF or EPUB document." + (or (derived-mode-p 'pdf-view-mode) + (derived-mode-p 'nov-mode))) + +(defun cj/org-noter--in-notes-file-p () + "Return non-nil if current buffer is an org-noter notes file." + (and (derived-mode-p 'org-mode) + (save-excursion + (goto-char (point-min)) + (org-entry-get nil "NOTER_DOCUMENT")))) + +(defun cj/org-noter--get-document-path () + "Get file path of current document." + (cond + ((derived-mode-p 'nov-mode) nov-file-name) + ((derived-mode-p 'pdf-view-mode) (buffer-file-name)) + (t nil))) + +(defun cj/org-noter--extract-document-title () + "Extract title from current document filename. +Uses filename (without extension) for both PDFs and EPUBs." + (file-name-base (cj/org-noter--get-document-path))) + +(defun cj/org-noter--find-notes-file () + "Find existing notes file for current document. +Searches `cj/org-noter-notes-directory' for org files with matching +NOTER_DOCUMENT property. Returns path to notes file or nil." + (let ((doc-path (cj/org-noter--get-document-path))) + (when doc-path + (cl-find-if + (lambda (file) + (with-temp-buffer + (insert-file-contents file nil 0 1000) + (string-match-p (regexp-quote doc-path) (buffer-string)))) + (directory-files cj/org-noter-notes-directory t "\\.org$"))))) + +(defun cj/org-noter--create-notes-file () + "Create new org-roam notes file for current document. +Prompts user to confirm/edit title (pre-slugified), generates filename, +creates org-roam node with proper properties. Returns path to new file." + (let* ((doc-path (cj/org-noter--get-document-path)) + (default-title (cj/org-noter--title-to-slug + (cj/org-noter--extract-document-title))) + (title (read-string "Notes title: " default-title)) + (slug (cj/org-noter--title-to-slug title)) + (filename (format "notes-on-%s.org" slug)) + (filepath (expand-file-name filename cj/org-noter-notes-directory))) + (unless (file-exists-p filepath) + (with-temp-file filepath + (insert (cj/org-noter--generate-notes-template title doc-path)))) + (find-file-noselect filepath) + filepath)) + +;;; Main Entry Point + +(defun cj/org-noter--session-active-p () + "Return non-nil if an org-noter session is active for current buffer." + (and (boundp 'org-noter--session) + org-noter--session)) + +(defun cj/org-noter--toggle-notes-window () + "Toggle visibility of notes window in active org-noter session. +Preserves PDF fit setting when toggling." + (let ((notes-window (org-noter--get-notes-window)) + (pdf-fit (and (derived-mode-p 'pdf-view-mode) + (bound-and-true-p pdf-view-display-size)))) + (if notes-window + (delete-window notes-window) + (org-noter--get-notes-window 'start)) + ;; Restore PDF fit setting + (when pdf-fit + (pcase pdf-fit + ('fit-width (pdf-view-fit-width-to-window)) + ('fit-height (pdf-view-fit-height-to-window)) + ('fit-page (pdf-view-fit-page-to-window)) + (_ nil))))) + +(defun cj/org-noter-start () + "Start org-noter session or toggle notes window if session active. +When called from a document (PDF/EPUB): + - If session active: toggle notes window visibility + - If no session: find or create notes file, start session +When called from a notes file: + - If session active: switch to document window + - If no session: start session" + (interactive) + (cond + ;; In document with active session - toggle notes + ((and (cj/org-noter--in-document-p) + (cj/org-noter--session-active-p)) + (cj/org-noter--toggle-notes-window)) + ;; In notes file with active session - switch to document + ((and (cj/org-noter--in-notes-file-p) + (cj/org-noter--session-active-p)) + (let ((doc-window (org-noter--get-doc-window))) + (when doc-window + (select-window doc-window)))) + ;; In document without session - start new session + ((cj/org-noter--in-document-p) + (let ((notes-file (or (cj/org-noter--find-notes-file) + (cj/org-noter--create-notes-file)))) + (when notes-file + ;; Open notes file and call org-noter from there + (find-file notes-file) + (goto-char (point-min)) + (org-noter)))) + ;; In notes file without session - start session + ((cj/org-noter--in-notes-file-p) + (org-noter)) + (t + (message "Not in a document or org-noter notes file")))) + +;;; Package Configuration + (use-package djvu :defer 0.5) -(use-package pdf-tools - :defer t - :mode ("\\.pdf\\'" . pdf-view-mode) - :config - (pdf-tools-install :no-query)) - (use-package org-pdftools :after (org pdf-tools) :hook (org-mode . org-pdftools-setup-link)) +(global-set-key (kbd "<f6>") #'cj/org-noter-start) + (use-package org-noter - :after (:any org pdf-tools djvu) + :after (:any org pdf-tools djvu nov) :commands org-noter :config + ;; Window layout based on cj/org-noter-split-direction + (setq org-noter-notes-window-location + (if (eq cj/org-noter-split-direction 'vertical) + 'horizontal-split ; confusingly named: horizontal-split = side-by-side + 'vertical-split)) ; vertical-split = stacked + + ;; Split ratio from configuration (first is notes, second is doc) + (setq org-noter-doc-split-fraction + (cons (- 1.0 cj/org-noter-split-fraction) + cj/org-noter-split-fraction)) + ;; Basic settings (setq org-noter-always-create-frame nil) - (setq org-noter-notes-window-location 'horizontal-split) - (setq org-noter-notes-window-behavior '(start scroll)) ; note: must be a list! - (setq org-noter-doc-split-fraction '(0.5 . 0.5)) - (setq org-noter-notes-search-path (list (concat org-dir "/org-noter/"))) - (setq org-noter-default-notes-file-names '("reading-notes.org")) + (setq org-noter-notes-window-behavior '(start scroll)) + (setq org-noter-notes-search-path (list cj/org-noter-notes-directory)) (setq org-noter-separate-notes-from-heading t) - (setq org-noter-kill-frame-at-session-end t) ; kill frame when closing session + (setq org-noter-kill-frame-at-session-end nil) - (setq org-noter-auto-save-last-location t) ; Save position when closing - (setq org-noter-insert-selected-text-inside-note t) ; Insert highlighted text - (setq org-noter-closest-tipping-point 0.3) ; When to show closest previous note - (setq org-noter-hide-other t) ; Hide unrelated notes + (setq org-noter-auto-save-last-location t) + (setq org-noter-insert-selected-text-inside-note t) + (setq org-noter-closest-tipping-point 0.3) + (setq org-noter-hide-other t) - ;; Load the integration file if it exists in your config + ;; Load integration file if exists (let ((integration-file (expand-file-name "org-noter-integration.el" - (file-name-directory (locate-library "org-noter"))))) - (when (file-exists-p integration-file) - (load integration-file))) + (file-name-directory (locate-library "org-noter"))))) + (when (file-exists-p integration-file) + (load integration-file))) - ;; If you want to use the org-noter-pdftools integration features + ;; PDF tools integration (when (featurep 'org-noter-integration) (setq org-noter-use-pdftools-link-location t) (setq org-noter-use-org-id t) (setq org-noter-use-unique-org-id t)) - (org-noter-enable-org-roam-integration)) + ;; Defer org-roam integration to avoid slowing PDF load + (with-eval-after-load 'org-roam + (org-noter-enable-org-roam-integration))) (provide 'org-noter-config) -;;; org-noter-config.el ends here. +;;; org-noter-config.el ends here diff --git a/tests/test-org-noter--generate-notes-template.el b/tests/test-org-noter--generate-notes-template.el new file mode 100644 index 00000000..df545ccf --- /dev/null +++ b/tests/test-org-noter--generate-notes-template.el @@ -0,0 +1,109 @@ +;;; test-org-noter--generate-notes-template.el --- Tests for cj/org-noter--generate-notes-template -*- lexical-binding: t; -*- + +;;; Commentary: +;; ERT tests for the generate-notes-template function used in org-noter workflow. +;; Tests cover normal, boundary, and error cases. + +;;; Code: + +(require 'ert) +(require 'org-noter-config) + +;;; Test Helpers + +(defun test-org-noter--template-has-property (template property value) + "Check if TEMPLATE contains PROPERTY with VALUE in properties drawer." + (string-match-p (format ":%s: %s" property (regexp-quote value)) template)) + +(defun test-org-noter--template-has-keyword (template keyword value) + "Check if TEMPLATE contains #+KEYWORD: VALUE." + (string-match-p (format "#\\+%s: %s" keyword (regexp-quote value)) template)) + +;;; Normal Cases + +(ert-deftest test-org-noter--generate-notes-template-normal-basic () + "Normal case: Basic template generation." + (let ((template (cj/org-noter--generate-notes-template "Test Book" "/path/to/book.pdf"))) + (should (stringp template)) + (should (string-match-p ":PROPERTIES:" template)) + (should (string-match-p ":END:" template)) + (should (string-match-p "\\* Notes" template)))) + +(ert-deftest test-org-noter--generate-notes-template-normal-has-id () + "Normal case: Template has ID property." + (let ((template (cj/org-noter--generate-notes-template "Test Book" "/path/to/book.pdf"))) + (should (string-match-p ":ID: [a-f0-9-]+" template)))) + +(ert-deftest test-org-noter--generate-notes-template-normal-has-noter-document () + "Normal case: Template has NOTER_DOCUMENT property." + (let ((template (cj/org-noter--generate-notes-template "Test Book" "/path/to/book.pdf"))) + (should (test-org-noter--template-has-property template "NOTER_DOCUMENT" "/path/to/book.pdf")))) + +(ert-deftest test-org-noter--generate-notes-template-normal-has-roam-refs () + "Normal case: Template has ROAM_REFS property." + (let ((template (cj/org-noter--generate-notes-template "Test Book" "/path/to/book.pdf"))) + (should (test-org-noter--template-has-property template "ROAM_REFS" "/path/to/book.pdf")))) + +(ert-deftest test-org-noter--generate-notes-template-normal-has-title () + "Normal case: Template has title with book name." + (let ((template (cj/org-noter--generate-notes-template "The Great Gatsby" "/books/gatsby.epub"))) + (should (test-org-noter--template-has-keyword template "title" "Notes on The Great Gatsby")))) + +(ert-deftest test-org-noter--generate-notes-template-normal-has-filetags () + "Normal case: Template has ReadingNotes filetag." + (let ((template (cj/org-noter--generate-notes-template "Test Book" "/path/to/book.pdf"))) + (should (test-org-noter--template-has-keyword template "FILETAGS" ":ReadingNotes:")))) + +(ert-deftest test-org-noter--generate-notes-template-normal-has-category () + "Normal case: Template has CATEGORY set to book title." + (let ((template (cj/org-noter--generate-notes-template "Clean Code" "/books/clean-code.pdf"))) + (should (test-org-noter--template-has-keyword template "CATEGORY" "Clean Code")))) + +;;; Boundary Cases + +(ert-deftest test-org-noter--generate-notes-template-boundary-long-title () + "Boundary case: Very long title." + (let* ((long-title "This Is An Incredibly Long Book Title That Goes On And On") + (template (cj/org-noter--generate-notes-template long-title "/books/long.pdf"))) + (should (test-org-noter--template-has-keyword template "title" (format "Notes on %s" long-title))) + (should (test-org-noter--template-has-keyword template "CATEGORY" long-title)))) + +(ert-deftest test-org-noter--generate-notes-template-boundary-special-chars-in-title () + "Boundary case: Special characters in title." + (let ((template (cj/org-noter--generate-notes-template "C++: A Guide" "/books/cpp.pdf"))) + (should (test-org-noter--template-has-keyword template "title" "Notes on C++: A Guide")))) + +(ert-deftest test-org-noter--generate-notes-template-boundary-special-chars-in-path () + "Boundary case: Special characters in path." + (let ((template (cj/org-noter--generate-notes-template "Test" "/path/with spaces/book.pdf"))) + (should (test-org-noter--template-has-property template "NOTER_DOCUMENT" "/path/with spaces/book.pdf")))) + +(ert-deftest test-org-noter--generate-notes-template-boundary-epub-path () + "Boundary case: EPUB file path." + (let ((template (cj/org-noter--generate-notes-template "Novel" "/library/novel.epub"))) + (should (test-org-noter--template-has-property template "NOTER_DOCUMENT" "/library/novel.epub")))) + +;;; Structure Tests + +(ert-deftest test-org-noter--generate-notes-template-structure-properties-first () + "Structure: Properties drawer comes first." + (let ((template (cj/org-noter--generate-notes-template "Test" "/path.pdf"))) + (should (string-match "\\`:PROPERTIES:" template)))) + +(ert-deftest test-org-noter--generate-notes-template-structure-notes-heading () + "Structure: Has Notes heading for content." + (let ((template (cj/org-noter--generate-notes-template "Test" "/path.pdf"))) + (should (string-match-p "^\\* Notes$" template)))) + +(ert-deftest test-org-noter--generate-notes-template-structure-unique-ids () + "Structure: Each call generates unique ID." + (let ((template1 (cj/org-noter--generate-notes-template "Test1" "/path1.pdf")) + (template2 (cj/org-noter--generate-notes-template "Test2" "/path2.pdf"))) + (string-match ":ID: \\([a-f0-9-]+\\)" template1) + (let ((id1 (match-string 1 template1))) + (string-match ":ID: \\([a-f0-9-]+\\)" template2) + (let ((id2 (match-string 1 template2))) + (should-not (equal id1 id2)))))) + +(provide 'test-org-noter--generate-notes-template) +;;; test-org-noter--generate-notes-template.el ends here diff --git a/tests/test-org-noter--title-to-slug.el b/tests/test-org-noter--title-to-slug.el new file mode 100644 index 00000000..b6880cf6 --- /dev/null +++ b/tests/test-org-noter--title-to-slug.el @@ -0,0 +1,100 @@ +;;; test-org-noter--title-to-slug.el --- Tests for cj/org-noter--title-to-slug -*- lexical-binding: t; -*- + +;;; Commentary: +;; ERT tests for the title-to-slug function used in org-noter workflow. +;; Tests cover normal, boundary, and error cases. + +;;; Code: + +(require 'ert) +(require 'org-noter-config) + +;;; Normal Cases + +(ert-deftest test-org-noter--title-to-slug-normal-simple-title () + "Normal case: Simple title with spaces." + (should (equal (cj/org-noter--title-to-slug "The Pragmatic Programmer") + "the-pragmatic-programmer"))) + +(ert-deftest test-org-noter--title-to-slug-normal-single-word () + "Normal case: Single word title." + (should (equal (cj/org-noter--title-to-slug "Dune") + "dune"))) + +(ert-deftest test-org-noter--title-to-slug-normal-with-numbers () + "Normal case: Title with numbers." + (should (equal (cj/org-noter--title-to-slug "1984 by George Orwell") + "1984-by-george-orwell"))) + +(ert-deftest test-org-noter--title-to-slug-normal-mixed-case () + "Normal case: Title with mixed case." + (should (equal (cj/org-noter--title-to-slug "SICP Structure and Interpretation") + "sicp-structure-and-interpretation"))) + +;;; Boundary Cases + +(ert-deftest test-org-noter--title-to-slug-boundary-special-chars () + "Boundary case: Title with special characters." + (should (equal (cj/org-noter--title-to-slug "C++: The Complete Guide") + "c-the-complete-guide"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-punctuation () + "Boundary case: Title with punctuation." + (should (equal (cj/org-noter--title-to-slug "Why's (Poignant) Guide to Ruby") + "why-s-poignant-guide-to-ruby"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-leading-special () + "Boundary case: Title starting with special character." + (should (equal (cj/org-noter--title-to-slug "...And Then There Were None") + "and-then-there-were-none"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-trailing-special () + "Boundary case: Title ending with special character." + (should (equal (cj/org-noter--title-to-slug "What Is This Thing Called Love?") + "what-is-this-thing-called-love"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-multiple-spaces () + "Boundary case: Title with multiple consecutive spaces." + (should (equal (cj/org-noter--title-to-slug "The Great Gatsby") + "the-great-gatsby"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-underscores () + "Boundary case: Title with underscores." + (should (equal (cj/org-noter--title-to-slug "file_name_example") + "file-name-example"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-hyphens () + "Boundary case: Title with existing hyphens." + (should (equal (cj/org-noter--title-to-slug "Self-Reliance") + "self-reliance"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-all-numbers () + "Boundary case: Title that is all numbers." + (should (equal (cj/org-noter--title-to-slug "2001") + "2001"))) + +;;; Edge Cases + +(ert-deftest test-org-noter--title-to-slug-edge-empty-string () + "Edge case: Empty string." + (should (equal (cj/org-noter--title-to-slug "") + ""))) + +(ert-deftest test-org-noter--title-to-slug-edge-only-special-chars () + "Edge case: Only special characters." + (should (equal (cj/org-noter--title-to-slug "!@#$%^&*()") + ""))) + +(ert-deftest test-org-noter--title-to-slug-edge-unicode () + "Edge case: Title with unicode characters." + (should (equal (cj/org-noter--title-to-slug "Café au Lait") + "caf-au-lait"))) + +(ert-deftest test-org-noter--title-to-slug-edge-long-title () + "Edge case: Very long title." + (let ((long-title "The Absolutely Incredibly Long Title of This Book That Goes On and On")) + (should (equal (cj/org-noter--title-to-slug long-title) + "the-absolutely-incredibly-long-title-of-this-book-that-goes-on-and-on")))) + +(provide 'test-org-noter--title-to-slug) +;;; test-org-noter--title-to-slug.el ends here |
