from datetime import date from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical, VerticalScroll from textual.widgets import Button, Checkbox, Header, Input, Label, Static from common_cents.screens._base import AppScreen from common_cents.screens._confirm import ConfirmModal from common_cents.widgets.autocomplete import AutocompleteInput from common_cents.widgets.footer import CenteredFooter # Sentinel returned when user wants to clear an optional text field. Stored in the # diff dict only when the user explicitly typed [clear] in the Merchant or Notes # field while its checkbox is on. Bulk-applying this writes None to the column. _CLEAR_TOKEN = "[clear]" def _split_tags(value: str) -> list[str]: return [t.strip() for t in value.split(",") if t.strip()] class BulkEditScreen(AppScreen[int]): """Edit a set of spending rows in one shot. Returns the count actually updated (0 means cancelled / nothing applicable). Only fields whose checkbox is on are written to each row; everything else is preserved via a per-row read of get_spending_by_id.""" DEFAULT_CSS = """ BulkEditScreen VerticalScroll { padding: 1 2; } BulkEditScreen #subtitle { height: auto; padding: 0 0 1 0; color: $text-muted; } BulkEditScreen .row { height: auto; margin-bottom: 1; } BulkEditScreen .check { width: 5; height: auto; padding-top: 1; } BulkEditScreen .field { width: 1fr; height: auto; } BulkEditScreen .field Label { height: 1; } BulkEditScreen Input, BulkEditScreen AutocompleteInput { width: 1fr; } BulkEditScreen #actions { height: auto; padding: 1 2; align: right middle; border-top: solid $primary; } BulkEditScreen #actions Button { margin-left: 1; } """ BINDINGS = [Binding("escape", "cancel", "Cancel")] _FIELDS = ("date", "category", "merchant", "tags", "notes") def __init__(self, spending_ids: list[int]) -> None: super().__init__() self._ids = list(spending_ids) def compose(self) -> ComposeResult: yield Header() with VerticalScroll(): yield Static(id="subtitle") yield Label( "Tick a row's checkbox to apply that field. Typing in a field " "auto-ticks its box. Use [clear] in Merchant or Notes to blank " "them. Tags replace the existing tag set on each record.", id="help", ) with Horizontal(classes="row"): yield Checkbox(value=False, classes="check", id="cb-date") with Vertical(classes="field"): yield Label("Date") yield Input(placeholder="YYYY-MM-DD", id="field-date") with Horizontal(classes="row"): yield Checkbox(value=False, classes="check", id="cb-category") with Vertical(classes="field"): yield Label("Category") yield AutocompleteInput( placeholder="Category name", id="field-category" ) with Horizontal(classes="row"): yield Checkbox(value=False, classes="check", id="cb-merchant") with Vertical(classes="field"): yield Label("Merchant") yield AutocompleteInput( placeholder="Merchant name (or [clear])", id="field-merchant" ) with Horizontal(classes="row"): yield Checkbox(value=False, classes="check", id="cb-tags") with Vertical(classes="field"): yield Label("Tags (replace, comma-separated)") yield Input(placeholder="work, lunch", id="field-tags") with Horizontal(classes="row"): yield Checkbox(value=False, classes="check", id="cb-notes") with Vertical(classes="field"): yield Label("Notes") yield Input(placeholder="Notes (or [clear])", id="field-notes") with Horizontal(id="actions"): yield Button("Apply", variant="primary", id="apply") yield Button("Cancel", id="cancel") yield CenteredFooter() def on_mount(self) -> None: self.title = "Bulk Edit Spending" n = len(self._ids) self.query_one("#subtitle", Static).update( f"Editing {n} selected record{'s' if n != 1 else ''}." ) categories = self.db.get_metadata("category") merchants = self.db.get_metadata("merchant") self.query_one("#field-category", AutocompleteInput).set_options( [c["name"] for c in categories] ) self.query_one("#field-merchant", AutocompleteInput).set_options( [m["name"] for m in merchants] ) def on_input_changed(self, event: Input.Changed) -> None: # AutocompleteInput emits Input.Changed via its inner Input child; the # event ID matches the inner field-* widget. Auto-tick the checkbox so # the user doesn't have to remember the dual-step. input_id = event.input.id if not input_id or not input_id.startswith("field-"): return field = input_id[len("field-") :] if field not in self._FIELDS: return if event.value.strip(): self.query_one(f"#cb-{field}", Checkbox).value = True def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "apply": self._apply() elif event.button.id == "cancel": self.dismiss(0) def action_cancel(self) -> None: self.dismiss(0) def _collect_diff(self) -> dict | None: """Read the form into a dict of overrides (only checked fields). Returns None on validation error (after notifying the user); empty dict if no boxes were checked.""" diff: dict = {} if self.query_one("#cb-date", Checkbox).value: v = self.query_one("#field-date", Input).value.strip() try: date.fromisoformat(v) except ValueError: self.app.notify("Invalid date — use YYYY-MM-DD", severity="error") return None diff["date"] = v if self.query_one("#cb-category", Checkbox).value: v = self.query_one("#field-category", AutocompleteInput).value.strip() if not v: self.app.notify("Category cannot be blank", severity="error") return None diff["category"] = v if self.query_one("#cb-merchant", Checkbox).value: v = self.query_one("#field-merchant", AutocompleteInput).value.strip() diff["merchant"] = None if v.lower() == _CLEAR_TOKEN else (v or None) if self.query_one("#cb-tags", Checkbox).value: v = self.query_one("#field-tags", Input).value diff["tag_names"] = _split_tags(v) if self.query_one("#cb-notes", Checkbox).value: v = self.query_one("#field-notes", Input).value.strip() diff["notes"] = None if v.lower() == _CLEAR_TOKEN else (v or None) return diff def _apply(self) -> None: diff = self._collect_diff() if diff is None: return if not diff: self.app.notify("No fields ticked — nothing to apply.", severity="warning") return n = len(self._ids) details = [f"Records: {n}"] for k, v in diff.items(): if k == "tag_names": details.append( "Tags: " + (", ".join(v) if v else "[clear]") ) else: details.append(f"{k.capitalize():9} {v if v is not None else '[clear]'}") def on_confirm(confirmed: bool) -> None: if not confirmed: return try: updated = self._write(diff) except Exception as e: self.app.notify(f"Bulk update failed: {e}", severity="error") return self.app.notify(f"Updated {updated} record(s).") self.dismiss(updated) self.app.push_screen( ConfirmModal( f"Apply changes to {n} record{'s' if n != 1 else ''}?", details=details, confirm_label="Apply", confirm_variant="warning", cancel_label="Cancel", focus_confirm=True, ), on_confirm, ) def _write(self, diff: dict) -> int: """Apply `diff` to every selected ID. Each row is fetched fresh, the diff is overlaid on its current values, and update_spending writes the result. Skips IDs that no longer exist (e.g. deleted in another window).""" tag_ids: list[int] | None = None if "tag_names" in diff: tag_ids = [ self.db.get_or_create_metadata("tag", name) for name in diff["tag_names"] ] updated = 0 for spending_id in self._ids: row = self.db.get_spending_by_id(spending_id) if row is None: continue new_date = diff.get("date", row["date"]) new_category = diff.get("category", row["category_name"]) new_merchant = ( diff["merchant"] if "merchant" in diff else row["merchant_name"] ) new_notes = diff["notes"] if "notes" in diff else row["notes"] new_tag_ids = ( tag_ids if tag_ids is not None else self.db.get_spending_metadata_ids(spending_id, "tag") ) self.db.update_spending( spending_id, new_date, row["cents"], new_category, new_merchant, new_notes, new_tag_ids, ) updated += 1 return updated