From a2a155c785cdf43c971b88db174c33ac1e867da3 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 27 May 2026 15:21:45 -0500 Subject: feat(render): render issue titles and the view name verbatim The buffer should mirror Linear: a value on the page appears exactly as Linear stores it, so the heading title matches what you'd see opening the issue in Linear itself. I flipped pearl-title-case-headings to default nil so titles render verbatim. I also stopped running the view name in the #+title through the title-caser, which was coupled to the same flag. The smart-title-case path stays as opt-in tidying for anyone who wants it. Two transformations remain, both forced by Org's syntax rather than chosen: state names become TODO keywords (Org keywords can't contain spaces, so the real name stays in the LINEAR-STATE-NAME drawer), and descriptions and comments convert between Markdown and Org. Documented under "Fidelity to Linear" in the README. --- README.org | 13 +++++++++++++ pearl.el | 23 +++++++++++++---------- tests/test-pearl-format.el | 6 +++--- tests/test-pearl-heading.el | 14 ++++++++++++-- tests/test-pearl-output.el | 7 ++++--- tests/test-pearl-save.el | 4 ++++ 6 files changed, 49 insertions(+), 18 deletions(-) diff --git a/README.org b/README.org index ad6c5a6..6dd3b92 100644 --- a/README.org +++ b/README.org @@ -266,6 +266,18 @@ A fetched Pearl file is intentionally readable. The header records the source, r The =LINEAR-*= properties store both ids and display names so common commands do not need a network lookup just to render. The hash properties are provenance for conflict-aware sync. +*** Fidelity to Linear +:PROPERTIES: +:CUSTOM_ID: fidelity-to-linear +:END: + +Anything on the page that is Linear's data renders verbatim — the buffer shows the same text you would see opening the issue in Linear itself. Titles, the view name, assignee and label names, comment bodies: Pearl does not re-case, reword, or tidy them. There are exactly two transformations, and both are forced by Org's own syntax rather than chosen: + +- *State names become TODO keywords.* Org keywords cannot contain spaces, so "Dev Review" renders as the keyword =DEV-REVIEW=. The real state name is preserved verbatim in the =:LINEAR-STATE-NAME:= drawer property. +- *Descriptions and comments convert between Markdown and Org.* Linear stores them as Markdown; the buffer renders them as Org and converts back on save. This is a representation translation, not an edit of the content, and round-trips for the supported subset. + +If you prefer a tidier outline, =pearl-title-case-headings= opts issue titles into smart title case (off by default, so titles match Linear out of the box). + ** Configuration :PROPERTIES: :CUSTOM_ID: configuration @@ -282,6 +294,7 @@ Most users only need an API key and an output path. The rest are knobs for teams | =pearl-max-issue-pages= | Pagination cap, 100 issues per page | | =pearl-request-timeout= | Synchronous request timeout in seconds | | =pearl-fold-after-update= | Re-fold the active page after fetch/refresh | +| =pearl-title-case-headings= | Opt-in smart title case for issue titles (off) | | =pearl-surface-buffer= | Show the active buffer after a command updates it | | =pearl-surface-select-window= | Move focus to the surfaced buffer | | =pearl-debug= | Log request/response details to =*Messages*= | diff --git a/pearl.el b/pearl.el index 5dd55a7..c1492fc 100644 --- a/pearl.el +++ b/pearl.el @@ -169,15 +169,17 @@ leave the buffer expanded after updates." :type 'boolean :group 'pearl) -(defcustom pearl-title-case-headings t +(defcustom pearl-title-case-headings nil "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." +The default is nil: titles render verbatim, exactly as Linear stores them, so +the buffer mirrors what you would see opening the issue in Linear itself. +Set this to t for opt-in tidying — it capitalizes each word for a neater +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 either way: 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." :type 'boolean :group 'pearl) @@ -2844,8 +2846,9 @@ yields the hardcoded default. Pure function, no side effects." (name (pearl--source-name src)) (filter (plist-get src :filter))) (with-temp-buffer - (insert (format "#+title: Linear — %s\n" - (if pearl-title-case-headings (pearl--title-case name) name))) + ;; The view name renders verbatim, matching Linear's own capitalization; + ;; `pearl-title-case-headings' governs issue headings only. + (insert (format "#+title: Linear — %s\n" name)) (insert "#+STARTUP: show3levels\n") (insert (format "#+TODO: %s\n" (pearl--derive-todo-line states))) ;; Source-tracking metadata: the serialized source drives refresh; the diff --git a/tests/test-pearl-format.el b/tests/test-pearl-format.el index 1119734..53af2e7 100644 --- a/tests/test-pearl-format.el +++ b/tests/test-pearl-format.el @@ -113,12 +113,12 @@ so these tests no longer need a state mapping bound." (let ((out (pearl--format-issue-as-org-entry '(:id "u" :identifier "ENG-1" :title "Fix [URGENT] bug" :priority 1 :state (:name "Todo"))))) - (should (string-match-p "^\\*\\* TODO \\[#A\\] ENG-1: Fix URGENT Bug$" out)) - ;; the title provenance hash is of the displayed (stripped + cased) title + (should (string-match-p "^\\*\\* TODO \\[#A\\] ENG-1: Fix URGENT bug$" out)) + ;; the title provenance hash is of the displayed (stripped, verbatim) title ;; without the identifier prefix, so a later no-op title sync matches the ;; heading and never clobbers brackets or pushes the prefix (should (string-match-p - (format "^:LINEAR-TITLE-SHA256: +%s$" (secure-hash 'sha256 "Fix URGENT Bug")) + (format "^:LINEAR-TITLE-SHA256: +%s$" (secure-hash 'sha256 "Fix URGENT bug")) out))))) ;;; build-org-content diff --git a/tests/test-pearl-heading.el b/tests/test-pearl-heading.el index 2675518..a780143 100644 --- a/tests/test-pearl-heading.el +++ b/tests/test-pearl-heading.el @@ -82,8 +82,18 @@ ;;; rendering on / off -(ert-deftest test-pearl-format-heading-defaults-prefix-and-title-case () - "By default the heading carries the identifier prefix and a title-cased title." +(ert-deftest test-pearl-format-heading-default-prefix-verbatim-title () + "By default the heading carries the identifier prefix and the verbatim title, +matching Linear's own capitalization." + (let ((pearl-show-identifier-in-heading t) + (pearl-title-case-headings nil)) + (let ((out (pearl--format-issue-as-org-entry + '(:id "u" :identifier "SE-401" :title "fix the refresh bug" + :priority 2 :state (:name "Todo"))))) + (should (string-match-p "^\\*\\* TODO \\[#B\\] SE-401: fix the refresh bug$" out))))) + +(ert-deftest test-pearl-format-heading-title-case-opt-in () + "With title-casing opted in, the title is smart-cased; the prefix is unaffected." (let ((pearl-show-identifier-in-heading t) (pearl-title-case-headings t)) (let ((out (pearl--format-issue-as-org-entry diff --git a/tests/test-pearl-output.el b/tests/test-pearl-output.el index 58cd446..a838bfb 100644 --- a/tests/test-pearl-output.el +++ b/tests/test-pearl-output.el @@ -68,12 +68,13 @@ (should (string-match-p "^#\\+title:" out)) (should-not (string-match-p "^\\*\\*\\* " out)))) -(ert-deftest test-pearl-build-org-content-title-cases-view-name () - "The view name in the file title is title-cased when title-casing is on, raw when off." +(ert-deftest test-pearl-build-org-content-title-uses-view-name-verbatim () + "The view name in the file title matches Linear's capitalization verbatim, +independent of `pearl-title-case-headings' (which governs issue headings only)." (let ((case-fold-search nil) ; strict so casing actually matters (source '(:type filter :name "my open bugs" :filter nil))) (let ((pearl-title-case-headings t)) - (should (string-match-p "^#\\+title: Linear — My Open Bugs$" + (should (string-match-p "^#\\+title: Linear — my open bugs$" (pearl--build-org-content '() source)))) (let ((pearl-title-case-headings nil)) (should (string-match-p "^#\\+title: Linear — my open bugs$" diff --git a/tests/test-pearl-save.el b/tests/test-pearl-save.el index 90e55cd..2f7b312 100644 --- a/tests/test-pearl-save.el +++ b/tests/test-pearl-save.el @@ -43,7 +43,11 @@ ;; `org-entry-put', which in batch makes the cache reconcile pathologically ;; (seconds per call). Disabling it keeps the suite fast and deterministic; ;; production never touches this binding. + ;; Title-case the rendered heading so these save-path tests can search for the + ;; cased title; the save logic hashes the rendered form regardless of casing, + ;; so the opt-in binding doesn't change what's under test. `(let ((pearl-comment-sort-order 'newest-first) + (pearl-title-case-headings t) (org-element-use-cache nil)) (with-temp-buffer (insert (pearl--format-issue-as-org-entry ,issue)) -- cgit v1.2.3