aboutsummaryrefslogtreecommitdiff
path: root/pearl.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-01 17:46:34 -0500
committerCraig Jennings <c@cjennings.net>2026-06-01 17:46:34 -0500
commit6206fde5acfd48f9eba2ab231c1fe1d32717c98e (patch)
treedd8659ff59e6c2da3a44eea503c702a2e6b1a558 /pearl.el
parentc2ba1d8f25463bf6342ac635850500a9738b31e9 (diff)
downloadpearl-6206fde5acfd48f9eba2ab231c1fe1d32717c98e.tar.gz
pearl-6206fde5acfd48f9eba2ab231c1fe1d32717c98e.zip
feat(views): add create and edit local views with a metadata-preserving writer
I added pearl-create-local-view and pearl-edit-local-view, plus the writer and guards the spec calls for. pearl--save-local-view replaces the old rebuild-from-scratch writer. It copies an existing entry and updates only the filter, sort, and order, so a published view keeps its :linear-view-* tracking link and a copied-down view keeps its provenance. A new entry gets the active account stamped under pearl-accounts. pearl--require-local-view-account is the one guard every read or mutate command shares, and pearl-run-local-view now calls it instead of an inline check. pearl--resolve-local-view-name carries the Replace/Rename/Cancel collision policy for create, edit-rename, and copy-down. The ad-hoc builder's save offer routes through the preserving writer and reads "save this as a local view". Edit re-runs the builder when you choose to rebuild and otherwise keeps the stored filter. Full per-dimension pre-seeding of the builder is deferred, since it needs the id-to-name reverse resolution that copy-down's reverse-compile introduces. I filed it as a follow-up. Phase 2 of the views spec. Tests cover metadata preservation, the account guard, the collision policy, and the metadata-preserving rename. Suite, compile, and lint are green.
Diffstat (limited to 'pearl.el')
-rw-r--r--pearl.el163
1 files changed, 143 insertions, 20 deletions
diff --git a/pearl.el b/pearl.el
index de803d5..6b9662e 100644
--- a/pearl.el
+++ b/pearl.el
@@ -3664,15 +3664,10 @@ active file with the query recorded as the source."
(source (list :type 'filter :name name :filter filter-plist
:sort sort :order order)))
;; Account guard, checked before any filter compilation or network call:
- ;; a query tagged for another account would otherwise resolve its team /
+ ;; a view tagged for another account would otherwise resolve its team /
;; state / label names against the wrong workspace. Inert in legacy mode
- ;; and for an untagged (shared) query.
- (let ((q-account (plist-get spec :account)))
- (when (and pearl-accounts q-account)
- (let ((active (plist-get (pearl--current-account-context) :name)))
- (unless (string-equal q-account active)
- (user-error "Local view '%s' belongs to %s; switch accounts first"
- name q-account)))))
+ ;; and for an untagged (shared) view.
+ (pearl--require-local-view-account name spec)
;; Validate the local view's authoring plist at the command boundary so
;; a typo'd key or bad value surfaces as a clear user-error instead of
;; being silently dropped or failing deeper in the compiler.
@@ -4004,18 +3999,71 @@ by hand."
(t
(message "Kept local view %s; Linear view also untouched" name)))))
-(defun pearl--save-query (name filter-plist &optional sort order)
- "Save FILTER-PLIST as the local view NAME, replacing any entry of that NAME.
-Persists `pearl-local-views' via Customize. SORT and ORDER are
-stored when given."
- (let ((entry (cons name (append (list :filter filter-plist)
- (when sort (list :sort sort))
- (when order (list :order order))))))
+(defun pearl--save-local-view (name filter-plist &optional sort order)
+ "Save FILTER-PLIST as local view NAME, preserving an existing entry's metadata.
+On an existing entry, replaces only `:filter' (and `:sort' / `:order' when
+non-nil) and keeps every other key -- `:linear-view-id', `:linear-view-team-id',
+`:linear-view-shared', `:linear-view-url', `:linear-view-synced-at', `:account',
+and `:copied-from-view-id'. A new entry is stamped with the active account's
+name under `:account' when `pearl-accounts' is configured. Persists
+`pearl-local-views' via Customize. This is the metadata-preserving writer;
+unlike a naive rebuild it never drops a published view's tracking link."
+ (let* ((existing (assoc name pearl-local-views))
+ (spec (and existing (copy-sequence (cdr existing)))))
+ (setq spec (plist-put spec :filter filter-plist))
+ (when sort (setq spec (plist-put spec :sort sort)))
+ (when order (setq spec (plist-put spec :order order)))
+ (unless existing
+ (when pearl-accounts
+ (let ((active (plist-get (pearl--current-account-context) :name)))
+ (when active (setq spec (plist-put spec :account active))))))
(setq pearl-local-views
- (cons entry (assoc-delete-all name (copy-sequence pearl-local-views))))
+ (cons (cons name spec)
+ (assoc-delete-all name (copy-sequence pearl-local-views))))
(ignore-errors
(customize-save-variable 'pearl-local-views pearl-local-views))))
+(defun pearl--require-local-view-account (name spec)
+ "Refuse when local view NAME (SPEC plist) belongs to a non-active account.
+Inert in legacy single-account mode and for an untagged entry -- the same
+guard `pearl-run-local-view' applies, shared so every read/mutate command
+enforces it identically. Returns the active account name, or nil in legacy
+mode, so callers can stamp newly-created entries."
+ (let ((v-account (plist-get spec :account)))
+ (cond
+ ((and pearl-accounts v-account)
+ (let ((active (plist-get (pearl--current-account-context) :name)))
+ (unless (string-equal v-account active)
+ (user-error "Local view '%s' belongs to %s; switch accounts first"
+ name v-account))
+ active))
+ (pearl-accounts (plist-get (pearl--current-account-context) :name))
+ (t nil))))
+
+(defun pearl--resolve-local-view-name (proposed &optional current)
+ "Resolve a local-view name collision for PROPOSED, returning the final name.
+CURRENT, when given, is the entry's existing name -- keeping the same name on
+an edit is not a collision. When PROPOSED already names a different local
+view, prompts Replace / Rename / Cancel (default Rename); Replace returns the
+name to overwrite, Cancel returns nil. Used by create, edit-rename, and
+copy-down so the three share one collision policy."
+ (let ((name proposed) (done nil) (result nil))
+ (while (not done)
+ (cond
+ ((or (null name) (string-empty-p name))
+ (setq done t result nil))
+ ((or (and current (string-equal name current))
+ (not (assoc name pearl-local-views)))
+ (setq done t result name))
+ (t
+ (pcase (completing-read
+ (format "Local view \"%s\" already exists: " name)
+ '("Rename" "Replace" "Cancel") nil t nil nil "Rename")
+ ("Replace" (setq done t result name))
+ ("Cancel" (setq done t result nil))
+ (_ (setq name (read-string "New local-view name: ")))))))
+ result))
+
;;; Saved-query -> Linear view sync (see docs/saved-query-sync-spec.org)
(defun pearl--sync-scope-label (team-name shared)
@@ -4418,17 +4466,19 @@ the scope-and-visibility display string for the success message."
"Build an ad-hoc issue filter interactively, run it, and render it.
Interactively, completes each dimension from the chosen team's fetched
projects/states/labels (so a typo can't produce a confusing empty result), and
-offers to save the filter as a local query. FILTER-PLIST is the authoring
-filter; SAVE-NAME, when given, persists it via `pearl--save-query'."
+offers to save it as a local view. FILTER-PLIST is the authoring filter;
+SAVE-NAME, when given, persists it via `pearl--save-local-view'. For a
+deliberate create use `pearl-create-local-view'; this is the ad-hoc path with
+a save offer at the end."
(interactive
(list (pearl--read-filter-interactively)
- (when (pearl--read-yes-no "Save this filter locally so you can re-run it (not pushed to Linear)? ")
+ (when (pearl--read-yes-no "Save this as a local view so you can re-run it (not published to Linear)? ")
(read-string "Name for this local view: "))))
;; Validate before saving or running, so a bad filter never gets persisted
;; or run -- the command boundary is where the clear error belongs.
(pearl--validate-issue-filter filter-plist)
(when (and save-name (not (string-empty-p save-name)))
- (pearl--save-query save-name filter-plist))
+ (pearl--save-local-view save-name filter-plist))
(let ((source (list :type 'filter
:name (if (and save-name (not (string-empty-p save-name)))
save-name
@@ -4439,6 +4489,79 @@ filter; SAVE-NAME, when given, persists it via `pearl--save-query'."
(pearl--build-issue-filter filter-plist)
(lambda (result) (pearl--render-query-result result source)))))
+;;;###autoload
+(defun pearl-create-local-view ()
+ "Build a filter interactively, name it, and save it as a local view.
+Completes each dimension from the chosen team's fetched values, then prompts a
+name (Replace / Rename / Cancel on a collision), persists via
+`pearl--save-local-view' (stamping the active account under `pearl-accounts'),
+and runs the new view. The deliberate create path;
+`pearl-list-issues-filtered' is the ad-hoc run that merely offers to save."
+ (interactive)
+ (let ((filter-plist (pearl--read-filter-interactively)))
+ (pearl--validate-issue-filter filter-plist)
+ (let ((name (pearl--resolve-local-view-name
+ (read-string "Name for this local view: "))))
+ (unless name (user-error "Cancelled; no local view created"))
+ (pearl--save-local-view name filter-plist)
+ (let ((source (list :type 'filter :name name :filter filter-plist)))
+ (pearl--progress "Running local view %s..." name)
+ (pearl--query-issues-async
+ (pearl--build-issue-filter filter-plist)
+ (lambda (result) (pearl--render-query-result result source)))))))
+
+(defun pearl--read-sort-or-keep (current)
+ "Read a sort key, defaulting to CURRENT. Returns a symbol, or nil for none."
+ (let ((choice (completing-read
+ "Sort: "
+ (pearl--completion-table-keep-order
+ '("keep" "updated" "created" "priority" "title" "none"))
+ nil t nil nil "keep")))
+ (pcase choice ("keep" current) ("none" nil) (_ (intern choice)))))
+
+(defun pearl--read-order-or-keep (current)
+ "Read an order, defaulting to CURRENT. Returns `asc', `desc', or nil."
+ (let ((choice (completing-read
+ "Order: "
+ (pearl--completion-table-keep-order '("keep" "desc" "asc"))
+ nil t nil nil "keep")))
+ (pcase choice ("keep" current) (_ (intern choice)))))
+
+;;;###autoload
+(defun pearl-edit-local-view (name)
+ "Edit the local view NAME, preserving its non-edited metadata.
+Refuses a cross-account entry. Offers to rebuild the filter (otherwise keeps
+the stored one), then prompts a new name (Replace / Rename / Cancel on a
+collision; keeping the same name is fine), and a sort and order. Saves through
+`pearl--save-local-view', so a published view keeps its `:linear-view-*' link
+and a copied-down view keeps its `:copied-from-view-id'."
+ (interactive
+ (list (if (null pearl-local-views)
+ (user-error "No local views to edit")
+ (completing-read "Edit local view: "
+ (mapcar #'car pearl-local-views) nil t))))
+ (let ((entry (assoc name pearl-local-views)))
+ (unless entry (user-error "No local view named %s" name))
+ (let ((spec (cdr entry)))
+ (pearl--require-local-view-account name spec)
+ (let* ((filter (if (pearl--read-yes-no "Rebuild the filter? " "no")
+ (pearl--read-filter-interactively)
+ (plist-get spec :filter)))
+ (sort (pearl--read-sort-or-keep (plist-get spec :sort)))
+ (order (pearl--read-order-or-keep (plist-get spec :order)))
+ (new-name (pearl--resolve-local-view-name
+ (read-string "Name: " name) name)))
+ (pearl--validate-issue-filter filter)
+ (unless new-name (user-error "Cancelled; no edit saved"))
+ ;; On rename, carry the existing spec to the new name first so the
+ ;; metadata survives, then let `pearl--save-local-view' update it.
+ (unless (string-equal new-name name)
+ (setq pearl-local-views
+ (cons (cons new-name (copy-sequence spec))
+ (assoc-delete-all name (copy-sequence pearl-local-views)))))
+ (pearl--save-local-view new-name filter sort order)
+ (message "Saved local view %s" new-name)))))
+
(defun pearl--cap-issue-list-comments (issue)
"Cap a bulk-list ISSUE's fetched comments and record a `:comment-count' marker.
The list/view fetch pulls the newest `pearl-list-comments-count-cap'+1 comments