265 lines
10 KiB
Python
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
|