From 380fada98f77b86382370cef5416f7a14fd7384a Mon Sep 17 00:00:00 2001 From: Jason Farrar Date: Sat, 8 Aug 2026 14:21:48 +0100 Subject: [PATCH 1/3] feat(anonymiser): add retain-descriptions option with security warning - Add StanCheckBox + StanHelpIcon in Section 3 (Run Anonymisation area) - Checkbox hidden by default, shown only when always_anonymise table has data - Warning dialog fires on check with Security Warning icon, Yes/Cancel - _retain_descriptions threaded through _AnonymiseWorker to bsa.anonymise_pdf() - Status label appends [Retain descriptions: ON] when active - 15 unit tests covering worker param, config loading, visibility, and status Closes #134 --- .../presenters/anonymise_presenter.py | 100 +++++++- src/openstan/views/anonymise_dialog.py | 33 +++ .../test_anonymise_retain_descriptions.py | 221 ++++++++++++++++++ 3 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_anonymise_retain_descriptions.py diff --git a/src/openstan/presenters/anonymise_presenter.py b/src/openstan/presenters/anonymise_presenter.py index eb2aebd..6d942f8 100644 --- a/src/openstan/presenters/anonymise_presenter.py +++ b/src/openstan/presenters/anonymise_presenter.py @@ -18,7 +18,7 @@ import bank_statement_anonymiser as bsa from PySide6.QtCore import QObject, QRunnable, QThreadPool, QUrl, Signal, Slot from PySide6.QtGui import QDesktopServices -from PySide6.QtWidgets import QFileDialog +from PySide6.QtWidgets import QFileDialog, QMessageBox from openstan.components import StanErrorMessage, StanFolderDialog, StanInfoMessage @@ -138,6 +138,7 @@ def __init__( always_anonymise_path: Path | None = None, never_anonymise_path: Path | None = None, output_dir: Path | None = None, + retain_descriptions: bool = False, ) -> None: super().__init__() self.signals = _AnonymiseSignals() @@ -146,6 +147,7 @@ def __init__( self._always_path = always_anonymise_path self._never_path = never_anonymise_path self._output_dir = output_dir + self._retain_descriptions = retain_descriptions @Slot() def run(self) -> None: @@ -162,6 +164,7 @@ def _run_single(self) -> None: self._input, always_anonymise_path=self._always_path, never_anonymise_path=self._never_path, + retain_descriptions=self._retain_descriptions, ) self.signals.finished.emit(out) except Exception as exc: # noqa: BLE001 @@ -179,6 +182,7 @@ def _run_batch(self) -> None: input_path, always_anonymise_path=self._always_path, never_anonymise_path=self._never_path, + retain_descriptions=self._retain_descriptions, ) # Move output to the dedicated subfolder if specified if self._output_dir is not None: @@ -247,6 +251,9 @@ def __init__( self._always_config = AlwaysAnonymiseConfig() self._never_config = NeverAnonymiseConfig() + # Retain-descriptions option (per-session, not persisted) + self._retain_descriptions: bool = False + # Wire buttons self.dialog.button_browse.clicked.connect(self._browse_pdf) self.dialog.button_browse_folder.clicked.connect(self._browse_folder) @@ -262,6 +269,14 @@ def __init__( self.dialog.button_add_never.clicked.connect(self._add_never_row) self.dialog.button_remove_never.clicked.connect(self._remove_never_row) + # Wire retain-descriptions checkbox + self.dialog.checkbox_retain_descriptions.stateChanged.connect( + self._on_retain_descriptions_toggled + ) + + # Wire table change signals for dynamic visibility + self.dialog.table_always.cellChanged.connect(self._on_always_table_changed) + # Ensure config directory exists self._config_dir.mkdir(parents=True, exist_ok=True) @@ -292,6 +307,9 @@ def _load_and_populate_tables(self) -> None: # Populate "Never Anonymise" table self.dialog.populate_never_table(self._never_config.exclude) + # Refresh retain-descriptions button visibility + self._update_retain_description_visibility() + # --------------------------------------------------------------------------- # Config saving with retry logic # --------------------------------------------------------------------------- @@ -357,6 +375,7 @@ def _remove_always_row(self) -> None: current_row = self.dialog.table_always.currentRow() if current_row >= 0: self.dialog.table_always.removeRow(current_row) + self._update_retain_description_visibility() @Slot() def _add_never_row(self) -> None: @@ -378,6 +397,83 @@ def _remove_never_row(self) -> None: if current_row >= 0: self.dialog.table_never.removeRow(current_row) + # --------------------------------------------------------------------------- + # Retain-descriptions logic + # --------------------------------------------------------------------------- + + @Slot(int) + def _on_retain_descriptions_toggled(self, state: int) -> None: + """Handle the retain-descriptions checkbox toggle. + + When checked, show a warning dialog. If the user declines, + uncheck the box. + """ + is_checked = state == 2 # Qt.CheckState.Checked.value + if is_checked and not self._show_retain_descriptions_warning(): + self.dialog.checkbox_retain_descriptions.setChecked(False) + return + self._retain_descriptions = is_checked + self._update_retain_description_status() + + def _show_retain_descriptions_warning(self) -> bool: + """Show a warning about retain_descriptions security risks. + + Returns True if the user confirmed, False otherwise. + """ + msg = StanInfoMessage(parent=self.dialog) + msg.setWindowTitle("Security Warning — Retain Descriptions") + msg.setIcon(QMessageBox.Icon.Warning) + msg.setText("Retain Transaction Descriptions — Security Risk") + msg.setInformativeText( + "Enabling this option means transaction descriptions and free text " + "will NOT be scrambled.\n\n" + "Risks:\n" + "• Transaction descriptions may contain personally identifiable " + "information (names, references, addresses).\n" + "• Only your explicit always-anonymise replacements and numeric ID " + "substitutions will be applied.\n\n" + "Before proceeding, ensure you have added ALL personally identifiable " + "information to your Always Anonymise file.\n\n" + "Recommendation: files produced with this option must NOT be shared " + "externally. Use them only for internal testing and demonstration " + "at an AGGREGATED level." + ) + msg.setStandardButtons( + StanInfoMessage.StandardButton.Yes | StanInfoMessage.StandardButton.Cancel + ) + msg.setDefaultButton(StanInfoMessage.StandardButton.Cancel) + return msg.exec() == StanInfoMessage.StandardButton.Yes + + @Slot(int, int) + def _on_always_table_changed(self, row: int, col: int) -> None: + """Refresh retain-descriptions visibility when always_anonymise table changes.""" + self._update_retain_description_visibility() + + def _update_retain_description_visibility(self) -> None: + """Show/hide the retain-descriptions checkbox based on always_anonymise data. + + The checkbox only appears when the user has at least one non-empty + replacement row in the always_anonymise table — bsa.anonymise_pdf() + with retain_descriptions=True requires a non-empty always_anonymise file. + """ + has_replacements = bool(self.dialog.get_always_table_data()) + self.dialog.checkbox_retain_descriptions.setVisible(has_replacements) + self.dialog.help_retain_descriptions.setVisible(has_replacements) + + if not has_replacements and self._retain_descriptions: + self.dialog.checkbox_retain_descriptions.setChecked(False) + self._retain_descriptions = False + self._update_retain_description_status() + + def _update_retain_description_status(self) -> None: + """Append retain-descriptions indicator to the current status label.""" + current = self.dialog.label_status.text() + marker = " [Retain descriptions: ON]" + current = current.replace(marker, "") + if self._retain_descriptions: + current += marker + self.dialog.label_status.setText(current) + @Slot() def _browse_pdf(self) -> None: """Open a file dialog to choose the source PDF.""" @@ -506,6 +602,7 @@ def _run_single_anonymisation(self) -> None: input_path=self._input_path, always_anonymise_path=always_path, never_anonymise_path=never_path, + retain_descriptions=self._retain_descriptions, ) worker.signals.finished.connect(self._on_finished) worker.signals.error.connect(self._on_error) @@ -559,6 +656,7 @@ def _run_folder_anonymisation(self) -> None: always_anonymise_path=always_path, never_anonymise_path=never_path, output_dir=output_dir, + retain_descriptions=self._retain_descriptions, ) worker.signals.progress.connect(self._on_progress) worker.signals.batch_finished.connect(self._on_batch_finished) diff --git a/src/openstan/views/anonymise_dialog.py b/src/openstan/views/anonymise_dialog.py index 6df010e..7fa7f17 100644 --- a/src/openstan/views/anonymise_dialog.py +++ b/src/openstan/views/anonymise_dialog.py @@ -26,8 +26,10 @@ from openstan.components import ( Qt, StanButton, + StanCheckBox, StanDialog, StanFrame, + StanHelpIcon, StanLabel, StanLineEdit, StanMutedLabel, @@ -194,6 +196,36 @@ def __init__(self, parent: QWidget | None = None) -> None: layout_run.setSpacing(8) lbl_run_title = StanLabel("##### Run Anonymisation") + + self.checkbox_retain_descriptions = StanCheckBox( + "Retain transaction descriptions (do not scramble free text)" + ) + self.checkbox_retain_descriptions.setVisible(False) + self.checkbox_retain_descriptions.setToolTip( + "Only always-anonymise replacements and numeric IDs are applied.\n" + "Transaction descriptions remain unchanged — this is a security risk." + ) + + self.help_retain_descriptions = StanHelpIcon( + "RETAIN DESCRIPTIONS — SECURITY WARNING\n\n" + "When enabled, only your explicit always-anonymise replacements and " + "numeric substitutions (sort codes, account numbers, card numbers) " + "are applied. Transaction descriptions and free text are NOT scrambled.\n\n" + "IMPORTANT:\n" + "• You MUST add all personally identifiable information to your " + "always_anonymise.toml file before using this option.\n" + "• Anonymised files with unscrambled descriptions must NOT be shared " + "externally.\n" + "• Use these files only for internal testing and demonstration at an " + "AGGREGATED level." + ) + self.help_retain_descriptions.setVisible(False) + + row_retain = QHBoxLayout() + row_retain.addWidget(self.checkbox_retain_descriptions) + row_retain.addWidget(self.help_retain_descriptions) + row_retain.addStretch() + self.button_run = StanButton("Run Anonymisation", min_width=200) self.button_run.setEnabled(False) @@ -204,6 +236,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.label_status.setWordWrap(True) layout_run.addWidget(lbl_run_title) + layout_run.addLayout(row_retain) layout_run.addWidget(self.button_run, alignment=Qt.AlignmentFlag.AlignLeft) layout_run.addWidget(self.progress_bar) layout_run.addWidget(self.label_status) diff --git a/tests/unit/test_anonymise_retain_descriptions.py b/tests/unit/test_anonymise_retain_descriptions.py new file mode 100644 index 0000000..6b78661 --- /dev/null +++ b/tests/unit/test_anonymise_retain_descriptions.py @@ -0,0 +1,221 @@ +"""Tests for the retain-descriptions feature in AnonymisePresenter. + +Covers: + - _update_retain_description_visibility (show/hide checkbox based on table data) + - _AnonymiseWorker receiving retain_descriptions parameter + - _update_retain_description_status (status label marker) +""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from openstan.presenters.anonymise_presenter import ( + AlwaysAnonymiseConfig, + AnonymisePresenter, + _AnonymiseWorker, +) + +# --------------------------------------------------------------------------- +# _AnonymiseWorker — retain_descriptions parameter +# --------------------------------------------------------------------------- + + +class TestAnonymiseWorkerRetainDescriptions: + """Verify that _AnonymiseWorker threads retain_descriptions through to bsa.""" + + def test_worker_stores_retain_descriptions_false(self, tmp_path: Path) -> None: + worker = _AnonymiseWorker( + input_path=tmp_path / "test.pdf", + retain_descriptions=False, + ) + assert worker._retain_descriptions is False + + def test_worker_stores_retain_descriptions_true(self, tmp_path: Path) -> None: + worker = _AnonymiseWorker( + input_path=tmp_path / "test.pdf", + retain_descriptions=True, + ) + assert worker._retain_descriptions is True + + def test_worker_batch_stores_retain_descriptions(self, tmp_path: Path) -> None: + worker = _AnonymiseWorker( + input_paths=[tmp_path / "a.pdf", tmp_path / "b.pdf"], + retain_descriptions=True, + ) + assert worker._retain_descriptions is True + + @patch("openstan.presenters.anonymise_presenter.bsa") + def test_worker_passes_retain_descriptions_to_bsa_single( + self, mock_bsa: MagicMock, tmp_path: Path + ) -> None: + """Single-file mode should pass retain_descriptions to bsa.anonymise_pdf.""" + input_pdf = tmp_path / "test.pdf" + input_pdf.write_text("fake pdf") + mock_bsa.anonymise_pdf.return_value = tmp_path / "anonymised_test.pdf" + + worker = _AnonymiseWorker( + input_path=input_pdf, + always_anonymise_path=tmp_path / "always.toml", + never_anonymise_path=tmp_path / "never.toml", + retain_descriptions=True, + ) + worker.run() + + mock_bsa.anonymise_pdf.assert_called_once_with( + input_pdf, + always_anonymise_path=tmp_path / "always.toml", + never_anonymise_path=tmp_path / "never.toml", + retain_descriptions=True, + ) + + @patch("openstan.presenters.anonymise_presenter.bsa") + def test_worker_passes_retain_descriptions_to_bsa_batch( + self, mock_bsa: MagicMock, tmp_path: Path + ) -> None: + """Batch mode should pass retain_descriptions to each bsa.anonymise_pdf call.""" + pdf_a = tmp_path / "a.pdf" + pdf_b = tmp_path / "b.pdf" + pdf_a.write_text("fake a") + pdf_b.write_text("fake b") + mock_bsa.anonymise_pdf.side_effect = lambda p, **kw: tmp_path / f"anon_{p.name}" + + worker = _AnonymiseWorker( + input_paths=[pdf_a, pdf_b], + always_anonymise_path=tmp_path / "always.toml", + retain_descriptions=True, + ) + worker.run() + + assert mock_bsa.anonymise_pdf.call_count == 2 + for call in mock_bsa.anonymise_pdf.call_args_list: + assert call.kwargs.get("retain_descriptions") is True + + +# --------------------------------------------------------------------------- +# AlwaysAnonymiseConfig — from_toml with replacements +# --------------------------------------------------------------------------- + + +class TestAlwaysAnonymiseConfig: + """Verify AlwaysAnonymiseConfig loads correctly from TOML.""" + + def test_empty_file_returns_empty_config(self, tmp_path: Path) -> None: + toml_path = tmp_path / "always_anonymise.toml" + toml_path.write_text("# No replacements\n") + config = AlwaysAnonymiseConfig.from_toml(toml_path) + assert config.replacements == {} + + def test_file_with_replacements(self, tmp_path: Path) -> None: + toml_path = tmp_path / "always_anonymise.toml" + toml_path.write_text('"John Smith" = "J. Smith"\n"123456" = "000000"\n') + config = AlwaysAnonymiseConfig.from_toml(toml_path) + assert config.replacements == {"John Smith": "J. Smith", "123456": "000000"} + + def test_missing_file_returns_empty_config(self, tmp_path: Path) -> None: + config = AlwaysAnonymiseConfig.from_toml(tmp_path / "nonexistent.toml") + assert config.replacements == {} + + +# --------------------------------------------------------------------------- +# Visibility logic — use a plain-class stand-in with the same attributes +# --------------------------------------------------------------------------- + + +class _PresenterStub: + """Minimal stand-in for AnonymisePresenter — holds only the attributes + needed by the visibility and status methods.""" + + def __init__(self, dialog: MagicMock, retain_descriptions: bool = False) -> None: + self.dialog = dialog + self._retain_descriptions = retain_descriptions + + # Bind the real methods from the class (type ignore: stub is not AnonymisePresenter) + _update_retain_description_visibility = ( # type: ignore[assignment] + AnonymisePresenter._update_retain_description_visibility + ) + _update_retain_description_status = ( # type: ignore[assignment] + AnonymisePresenter._update_retain_description_status + ) + + +class TestRetainDescriptionVisibility: + """Test _update_retain_description_visibility with a stub presenter.""" + + def test_checkbox_hidden_when_table_empty(self) -> None: + dialog = MagicMock() + dialog.get_always_table_data.return_value = {} + stub = _PresenterStub(dialog, retain_descriptions=False) + + stub._update_retain_description_visibility() # type: ignore[bad-argument-type] + + dialog.checkbox_retain_descriptions.setVisible.assert_called_with(False) + dialog.help_retain_descriptions.setVisible.assert_called_with(False) + + def test_checkbox_visible_when_table_has_data(self) -> None: + dialog = MagicMock() + dialog.get_always_table_data.return_value = {"John": "J. Smith"} + stub = _PresenterStub(dialog, retain_descriptions=False) + + stub._update_retain_description_visibility() # type: ignore[bad-argument-type] + + dialog.checkbox_retain_descriptions.setVisible.assert_called_with(True) + dialog.help_retain_descriptions.setVisible.assert_called_with(True) + + def test_checkbox_unchecked_when_table_cleared(self) -> None: + dialog = MagicMock() + dialog.checkbox_retain_descriptions.isChecked.return_value = True + dialog.get_always_table_data.return_value = {} + stub = _PresenterStub(dialog, retain_descriptions=True) + + stub._update_retain_description_visibility() # type: ignore[bad-argument-type] + + dialog.checkbox_retain_descriptions.setChecked.assert_called_with(False) + assert stub._retain_descriptions is False + + def test_checkbox_not_unchecked_when_still_has_data(self) -> None: + dialog = MagicMock() + dialog.get_always_table_data.return_value = {"John": "J. Smith"} + stub = _PresenterStub(dialog, retain_descriptions=True) + + stub._update_retain_description_visibility() # type: ignore[bad-argument-type] + + dialog.checkbox_retain_descriptions.setChecked.assert_not_called() + assert stub._retain_descriptions is True + + +# --------------------------------------------------------------------------- +# Status label update +# --------------------------------------------------------------------------- + + +class TestRetainDescriptionStatus: + """Test _update_retain_description_status appends marker to status label.""" + + def test_appends_marker_when_enabled(self) -> None: + dialog = MagicMock() + dialog.label_status.text.return_value = "Ready" + stub = _PresenterStub(dialog, retain_descriptions=True) + + stub._update_retain_description_status() # type: ignore[bad-argument-type] + + dialog.label_status.setText.assert_called_with( + "Ready [Retain descriptions: ON]" + ) + + def test_no_marker_when_disabled(self) -> None: + dialog = MagicMock() + dialog.label_status.text.return_value = "Ready" + stub = _PresenterStub(dialog, retain_descriptions=False) + + stub._update_retain_description_status() # type: ignore[bad-argument-type] + + dialog.label_status.setText.assert_called_with("Ready") + + def test_strips_existing_marker_before_appending(self) -> None: + dialog = MagicMock() + dialog.label_status.text.return_value = "Done [Retain descriptions: ON]" + stub = _PresenterStub(dialog, retain_descriptions=True) + + stub._update_retain_description_status() # type: ignore[bad-argument-type] + + dialog.label_status.setText.assert_called_with("Done [Retain descriptions: ON]") From 4cfe7ce93516d3b35fadad653e439febd8b3d54e Mon Sep 17 00:00:00 2001 From: Jason Farrar Date: Sat, 8 Aug 2026 16:08:57 +0100 Subject: [PATCH 2/3] docs(anonymise): add retain-descriptions documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'Retaining transaction descriptions' section with usage guidance - Add security warning admonition (danger) covering PII risks - Fix typo: bsp.anonymise_pdf → bsa.anonymise_pdf - Update SCREENSHOTS.md description to note new checkbox --- docs/assets/screenshots/SCREENSHOTS.md | 2 +- docs/screens/anonymise.md | 27 ++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/assets/screenshots/SCREENSHOTS.md b/docs/assets/screenshots/SCREENSHOTS.md index fe7624d..2a39a4f 100644 --- a/docs/assets/screenshots/SCREENSHOTS.md +++ b/docs/assets/screenshots/SCREENSHOTS.md @@ -65,7 +65,7 @@ Both variants use the **same filename** — the `dark/` subfolder distinguishes | `run_reports.png` | `screens/run-reports.md` | Run Reports — builder and preview | Builder pane: report title filled, 3+ columns ticked, 1 filter row added, Group By populated; Preview pane: results table showing data | | `admin.png` | `screens/admin.md` | Admin dialog | Dialog open; all **four** sections visible (Delete, Remove, Reset, Anonymise PDF); project drop-downs populated | | `debug_info.png` | `screens/import-results.md` | Debug Info dialog | Dialog open; at least one REVIEW or FAILURE row visible; debug status column showing `done`; Open JSON, Open PDF, and **Anonymise** buttons all visible | -| `anonymise.png` | `screens/anonymise.md` | Anonymise PDF dialog | Dialog open; PDF path field populated; Browse and Browse Folder buttons visible; TOML tabs showing Always Anonymise and Never Anonymise configs; Run Anonymisation button enabled; status label showing "Ready" or "Done" | +| `anonymise.png` | `screens/anonymise.md` | Anonymise PDF dialog | Dialog open; PDF path field populated; Browse and Browse Folder buttons visible; TOML tabs showing Always Anonymise and Never Anonymise configs; Retain transaction descriptions checkbox visible (requires data in Always Anonymise table); Run Anonymisation button enabled; status label showing "Ready" or "Done" | | `about.png` | `screens/about.md` | About dialog | Dialog open; version number, links, and BSP version all visible | Each of the 15 files above must exist in **both** `docs/assets/screenshots/` (light) diff --git a/docs/screens/anonymise.md b/docs/screens/anonymise.md index 319d95b..8e7a8aa 100644 --- a/docs/screens/anonymise.md +++ b/docs/screens/anonymise.md @@ -4,7 +4,7 @@ description: "Redact bank statement PDFs for safe sharing using openstan's anony # Anonymise PDF -The **Anonymise PDF** tool lets you produce redacted copies of bank statement PDFs suitable for sharing — for example, when attaching a failing statement to a GitHub issue. You can anonymise a single file or an entire folder of PDFs in one go. All text is scrambled by default. You use two simple tables to control which phrases are left unchanged (so the parser can still read them) and which strings are replaced with safe alternatives (before scrambling occurs). +The **Anonymise PDF** tool lets you produce redacted copies of bank statement PDFs suitable for sharing — for example, when attaching a failing statement to a GitHub issue. You can anonymise a single file or an entire folder of PDFs in one go. All text is scrambled by default. You use two simple tables to control which phrases are left unchanged (so the parser can still read them) and which strings are replaced with safe alternatives (before scrambling occurs). An optional **retain descriptions** mode lets you skip the scrambling pass for transaction descriptions, applying only your explicit always-anonymise replacements and numeric ID substitutions. --- @@ -78,7 +78,7 @@ Example: Click **Run Anonymisation**. -**Single-file mode:** The tool calls `bsp.anonymise_pdf` in a background thread so the UI remains responsive. The anonymised PDF is written **alongside the source file** with `anonymised_` prepended to the filename (after any filename replacements are applied). +**Single-file mode:** The tool calls `bsa.anonymise_pdf` in a background thread so the UI remains responsive. The anonymised PDF is written **alongside the source file** with `anonymised_` prepended to the filename (after any filename replacements are applied). **Folder mode:** A confirmation dialog warns that each file must be reviewed individually before sharing — automated anonymisation may not catch all sensitive information. Once confirmed, a progress bar shows "Anonymising file N of M…" as each PDF is processed. All output files are written to an `anonymised/` subfolder inside the selected folder. @@ -89,6 +89,29 @@ When the batch completes, the status line shows the number of files that succeed --- +## Retaining transaction descriptions + +By default, all text in the PDF is scrambled — including transaction descriptions. An optional **Retain transaction descriptions** checkbox in the Run Anonymisation section lets you skip the scrambling pass, applying only your explicit Always Anonymise replacements and numeric ID substitutions (sort codes, account numbers, card numbers). Transaction descriptions and free text are left unchanged. + +### When to use this + +This option is useful when you need to demonstrate or test the parser's output with realistic-looking transaction descriptions — for example, in aggregated reports or internal demos where readability matters more than full redaction. + +### How it works + +1. The checkbox only appears when your **Always Anonymise** table contains at least one non-empty replacement row. This is a hard requirement — the underlying library raises an error if no always-anonymise file is provided, because sensitive names and addresses would otherwise remain un-anonymised. +2. Checking the box triggers a **security warning dialog**. You must confirm that you understand the risks before the option is enabled. +3. When active, the status label shows `[Retain descriptions: ON]` so you can see at a glance that descriptions will not be scrambled. +4. The checkbox resets each time you open the dialog — you must re-confirm the warning on every session. + +!!! danger "Security warning" + Enabling this option means transaction descriptions and free text are **not** scrambled. Transaction descriptions may contain personally identifiable information such as merchant names, payment references, or addresses. + + - Ensure you have added **all** personally identifiable information to your Always Anonymise file before using this option. + - Files produced with retain descriptions enabled must **not** be shared externally. Use them only for internal testing and demonstration at an **aggregated level**. + +--- + ## Viewing the results **Single-file mode:** Once a run completes, **Open Original PDF** and **Open Anonymised PDF** both become active. Click either button to open the file in your system's default PDF viewer. Open both to compare them side-by-side. From 4f67105bdc71ed30e206634c304504612ab4306c Mon Sep 17 00:00:00 2001 From: Jason Farrar Date: Sat, 8 Aug 2026 17:28:29 +0100 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Jason Farrar --- src/openstan/presenters/anonymise_presenter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openstan/presenters/anonymise_presenter.py b/src/openstan/presenters/anonymise_presenter.py index 6d942f8..f6b8761 100644 --- a/src/openstan/presenters/anonymise_presenter.py +++ b/src/openstan/presenters/anonymise_presenter.py @@ -408,7 +408,7 @@ def _on_retain_descriptions_toggled(self, state: int) -> None: When checked, show a warning dialog. If the user declines, uncheck the box. """ - is_checked = state == 2 # Qt.CheckState.Checked.value + is_checked = self.dialog.checkbox_retain_descriptions.isChecked() if is_checked and not self._show_retain_descriptions_warning(): self.dialog.checkbox_retain_descriptions.setChecked(False) return