diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-24 16:43:00 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-24 16:43:00 -0500 |
| commit | d03d5582197def92ad72e113815a3c4836da1330 (patch) | |
| tree | 16901e08fcd7940a864799918e523176dcf0e157 /pearl.el | |
| parent | 0e3c7b23f610834b1300ef0821e2a5978201fbf6 (diff) | |
| download | pearl-d03d5582197def92ad72e113815a3c4836da1330.tar.gz pearl-d03d5582197def92ad72e113815a3c4836da1330.zip | |
feat: render issue headings with the identifier prefix and title case
I added two display-only heading tweaks, each a defcustom defaulting on. pearl-show-identifier-in-heading prefixes the title with the Linear identifier (** TODO [#B] SE-401: Fix the Bug). pearl-title-case-headings renders the title in smart title case, keeping minor words lowercase unless first or last and leaving words that already carry an uppercase letter (acronyms, identifiers) untouched.
Both stay display-only. The LINEAR-TITLE-SHA256 hash covers the rendered (cased, un-prefixed) title, and the title-sync reader strips the identifier prefix before hashing, so an unedited heading is a no-op and neither the casing nor the prefix ever pushes to Linear. A render-then-read round-trip test locks that invariant.
Diffstat (limited to 'pearl.el')
| -rw-r--r-- | pearl.el | 99 |
1 files changed, 90 insertions, 9 deletions
@@ -185,6 +185,82 @@ leave the buffer expanded after updates." :type 'boolean :group 'pearl) +(defcustom pearl-title-case-headings t + "When non-nil, render issue titles in the heading in smart title case. +Linear stores titles however they were typed (often sentence case); this +capitalizes each word for a tidier outline, keeping minor words (articles, +short conjunctions and prepositions) lowercase unless they are first or last, +and leaving words that already contain an uppercase letter (acronyms like API, +identifiers, camelCase) untouched. Display-only: the title's provenance hash +is taken over the rendered form, so an unedited title is still a no-op on sync +and is never rewritten on Linear. Set to nil to render titles verbatim." + :type 'boolean + :group 'pearl) + +(defcustom pearl-show-identifier-in-heading t + "When non-nil, prefix the issue heading title with the Linear identifier. +For example `** TODO [#B] SE-401: Fix the bug'. Display-only: title sync +strips the `IDENT: ' prefix before hashing and pushing, so the identifier +never leaks into the title on Linear. Set to nil to omit the prefix." + :type 'boolean + :group 'pearl) + +(defconst pearl--title-case-minor-words + '("a" "an" "and" "as" "at" "but" "by" "for" "if" "in" "nor" "of" "on" "or" + "per" "the" "to" "vs" "via") + "Words kept lowercase by `pearl--title-case' unless first or last.") + +(defun pearl--title-case (title) + "Return TITLE in smart title case. +Each word is capitalized except minor words (see +`pearl--title-case-minor-words') in non-edge positions; a word that already +contains an uppercase letter is left as-is so acronyms and identifiers survive. +Internal whitespace is normalized to single spaces." + (let* ((words (split-string title)) + (last (1- (length words))) + (case-fold-search nil)) ; so [[:upper:]] is genuinely upper-only + (mapconcat + (lambda (cell) + (let ((i (car cell)) (w (cdr cell))) + (cond + ((string-match-p "[[:upper:]]" w) w) + ((and (/= i 0) (/= i last) + (member (downcase w) pearl--title-case-minor-words)) + (downcase w)) + ;; upcase only the first char (not `capitalize', which treats an + ;; apostrophe/hyphen as a word break: "don't" -> "Don'T") + ((string-empty-p w) w) + (t (concat (upcase (substring w 0 1)) (substring w 1)))))) + (let ((i -1)) (mapcar (lambda (w) (cons (cl-incf i) w)) words)) + " "))) + +(defun pearl--heading-title (issue) + "Return the displayed heading title for ISSUE (the form the renderer writes). +Brackets are stripped (they break Org parsing); title case is applied when +`pearl-title-case-headings' is non-nil. This is the bare title without the +identifier prefix -- the form the title provenance hash is taken over." + (let ((stripped (replace-regexp-in-string + "\\[\\|\\]" "" (or (plist-get issue :title) "")))) + (if pearl-title-case-headings (pearl--title-case stripped) stripped))) + +(defun pearl--heading-with-identifier (display-title identifier) + "Prefix DISPLAY-TITLE with \"IDENTIFIER: \" when prefixing is enabled. +Returns DISPLAY-TITLE unchanged when `pearl-show-identifier-in-heading' is nil +or IDENTIFIER is empty." + (if (and pearl-show-identifier-in-heading + identifier (not (string-empty-p identifier))) + (format "%s: %s" identifier display-title) + display-title)) + +(defun pearl--strip-identifier-prefix (heading identifier) + "Strip a leading \"IDENTIFIER: \" prefix from HEADING when present. +Returns HEADING unchanged when IDENTIFIER is empty or absent from the front." + (let ((prefix (and identifier (not (string-empty-p identifier)) + (concat identifier ": ")))) + (if (and prefix (string-prefix-p prefix heading)) + (substring heading (length prefix)) + heading))) + (defun pearl--hide-all-drawers () "Collapse every property drawer in the current buffer, across Org versions." (cond ((fboundp 'org-fold-hide-drawer-all) (org-fold-hide-drawer-all)) @@ -1708,21 +1784,23 @@ description renders as the entry body. `LINEAR-DESC-SHA256' (the markdown) and conflict gates; `LINEAR-DESC-ORG-SHA256' hashes the rendered Org body so a later refresh can tell a real local edit from a lossy md->org round-trip without re-deriving the markdown. `LINEAR-TITLE-SHA256' is the title's hash -(over the rendered, bracket-stripped title)." - (let* ((title (or (plist-get issue :title) "")) - (description (or (plist-get issue :description) "")) +(over the rendered title -- bracket-stripped and title-cased, without the +identifier prefix -- so an unedited heading is a no-op on title sync)." + (let* ((description (or (plist-get issue :description) "")) (state (plist-get issue :state)) (team (plist-get issue :team)) (project (plist-get issue :project)) (assignee (plist-get issue :assignee)) (todo (pearl--map-linear-state-to-org (plist-get state :name))) (priority (pearl--map-linear-priority-to-org (plist-get issue :priority))) - (sanitized-title (replace-regexp-in-string "\\[\\|\\]" "" title)) + (display-title (pearl--heading-title issue)) + (heading-title (pearl--heading-with-identifier + display-title (plist-get issue :identifier))) (label-names (mapconcat (lambda (l) (or (plist-get l :name) "")) (plist-get issue :labels) ", ")) (body-org (pearl--md-to-org description))) (concat - (format "** %s %s %s\n" todo priority sanitized-title) + (format "** %s %s %s\n" todo priority heading-title) ":PROPERTIES:\n" (format ":LINEAR-ID: %s\n" (or (plist-get issue :id) "")) (format ":LINEAR-IDENTIFIER: %s\n" (or (plist-get issue :identifier) "")) @@ -1741,7 +1819,7 @@ without re-deriving the markdown. `LINEAR-TITLE-SHA256' is the title's hash (format ":LINEAR-DESC-SHA256: %s\n" (secure-hash 'sha256 description)) (format ":LINEAR-DESC-ORG-SHA256: %s\n" (secure-hash 'sha256 (string-trim body-org))) (format ":LINEAR-DESC-UPDATED-AT: %s\n" (or (plist-get issue :updated-at) "")) - (format ":LINEAR-TITLE-SHA256: %s\n" (secure-hash 'sha256 sanitized-title)) + (format ":LINEAR-TITLE-SHA256: %s\n" (secure-hash 'sha256 display-title)) ":END:\n" (if (string-empty-p body-org) "" (concat body-org "\n")) (pearl--format-comments (plist-get issue :comments))))) @@ -2041,11 +2119,14 @@ CALLBACK is called with a plist (:success BOOL :updated-at STR)." (defun pearl--issue-title-at-point () "Return the title of the Linear issue heading at point. -Strips the TODO keyword, priority cookie, and tags, leaving the bare title -text (which is the bracket-stripped form the renderer wrote)." +Strips the TODO keyword, priority cookie, and tags, then strips the +`IDENT: ' identifier prefix the renderer may have added, leaving the bare +title text (the form the renderer hashed into `LINEAR-TITLE-SHA256')." (save-excursion (org-back-to-heading t) - (org-get-heading t t t t))) + (pearl--strip-identifier-prefix + (org-get-heading t t t t) + (org-entry-get nil "LINEAR-IDENTIFIER")))) (defun pearl--goto-heading-or-error (&optional message) "Move point to the enclosing heading, or signal a `user-error'. |
