diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-01 18:21:47 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-01 18:21:47 -0500 |
| commit | 9b9a264edb0c208892fcfe5101912d00858de766 (patch) | |
| tree | c37d2aaac2d7e7e555cc1d19c23f6c8135233c55 /pearl.el | |
| parent | 96ac9855cfda77f81d7f12e55d737e8bdd54eccc (diff) | |
| download | pearl-9b9a264edb0c208892fcfe5101912d00858de766.tar.gz pearl-9b9a264edb0c208892fcfe5101912d00858de766.zip | |
feat(views): save a Linear view locally via the reverse-compile (copy down)
I implemented copy-down, the one direction that needed real engine work. A live probe showed CustomView.filterData is readable but stored as Linear's and/or view-filter tree, not the flat IssueFilter Pearl emits, so pearl--reverse-compile-issue-filter is a normalize-then-match: it flattens the top-level and, unwraps single-branch and/or, reads a one-element in as a scalar, then matches each conjunct to one authoring key. Every helper returns (ok . plist) or (refuse . reason).
The contract follows the spec's dimension table: distinct :state and :state-type, canonical numeric :priority, :open t mapped from the exact open-state nin while generic nin refuses, and a multi-value in on any singular key refuses rather than silently narrow the view. Anything outside Pearl's AND-only model refuses with a structured reason that names the construct.
pearl-save-linear-view-locally lists favorited Linear views, fetches the chosen view's filterData through a small async helper, reverse-compiles it, and saves a forked local view through pearl--save-local-view: no tracking link, :copied-from-view-id provenance only, active account stamped. On a refusal it points the user at pearl-run-linear-view; on success it states the fork's independence. Bound at f d (down) and the transient Views column.
Phase 6 of the views spec. 30 tests cover per-dimension round-trips (build->encode->read bridge), normalize-from-real-JSON trees, the full refusal set, and the command-side. Suite, compile, and lint are green.
Diffstat (limited to 'pearl.el')
| -rw-r--r-- | pearl.el | 277 |
1 files changed, 277 insertions, 0 deletions
@@ -909,6 +909,198 @@ the ordering keys `:sort' / `:order'." filter))) (nreverse filter))) +;;; Reverse-compile: Linear filterData -> Pearl authoring plist (copy-down) +;; +;; Linear stores a Custom View's filter as `filterData', a JSON `and'/`or' tree, +;; NOT the flat IssueFilter Pearl's compiler emits. Copy-down reads it +;; (json-read shaped: symbol-keyed alists, vectors for arrays, t / :json-false / +;; nil for the JSON literals) and reverse-compiles it to a Pearl authoring plist +;; so the copied view is editable. Contract: normalize-then-match -- flatten the +;; top-level `and', unwrap single-branch `and'/`or', read a one-element `in' as a +;; scalar, then match each conjunct to one authoring key. Anything Pearl's +;; AND-only, singular-keyed model can't hold refuses with a structured reason. +;; Every helper returns `(ok . VALUE)' or `(refuse . REASON)'. + +(defun pearl--rc-refuse (fmt &rest args) + "Build a structured refusal result `(refuse . REASON)' from FMT and ARGS." + (cons 'refuse (apply #'format fmt args))) + +(defun pearl--rc-unwrap (node) + "Collapse a single-branch `and'/`or' wrapper in dimension-condition NODE. +When NODE is `((and . [X]))' or `((or . [X]))' with exactly one branch, return X +unwrapped. A multi-branch wrapper is a real conjunction/disjunction and is +returned as-is for the matcher to refuse. Other NODEs pass through." + (let ((wrap (and (consp node) (= 1 (length node)) + (or (assq 'and node) (assq 'or node))))) + (if wrap + (let ((branches (cdr wrap))) + (if (and (vectorp branches) (= 1 (length branches))) + (pearl--rc-unwrap (aref branches 0)) + node)) + node))) + +(defun pearl--rc-scalar (opnode dim) + "Extract a singular scalar from operator OPNODE for dimension DIM. +Accepts `eq' or a one-element `in'. A multi-value `in' or any other operator +refuses -- Pearl's singular keys hold one value, and silently narrowing a +multi-value filter would change which issues match." + (cond + ((not (consp opnode)) (pearl--rc-refuse "%s has an unexpected shape" dim)) + ((assq 'eq opnode) (cons 'ok (cdr (assq 'eq opnode)))) + ((assq 'in opnode) + (let ((v (cdr (assq 'in opnode)))) + (cond + ((and (vectorp v) (= 1 (length v))) (cons 'ok (aref v 0))) + ((and (vectorp v) (> (length v) 1)) + (pearl--rc-refuse + "%s filters on several values; Pearl has no plural key for it" dim)) + (t (pearl--rc-refuse "%s has an empty or malformed `in'" dim))))) + (t (pearl--rc-refuse + "%s uses an operator (%s) Pearl can't represent" + dim (mapconcat (lambda (p) (symbol-name (car p))) opnode "/"))))) + +(defun pearl--rc-match-assignee (cnode) + "Match an assignee condition CNODE to an authoring fragment." + (let ((node (pearl--rc-unwrap cnode))) + (cond + ((or (assq 'and node) (assq 'or node)) + (pearl--rc-refuse "assignee uses OR logic Pearl can't represent")) + ((assq 'isMe node) + (if (eq (cdr (assq 'eq (cdr (assq 'isMe node)))) t) + (cons 'ok (list :assignee :me)) + (pearl--rc-refuse "assignee.isMe is negated"))) + ((assq 'id node) + (let ((r (pearl--rc-scalar (cdr (assq 'id node)) "assignee.id"))) + (if (eq (car r) 'ok) (cons 'ok (list :assignee-id (cdr r))) r))) + ((assq 'email node) + (let ((r (pearl--rc-scalar (cdr (assq 'email node)) "assignee.email"))) + (if (eq (car r) 'ok) (cons 'ok (list :assignee (cdr r))) r))) + (t (pearl--rc-refuse "assignee uses a field Pearl can't represent"))))) + +(defun pearl--rc-match-state (cnode) + "Match a state condition CNODE to `:state', `:state-type', or `:open'." + (let ((node (pearl--rc-unwrap cnode))) + (cond + ((or (assq 'and node) (assq 'or node)) + (pearl--rc-refuse "state uses OR logic Pearl can't represent")) + ((assq 'name node) + (let ((r (pearl--rc-scalar (cdr (assq 'name node)) "state.name"))) + (if (eq (car r) 'ok) (cons 'ok (list :state (cdr r))) r))) + ((assq 'type node) + (let ((op (cdr (assq 'type node)))) + (cond + ((assq 'nin op) + (let ((vals (append (cdr (assq 'nin op)) nil))) + (if (and (= (length vals) (length pearl--open-state-types)) + (cl-subsetp vals pearl--open-state-types :test #'string=) + (cl-subsetp pearl--open-state-types vals :test #'string=)) + (cons 'ok (list :open t)) + (pearl--rc-refuse + "state.type uses a `nin' that isn't Pearl's open predicate")))) + ((assq 'in op) + (let ((vals (append (cdr (assq 'in op)) nil))) + (cons 'ok (list :state-type (if (= 1 (length vals)) (car vals) vals))))) + ((assq 'eq op) + (cons 'ok (list :state-type (cdr (assq 'eq op))))) + (t (pearl--rc-refuse "state.type uses an operator Pearl can't represent"))))) + (t (pearl--rc-refuse "state uses a field Pearl can't represent"))))) + +(defun pearl--rc-match-scalar-dim (cnode field dim authoring-key) + "Match a single-field scalar dimension (project.id, team.key, cycle.id)." + (let ((node (pearl--rc-unwrap cnode))) + (cond + ((or (assq 'and node) (assq 'or node)) + (pearl--rc-refuse "%s uses OR logic Pearl can't represent" dim)) + ((assq field node) + (let ((r (pearl--rc-scalar (cdr (assq field node)) dim))) + (if (eq (car r) 'ok) (cons 'ok (list authoring-key (cdr r))) r))) + (t (pearl--rc-refuse "%s uses a field Pearl can't represent" dim))))) + +(defun pearl--rc-match-labels (cnode) + "Match a labels condition CNODE to `:labels' (names) or `:label-id' (one id)." + (let ((node (pearl--rc-unwrap cnode))) + (cond + ((or (assq 'every node) (assq 'none node)) + (pearl--rc-refuse "labels uses `every'/`none'; Pearl only does `some'")) + ((or (assq 'and node) (assq 'or node)) + (pearl--rc-refuse + "labels uses nested and/or (e.g. a label parent) Pearl can't represent")) + ((assq 'some node) + (let ((some (pearl--rc-unwrap (cdr (assq 'some node))))) + (cond + ((or (assq 'and some) (assq 'or some)) + (pearl--rc-refuse + "labels.some uses nested and/or Pearl can't represent")) + ((assq 'name some) + (let ((op (cdr (assq 'name some)))) + (if (assq 'in op) + (cons 'ok (list :labels (append (cdr (assq 'in op)) nil))) + (let ((r (pearl--rc-scalar op "labels.some.name"))) + (if (eq (car r) 'ok) (cons 'ok (list :labels (list (cdr r)))) r))))) + ((assq 'id some) + (let ((r (pearl--rc-scalar (cdr (assq 'id some)) "labels.some.id"))) + (if (eq (car r) 'ok) (cons 'ok (list :label-id (cdr r))) r))) + (t (pearl--rc-refuse "labels.some uses a field Pearl can't represent"))))) + (t (pearl--rc-refuse "labels uses a shape Pearl can't represent"))))) + +(defun pearl--rc-match-priority (cnode) + "Match a priority condition CNODE to canonical numeric `:priority' (0..4)." + (let ((node (pearl--rc-unwrap cnode))) + (if (assq 'eq node) + (let ((n (cdr (assq 'eq node)))) + (if (and (integerp n) (<= 0 n 4)) + (cons 'ok (list :priority n)) + (pearl--rc-refuse "priority %S is outside 0..4" n))) + (pearl--rc-refuse "priority uses an operator Pearl can't represent")))) + +(defun pearl--rc-match-conjunct (conjunct) + "Match one CONJUNCT (a one-key alist) to an authoring plist fragment." + (if (or (not (consp conjunct)) (not (= 1 (length conjunct)))) + (pearl--rc-refuse "a filter clause has an unexpected shape") + (let ((dim (caar conjunct)) + (cnode (cdar conjunct))) + (pcase dim + ('assignee (pearl--rc-match-assignee cnode)) + ('state (pearl--rc-match-state cnode)) + ('project (pearl--rc-match-scalar-dim cnode 'id "project" :project)) + ('team (pearl--rc-match-scalar-dim cnode 'key "team" :team)) + ('cycle (pearl--rc-match-scalar-dim cnode 'id "cycle" :cycle)) + ('labels (pearl--rc-match-labels cnode)) + ('priority (pearl--rc-match-priority cnode)) + (_ (pearl--rc-refuse + "filters on `%s', a dimension Pearl can't represent" dim)))))) + +(defun pearl--rc-conjuncts (filter-data) + "Return `(ok . CONJUNCTS)' or `(refuse . REASON)' from FILTER-DATA. +Flattens a top-level `and' (the real Linear shape) and the flat sibling-keyed +object Pearl's own compiler emits; refuses a top-level `or' or an empty filter." + (cond + ((null filter-data) (pearl--rc-refuse "the Linear view has no filter")) + ((not (consp filter-data)) + (pearl--rc-refuse "the Linear view filter has an unexpected shape")) + ((and (assq 'and filter-data) (= 1 (length filter-data))) + (let ((branches (append (cdr (assq 'and filter-data)) nil))) + (if branches (cons 'ok branches) + (pearl--rc-refuse "the Linear view has no filter")))) + ((and (assq 'or filter-data) (= 1 (length filter-data))) + (pearl--rc-refuse "the Linear view uses top-level OR logic Pearl can't represent")) + (t (cons 'ok (mapcar (lambda (pair) (list pair)) filter-data))))) + +(defun pearl--reverse-compile-issue-filter (filter-data) + "Reverse-compile Linear FILTER-DATA (json-read shaped) to an authoring plist. +Returns `(ok . PLIST)' on success, or `(refuse . REASON)' when the view's filter +is outside Pearl's AND-only, singular-keyed authoring model. The REASON names +the offending construct so the command can tell the user what to do instead." + (let ((conj (pearl--rc-conjuncts filter-data))) + (if (eq (car conj) 'refuse) conj + (catch 'done + (let ((plist nil)) + (dolist (c (cdr conj)) + (let ((m (pearl--rc-match-conjunct c))) + (when (eq (car m) 'refuse) (throw 'done m)) + (setq plist (append plist (cdr m))))) + (cons 'ok plist)))))) + (defun pearl--validate-issue-filter (plist) "Validate issue-filter PLIST, signaling a `user-error' on any problem. Return t when PLIST is well-formed. Checks plist shape, unknown keys, the @@ -3838,6 +4030,89 @@ missing setup." (lambda (result) (pearl--render-query-result result source)) (pearl--sort->order-by (plist-get source :sort)))))))))))) +;;; Copy down: save a Linear view as a local view (see the reverse-compile above) + +(defun pearl--customview-filterdata-async (view-id callback) + "Fetch the `filterData' of Custom View VIEW-ID and call CALLBACK with it. +CALLBACK receives the json-read-shaped filter object, or nil on a transport / +GraphQL error or a view with no readable filter." + (pearl--graphql-request-async + "query ViewFilter($id: String!) { customView(id: $id) { filterData } }" + `(("id" . ,view-id)) + (lambda (response) + (let* ((cv (cdr (assq 'customView (cdr (assq 'data response))))) + (fd (and cv (cdr (assq 'filterData cv))))) + (funcall callback fd))) + (lambda (&rest _) (funcall callback nil)))) + +(defun pearl--linear-view-favorites (favorites) + "Return `((TITLE . VIEW-ID) ...)' for the view-kind entries in FAVORITES. +These are the copy-down candidates -- only a favorited Linear view carries a +Custom View id and a readable filter." + (delq nil + (mapcar (lambda (fav) + (when (eq (plist-get fav :kind) 'view) + (cons (plist-get fav :title) (plist-get fav :id)))) + favorites))) + +(defun pearl--save-linear-view-locally-finish (title view-id filter-data) + "Reverse-compile FILTER-DATA for Linear view TITLE/VIEW-ID and save a fork. +On a refusal, signal a `user-error' naming the construct and pointing at +`pearl-run-linear-view'. On success, save a forked local view (no tracking +link, `:copied-from-view-id' provenance only) and report the independence." + (when (null filter-data) + (user-error + "Couldn't read a filter for \"%s\" (it may have none, or the fetch failed); try again or run it with `pearl-run-linear-view'" + title)) + (let ((rc (pearl--reverse-compile-issue-filter filter-data))) + (if (eq (car rc) 'refuse) + (user-error + "Can't copy \"%s\" down: %s. Run it with `pearl-run-linear-view' instead" + title (cdr rc)) + (let* ((filter (cdr rc)) + (name (pearl--resolve-local-view-name + (read-string "Name for the local view: " title)))) + (unless name (user-error "Cancelled; no local view created")) + (pearl--save-local-view name filter) + ;; Record provenance for reference only -- this is a fork, not a sync + ;; link, so it carries no :linear-view-id. + (let ((entry (assoc name pearl-local-views))) + (when entry + (setcdr entry (plist-put (cdr entry) :copied-from-view-id view-id)) + (ignore-errors + (customize-save-variable 'pearl-local-views pearl-local-views)))) + (message + "Saved Linear view %s as independent local view %s; edits won't update the original until you publish it back" + title name))))) + +;;;###autoload +(defun pearl-save-linear-view-locally () + "Copy a favorited Linear view down into a new, editable local view. +Lists your favorited Linear views, fetches the chosen view's filter, reverse- +compiles it into a Pearl authoring filter, and saves a forked local view -- no +tracking link, so editing or renaming it never touches the Linear view. The new +view is stamped with the active account. When the view's filter is outside +Pearl's local model (OR logic, an unmodeled dimension, a multi-value filter), +refuses and names the construct; run such a view directly with +`pearl-run-linear-view'." + (interactive) + (pearl--require-account-context t) + (pearl--progress "Fetching favorited Linear views...") + (pearl--favorites-async + (lambda (favorites) + (let ((views (pearl--linear-view-favorites favorites))) + (unless views + (user-error + "No favorited Linear views; favorite a view in Linear first, then copy it down")) + (let* ((title (completing-read "Save Linear view locally: " + (mapcar #'car views) nil t)) + (view-id (cdr (assoc title views)))) + (pearl--progress "Fetching filter for %s..." title) + (pearl--customview-filterdata-async + view-id + (lambda (filter-data) + (pearl--save-linear-view-locally-finish title view-id filter-data)))))))) + (defun pearl--assemble-filter (team open state project labels assignee) "Assemble a filter authoring plist from the chosen dimensions. The non-nil of TEAM, OPEN, STATE, PROJECT, LABELS, and ASSIGNEE appear (an @@ -6308,6 +6583,7 @@ body stay." ("Q" "run local view" pearl-run-local-view) ("v" "run Linear view" pearl-run-linear-view) ("u" "publish local view" pearl-publish-local-view) + ("d" "save Linear view locally" pearl-save-linear-view-locally) ("U" "publish current view" pearl-publish-current-view) ("X" "delete local view" pearl-delete-local-view)] ["Buffer" @@ -6352,6 +6628,7 @@ body stay." (define-key map "v" (cons "run Linear view" #'pearl-run-linear-view)) (define-key map "l" (cons "run local view" #'pearl-run-local-view)) (define-key map "u" (cons "publish local view" #'pearl-publish-local-view)) + (define-key map "d" (cons "save Linear view locally" #'pearl-save-linear-view-locally)) (define-key map "P" (cons "publish current view" #'pearl-publish-current-view)) map) "Pearl fetch commands; a sub-keymap of `pearl-prefix-map'. |
