This commit is contained in:
Joe Arndt 2026-05-06 18:25:17 -05:00
commit ac93e6074b
38 changed files with 7162 additions and 0 deletions

View file

@ -0,0 +1,265 @@
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