aboutsummaryrefslogtreecommitdiffstats
path: root/src/tagrulesdialog.cpp
AgeCommit message (Collapse)AuthorFilesLines
8 daysfeat(rules): show each rule's note in the rule listDanilo M.1-5/+54
The `note` field explains why a rule is shaped the way it is, and it was reachable only by selecting the rule and reading the editor form, which is the wrong way round for the one field that says what a rule is for. Note is the LAST column, after Matches, at the user's request: a note is prose and the widest thing in the table, so it belongs where it can run on without pushing the narrow columns off screen. That is fiddlier than it looks, because "Matches" is not in the Column enum at all: it is appended past the end at index ColumnCount. Note therefore has to be declared before ColumnCount and still draw after it, and setColumnCount takes a new ColumnTotal rather than ColumnCount + 1. Both columns hold text, so a mix-up puts the counts under Note and looks entirely plausible; the test asserts the counts land under Matches as well as asserting the header order, since the header assertion alone passes with the two swapped. The cell is simplified(), because a note is free text and a newline truncates a tree row at it. The full text is the cell's tooltip and is untouched in the editor. Also fixes a defect found on the way, which is not in the backlog entry. QHeaderView::restoreState REFUSES a state saved with a different column count, returning false and leaving the header untouched, which is what every existing uistate.conf now does. The restore path set m_columnsSized and m_countColumnSized regardless, spending the one auto-size each column gets on a restore that did nothing: the new Note column would have opened at its default width, once, permanently. Now guarded on the return value. Upgrading costs one reset of this dialog's column widths, which is unavoidable, since the saved state genuinely describes a table that no longer exists. Backlog item 102.
10 daysfeat(i18n): wire translations and ship an Italian one (item 22)Danilo M.1-8/+17
Nothing loaded a translation before this: no QTranslator, no .ts file and no build rule, so every string was English whatever the locale said. The language now comes from the environment, LANG=it_IT.UTF-8, and any other locale runs in English as before. The audit found that the tr() discipline was largely holding, and found eight strings that could never be translated into any language. kFields[] in tagrulesdialog.cpp declared the rule-builder field labels with QT_TR_NOOP inside an anonymous namespace, where lupdate reports "tr() cannot be called without context" and extracts nothing, while the use site calls TagRulesDialog::tr() on them at runtime. From, To, Cc, Subject, Tag, Folder, Attachment and Date: the whole vocabulary of the rule builder, absent from every translation file that could ever exist. The source compiles and reads correctly; only lupdate reveals it. Q_DECLARE_TR_FUNCTIONS is not the fix for that case, though it is the fix for a free function calling tr(). Measured against lupdate: a class carrying the macro beside the array still extracts 0 strings, because the context must be attached to the literal itself. QT_TRANSLATE_NOOP names it explicitly and matches the tr() that already reads them, so the use site needed no change. Twenty configuration and keybinding warnings were not translatable either. They are user-facing, reaching the status label and the "Configuration problems" dialog. Config already had the tr() macro; KeyMap needed it. Translating the filter labels then broke startup_query, found in hand testing: a filter's name is a translated label, so `startup_query = Inbox` matched nothing where the filter shows as "In arrivo". The application opened a different view and reported the user's own working config as invalid. Resolution matches the generator as well now, which is stored in queries.json and identical in every locale; the translated name still works. The regression test installs a real QTranslator rather than a stub, since the bug lives in the gap between the stored string and the displayed one, and it writes a queries.json because the warning it asserts on is guarded by a non-empty saved-query list: without one the branch never runs and the test passes against a broken check. main.cpp's --help and --version stay bare printf, as they run before QApplication exists and no translator could serve them. Verified per the backlog's own standard, that lupdate output is the evidence rather than reading: 355 strings extracted with zero context warnings, where before there were 327 with eight; lrelease reporting 355 finished and 0 unfinished; the built .qm loaded in a standalone probe printing "From -> Da" and both Italian plural forms; and the install rule placing it where main.cpp looks. test_translations guards it and was mutation checked, failing on an emptied translation and naming the defect when QT_TRANSLATE_NOOP is reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfeat(rules): open the rules dialog on a seeded ruleDanilo M.1-0/+44
The seed is a whole TagRule rather than a query string, so item 78 can reuse the same path to seed from a sender. It is a pending edit like one made with Add rule: appended, selected, Add tags focused, and written only on Save. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(rules): stop a rule with a spaced name from vanishing on saveDanilo M.1-15/+169
A rule named "justeat orders" in the field labelled Name was written to rules.json correctly and then dropped by every reader, because load() required ^[a-z0-9][a-z0-9-]*$ and the save path validated nothing. The rule stayed in the file, invisible in the dialog, never applied by the post-new hook, and the next save from the dialog would have deleted it outright. The asymmetry was the defect, not the pattern. TagRules::validate() is now the single predicate: the dialog refuses to save against it, and load() uses it to repair rather than drop, so a rule that fails is visible and fixable instead of silently discarded. - The typed name is sanitised into an id when the field is committed, so the field shows what will reach the file. uniqueId() suffixes a collision, since sanitising is many-to-one and can manufacture the duplicate that load() then drops. - An already-legal id is never rewritten, including one like "a---b" that sanitising would otherwise collapse. Rewriting valid ids would churn a file mailctl also reads. - A bad id loads repaired, with the warning kept: what is on disk is not what the hook runs until the file is saved back. Deliberately not mirrored into mailrules.py. The hook tags real mail unattended every ten minutes, where silently renaming an id is worse than dropping the rule; the file converges as soon as the dialog saves. No format change, so no version bump and no two-repo commitment. The load warning was not missing: it had been showing "1 rule could not be read and was skipped" on every open, in the same font and colour as the intro prose two lines above it, and read as more explanation. It is now a red banner beside Save, with an icon and a dismiss button, and it says the rules need attention rather than that they were skipped, which is no longer true. Dismissal is per-appearance only; a persistent one would re-hide the problem that went unnoticed for a session. Both new dialog tests were confirmed to fail with the sanitiser reverted, and the banner's styling, position and dismissal each fail under mutation. 20 of 20 suites green, 34 tests in test_tagrules. Closes item 83. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(rules): preview a rule's mail in the thread listDanilo M.1-0/+24
Item 77. The dialog could say how many messages a rule matched and not which ones. A Preview in list button now runs the selected rule's query in the main window; the dialog stays open, since comparing the rule against its results is the point. Two constraints from the backlog entry, both now asserted and both mutation-checked. The query runs exactly as stored, with no tag:new and no wrapping parentheses. The post-new hook adds those when it applies a rule, and a preview that copied them would match nothing outside a sync window, since tag:new is set only on mail that has just arrived. The account selector is cleared first. runQuery() wraps the bar's text in the selected account's scope, and a rule query usually names its own path already, so previewing one with an account selected would scope it twice and show an empty list, which reads as "this rule collects no mail". The second mutation only fails once the test's config has an account to select: with the default empty config the selector sits on "All accounts" anyway, and asserting that a preview leaves it there passed against the mutation. Recorded in the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(rules): a long rule no longer squeezes the rule list awayDanilo M.1-6/+70
Item 80. A rule with eight From conditions left the list showing about one and a half rows. The list was added with stretch 1 and the form below it with none, which looks decisive and is not: a stretch factor only distributes space above each widget's minimum, and the form's minimum grew with every condition row, so each row came straight out of the list. The builder asked for 120px with one row and 414px with eight. A QSplitter now divides the list from the editor, so the balance is the user's and is saved beside the column widths, and the condition rows sit in a QScrollArea capped at 190px so the editor cannot grow without bound however the splitter is set. The scroll area is what text mode hides; hiding the builder inside it would leave an empty frame. Three measures were tried in the test before one told the bug and the fix apart, and two passed against broken code: the dialog's minimumSizeHint does not track form rows and read 580 either way, and a qMin against the scroll area's own hint read small whether or not the cap was set, since an uncapped maximumHeight is QWIDGETSIZE_MAX. What survives mutation is the editor pane's minimum inside the splitter, plus the cap read directly, and both are asserted. A row's size hint is invalid until the event loop runs, so the test calls processEvents after selecting a rule or it measures the same height twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(rules): save the window size on Cancel and Save, not only on XDanilo M.1-6/+14
The geometry was saved from closeEvent, and neither dialog button sends one: Cancel calls reject(), Save calls accept(), and only the window manager's X button produces a QCloseEvent. So the size and the column widths were kept for the one route out of three that a user almost never takes, and a resize followed by Cancel came back forgotten. The save moves to a done(int) override, which both buttons funnel through and which QWidget::close() also reaches. The test that covered this passed against the bug because it asserted with close(). It now drives all three routes rather than trusting one to stand for the others, and shows the dialog before the close leg: close() on a widget that was never visible returns early without reaching done(), so that assertion would otherwise prove nothing. Both traps recorded in CLAUDE.md, since neither is specific to this dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(rules): the rules window keeps its size and column widthsDanilo M.1-3/+90
Item 75. saveGeometry() and the rule list header's saveState() go to uistate.conf under keys of their own, written on closeEvent so a size survives Cancel as well as Save. The 760x520 resize stays as the first-run fallback. The backlog's approach was wrong on one point and a test caught it. It said to drop the resizeColumnToContents calls once a saved header state exists, which fixes the restore and leaves the original defect standing: with nothing saved, a width the user had just dragged was still discarded by the next add or delete. Each column is instead auto-sized once, on its first fill, after which the width belongs to the user however it was set. Two flags, because the count column is filled later by a reply from the worker. The window stays a QDialog. Making it a top-level window needs the unsaved-edit story that being modal currently sidesteps, and that is its own decision rather than part of this item. Both tests redirect XDG_STATE_HOME as well as XDG_CONFIG_HOME, so they cannot write the real uistate.conf. The geometry is asserted on the stored value rather than the reopened frame, per item 46: the offscreen platform does not honour a resize. Also corrects setFolders' doc comment, which still described the folder list as coming from Config. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(rules): keep the text-mode toggle reachableDanilo M.1-6/+23
Ticking "Edit as text" was a one-way trip: the only way back to the rows was closing the dialog and reopening it. The checkbox was parented to the builder widget and sat on the match row, and switching to text mode hides that widget, so the toggle disappeared along with the rows it governs. Move it to the query row, which is visible in both modes. The existing tests all passed against this, because they drove the toggle through setChecked and then asserted on the checked STATE. A hidden checkbox reports its state perfectly well, so every one of those assertions held while the widget was unreachable. The new test asks the question that matters, whether the toggle would be on screen, and it uses isVisibleTo since nothing is isVisible on a dialog that was never shown. Worth recording how close the mutation check came to endorsing this too. Reparenting the checkbox alone left it in the query row's layout, so it stayed visible and the test still passed. Only restoring the full shipped shape, parent and layout together, reproduced the fault and failed the test. A mutation that does not reproduce the original bug proves nothing about the test that is meant to catch it. The spec's layout sketch carried the same error and is corrected, with the reason, so the next reader does not reintroduce it.
12 daysfeat(rules): a folder dropdown, so the path suffix is never typedDanilo M.1-6/+72
12 daysfeat(rules): report the text-mode refusal without a modalDanilo M.1-4/+35
Leaving text mode with a query the builder cannot represent has to refuse, since there are no rows that mean that query. It announced this with a QMessageBox, which made the branch untestable: a modal blocks the test that reaches it, so the one path that can strand a user was the one path shipping unverified. Say it in the warning label the dialog already has instead. That also suits the moment better, since it does not interrupt someone mid-edit to tell them something the label can hold while they keep typing, and it matches how the tag dialog reports a bad tag. Returning to the rows now calls showWarnings(), because the refusal writes into the same label the load warnings use and a stale complaint would otherwise outlive the query that caused it. The test drives the refusal and the recovery, and asserts the warning appears and then clears. Verified by mutation: letting the checkbox clear regardless fails it. warningTextForTest uses isVisibleTo rather than isVisible. Every child of a dialog that was never shown reports isVisible() false, so the seam would have reported no warning whatever the label held, which is a probe that cannot see the thing it checks.
12 daysfeat(rules): text mode, and leave untouched rules unwrittenDanilo M.1-1/+69
12 daysdocs: record the rules dialog data-loss defect as item 79Danilo M.1-2/+3
Opening the tagging rules dialog and pressing Save destroyed the first rule in the list, without any editing. The rule lost its query and its tags, then vanished entirely on the next load, since a rule with an empty query is dropped as malformed. Reproduced against the released tag rather than the branch, in a throwaway worktree at 9585674 with a two-rule fixture: constructing the dialog and running its save path left one rule of two. onSelectionChanged blocked signals for the note widget only, while m_enabled::toggled two lines later reached applyEditsToCurrentRule, which writes every field from widgets the loader has not filled yet. The existing comment there shows the hazard was known for one widget and not extended to the other. The fix landed with the builder work: the reloading flag now covers the whole load, and switchingRulesDoesNotLeakRowsBetweenThem is the regression test, verified by mutation to fail without the guard. The live rules file had one casualty, the account rule sitting first in the list, with both its query and its tags empty while every sibling was intact. Restored from the shell backup that the earlier migration kept and verified through mailctl's own reader. The rule had stopped tagging, but only one message had arrived meanwhile; that message is now tagged and the account is complete again at 14969 of 14969.
12 daysfeat(rules): load a rule into the builder rowsDanilo M.1-8/+89
Selecting a rule now parses its stored query and rebuilds the builder rows from it, and a row edit compiles back onto the query line and into the working copy. Populating the form was already able to write the rule just loaded over whichever rule is current: m_enabled's toggled runs applyEditsToCurrentRule while m_query still holds the previous rule's text, which emptied the first rule's query on open. The existing m_reloading guard now covers the whole load rather than one signal blocker on the note, which also covers the combo boxes rebuildRows populates.
12 daysfeat(rules): add the builder row widgetsDanilo M.1-0/+216
13 daysfeat(rules): a dialog to view and edit the tagging rulesDanilo M.1-0/+341
Edits land on a working copy and reach the file only on Save. The dialog never opens a notmuch database of its own: it publishes the queries it wants counted and MainWindow runs them through the worker, because the worker owns the only handle. Two departures from the drafted version, both of which lost edits. QPlainTextEdit has no editingFinished, so the note reached the working copy only for whichever row was current at Save; it is driven from textChanged instead, with the selection handler blocking the signal so loading a rule cannot write itself back over the one now current. And reloadList()'s setCurrentItem emits currentItemChanged, so New and Copy repopulated the form from m_working before the pending edit had been flushed into it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>