diff options
| -rw-r--r-- | modules/calibredb-epub-config.el | 133 | ||||
| -rw-r--r-- | modules/org-capture-config.el | 89 | ||||
| -rw-r--r-- | tests/test-calibredb-epub-config--bookmark-name.el | 87 | ||||
| -rw-r--r-- | tests/test-calibredb-epub-config--menu.el | 52 | ||||
| -rw-r--r-- | tests/test-org-capture-config-project-target.el | 174 | ||||
| -rw-r--r-- | todo.org | 347 |
6 files changed, 747 insertions, 135 deletions
diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el index 4243e509..a17bf8c9 100644 --- a/modules/calibredb-epub-config.el +++ b/modules/calibredb-epub-config.el @@ -51,6 +51,7 @@ (require 'user-constants) ;; for books-dir (require 'subr-x) +(require 'transient) ;; cj/calibredb-menu is a transient prefix ;; Declare functions from lazy-loaded packages (declare-function calibredb-find-create-search-buffer "calibredb" ()) @@ -59,6 +60,24 @@ (declare-function nov-render-document "nov" ()) (defvar nov-text-width) ; from nov.el; set buffer-local here +;; calibredb commands the curated menu drives (all autoloaded by calibredb) +(declare-function calibredb-switch-library "calibredb" ()) +(declare-function calibredb-filter-by-book-format "calibredb" ()) +(declare-function calibredb-filter-by-author-sort "calibredb" ()) +(declare-function calibredb-search-clear-filter "calibredb" ()) +(declare-function calibredb-sort-by-author "calibredb" ()) +(declare-function calibredb-sort-by-title "calibredb" ()) +(declare-function calibredb-sort-by-pubdate "calibredb" ()) +(declare-function calibredb-sort-by-format "calibredb" ()) +(declare-function calibredb-find-file "calibredb" ()) +(declare-function calibredb-dispatch "calibredb" ()) +(declare-function calibredb-show-entry "calibredb" (entry &optional switch)) +(declare-function calibredb-find-candidate-at-point "calibredb" ()) +(declare-function calibredb-search-refresh-or-resume "calibredb" (&optional begin position)) +(defvar calibredb-show-entry-switch) ; from calibredb-show.el +(defvar calibredb-sort-by) ; from calibredb-core.el +(defvar calibredb-search-filter) ; from calibredb-search.el + ;; -------------------------- CalibreDB Ebook Manager -------------------------- (defun cj/calibredb-clear-filters () @@ -73,6 +92,23 @@ ;; empty string resets keyword filter and refreshes listing (calibredb-search-keyword-filter "")) +(defun cj/calibredb-describe-at-point () + "Show the book at point in the docked *calibredb-entry* buffer. +Displays the entry without switching focus back to the list, so it lands +in the bottom-docked window (see the `display-buffer-alist' entry below) +and q (`calibredb-entry-quit') dismisses it." + (interactive) + (calibredb-show-entry (car (calibredb-find-candidate-at-point)))) + +(defun cj/--calibredb-sort-preserving-filter (field) + "Set `calibredb-sort-by' to FIELD and refresh, keeping the active filter. +calibredb's own `calibredb-sort-by-*' commands refresh with +`calibredb-search-refresh-and-clear-filter', which drops the active filter +on every sort. This refreshes with `calibredb-search-refresh-or-resume', +which re-applies `calibredb-search-filter' instead." + (setq calibredb-sort-by field) + (calibredb-search-refresh-or-resume)) + (use-package calibredb :commands calibredb :bind @@ -80,7 +116,10 @@ ;; use built-in filter by tag, add clear-filters (:map calibredb-search-mode-map ("l" . calibredb-filter-by-tag) - ("L" . cj/calibredb-clear-filters)) + ("L" . cj/calibredb-clear-filters) + ;; "?" -> curated menu of frequent workflows; "H" -> the full dispatch + ("?" . cj/calibredb-menu) + ("H" . calibredb-dispatch)) :config ;; basic config (setq calibredb-root-dir books-dir) @@ -88,6 +127,50 @@ (setq calibredb-program "/usr/bin/calibredb") (setq calibredb-preferred-format "epub") (setq calibredb-search-page-max-rows 500) + ;; Dock the book-detail buffer to the bottom 30%; q dismisses it. + ;; `pop-to-buffer' honours `display-buffer-alist' (the default + ;; `switch-to-buffer-other-window' would not). + (setq calibredb-show-entry-switch #'pop-to-buffer) + (add-to-list 'display-buffer-alist + '("\\`\\*calibredb-entry\\*\\'" + (display-buffer-at-bottom) + (window-height . 0.3))) + ;; A curated menu of the frequent calibredb workflows, bound to `?' in the + ;; search buffer; calibredb's own full dispatch (the wall of every command) + ;; moves to `H'. Defined here in `:config' so it only builds once calibredb + ;; (and its matching transient) is loaded. This is the "? brings up a + ;; discoverable help menu" convention. + (transient-define-prefix cj/calibredb-menu () + "Frequent calibredb workflows." + [["Library" + ("l" "switch library" calibredb-switch-library)] + ["Filter" + ("f" "format" calibredb-filter-by-book-format) + ("a" "author" calibredb-filter-by-author-sort) + ("x" "reset filter" calibredb-search-clear-filter)] + ["Sort" + ("A" "author (last name)" calibredb-sort-by-author) + ("t" "title" calibredb-sort-by-title) + ("p" "pubdate" calibredb-sort-by-pubdate) + ("g" "group by format" calibredb-sort-by-format)] + ["Book" + ("o" "open" calibredb-find-file) + ("d" "describe" cj/calibredb-describe-at-point) + ("H" "full calibredb menu" calibredb-dispatch)]] + [("q" "quit" transient-quit-one)]) + + ;; Keep the active filter when sorting. calibredb's macro-generated + ;; `calibredb-sort-by-*' commands refresh-and-clear-filter, dropping the + ;; filter on every sort; override each to refresh-or-resume so the filter + ;; survives. Named advice keeps the override idempotent across reloads. + (dolist (field '(id title author format date pubdate tag size language)) + (let ((cmd (intern (format "calibredb-sort-by-%s" field))) + (adv (intern (format "cj/--calibredb-sort-keep-filter-%s" field))) + (f field)) + (defalias adv + (lambda (&rest _) (interactive) (cj/--calibredb-sort-preserving-filter f)) + (format "Sort by %s, keeping the active filter (override)." field)) + (advice-add cmd :override adv))) ;; search window display (setq calibredb-size-show nil) @@ -327,6 +410,54 @@ Try to use the Calibre book id from the parent folder name (for example, ("t" . nov-goto-toc) ("C-c C-b" . cj/nov-jump-to-calibredb))) +;; ------------------------- Nov bookmark naming ------------------------------- +;; In a nov buffer "m" is bound to `bookmark-set' (above). nov's +;; `nov-bookmark-make-record' names the record after `(buffer-name)' -- the EPUB +;; filename, extension and all. Rebuild it as "Author, Title" parsed from the +;; filename: under Calibre's "<Title> - <Author>.epub" naming the filename is +;; more complete than the EPUB's embedded metadata (which carries truncated +;; titles and author-sort "Last, First" forms). + +(defun cj/--nov-clean-title (s) + "Clean a title or author S parsed from an EPUB filename, or nil when blank. +Restores a colon where Calibre sanitized \":\" to \"_\" (\"Frege_ A Guide\" +-> \"Frege: A Guide\"), turns any leftover underscore into a space, and +collapses runs of whitespace." + (when (stringp s) + (let* ((colon (replace-regexp-in-string "_ " ": " s)) + (spaced (replace-regexp-in-string "_" " " colon)) + (out (string-trim (replace-regexp-in-string "[ \t]+" " " spaced)))) + (and (not (string-empty-p out)) out)))) + +(defun cj/--nov-bookmark-name-from-file (path) + "Return \"Author, Title\" derived from an EPUB PATH's filename, or nil. +Splits the filename (sans extension) on its last \" - \" into title and +author per Calibre's \"<Title> - <Author>\" convention, restoring colons and +reordering to \"Author, Title\". Falls back to the cleaned whole name when +there is no \" - \" separator." + (when (and (stringp path) (not (string-empty-p path))) + (let ((base (file-name-sans-extension (file-name-nondirectory path)))) + (if (string-match "\\`\\(.+\\) - \\(.+\\)\\'" base) + (let ((title (cj/--nov-clean-title (match-string 1 base))) + (author (cj/--nov-clean-title (match-string 2 base)))) + (cond ((and author title) (format "%s, %s" author title)) + (title title) + (author author) + (t nil))) + (cj/--nov-clean-title base))))) + +(defun cj/--nov-bookmark-rename-record (record) + "Replace RECORD's bookmark name with \"Author, Title\" from its EPUB filename. +Advice (:filter-return) on `nov-bookmark-make-record'. RECORD is +\(NAME . ALIST) carrying a `filename'; left unchanged when no name derives." + (let ((name (cj/--nov-bookmark-name-from-file + (alist-get 'filename (cdr record))))) + (if name (cons name (cdr record)) record))) + +(with-eval-after-load 'nov + (advice-add 'nov-bookmark-make-record :filter-return + #'cj/--nov-bookmark-rename-record)) + (defun cj/--nov-image-padding-cols (col-width img-px font-width-px) "Return left-padding columns to center an IMG-PX-wide image in COL-WIDTH cols. FONT-WIDTH-PX is the column width in pixels; clamped up to 1 so a zero or diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el index 43b42b5e..4987eee8 100644 --- a/modules/org-capture-config.el +++ b/modules/org-capture-config.el @@ -42,6 +42,8 @@ (declare-function org-get-heading "org") (declare-function org-parse-time-string "org") (declare-function pdf-view-active-region-text "pdf-view") +(declare-function projectile-project-root "projectile" (&optional dir)) +(defvar inbox-file) (defvar cj/org-capture--file-headline-target-cache (make-hash-table :test #'equal) "Cache Org capture file+headline target markers by expanded file and headline.") @@ -132,6 +134,88 @@ re-scanning large target files after the first successful lookup." (advice-add 'org-capture-set-target-location :around #'cj/org-capture--set-target-location-advice)) +;; ----------------------- Project-Aware Capture Target ------------------------ +;; C-c c t (Task) and C-c c b (Bug) file into the current projectile project's +;; todo.org under its "... Open Work" heading. Outside a project they fall back +;; to the global inbox; in a project with no todo.org they fall back to the +;; inbox with a warning (they never create a project's todo.org). + +(defconst cj/--org-open-work-heading-regexp + "^\\*[ \t]+.*Open Work\\(?:[ \t]+:[^\n]*:\\)?[ \t]*$" + "Regexp matching a top-level \"... Open Work\" Org heading line.") + +(defun cj/--org-capture-project-name (root) + "Return a display project name for ROOT directory, or nil. +The basename of ROOT with a single leading dot stripped and the first +letter upcased: \"~/.emacs.d/\" -> \"Emacs.d\", \"~/code/duet/\" -> \"Duet\"." + (when (and (stringp root) (not (string-empty-p root))) + (let* ((base (file-name-nondirectory (directory-file-name root))) + (clean (if (and (> (length base) 1) (eq ?. (aref base 0))) + (substring base 1) + base))) + (and (not (string-empty-p clean)) + (concat (upcase (substring clean 0 1)) (substring clean 1)))))) + +(defun cj/--org-capture-project-target (root inbox) + "Pure capture-target decision for project-aware capture. +ROOT is the projectile project root (or nil); INBOX is the global inbox +file path. Return a plist (:file F :open-work BOOL :project NAME :warn MSG): +- ROOT with a todo.org -> F is that todo.org, :open-work t. +- ROOT without a todo.org -> F is INBOX, :open-work nil, :warn names the project. +- ROOT nil -> F is INBOX, :open-work nil, :warn nil." + (if (and (stringp root) (not (string-empty-p root))) + (let ((todo (expand-file-name "todo.org" root)) + (name (cj/--org-capture-project-name root))) + (if (file-exists-p todo) + (list :file todo :open-work t :project name :warn nil) + (list :file inbox :open-work nil :project name + :warn (format "No todo.org in project \"%s\"; captured to the inbox instead" + name)))) + (list :file inbox :open-work nil :project nil :warn nil))) + +(defun cj/--org-capture-goto-open-work (project-name) + "Move point to a top-level \"... Open Work\" heading in the current buffer. +Create \"* PROJECT-NAME Open Work\" at end of buffer when none exists. +Leave point at the start of the heading line." + (goto-char (point-min)) + (if (re-search-forward cj/--org-open-work-heading-regexp nil t) + (forward-line 0) + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (insert (format "* %s Open Work\n" project-name)) + (forward-line -1))) + +(defun cj/--org-capture-goto-exact-headline (headline) + "Move point to the top-level HEADLINE in the current buffer. +Create \"* HEADLINE\" at end of buffer when absent. Leave point at the +start of the heading line." + (goto-char (point-min)) + (if (re-search-forward (format org-complex-heading-regexp-format + (regexp-quote headline)) + nil t) + (forward-line 0) + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (insert "* " headline "\n") + (forward-line -1))) + +(defun cj/--org-capture-project-location () + "Org-capture `function' target for project-aware Task/Bug capture. +File into the current projectile project's todo.org under its \"... Open +Work\" heading, else the global inbox (`inbox-file') under \"Inbox\"." + (let* ((root (and (fboundp 'projectile-project-root) + (ignore-errors (projectile-project-root)))) + (plan (cj/--org-capture-project-target root inbox-file))) + (when (plist-get plan :warn) + (message "%s" (plist-get plan :warn))) + (set-buffer (org-capture-target-buffer (plist-get plan :file))) + (unless (derived-mode-p 'org-mode) (org-mode)) + (org-capture-put-target-region-and-position) + (widen) + (if (plist-get plan :open-work) + (cj/--org-capture-goto-open-work (plist-get plan :project)) + (cj/--org-capture-goto-exact-headline "Inbox")))) + ;; --------------------------- Org-Capture Templates --------------------------- ;; you can bring up the org capture menu with C-c c @@ -201,9 +285,12 @@ Intended to be called within an org capture template." ;; ORG-CAPTURE TEMPLATES (setq org-protocol-default-template-key "L") (setq org-capture-templates - '(("t" "Task" entry (file+headline inbox-file "Inbox") + '(("t" "Task" entry (function cj/--org-capture-project-location) "* TODO %?" :prepend t) + ("b" "Bug" entry (function cj/--org-capture-project-location) + "* TODO [#C] %?" :prepend t) + ("e" "Event" entry (file+headline schedule-file "Scheduled Events") "* %?%:description SCHEDULED: %^t%(cj/org-capture-event-content) diff --git a/tests/test-calibredb-epub-config--bookmark-name.el b/tests/test-calibredb-epub-config--bookmark-name.el new file mode 100644 index 00000000..2e1d253e --- /dev/null +++ b/tests/test-calibredb-epub-config--bookmark-name.el @@ -0,0 +1,87 @@ +;;; test-calibredb-epub-config--bookmark-name.el --- Nov bookmark naming tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the clean "Author, Title" bookmark naming that replaces nov.el's +;; filename-based default. The name is parsed from the EPUB filename (Calibre's +;; "<Title> - <Author>.epub" convention), restoring colons that Calibre +;; sanitized to underscores and reordering to "Author, Title". + +;;; Code: + +(require 'ert) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'calibredb-epub-config) + +;;; cj/--nov-clean-title + +(ert-deftest test-nov-clean-title-passthrough () + "Normal: a clean string is returned unchanged." + (should (equal (cj/--nov-clean-title "Agatha Christie") "Agatha Christie")) + (should (equal (cj/--nov-clean-title "The A.B.C. Murders") "The A.B.C. Murders"))) + +(ert-deftest test-nov-clean-title-restores-colon () + "Boundary: Calibre's \"_ \" colon substitution is restored to \": \"." + (should (equal (cj/--nov-clean-title "Frege_ A Guide for the Perplexed") + "Frege: A Guide for the Perplexed")) + (should (equal (cj/--nov-clean-title "The Fool's Progress_ An Honest Novel") + "The Fool's Progress: An Honest Novel"))) + +(ert-deftest test-nov-clean-title-stray-underscore-and-whitespace () + "Boundary: a non-colon underscore becomes a space; whitespace collapses." + (should (equal (cj/--nov-clean-title "a_b") "a b")) + (should (equal (cj/--nov-clean-title " x y ") "x y"))) + +(ert-deftest test-nov-clean-title-rejects-blank-and-nonstring () + "Error: nil, empty, all-whitespace, or non-string yields nil." + (should-not (cj/--nov-clean-title nil)) + (should-not (cj/--nov-clean-title "")) + (should-not (cj/--nov-clean-title " ")) + (should-not (cj/--nov-clean-title 42))) + +;;; cj/--nov-bookmark-name-from-file + +(ert-deftest test-nov-bookmark-name-real-examples () + "Normal: real Calibre filenames become \"Author, Title\" with colons restored." + (should (equal (cj/--nov-bookmark-name-from-file + "/books/Frege_ A Guide for the Perplexed - Edward Kanterian.epub") + "Edward Kanterian, Frege: A Guide for the Perplexed")) + (should (equal (cj/--nov-bookmark-name-from-file + "/books/The A.B.C. Murders - Agatha Christie.epub") + "Agatha Christie, The A.B.C. Murders")) + (should (equal (cj/--nov-bookmark-name-from-file + "/books/The Fool's Progress_ An Honest Novel - Edward Abbey.epub") + "Edward Abbey, The Fool's Progress: An Honest Novel"))) + +(ert-deftest test-nov-bookmark-name-splits-on-last-separator () + "Boundary: a title containing \" - \" splits on the LAST separator." + (should (equal (cj/--nov-bookmark-name-from-file "/b/Title - Part Two - Some Author.epub") + "Some Author, Title - Part Two"))) + +(ert-deftest test-nov-bookmark-name-no-separator () + "Boundary: a filename with no \" - \" falls back to the cleaned whole name." + (should (equal (cj/--nov-bookmark-name-from-file "/b/Untitled_ Draft.epub") + "Untitled: Draft"))) + +(ert-deftest test-nov-bookmark-name-nil-and-empty () + "Error: nil or empty path yields nil." + (should-not (cj/--nov-bookmark-name-from-file nil)) + (should-not (cj/--nov-bookmark-name-from-file ""))) + +;;; cj/--nov-bookmark-rename-record + +(ert-deftest test-nov-bookmark-rename-record-replaces-name () + "Normal: the record's name is rebuilt from its filename; the alist is kept." + (let* ((record (cons "The A.B.C. Murders - Agatha Christie.epub" + '((filename . "/b/The A.B.C. Murders - Agatha Christie.epub") + (index . 0)))) + (out (cj/--nov-bookmark-rename-record record))) + (should (equal (car out) "Agatha Christie, The A.B.C. Murders")) + (should (equal (cdr out) (cdr record))))) + +(ert-deftest test-nov-bookmark-rename-record-keeps-original-without-filename () + "Boundary: a record with no usable filename is returned unchanged." + (let ((record (cons "whatever" '((index . 0))))) + (should (equal (cj/--nov-bookmark-rename-record record) record)))) + +(provide 'test-calibredb-epub-config--bookmark-name) +;;; test-calibredb-epub-config--bookmark-name.el ends here diff --git a/tests/test-calibredb-epub-config--menu.el b/tests/test-calibredb-epub-config--menu.el new file mode 100644 index 00000000..4860efc3 --- /dev/null +++ b/tests/test-calibredb-epub-config--menu.el @@ -0,0 +1,52 @@ +;;; test-calibredb-epub-config--menu.el --- calibredb curated-menu tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the docked book-description command bound into the curated calibredb +;; menu. The transient itself, its `?'/`H' keybindings, and the +;; display-buffer-alist dock live in calibredb's deferred `use-package' config +;; (they need the elpa transient, which batch does not load) and are verified +;; live in the daemon; here we cover the describe command, which has no transient +;; dependency. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'calibredb-epub-config) + +;; calibredb vars (defvar'd here so the tests' `let' bindings are dynamic; the +;; module's bare defvars are file-local to its own compilation unit). +(defvar calibredb-sort-by) +(defvar calibredb-search-filter) +(defvar calibredb-format-filter-p) + +(ert-deftest test-calibredb-describe-at-point-shows-entry-without-switch () + "Normal: describe calls `calibredb-show-entry' on the entry at point with no +switch argument, so the entry lands in the docked window with focus (q quits)." + (let (call) + (cl-letf (((symbol-function 'calibredb-find-candidate-at-point) + (lambda () '(the-entry extra))) + ((symbol-function 'calibredb-show-entry) + (lambda (&rest args) (setq call args)))) + (cj/calibredb-describe-at-point) + ;; one argument only -- the entry -- and switch is therefore nil + (should (equal call '(the-entry)))))) + +(ert-deftest test-calibredb-sort-preserving-filter-keeps-filter () + "Normal: the filter-preserving sort sets the field and refreshes via +`calibredb-search-refresh-or-resume' without touching the active filter." + (let ((calibredb-sort-by 'id) + (calibredb-search-filter "epub") + (calibredb-format-filter-p t) + (refreshed nil)) + (cl-letf (((symbol-function 'calibredb-search-refresh-or-resume) + (lambda (&rest _) (setq refreshed t)))) + (cj/--calibredb-sort-preserving-filter 'author) + (should (eq calibredb-sort-by 'author)) ; field updated + (should refreshed) ; refreshed + (should (equal calibredb-search-filter "epub")) ; filter kept + (should calibredb-format-filter-p)))) ; filter flag kept + +(provide 'test-calibredb-epub-config--menu) +;;; test-calibredb-epub-config--menu.el ends here diff --git a/tests/test-org-capture-config-project-target.el b/tests/test-org-capture-config-project-target.el new file mode 100644 index 00000000..c9091c91 --- /dev/null +++ b/tests/test-org-capture-config-project-target.el @@ -0,0 +1,174 @@ +;;; test-org-capture-config-project-target.el --- Project-aware capture tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the project-aware capture target shared by C-c c t (Task) and +;; C-c c b (Bug): the pure project-name and target-decision helpers, the +;; find-or-create "Open Work" / "Inbox" heading helpers, the function-target +;; wiring, and the two template registrations. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'org) +(require 'org-capture) +(require 'user-constants) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'org-capture-config) + +;;; cj/--org-capture-project-name + +(ert-deftest test-org-capture-project-name-normal () + "Normal: basename, first letter upcased; trailing slash ignored." + (should (equal (cj/--org-capture-project-name "/home/cj/code/duet/") "Duet")) + (should (equal (cj/--org-capture-project-name "/home/cj/code/duet") "Duet"))) + +(ert-deftest test-org-capture-project-name-strips-leading-dot () + "Boundary: a single leading dot is stripped before upcasing." + (should (equal (cj/--org-capture-project-name "/home/cj/.emacs.d/") "Emacs.d"))) + +(ert-deftest test-org-capture-project-name-nil-and-empty () + "Error: nil or empty root yields nil." + (should-not (cj/--org-capture-project-name nil)) + (should-not (cj/--org-capture-project-name ""))) + +;;; cj/--org-capture-project-target + +(ert-deftest test-org-capture-target-project-with-todo () + "Normal: a projectile root whose todo.org exists targets that file's Open Work." + (let ((root (make-temp-file "captest-" t))) + (unwind-protect + (progn + (with-temp-file (expand-file-name "todo.org" root) + (insert "* X Open Work\n")) + (let ((plan (cj/--org-capture-project-target root "/tmp/inbox.org"))) + (should (string= (plist-get plan :file) + (expand-file-name "todo.org" root))) + (should (plist-get plan :open-work)) + (should-not (plist-get plan :warn)))) + (delete-directory root t)))) + +(ert-deftest test-org-capture-target-project-without-todo () + "Boundary: a projectile root with no todo.org falls back to inbox and warns." + (let ((root (make-temp-file "captest-" t))) + (unwind-protect + (let ((plan (cj/--org-capture-project-target root "/tmp/inbox.org"))) + (should (string= (plist-get plan :file) "/tmp/inbox.org")) + (should-not (plist-get plan :open-work)) + (should (stringp (plist-get plan :warn))) + (should (string-match-p (regexp-quote (cj/--org-capture-project-name root)) + (plist-get plan :warn)))) + (delete-directory root t)))) + +(ert-deftest test-org-capture-target-no-project () + "Boundary: nil root targets the inbox with no warning." + (let ((plan (cj/--org-capture-project-target nil "/tmp/inbox.org"))) + (should (string= (plist-get plan :file) "/tmp/inbox.org")) + (should-not (plist-get plan :open-work)) + (should-not (plist-get plan :warn)))) + +;;; cj/--org-capture-goto-open-work + +(ert-deftest test-org-capture-goto-open-work-finds-existing () + "Normal: an existing top-level \"... Open Work\" heading is reused, not duplicated." + (with-temp-buffer + (org-mode) + (insert "* Emacs Open Work\n** TODO a\n* Emacs Resolved\n") + (cj/--org-capture-goto-open-work "Ignored") + (should (string= (org-get-heading t t t t) "Emacs Open Work")) + (should-not (string-match-p "Ignored" (buffer-string))))) + +(ert-deftest test-org-capture-goto-open-work-matches-tagged-heading () + "Boundary: a tagged \"... Open Work\" heading still matches and is not duplicated." + (with-temp-buffer + (org-mode) + (insert "* Foo Open Work :stuff:\n") + (cj/--org-capture-goto-open-work "Bar") + (should (string-match-p "Open Work" (org-get-heading t t t t))) + (should-not (string-match-p "Bar Open Work" (buffer-string))))) + +(ert-deftest test-org-capture-goto-open-work-creates-when-absent () + "Boundary: with no Open Work heading, create \"* NAME Open Work\" at end." + (with-temp-buffer + (org-mode) + (insert "* Something Else\n") + (cj/--org-capture-goto-open-work "Duet") + (should (string-match-p "^\\* Duet Open Work$" (buffer-string))) + (should (string= (org-get-heading t t t t) "Duet Open Work")))) + +;;; cj/--org-capture-goto-exact-headline + +(ert-deftest test-org-capture-goto-exact-headline-finds () + "Normal: an existing Inbox heading is found." + (with-temp-buffer + (org-mode) + (insert "* Inbox\n** TODO x\n") + (cj/--org-capture-goto-exact-headline "Inbox") + (should (string= (org-get-heading t t t t) "Inbox")))) + +(ert-deftest test-org-capture-goto-exact-headline-creates () + "Boundary: a missing Inbox heading is created at end of buffer." + (with-temp-buffer + (org-mode) + (insert "* Other\n") + (cj/--org-capture-goto-exact-headline "Inbox") + (should (string-match-p "^\\* Inbox$" (buffer-string))))) + +;;; cj/--org-capture-project-location (function-target wiring) + +(ert-deftest test-org-capture-location-files-into-project-open-work () + "Integration: in a project with a todo.org, the location function visits that +file and lands point on its Open Work heading." + (let* ((root (make-temp-file "captest-" t)) + (todo (expand-file-name "todo.org" root)) + (org-capture-plist nil) + visited) + (unwind-protect + (progn + (with-temp-file todo (insert "* Captest Open Work\n** TODO old\n")) + (cl-letf (((symbol-function 'projectile-project-root) + (lambda (&optional _d) root))) + (cj/--org-capture-project-location) + (setq visited (current-buffer)) + (should (string= (buffer-file-name) todo)) + (should (string-match-p "Open Work" (org-get-heading t t t t))))) + (when (buffer-live-p visited) (kill-buffer visited)) + (delete-directory root t)))) + +(ert-deftest test-org-capture-location-falls-back-to-inbox-without-project () + "Integration: with no project, the location function visits the inbox file +under its Inbox heading." + (let* ((inbox (make-temp-file "captest-inbox-" nil ".org" "* Inbox\n")) + (inbox-file inbox) + (org-capture-plist nil) + visited) + (unwind-protect + (cl-letf (((symbol-function 'projectile-project-root) + (lambda (&optional _d) nil))) + (cj/--org-capture-project-location) + (setq visited (current-buffer)) + (should (string= (buffer-file-name) inbox)) + (should (string= (org-get-heading t t t t) "Inbox"))) + (when (buffer-live-p visited) (kill-buffer visited)) + (delete-file inbox)))) + +;;; templates + +(ert-deftest test-org-capture-task-template-is-project-aware () + "Normal: the Task template (t) targets the project-aware function." + (let ((entry (assoc "t" org-capture-templates))) + (should entry) + (should (equal (nth 3 entry) + '(function cj/--org-capture-project-location))))) + +(ert-deftest test-org-capture-bug-template-registered () + "Normal: the Bug template (b) exists, targets the project-aware function, and +defaults to the [#C] priority." + (let ((entry (assoc "b" org-capture-templates))) + (should entry) + (should (equal (nth 3 entry) + '(function cj/--org-capture-project-location))) + (should (string-match-p "\\[#C\\]" (nth 4 entry))))) + +(provide 'test-org-capture-config-project-target) +;;; test-org-capture-config-project-target.el ends here @@ -41,16 +41,10 @@ Tags are additive. For example, a small wrong-behavior fix can be =:bug:quick:=, and a feature that requires internal restructuring can be =:feature:refactor:=. * Emacs Open Work -** TODO [#C] Pearl vanilla dogfooding follow-ups :pearl:cleanup: -:PROPERTIES: -:LAST_REVIEWED: 2026-06-05 -:END: -From the pearl-session handoff (2026-06-02) after =modules/linear-config.el= was reduced to a vanilla pearl setup (commit 09b3b13). Open items to revisit after dogfooding pearl's out-of-box config: - -- Decide whether to restore any conveniences dropped in the vanilla rework: default team (DeepSat SE id, in a commented =:custom= block in linear-config.el), the custom keymap, the lazy-key advice. Weigh the eager-:config key read (fires the GPG prompt on first pearl command vs never-at-startup). -- Confirm linear-config.el's module-header fields (Layer / Runtime requires / Direct test load) pass any module-doc linter this repo runs. -- custom-file interaction: =system-defaults.el= redirects =custom-file= to a throwaway temp, so pearl's =customize-save-variable= persistence (default view/team) holds for the session but vanishes on restart. Set those via init-level =:custom= instead. (A pearl-side task was filed to harden pearl's persistence against a disabled custom-file.) - +** TODO [#A] dashboard keybinding changes +pressing g has should refresh. find another binding for Telegram. +** TODO [#C] Consider consolidating/harmonizing the UI in all Message Clients +They should have the same UI paradigms and patters for consistency. ** TODO [#B] TTY-accessible personal C-; keymap :feature:ux:solo:quick: :PROPERTIES: :LAST_REVIEWED: 2026-06-05 @@ -66,10 +60,7 @@ Easy prefix candidates (home-row-leaning, TTY-safe), same leaf keys under each: While in here, audit individual leaf chords for other non-TTY keys (any =C-RET=, super/hyper bindings — terminals can't send super/hyper either) and note or remap them. Verify the result in an actual =emacs -nw= / =emacsclient -nw= frame, not just GUI. Relates to the standing "org-mode keybinding consolidation" reminder. -** DOING [#B] Signal client — forked signel :feature: -:PROPERTIES: -:LAST_REVIEWED: 2026-06-05 -:END: +** DOING [#B] Signel Client Open Work Parent task for the Emacs Signal client. Engine: signal-cli (linked secondary device). Front end: a fork of signel at =~/code/signel=, wired through =modules/signal-config.el=. Design: [[file:docs/design/signal-client.org][docs/design/signal-client.org]]. Child issues below. *** 2026-05-26 Tue @ 20:06:58 -0500 Decided: fork signel rather than depend on it @@ -110,125 +101,7 @@ Verified: (1) new contract test =test-signal-config-prefix-map-registered-under- *** 2026-05-28 Thu @ 03:09:18 -0500 Chat buffer docks bottom 30% and C-c C-k cancels =display-buffer-alist= entry in =modules/signal-config.el= matches =^\*Signel: = chat buffers and routes them through =display-buffer-at-bottom= with =window-height . 0.3=, so the chat docks to the bottom 30% of the frame. The signel fork's =signel-chat= switched from =switch-to-buffer= to =pop-to-buffer= so the rule can apply (=switch-to-buffer= ignores =display-buffer-alist=). =C-c C-c= was already bound to =signel--send-input= in the mode; =C-c C-k= now binds =signel--cancel-input=, a new fork helper that clears the editable region between =signel--input-marker= and =point-max= and then calls =quit-window=. Buffer stays alive so chat history above the marker survives revisits; cleared input means the next visit lands on a fresh prompt. Five ERT tests in =tests/test-signel-cancel-input.el= (clears pending, empty-area no-op, quit-window called, buffer preserved, keymap binding) and two new tests in =tests/test-signal-config.el= (entry shape + regex match set). Dotemacs commit 998e9c7a, fork commit df02d79. -** TODO [#B] Emacs Manual Testing and Validation :verify: -SCHEDULED: <2026-05-29 Fri> -:PROPERTIES: -:LAST_REVIEWED: 2026-05-28 -:END: - -Hand-verify checklist Craig walks one item at a time after the relevant code lands. Each child names what is being verified, the exact steps to run, and the observable expected result. On pass, the child gets marked or deleted. On fail, the actual behavior gets logged under the step and the child is promoted to a top-level =TODO= bug per the verification.md handoff rule. - -Walk started 2026-05-28 (tests 1 + 2 verified — surfaced two Signel bugs along the way, both fixed before continuing). Deferred to 2026-05-29: test 3 onward needs sending an actual Signal message, too late at night to be polite about it. Picker → chat buffer opens cleanly; the send half is what remains to exercise. - -*** 2026-05-28 Thu @ 02:13:55 -0500 Verified: connect starts the daemon (after fix) -=C-; M SPC= → "Signel connected." in echo area; =M-x list-processes= shows =signal-rpc= running (PID 1775279, command =/usr/bin/signal-cli -a +1510...=). Two bugs surfaced and fixed during the verify: -- The =with-eval-after-load 'keybindings= binding at =signal-config.el:280= didn't take effect on a fresh Emacs restart; a live-reload of =signal-config.el= activated the =C-; M= prefix. Logged as a separate top-level TODO for follow-up (load-order or use-package interaction). -- =cj/signel--ensure-started= referenced =signel--process-name= before signel had been autoloaded — the bare forward-declared =(defvar signel--process-name)= didn't actually bind the variable. Fix: added =(require 'signel)= at the top of the function (=signal-config.el:170=) so the package loads before any of its private variables are read. New ERT test =test-signal-config-ensure-started-requires-signel= captures the bug. - -*** 2026-05-28 Thu @ 02:16:45 -0500 Verified: picker opens with contact names -=C-; M m= → minibuffer opened within ~1s, "Note to Self" pinned at the top, the 94 Signal contacts followed labeled "Name (+number)". Picker behavior matches spec. Surfaced a follow-up on the chat buffer that opens after a pick — placement + exit keys want refining; filed under L44 Signel. - -*** Signel: pick a contact and send a message -What we're verifying: choosing a contact opens a chat buffer, =RET= at the prompt sends through =signel--send-input=, and the message arrives on the recipient's phone. -- =C-; M m=, pick a contact you trust. -- Type a short message at the prompt, press =RET=. -- Check the recipient's phone. -Expected: a =*Signel: +<number>*= buffer opens, the typed message renders with the =[HH:MM] <Me>= prefix on send, and arrives on the recipient's phone within a few seconds. - -*** Signel: Note-to-Self lands in the right Signal thread -What we're verifying: =cj/signel-message-self= (=C-; M s=) resolves to =signel-account= and sending through it lands in the *Note to Self* thread on the phone, NOT a self-addressed display anomaly. This is the spec's medium-priority manual verify from D3. -- Press =C-; M s=. -- Type "test note to self" at the prompt, press =RET=. -- Open Signal on your phone, scroll to the *Note to Self* thread. -Expected: a =*Signel: +<your-number>*= buffer opens in Emacs, the message sends, and the message appears in the phone's *Note to Self* thread (not in any other conversation). - -*** Signel: Note-to-Self via the picker's pinned entry -What we're verifying: picking the pinned "Note to Self" entry through =cj/signel-message= resolves the same way as the direct command. -- =C-; M m=, choose "Note to Self". -Expected: the same =*Signel: +<your-number>*= buffer opens. (No need to re-send; opening the right buffer proves the resolution.) - -*** Signel: typed input survives an incoming message -What we're verifying: the clobber fix (fork commit 5ec56c0) preserves in-progress prompt input across =signel--insert-msg= when a message arrives mid-typing. -- =C-; M m=, pick a contact. -- Type a long unsent message at the prompt, do NOT press =RET=. -- From a second device or by asking someone, send yourself a Signal message that lands in this chat (or any active chat). -Expected: the incoming message renders above the prompt, the prompt redraws, and your typed text is still there at the prompt ready to send. - -*** Signel: dashboard opens -What we're verifying: =signel-dashboard= (=C-; M d=) opens the active-chats dashboard. -- Press =C-; M d=. -Expected: a dashboard buffer opens listing active chats. - -*** Signel: stop tears down the daemon -What we're verifying: =signel-stop= (=C-; M q=) deletes the process and clears the request-handler / buffer maps (the reconnect-invalidation contract from fork commit 4740d97). -- Press =C-; M q=. -- =M-x list-processes=. -Expected: echo area shows "Signel service stopped.", and =list-processes= no longer lists =signal-rpc=. - -*** Signel: refresh forces a fresh contact fetch -What we're verifying: =cj/signel-refresh-contacts= clears the cache and re-fetches via the new callback contract. -- =C-; M SPC= to reconnect if you ran the stop test above. -- =M-x cj/signel-refresh-contacts=. -- Immediately =C-; M m=. -Expected: the picker still opens cleanly with the same contact list (the refresh is silent; the picker is the visible check). If you added a contact on the phone, it now appears. - -*** Font setup reaches a GUI frame created after a TTY frame (daemon) -What we're verifying: emoji glyphs + fonts apply in a GUI frame even when the first daemon frame was a TTY. -- emacs --daemon -- emacsclient -t (TTY frame first) -- emacsclient -c (then a GUI frame) -- in the GUI frame, open a buffer with an emoji and check it renders, and M-S-f / fonts look right -Expected: emoji renders and fonts are applied in the GUI frame. - -*** ghostel migration: Claude Code TUI in a GUI frame -What we're verifying: an agent runs in ghostel with good rendering (the reason for the engine swap). -- restart Emacs (the migration changes load order + a use-package :config block) -- in a GUI frame press F9, pick a project, let Claude stream a long response (big diff or file read) -Expected: colors look right (not washed out), no flicker/strobing during the stream, box-drawing and the cursor render correctly. - -*** ghostel migration: Claude Code TUI in a TTY frame (replaces the old refuse test) -What we're verifying: D4 dropped the GUI-only guard, so F9 now launches in a terminal frame too. -- emacsclient -t (TTY frame, off the running daemon) -- in the TTY frame press F9 and pick a project -Expected: the agent launches and renders as text + color in the TTY (no echo-area refusal message); inline images are absent, which is expected. - -*** ghostel migration: F9 / C-F9 / M-F9 dispatch -What we're verifying: the agent dispatch behaves as it did on vterm. -- F9 toggles the agent window off/on; C-F9 always opens the project picker; M-F9 closes (kills the tmux session) after confirm -- press F9 from inside an agent buffer (full-frame) — it should toggle, not get swallowed by the terminal -Expected: each chord does its job from both normal and agent buffers. - -*** ghostel migration: tmux integration + C-; x menu -What we're verifying: the tmux machinery ported intact. -- launch an agent; M-x list it — runs in tmux session aiv-<project> -- second F9 on the same project reattaches (no duplicate session) -- C-; x h captures the tmux pane history into an Emacs buffer; C-; x c enters tmux copy-mode -- C-; x l clears scrollback; C-; x n / p navigate prompts -Expected: all menu commands work against the ghostel buffer; history capture + copy-mode behave as before. - -*** ghostel migration: copy-mode parity + mouse wheel -What we're verifying: copy/selection and wheel scrolling survived the engine swap. -- in a ghostel buffer enter copy-mode (C-; x c without tmux, or the tmux path with tmux); M-w copies and stays; q / C-g exit -- mouse-wheel scroll inside tmux, inside Claude Code, and inside lazygit -Expected: M-w copies without leaving; q/C-g exit; the wheel scrolls the program (this replaces the removed vterm wheel-forwarding — confirm ghostel's native SGR mouse covers it). - -*** ghostel migration: other TUIs + ssh -What we're verifying: general terminal workloads render. -- run lazygit, htop/btop, a heavy-output build, and ssh to a remote host in a ghostel terminal (F12) -Expected: each renders and behaves correctly; ssh out works (if a remote lacks xterm-ghostty terminfo, note it — ghostel-ssh-install-terminfo / ghostel-term is the lever). - -*** ghostel migration: F12 general terminal + dashboard launcher -What we're verifying: F12 manages non-agent terminals only, and the dashboard launcher uses ghostel. -- F12 opens/toggles a general terminal; confirm it does NOT grab an agent buffer; resize it, toggle off and on — geometry is preserved -- from the dashboard press t (Terminal) — opens a ghostel terminal (tooltip reads "Launch Terminal") -Expected: F12 excludes agent buffers and keeps saved geometry; the dashboard launches ghostel. - -*** ghostel migration: crash recovery -What we're verifying: the aiv- tmux session survives an Emacs crash and reattaches. -- with a live agent, kill Emacs (not the tmux session); restart Emacs; F9 → project picker -Expected: the project shows "[detached]" and reattaches to the surviving tmux session. - -** DOING [#B] Migrate all terminals from vterm to ghostel :terminal:ghostel: +** DOING [#B] Migrate All Terminals From Vterm to Ghostel :terminal:ghostel: :PROPERTIES: :LAST_REVIEWED: 2026-06-04 :END: @@ -3966,6 +3839,55 @@ Three small reveal.js improvements; collected into one task because each on its :PROPERTIES: :LAST_REVIEWED: 2026-06-01 :END: +** DOING Project-aware bug capture via C-c c t :feature:capture: +Relocated from the global capture inbox 2026-06-06. When inside a projectile project, C-c c t (Task) files into that project's root todo.org under the "<Project> Open Work" header. If the project has no todo.org, fall back to the global inbox-file and warn naming the project. + +Implemented 2026-06-06 in =modules/org-capture-config.el=: a shared project-aware =function= capture target (=cj/--org-capture-project-location=) used by =C-c c t= (Task, =* TODO=) and a new =C-c c b= (Bug, =* TODO [#C]=). Matches an existing top-level "... Open Work" heading (so ~/.emacs.d hits "Emacs Open Work") and creates "<Capitalized project> Open Work" only when absent. Outside a project / no todo.org -> global inbox under "Inbox" (with a warning in the no-todo.org case). 15 ERT tests in =tests/test-org-capture-config-project-target.el=; daemon e2e confirmed a real capture lands "** TODO [#C] ..." prepended under Open Work. Awaiting Craig's interactive manual verify (see the Manual Testing task) before close. NOTE: the matching "<Project> Resolved Work" header for the wrap-up workflow is a separate concern, not handled here. + +** TODO [#A] Calibre Open Work :calibre: +Parent grouping the open Calibre / ebook-workflow issues; close each child independently. The EPUB reading-width tasks were already resolved (2026-05-12/14). + +*** DOING Calibre bookmark title format :feature:solo:quick: +When I hit m in calibre, I'm making my place in the book with a bookmark. +While sometimes, the books look fine: "The A.B.C. Murders - Agatha Christie.epub" +Sometimes they look not so good: Engines of Logic_ Mathematicians and the O - Martin Davis.pdf or Software Architecture_ The Hard Parts _ Mo - Neal Ford.pdf + +What I would like to do is to have the bookmarks be saved in the following format: + +Author, Title [no extension]. Underscores should be stripped. + +Root cause: in a nov buffer =m= is =bookmark-set= (rebound at calibredb-epub-config.el:311); nov's =nov-bookmark-make-record= names the record =(buffer-name)= -- the EPUB filename. + +Implemented 2026-06-06. Source decision: parse the *filename*, not the embedded EPUB metadata -- under Calibre's "<Title> - <Author>.epub" naming the filename is more complete (the embedded metadata had truncated titles, author-sort "Last, First" forms, and lost punctuation; see the separate metadata-cleanup task). A =:filter-return= advice on =nov-bookmark-make-record= rebuilds the name from the record's filename: split on the last " - " into title/author, restore the colon Calibre sanitized to "_ " (-> ": "), reorder to "Author, Title". Pure helpers =cj/--nov-clean-title= + =cj/--nov-bookmark-name-from-file= in =modules/calibredb-epub-config.el=; 10 ERT tests in =tests/test-calibredb-epub-config--bookmark-name.el=. Live in the daemon. + +Existing bookmarks: the 3 nov bookmarks in =~/sync/org/emacs_bookmarks= were renamed by hand (one-pass, in the daemon + saved; backup at =emacs_bookmarks.bak-2026-06-06=): "Edward Kanterian, Frege: A Guide for the Perplexed", "Agatha Christie, The A.B.C. Murders", "Edward Abbey, The Fool's Progress: An Honest Novel". + +Awaiting Craig's manual confirm: make a NEW bookmark (open an EPUB, hit m) and check the default name is "Author, Title" from the filename. + +*** DOING [#A] Reconsider Calibre keybindings :feature:ux: +Relocated from the global capture inbox 2026-06-06. Want a discoverable set of keybindings (visible in which-key) for the most frequent calibredb workflows: +- Switch to a library (e.g. Literature), sort by last name, scroll the list. +- Scope/filter the list in place, keeping the current library scope: + - by format (e.g. epubs only) + - by author last name (exact == or ^begins-with some text) + - sort by title, publication date, or group by format +- One key pops up the selected book's description in a bottom-30% buffer, dismissed with q (same display pattern as the signel chat dock). +- RET opens the book in the appropriate viewer. +Survey finding 2026-06-06: calibredb already binds almost all of this in calibredb-search-mode-map (S/L library, g filter [f format, a author, t tag, d date], o sort [t title, a author, p pubdate, f format], RET open) and even ships transient menus (? = calibredb-dispatch, g, o). The real problem was discoverability -- they are top-level single keys (which-key never pops up) and Craig didn't know ? opened a menu. calibredb-quick-look is macOS-only; the detail view (v -> *calibredb-entry*, q quits) is the description but opens full-window. + +Implemented 2026-06-06 in =modules/calibredb-epub-config.el=: +- A curated transient =cj/calibredb-menu= (library switch; filter format/author/reset; sort author/title/pubdate/format; open; describe; H = full calibredb-dispatch) bound to =?= in calibredb-search-mode-map. calibredb's own full dispatch moved to =H=. Defined in the use-package =:config= (needs the elpa transient, which batch doesn't load) -- the "? brings up a curated help menu" convention. +- Bottom-30% description dock: =calibredb-show-entry-switch= -> =pop-to-buffer= + a =display-buffer-alist= rule for =*calibredb-entry*= (display-buffer-at-bottom, height 0.3); =cj/calibredb-describe-at-point= shows the entry without switching focus so q dismisses it. Same pattern as the signel chat dock. +1 ERT test (the describe command; the transient/bindings/dock need the elpa transient + live calibredb, verified in the daemon). Author "begins-with" is covered well enough by g a's completing-read over "Last, First"; a true regex filter was not built. Awaiting Craig's manual verify (M-B -> ? menu; d/v docked description; H full menu). + +*** TODO Embed Calibre DB metadata into the EPUB files :data:maintenance: +Surfaced 2026-06-06 while building the bookmark naming: the metadata embedded in the EPUB files' OPF is worse than Calibre's database metadata. nov reads the embedded OPF and got truncated titles ("Frege" vs the filename's "Frege: A Guide for the Perplexed"), author-sort "Last, First" forms ("Christie, Agatha"), and lost punctuation ("A.B.C." -> "A B C"). The filenames (from Calibre's curated DB) are the good copy. Fix on the Calibre side: select all (or by library), run "Edit metadata -> Embed metadata into book files" so the DB metadata is written into each EPUB's OPF. Consider auditing author vs author_sort first. After embedding, the in-file metadata matches the library and any tool reading the files (nov, other readers, re-imports) gets the good data. Not an Emacs task; Calibre-side bulk maintenance. + +** TODO "? = curated help menu" convention across modes :feature:ux:discoverability: +From the calibredb keybindings work 2026-06-06. The pattern that worked: in a modal/major-mode buffer (calibredb), bind =?= to a curated transient of the frequent workflows, and move the package's own full dispatch to =H=. It fixes the "I can't discover the keys" problem that which-key can't help with (which-key only pops up after a prefix, not for top-level single keys in a mode-map). + +Task: survey the modes/modules Craig works in and identify where a =?= -> curated-help-menu (transient) makes sense. Candidates: any major-mode buffer with single-key bindings and no good discovery affordance -- calibredb (done), nov, dirvish, mu4e, ghostel/term, signel, pearl/linear, ELFeed, etc. For each, note whether =?= is free or already a help dispatch, and whether a curated menu (vs the package's own) adds value. Establish it as a convention (and maybe a small helper/macro to define a curated =?= menu consistently). + * Emacs Resolved ** DONE [#B] Fix likely =elpa-mirror-location= path bug :bug:quick: CLOSED: [2026-05-03 Sun] @@ -6983,3 +6905,162 @@ Changes in progress (modules/auth-config.el): - Use epa-pinentry-mode 'loopback in terminal - Use external pinentry (pinentry-dmenu) in GUI - Requires env-terminal-p from host-environment module +** DONE [#B] Emacs Manual Testing and Validation :verify: +CLOSED: [2026-06-06 Sat 13:59] SCHEDULED: <2026-05-29 Fri> +:PROPERTIES: +:LAST_REVIEWED: 2026-05-28 +:END: + +Hand-verify checklist Craig walks one item at a time after the relevant code lands. Each child names what is being verified, the exact steps to run, and the observable expected result. On pass, the child gets marked or deleted. On fail, the actual behavior gets logged under the step and the child is promoted to a top-level =TODO= bug per the verification.md handoff rule. + +Walk started 2026-05-28 (tests 1 + 2 verified — surfaced two Signel bugs along the way, both fixed before continuing). Deferred to 2026-05-29: test 3 onward needs sending an actual Signal message, too late at night to be polite about it. Picker → chat buffer opens cleanly; the send half is what remains to exercise. + +*** Project-aware capture: C-c c t files into the project's Open Work +What we're verifying: inside a projectile project that has a root todo.org, C-c c t (Task) files the new entry under that project's "<Project> Open Work" heading. +- Open a file inside a projectile project whose root has a todo.org (e.g. this one, ~/.emacs.d). +- Press C-c c, then t. +- Type a short task, finish with C-c C-c. +Expected: the entry lands as a new "** TODO ..." at the top of that project's "... Open Work" heading (e.g. "Emacs Open Work"), not in the global inbox. + +*** Project-aware capture: C-c c b files a [#C] bug +What we're verifying: C-c c b (Bug) behaves like the Task capture but stamps the entry [#C]. +- Inside the same project, press C-c c, then b. +- Type a short bug description, finish with C-c C-c. +Expected: a "** TODO [#C] ..." entry lands at the top of the project's "... Open Work" heading. + +*** Nov bookmark naming: "Author, Title" instead of the raw filename +What we're verifying: bookmarking your place in an EPUB names the bookmark "Author, Title" parsed from the filename (Calibre's "<Title> - <Author>.epub"), reordered with the colon restored — not the raw filename. +- Open an EPUB in nov (m is bound to bookmark-set there). +- Press m to set a bookmark. +- Look at the default name in the bookmark prompt. +Expected: the default is "<Author>, <Title>" (e.g. "Agatha Christie, The A.B.C. Murders"; a colon where the filename had "_ "), no extension, no underscores — not the raw filename. + +*** Calibredb curated menu on ? and full dispatch on H +What we're verifying: in the calibredb buffer, ? opens the curated workflow menu and H opens calibredb's full dispatch. +- M-B to open calibredb. +- Press ?. +- Press a key for a workflow (e.g. o to open, f format filter), or q to quit the menu. +- Press H. +Expected: ? shows the curated transient (Library / Filter / Sort / Book columns with your workflows); the keys run the right calibredb commands; q quits. H shows calibredb's full menu. + +*** Calibredb description docks to the bottom 30% +What we're verifying: viewing a book's description docks it to the bottom 30% and q dismisses it. +- M-B, move to a book. +- Press ? then d (or v). +- Read the description. +- Press q. +Expected: the *calibredb-entry* detail buffer opens docked across the bottom ~30% of the frame (not full-window); q closes it and returns to the list. + +*** Project-aware capture: inbox fallback + warning +What we're verifying: outside a project (or in a project with no todo.org) the capture falls back to the global inbox; the no-todo.org case also warns. +- Open a scratch file not inside any projectile project, C-c c t, type a task, C-c C-c. Expect it under "Inbox" in the global inbox file. +- (If easy) open a file in a projectile project that has NO todo.org, C-c c t. Expect it in the global inbox AND an echo-area message naming the project. + +*** 2026-05-28 Thu @ 02:13:55 -0500 Verified: connect starts the daemon (after fix) +=C-; M SPC= → "Signel connected." in echo area; =M-x list-processes= shows =signal-rpc= running (PID 1775279, command =/usr/bin/signal-cli -a +1510...=). Two bugs surfaced and fixed during the verify: +- The =with-eval-after-load 'keybindings= binding at =signal-config.el:280= didn't take effect on a fresh Emacs restart; a live-reload of =signal-config.el= activated the =C-; M= prefix. Logged as a separate top-level TODO for follow-up (load-order or use-package interaction). +- =cj/signel--ensure-started= referenced =signel--process-name= before signel had been autoloaded — the bare forward-declared =(defvar signel--process-name)= didn't actually bind the variable. Fix: added =(require 'signel)= at the top of the function (=signal-config.el:170=) so the package loads before any of its private variables are read. New ERT test =test-signal-config-ensure-started-requires-signel= captures the bug. + +*** 2026-05-28 Thu @ 02:16:45 -0500 Verified: picker opens with contact names +=C-; M m= → minibuffer opened within ~1s, "Note to Self" pinned at the top, the 94 Signal contacts followed labeled "Name (+number)". Picker behavior matches spec. Surfaced a follow-up on the chat buffer that opens after a pick — placement + exit keys want refining; filed under L44 Signel. + +*** Signel: pick a contact and send a message +What we're verifying: choosing a contact opens a chat buffer, =RET= at the prompt sends through =signel--send-input=, and the message arrives on the recipient's phone. +- =C-; M m=, pick a contact you trust. +- Type a short message at the prompt, press =RET=. +- Check the recipient's phone. +Expected: a =*Signel: +<number>*= buffer opens, the typed message renders with the =[HH:MM] <Me>= prefix on send, and arrives on the recipient's phone within a few seconds. + +*** Signel: Note-to-Self lands in the right Signal thread +What we're verifying: =cj/signel-message-self= (=C-; M s=) resolves to =signel-account= and sending through it lands in the *Note to Self* thread on the phone, NOT a self-addressed display anomaly. This is the spec's medium-priority manual verify from D3. +- Press =C-; M s=. +- Type "test note to self" at the prompt, press =RET=. +- Open Signal on your phone, scroll to the *Note to Self* thread. +Expected: a =*Signel: +<your-number>*= buffer opens in Emacs, the message sends, and the message appears in the phone's *Note to Self* thread (not in any other conversation). + +*** Signel: Note-to-Self via the picker's pinned entry +What we're verifying: picking the pinned "Note to Self" entry through =cj/signel-message= resolves the same way as the direct command. +- =C-; M m=, choose "Note to Self". +Expected: the same =*Signel: +<your-number>*= buffer opens. (No need to re-send; opening the right buffer proves the resolution.) + +*** Signel: typed input survives an incoming message +What we're verifying: the clobber fix (fork commit 5ec56c0) preserves in-progress prompt input across =signel--insert-msg= when a message arrives mid-typing. +- =C-; M m=, pick a contact. +- Type a long unsent message at the prompt, do NOT press =RET=. +- From a second device or by asking someone, send yourself a Signal message that lands in this chat (or any active chat). +Expected: the incoming message renders above the prompt, the prompt redraws, and your typed text is still there at the prompt ready to send. + +*** Signel: dashboard opens +What we're verifying: =signel-dashboard= (=C-; M d=) opens the active-chats dashboard. +- Press =C-; M d=. +Expected: a dashboard buffer opens listing active chats. + +*** Signel: stop tears down the daemon +What we're verifying: =signel-stop= (=C-; M q=) deletes the process and clears the request-handler / buffer maps (the reconnect-invalidation contract from fork commit 4740d97). +- Press =C-; M q=. +- =M-x list-processes=. +Expected: echo area shows "Signel service stopped.", and =list-processes= no longer lists =signal-rpc=. + +*** Signel: refresh forces a fresh contact fetch +What we're verifying: =cj/signel-refresh-contacts= clears the cache and re-fetches via the new callback contract. +- =C-; M SPC= to reconnect if you ran the stop test above. +- =M-x cj/signel-refresh-contacts=. +- Immediately =C-; M m=. +Expected: the picker still opens cleanly with the same contact list (the refresh is silent; the picker is the visible check). If you added a contact on the phone, it now appears. + +*** Font setup reaches a GUI frame created after a TTY frame (daemon) +What we're verifying: emoji glyphs + fonts apply in a GUI frame even when the first daemon frame was a TTY. +- emacs --daemon +- emacsclient -t (TTY frame first) +- emacsclient -c (then a GUI frame) +- in the GUI frame, open a buffer with an emoji and check it renders, and M-S-f / fonts look right +Expected: emoji renders and fonts are applied in the GUI frame. + +*** ghostel migration: Claude Code TUI in a GUI frame +What we're verifying: an agent runs in ghostel with good rendering (the reason for the engine swap). +- restart Emacs (the migration changes load order + a use-package :config block) +- in a GUI frame press F9, pick a project, let Claude stream a long response (big diff or file read) +Expected: colors look right (not washed out), no flicker/strobing during the stream, box-drawing and the cursor render correctly. + +*** ghostel migration: Claude Code TUI in a TTY frame (replaces the old refuse test) +What we're verifying: D4 dropped the GUI-only guard, so F9 now launches in a terminal frame too. +- emacsclient -t (TTY frame, off the running daemon) +- in the TTY frame press F9 and pick a project +Expected: the agent launches and renders as text + color in the TTY (no echo-area refusal message); inline images are absent, which is expected. + +*** ghostel migration: F9 / C-F9 / M-F9 dispatch +What we're verifying: the agent dispatch behaves as it did on vterm. +- F9 toggles the agent window off/on; C-F9 always opens the project picker; M-F9 closes (kills the tmux session) after confirm +- press F9 from inside an agent buffer (full-frame) — it should toggle, not get swallowed by the terminal +Expected: each chord does its job from both normal and agent buffers. + +*** ghostel migration: tmux integration + C-; x menu +What we're verifying: the tmux machinery ported intact. +- launch an agent; M-x list it — runs in tmux session aiv-<project> +- second F9 on the same project reattaches (no duplicate session) +- C-; x h captures the tmux pane history into an Emacs buffer; C-; x c enters tmux copy-mode +- C-; x l clears scrollback; C-; x n / p navigate prompts +Expected: all menu commands work against the ghostel buffer; history capture + copy-mode behave as before. + +*** ghostel migration: copy-mode parity + mouse wheel +What we're verifying: copy/selection and wheel scrolling survived the engine swap. +- in a ghostel buffer enter copy-mode (C-; x c without tmux, or the tmux path with tmux); M-w copies and stays; q / C-g exit +- mouse-wheel scroll inside tmux, inside Claude Code, and inside lazygit +Expected: M-w copies without leaving; q/C-g exit; the wheel scrolls the program (this replaces the removed vterm wheel-forwarding — confirm ghostel's native SGR mouse covers it). + +*** ghostel migration: other TUIs + ssh +What we're verifying: general terminal workloads render. +- run lazygit, htop/btop, a heavy-output build, and ssh to a remote host in a ghostel terminal (F12) +Expected: each renders and behaves correctly; ssh out works (if a remote lacks xterm-ghostty terminfo, note it — ghostel-ssh-install-terminfo / ghostel-term is the lever). + +*** ghostel migration: F12 general terminal + dashboard launcher +What we're verifying: F12 manages non-agent terminals only, and the dashboard launcher uses ghostel. +- F12 opens/toggles a general terminal; confirm it does NOT grab an agent buffer; resize it, toggle off and on — geometry is preserved +- from the dashboard press t (Terminal) — opens a ghostel terminal (tooltip reads "Launch Terminal") +Expected: F12 excludes agent buffers and keeps saved geometry; the dashboard launches ghostel. + +*** ghostel migration: crash recovery +What we're verifying: the aiv- tmux session survives an Emacs crash and reattaches. +- with a live agent, kill Emacs (not the tmux session); restart Emacs; F9 → project picker +Expected: the project shows "[detached]" and reattaches to the surviving tmux session. + |
