common-cents-tui/src/common_cents/screens/_spending_bulk_form.py
Joe Arndt ac93e6074b init
2026-05-06 18:25:17 -05:00

265 lines
10 KiB
Python

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