summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-11-21 08:43:19 -0800
committerCraig Jennings <c@cjennings.net>2025-11-21 08:43:19 -0800
commit1752c6e58906721a47afc56e357b7fb1c0c8a5a1 (patch)
treed7ea54fa3831c198c96d85eb4a17de7b41d30f75
parent42e3fd284600c84c4158bee6bb0092eba99bece5 (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.el2
-rw-r--r--modules/org-noter-config.el254
-rw-r--r--tests/test-org-noter--generate-notes-template.el109
-rw-r--r--tests/test-org-noter--title-to-slug.el100
4 files changed, 431 insertions, 34 deletions
diff --git a/init.el b/init.el
index 042c0e27..1f67b782 100644
--- a/init.el
+++ b/init.el
@@ -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