diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-14 11:17:59 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-14 11:17:59 +0200 |
| commit | 847f3051b0918efc315321d4b65ad0109ea7979d (patch) | |
| tree | caac031fe5ff750b7323a5ec71d1c24d17f9604d /docs | |
| parent | b08923df88de7ba03135234aaf3c602f23e49e03 (diff) | |
| download | qtmaildir-847f3051b0918efc315321d4b65ad0109ea7979d.tar.gz qtmaildir-847f3051b0918efc315321d4b65ad0109ea7979d.zip | |
docs: spec turning a saved query into a tagging rule
Item 81. A context-menu action on a stored saved query, seeding the rules
dialog with the query and a sanitised id, tags left empty and focused.
Three decisions worth the record. The backlog's own proposal, a checkbox
in the Save query dialog, is rejected: it would make one dialog write both
queries.json and the shared rules.json, and SaveQueryDialog is
deliberately pure UI that writes nothing. Generated entries are excluded,
since their query is composed from the accounts at runtime and a rule made
from one would freeze a snapshot that goes stale when an account is added.
And the empty tags are load-bearing rather than an omission: validate()
refuses a rule that tags nothing, so the one field the user must supply is
the one the dialog opens on.
This also turns out to be a single-repo change. The rule it creates is an
ordinary one, so mailrules.py is untouched; the backlog's note that item
81 spans two repos was about the file it lands in, not the work.
Item 78 becomes a second caller of the same seeded-dialog path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 63 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-08-14-query-to-rule-design.md | 125 |
2 files changed, 150 insertions, 38 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index e60b88c..316b5dc 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -145,7 +145,7 @@ taking that too literally. | 78 | No way to build a rule from something visible in a message | workflow | M | open; wants 76 first, so the created rule lands in a form that can hold it | | 80 | A rule with many conditions squeezes the rule list to one visible row | defect | XS | **done** 2026-08-13, shipped in 0.17.0. Follows item 76 | | 79 | Opening the rules dialog and saving destroys the first rule | defect | XS | **fixed on `rule-builder`** 2026-08-13, unreleased. Shipped in 0.16.0; damaged one real rule, repaired by hand | -| 81 | No way to turn a saved query into a tagging rule | workflow | S | open; 23 has shipped, so the dialog it depends on exists. Writes to the shared rules file, so it spans this repo and `mailctl` | +| 81 | No way to turn a saved query into a tagging rule | workflow | S | open, specified 2026-08-14; see `specs/2026-08-14-query-to-rule-design.md`. A context-menu action seeding the rules dialog, single repo | | 82 | A saved query cannot be edited, unpinned or deleted from the UI | defect | S | **done** 2026-08-13, shipped in 0.18.0. Right-click offers Edit, Pin/Unpin and Delete | | 83 | A rule named with spaces is written to the file and dropped by every reader | defect | S | **done** 2026-08-14, unreleased. The name is sanitised into an id, save validates, a bad id loads for repair | @@ -504,43 +504,30 @@ in CLAUDE.md. ## 81. No way to turn a saved query into a tagging rule -**Observed (user, 2026-08-13):** raised while describing item 23. The save-query -dialog could optionally keep the query as a tagging rule as well as a saved -query, "going the opposite route of creating a filter in the dialog and running -a preview first". Item 77 shipped that opposite route: a rule in the dialog can -be previewed in the thread list. This is the same journey backwards, from a -query the user has already run and can see the results of, to a rule that tags -it from then on. - -**Why it is its own item rather than part of 23.** A saved query is a view and -costs nothing if it is wrong. A rule is applied to real mail by a `post-new` -hook every ten minutes, and lives in `~/.config/mailrules/rules.json`, which has -two independent implementations that agree by test rather than by shared code. -Item 23 is otherwise a presentation change; folding this in would make it carry -a two-repo commitment and a live blast radius. Kept separate so 23 can ship -without it. - -**Approach.** A checkbox or a second button in the save dialog that hands the -query to `TagRules` alongside the `queries.json` write, with the tags to apply -asked for at that point: a rule needs an action, and a saved query has none. -Preserve unknown fields, per "Changing the shared rule format" in CLAUDE.md. -Note that two separate files are then written from one dialog, `queries.json` -here and `rules.json` shared with mailctl, so decide what happens when the -second write fails after the first succeeded. - -**Constraints.** A stored query carries no scope: the hook supplies `tag:new` -and parenthesises the query, because `tag:new and a or b` binds as -`(tag:new and a) or b`. A saved query written for a view is frequently a -disjunction, so this is the common case here rather than an edge one, and a -rule made this way is scoped differently from the query the user just ran. Say -so in the dialog. The hook also refuses to remove `unread` or `inbox`, so a -dialog that offers to write such a rule produces one the hook will not honour; -that is the correct direction, but it has to be said in the UI rather than -failing silently. - -**Depends on item 23**, which builds the dialog this hangs off. - -**Size: S** on top of 23, and not meaningful before it. +**Observed (user, 2026-08-13):** raised while describing item 23. A query the +user has already run and can see the results of should be turnable into a rule +that tags it from then on. Item 77 shipped the opposite route, previewing a +rule's mail in the thread list; this is that journey backwards. + +**Specified 2026-08-14. Read `specs/2026-08-14-query-to-rule-design.md` +instead of planning from here.** + +The three things that decide whether it can be picked up: + +- **A context-menu action, not a checkbox in the Save query dialog.** The + original approach below was rejected: it would make one dialog write both + `queries.json` and the shared `rules.json`, and `SaveQueryDialog` is + deliberately pure UI that writes nothing. +- **It is a single-repo change after all.** The rule it creates is an ordinary + one, so nothing about the shared format changes and `mailrules.py` is + untouched. The earlier note that this item spans two repos was about the file + it lands in, not about the work. +- **Stored queries only.** A generated entry's query is composed from the + accounts at runtime, so a rule made from one would freeze a snapshot that goes + stale when an account is added. + +**Item 78 folds into the same path**, as a second caller with a different seed +rather than a second mechanism. ## Deferred, unsized, or split out diff --git a/docs/superpowers/specs/2026-08-14-query-to-rule-design.md b/docs/superpowers/specs/2026-08-14-query-to-rule-design.md new file mode 100644 index 0000000..8c9c66b --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-query-to-rule-design.md @@ -0,0 +1,125 @@ +# Turning a saved query into a tagging rule + +Resolves backlog item **81**. Depends on item 23 (the saved-query file and its +context menu) and item 76 (the rule builder), both shipped. + +## What this is + +A saved query is a view: it costs nothing if it is wrong, and the user can see +exactly what it collects. A tagging rule is the same query given an action and +handed to a `post-new` hook that runs against real mail every ten minutes. This +item is the journey from the first to the second, for a query the user has +already run and whose results are on screen. + +Item 77 shipped the opposite direction: a rule in the rules dialog can be +previewed in the thread list. This is that road backwards. + +## The shape + +**Entry point.** The saved-query context menu gains **Create tagging rule...**, +after Delete and below a separator. The menu is built once, in +`MainWindow::addSavedQueryActions()` (`src/mainwindow.cpp:1743`), and is used +for both the pinned buttons and the More queries entries, so one action added +there appears in both places. + +**Stored queries only.** A generated entry (Sent, and the account-scoped ones) +does not get the item. Its query is composed at runtime from the configured +accounts, so a rule made from it would freeze a snapshot that goes stale the day +an account is added, in a file the hook reads unattended and nobody re-reads. +`SavedQuery::isGenerated()` (`src/config.h:144`) is the test, and item 82 +already uses it to make the Edit dialog show a generated query read-only. + +**What the action does.** Opens `TagRulesDialog` seeded with one new rule, +appended to the working list and selected: + +| field | value | +|---|---| +| `query` | the saved query's stored query, verbatim | +| `id` | `TagRules::uniqueId(saved.name, <ids already in the list>)` | +| `add` / `remove` | empty, and the Add tags field takes focus | +| `enabled` | `true` | +| `stage` | 50, the default | + +Nothing reaches disk until Save, exactly as the existing **Add rule** button +behaves. Cancel discards the seeded rule along with any other pending edit. + +## Decisions, and why + +**Not a checkbox in the Save query dialog.** The backlog proposed one. It was +rejected in favour of a separate action for three reasons: it would give the +save path a second, more dangerous job; it would make one dialog write two +files, `queries.json` here and `rules.json` shared with mailctl, raising a +partial-failure question with no good answer; and `SaveQueryDialog` is +deliberately pure UI that writes nothing and returns a value +(`src/savequerydialog.h:31-35`), which is worth keeping. The rules dialog is +also where the match count and the preview live, and those are what make a rule +safe to create. + +**The seeded rule is enabled.** It matches what Add rule already does, and an +inconsistent default between two ways of making the same thing is worse than +either default. A rule created disabled and then forgotten is its own silent +failure, and the match count in the list is the check that the user has anyway. + +**The tags are left empty on purpose.** A rule that adds and removes nothing +fails `TagRules::validate()`, so Save is refused with the red banner naming the +rule. The one field the user must supply is the one the dialog opens on, and +forgetting it is caught rather than written. This is the validation added for +item 83 doing the job it was built for. + +**The scope difference is not restated in the dialog.** When the hook applies a +rule it supplies `tag:new` and parenthesises the query, so a rule only ever +matches newly arrived mail, never the messages the user was just looking at. +That is a real and counterintuitive difference, and it is already stated in the +dialog's intro text: "Rules tag mail as it arrives ... it does not retag mail +you already have." A second notice beside the red warning banner would dilute +the banner, which item 83 has just finished making load-bearing. If the intro +turns out not to carry it, fix the intro rather than adding a second line. + +## Structure + +Three small changes, no new file. + +- **`TagRulesDialog`** gains a constructor taking a seed `TagRule`. It appends + the seed to `m_working` after the normal load, selects it, and focuses the Add + tags field. The existing constructor delegates to it with no seed. +- **`MainWindow::showTagRulesDialog()`** gains an optional seed parameter, + passed through. Every existing caller is unchanged. +- **`MainWindow::addSavedQueryActions()`** gains the action, guarded on + `!saved.isGenerated()`. + +**Item 78 folds into this.** "Create rule from sender" is the same seeded-dialog +path with a different seed, so it becomes a second caller rather than a second +mechanism. This is the reason the seed is a whole `TagRule` rather than a query +string: a sender rule will want to set tags too. + +## Testing + +In `test_tagrules`: + +- A seeded dialog carries the query and a sanitised id, and the rule is in the + working list and **not** on disk. +- Save writes it. Cancel does not, asserted by rereading the file. +- A seed whose name collides with an existing rule id gets a suffix rather than + replacing that rule. This is the case `uniqueId` exists for, and it is + reachable here in a way it is not from the rules dialog alone, since the name + comes from a different file. +- A seeded rule saved with no tags is refused, and the banner names it. + +In `test_mainwindow`: + +- The menu carries `Create tagging rule...` for a stored query and does not for + a generated one. The generated half needs a guard proving the menu was built + at all: item 82 records that a test asserting only the ABSENCE of a widget + passes against no implementation whatever. + +## Out of scope + +**Backfill.** Applying the new rule to the mail the user is looking at is the +obvious next question and is deliberately unbuilt, here as everywhere else. See +`2026-08-12-tagging-rules-design.md` for what it needs first, including the +revision it forces to the no-confirmation rule in CLAUDE.md. + +**Editing the saved query from the rules dialog.** The two objects are +independent once the rule exists. A rule that remembers where it came from would +raise the question of what happens when the query is later edited, and neither +answer is obviously right. |
