Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/assets/screenshots/SCREENSHOTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 25 additions & 2 deletions docs/screens/anonymise.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand Down
100 changes: 99 additions & 1 deletion src/openstan/presenters/anonymise_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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:
Expand All @@ -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 = self.dialog.checkbox_retain_descriptions.isChecked()
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."""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions src/openstan/views/anonymise_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
from openstan.components import (
Qt,
StanButton,
StanCheckBox,
StanDialog,
StanFrame,
StanHelpIcon,
StanLabel,
StanLineEdit,
StanMutedLabel,
Expand Down Expand Up @@ -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)
Comment thread
boscorat marked this conversation as resolved.
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)

Expand All @@ -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)
Expand Down
Loading