From 690faf7538bb7fcd47367b4801923beea40a0ff1 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 6 Jun 2026 15:11:15 -0500 Subject: feat(views): regroup issue subtrees in place, preserving edits Phase 2 of the interactive-grouping spec: the buffer transform, still no command. pearl--regroup-issue-subtrees re-lays-out the active view's issues by an issueGrouping value, or flattens them when given nil. It moves each issue subtree byte-for-byte and shifts only its heading stars, so descriptions, comments, and unsaved edits ride along untouched. The group label comes from the subtree's own drawer (pearl--buffer-issue-group-label), so the regroup matches a server-grouped render without a refetch. It works from either starting state. It climbs to the level-1 view parent, captures every issue (level-2 in a flat buffer, level-3 under a group heading in a grouped one) normalized to a canonical level, then re-emits them flat at level 2 or sectioned under level-2 group headings at level 3. Group headings are regenerated, so a stale one never lingers. A non-issue level-2 subtree is kept and placed after the groups rather than dropped. pearl--shift-heading-levels is the level mechanic, isolated and tested both directions. 29 tests cover the shift, grouping by each dimension, the unset bucket, regrouping to a new dimension, flatten and round-trip, edit preservation, malformed-last, and no-issues. --- pearl.el | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) (limited to 'pearl.el') diff --git a/pearl.el b/pearl.el index 662d7b9..875963f 100644 --- a/pearl.el +++ b/pearl.el @@ -6973,6 +6973,135 @@ server-side sort reorders the merged buffer without losing local edits." (insert (mapconcat (lambda (c) (plist-get c :text)) sorted "")))) t)))))) +(defun pearl--shift-heading-levels (text delta) + "Return TEXT with every Org heading line's star count shifted by DELTA. +A positive DELTA demotes (adds stars), negative promotes (removes, floor 1). +Only heading lines (`^\\*+ ') are touched; body and drawer lines are left +alone. pearl-rendered issue bodies never begin a line with `* ' (markdown +headings render as bold, bullets as `-'), so a captured issue subtree shifts +its issue + comment headings together without disturbing content." + (if (= delta 0) + text + (with-temp-buffer + (insert text) + (goto-char (point-min)) + (while (re-search-forward "^\\(\\*+\\) " nil t) + (let ((n (max 1 (+ (length (match-string 1)) delta)))) + (replace-match (concat (make-string n ?*) " ") t t))) + (buffer-string)))) + +(defun pearl--buffer-issue-group-label (grouping) + "Return the group label for the issue subtree at point under GROUPING. +Reads the relevant `LINEAR-*' drawer field at point and maps it to the same +bucket `pearl--view-group-label' produces from a normalized issue, so a buffer +regroup matches a server-grouped render. Point must be on the issue heading. +GROUPING outside the drawer-backed set yields nil." + (pcase grouping + ("workflowState" + (let ((v (org-entry-get nil "LINEAR-STATE-NAME"))) + (if (and v (not (string-empty-p v))) v "No status"))) + ("project" + (let ((v (org-entry-get nil "LINEAR-PROJECT-NAME"))) + (if (and v (not (string-empty-p v))) v "No project"))) + ("assignee" + (let ((v (org-entry-get nil "LINEAR-ASSIGNEE-NAME"))) + (if (and v (not (string-empty-p v))) v "No assignee"))) + ("priority" + (pearl--get-linear-priority-name + (string-to-number (or (org-entry-get nil "LINEAR-PRIORITY") "0")))) + (_ nil))) + +(defun pearl--subtree-contains-issue-p (beg end) + "Return non-nil when the region BEG..END holds an issue heading (a `LINEAR-ID')." + (save-excursion + (save-restriction + (narrow-to-region beg end) + (goto-char (point-min)) + (catch 'found + (while (re-search-forward "^\\*+ " nil t) + (let ((id (org-entry-get nil "LINEAR-ID"))) + (when (and id (not (string-empty-p id))) (throw 'found t)))) + nil)))) + +(defun pearl--regroup-issue-subtrees (grouping) + "Re-lay-out the active view's issue subtrees by GROUPING, in place. +GROUPING is an `issueGrouping' value (issues sectioned under level-2 group +headings, at level 3) or nil (a flat level-2 list). Each issue subtree moves +byte-for-byte with only its heading levels shifted, so unsaved edits survive; +the group label is read from the subtree's drawer (see +`pearl--buffer-issue-group-label'). Non-issue level-2 subtrees are kept, after +the issue groups. Works whether the buffer is currently flat or already +grouped. Returns t, or `no-issues' when there are none." + (save-excursion + (let ((markers (pearl--issue-subtree-markers))) + (if (null markers) + 'no-issues + ;; Climb from the first issue to the level-1 view parent. + (goto-char (cdr (car markers))) + (while (> (or (org-current-level) 1) 1) + (org-up-heading-safe)) + (let ((content-beg (save-excursion (forward-line 1) (point))) + (parent-end (save-excursion (org-end-of-subtree t t) (point))) + (issues '()) ; (:label L :text canonical-level-2-text), doc order + (malformed '())) ; non-issue level-2 subtree text, kept last + (goto-char content-beg) + (while (re-search-forward "^\\*\\* " parent-end t) + (beginning-of-line) + (let* ((beg (point)) + (end (save-excursion (org-end-of-subtree t t) (point))) + (id (org-entry-get nil "LINEAR-ID"))) + (cond + ;; A level-2 issue (flat buffer): already canonical level 2. + ((and id (not (string-empty-p id))) + (push (list :label (pearl--buffer-issue-group-label grouping) + :text (buffer-substring-no-properties beg end)) + issues)) + ;; A level-2 group heading (contains issues): descend, capture each + ;; level-3 issue normalized to level 2, drop the group heading. + ;; pearl only ever emits issues under a group section, so a + ;; non-issue heading nested here isn't expected; if one appears + ;; it's skipped (not re-emitted) rather than mis-bucketed. + ((pearl--subtree-contains-issue-p beg end) + (save-restriction + (narrow-to-region beg end) + (goto-char (point-min)) + (while (re-search-forward "^\\*+ " nil t) + (beginning-of-line) + (let ((cid (org-entry-get nil "LINEAR-ID"))) + (if (and cid (not (string-empty-p cid))) + (let* ((ibeg (point)) + (iend (save-excursion (org-end-of-subtree t t) (point))) + (lvl (org-current-level))) + (push (list :label (pearl--buffer-issue-group-label grouping) + :text (pearl--shift-heading-levels + (buffer-substring-no-properties ibeg iend) + (- 2 lvl))) + issues) + (goto-char iend)) + (forward-line 1)))))) + ;; A level-2 non-issue with no issues inside: malformed, keep it. + (t + (push (buffer-substring-no-properties beg end) malformed))) + (goto-char end))) + (setq issues (nreverse issues) + malformed (nreverse malformed)) + (delete-region content-beg parent-end) + (goto-char content-beg) + (if grouping + (let ((order '()) (table (make-hash-table :test 'equal))) + (dolist (it issues) + (let ((label (plist-get it :label))) + (unless (gethash label table) (push label order)) + (push it (gethash label table)))) + (dolist (label (nreverse order)) + (insert (format "** %s\n" label)) + (dolist (it (nreverse (gethash label table))) + (insert (pearl--shift-heading-levels (plist-get it :text) 1))))) + (dolist (it issues) + (insert (plist-get it :text)))) + (dolist (m malformed) (insert m)) + t))))) + (defun pearl--toggle-sort-target (source) "Return the (SORT . ORDER) to apply when toggling SOURCE's order. On a filter with no sort, establishes `updated' descending; otherwise flips the -- cgit v1.2.3