diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..4934069 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.12"] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: python -m pip install -r requirements.txt + - run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore index caa8736..7354fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -218,4 +218,5 @@ __marimo__/ .streamlit/secrets.toml exports/* +*.json.bak .opencode diff --git a/README.md b/README.md index b7d74bd..2be0cd5 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,16 @@ uv run python main.py --data "/Users/you/Library/Mobile Documents/com~apple~Clou ## Using the app -Everything is click-driven, with keyboard equivalents for everything: +Everything is click-driven, with keyboard equivalents for everything. Press +`?` in the app for the complete shortcut overlay. The most useful shortcuts +are: + +- **1 / 2 / 3** switches between Gear, Trips, and Reports. +- **/** focuses the search box; **Esc** clears search and returns to the table. +- **A** adds, **E** edits, **Delete** deletes/removes, and **R** toggles review + candidates when the relevant table is focused. +- **Ctrl+S** saves forms and picker dialogs; **Enter** confirms confirmations. +- **Ctrl+B** writes a manual `.bak` snapshot beside your data file. - **Click a table row** to select it; **click it again** (or press Enter) to open/edit it. This two-step click mirrors how most file browsers work @@ -68,15 +77,27 @@ Everything is click-driven, with keyboard equivalents for everything: ## Data safety -Every save writes to a temp file and atomically replaces the real one, so -a crash or a synced-file conflict mid-write can't corrupt your data. Since -it's still a flat JSON file with no locking, avoid editing it from two -machines within the same iCloud/Dropbox sync cycle — if you do, you'll get -a conflicted-copy file instead of a merge, and you'd need to reconcile by -hand. +Every save validates the complete data model, writes and flushes a temporary +file, and atomically replaces the real one. The previous version is retained +as `gear_data.json.bak`. Packrat also checks whether another process or sync +client changed the file after it was opened; if so, it refuses to overwrite +that newer copy and rolls the in-memory edit back. + +It is still a flat JSON file rather than a mergeable database. If Packrat +reports an external-change conflict, restart it to load the newer file before +editing again. ## Backing it up -It's one JSON file — `cp gear_data.json gear_data_backup_$(date +%F).json` -whenever you want a snapshot, or let iCloud/Dropbox/OneDrive version -history handle it. +Press **Ctrl+B** for an on-demand snapshot, copy the JSON file whenever you +want a dated archive, or use iCloud/Dropbox/OneDrive version history. + +## Development + +Run the core and headless Textual tests with: + +```bash +uv run python -m unittest discover -s tests -v +``` + +The same suite runs on Python 3.9 and 3.12 for every pull request. diff --git a/gear_core.py b/gear_core.py index a30a3f8..1c6d47d 100644 --- a/gear_core.py +++ b/gear_core.py @@ -6,8 +6,11 @@ """ import json +import math import os -from datetime import datetime, date +import shutil +import tempfile +from datetime import date, datetime CATEGORIES = [ "Shelter", "Sleep System", "Pack", "Cook System", "Water", @@ -28,18 +31,31 @@ REVIEW_WEIGHT_THRESHOLD_OZ = 8.0 REVIEW_USEFULNESS_THRESHOLD = 3 +DATA_VERSION = 1 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) DEFAULT_DATA_PATH = os.path.join(SCRIPT_DIR, "gear_data.json") DEFAULT_EXPORT_DIR = os.path.join(SCRIPT_DIR, "exports") + +class DataValidationError(ValueError): + """Raised when a data file doesn't match Packrat's expected schema.""" + + +class DataConflictError(OSError): + """Raised rather than overwriting a data file changed by another process.""" + # --------------------------------------------------------------------------- # Data model # --------------------------------------------------------------------------- def blank_data(): - return {"meta": {"created": date.today().isoformat(), "version": 1}, "gear": [], "trips": []} + return { + "meta": {"created": date.today().isoformat(), "version": DATA_VERSION}, + "gear": [], + "trips": [], + } def example_data(): @@ -86,23 +102,204 @@ def example_data(): return data -def load_data(path): - if not os.path.exists(path): - return example_data() - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - data.setdefault("meta", {"created": date.today().isoformat(), "version": 1}) - data.setdefault("gear", []) - data.setdefault("trips", []) +def _number(value, field, *, minimum=0.0): + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise DataValidationError(f"{field} must be a number") + if not math.isfinite(value): + raise DataValidationError(f"{field} must be finite") + if value < minimum: + raise DataValidationError(f"{field} must be at least {minimum:g}") + return value + + +def validate_data(data): + """Validate persisted data and add backward-compatible optional defaults. + + The function deliberately mutates ``data`` only to supply fields older files + may not contain. Invalid or ambiguous values are rejected rather than being + silently coerced and later producing incorrect pack weights. + """ + if not isinstance(data, dict): + raise DataValidationError("data file must contain a JSON object") + if not isinstance(data.setdefault("meta", {}), dict): + raise DataValidationError("meta must be an object") + data["meta"].setdefault("created", date.today().isoformat()) + version = data["meta"].setdefault("version", DATA_VERSION) + if not isinstance(data["meta"]["created"], str): + raise DataValidationError("meta.created must be a string") + if isinstance(version, bool) or not isinstance(version, int): + raise DataValidationError("meta.version must be an integer") + if version > DATA_VERSION: + raise DataValidationError( + f"data version {version} is newer than this Packrat supports ({DATA_VERSION})" + ) + + for collection in ("gear", "trips"): + if not isinstance(data.setdefault(collection, []), list): + raise DataValidationError(f"{collection} must be a list") + + gear_ids = set() + for index, gear in enumerate(data["gear"]): + label = f"gear[{index}]" + if not isinstance(gear, dict): + raise DataValidationError(f"{label} must be an object") + gear_id = gear.get("id") + if not isinstance(gear_id, str) or not gear_id.strip(): + raise DataValidationError(f"{label}.id must be a non-empty string") + if gear_id in gear_ids: + raise DataValidationError(f"duplicate gear id: {gear_id}") + gear_ids.add(gear_id) + for field in ("name", "category"): + if not isinstance(gear.get(field), str) or not gear[field].strip(): + raise DataValidationError(f"{label}.{field} must be a non-empty string") + if gear["category"] not in CATEGORIES: + raise DataValidationError( + f"{label}.category must be one of: {', '.join(CATEGORIES)}" + ) + gear.setdefault("brand", "") + gear.setdefault("notes", "") + gear.setdefault("added", date.today().isoformat()) + if not all(isinstance(gear[field], str) for field in ("brand", "notes", "added")): + raise DataValidationError(f"{label} text fields must be strings") + _number(gear.get("weight_oz"), f"{label}.weight_oz") + qty = _number(gear.get("qty"), f"{label}.qty", minimum=1) + if not isinstance(qty, int): + raise DataValidationError(f"{label}.qty must be an integer") + usefulness = _number(gear.get("usefulness"), f"{label}.usefulness", minimum=1) + if not isinstance(usefulness, int) or usefulness > 5: + raise DataValidationError(f"{label}.usefulness must be an integer from 1 to 5") + _number(gear.setdefault("cost", 0.0), f"{label}.cost") + if gear.get("weight_type") not in WEIGHT_TYPES: + raise DataValidationError( + f"{label}.weight_type must be one of: {', '.join(WEIGHT_TYPES)}" + ) + + trip_ids = set() + for index, trip in enumerate(data["trips"]): + label = f"trips[{index}]" + if not isinstance(trip, dict): + raise DataValidationError(f"{label} must be an object") + trip_id = trip.get("id") + if not isinstance(trip_id, str) or not trip_id.strip(): + raise DataValidationError(f"{label}.id must be a non-empty string") + if trip_id in trip_ids: + raise DataValidationError(f"duplicate trip id: {trip_id}") + trip_ids.add(trip_id) + if not isinstance(trip.get("name"), str) or not trip["name"].strip(): + raise DataValidationError(f"{label}.name must be a non-empty string") + trip.setdefault("dates", "") + trip.setdefault("notes", "") + trip.setdefault("created", date.today().isoformat()) + if not all(isinstance(trip[field], str) for field in ("dates", "notes", "created")): + raise DataValidationError(f"{label} text fields must be strings") + target = trip.setdefault("target_base_weight_lb", None) + if target is not None: + _number(target, f"{label}.target_base_weight_lb") + if not isinstance(trip.setdefault("items", []), list): + raise DataValidationError(f"{label}.items must be a list") + assigned = set() + for item_index, entry in enumerate(trip["items"]): + entry_label = f"{label}.items[{item_index}]" + if not isinstance(entry, dict): + raise DataValidationError(f"{entry_label} must be an object") + gear_id = entry.get("gear_id") + if not isinstance(gear_id, str) or not gear_id.strip(): + raise DataValidationError(f"{entry_label}.gear_id must be a non-empty string") + if gear_id in assigned: + raise DataValidationError(f"{label} assigns gear {gear_id} more than once") + assigned.add(gear_id) + entry.setdefault("note", "") + if not isinstance(entry["note"], str): + raise DataValidationError(f"{entry_label}.note must be a string") return data -def save_data(path, data): - os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True) - tmp_path = path + ".tmp" - with open(tmp_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - os.replace(tmp_path, path) +def load_data(path): + path = os.fspath(path) + if not os.path.exists(path): + return example_data() + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except json.JSONDecodeError as exc: + raise DataValidationError( + f"invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}" + ) from exc + return validate_data(data) + + +def _atomic_write(path, content): + path = os.fspath(path) + directory = os.path.dirname(os.path.abspath(path)) or "." + os.makedirs(directory, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix=f".{os.path.basename(path)}.", suffix=".tmp", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, path) + except Exception: + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass + raise + + +def file_signature(path): + path = os.fspath(path) + try: + stat = os.stat(path) + except FileNotFoundError: + return None + return stat.st_mtime_ns, stat.st_size + + +def save_data(path, data, expected_signature=None): + path = os.fspath(path) + validate_data(data) + if expected_signature is not None and file_signature(path) != expected_signature: + raise DataConflictError( + "the data file changed on disk; restart Packrat to load the newer copy" + ) + content = json.dumps(data, indent=2, ensure_ascii=False) + "\n" + if os.path.exists(path): + backup_data(path) + _atomic_write(path, content) + return file_signature(path) + + +def backup_data(path): + """Create or replace a recoverable snapshot next to the data file.""" + path = os.fspath(path) + if not os.path.exists(path): + raise FileNotFoundError(path) + backup_path = path + ".bak" + directory = os.path.dirname(os.path.abspath(path)) or "." + fd, tmp_path = tempfile.mkstemp(prefix=f".{os.path.basename(path)}.", suffix=".bak", dir=directory) + os.close(fd) + try: + shutil.copy2(path, tmp_path) + os.replace(tmp_path, backup_path) + except Exception: + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass + raise + return backup_path + + +def export_dir_for_data(data_path): + """Keep exports with the selected portable data file.""" + return os.path.join(os.path.dirname(os.path.abspath(os.fspath(data_path))), "exports") + + +def write_export(path, content): + path = os.fspath(path) + _atomic_write(path, content) + return path def next_id(items, prefix): diff --git a/gear_tui.py b/gear_tui.py index e58079b..1b3aaf8 100644 --- a/gear_tui.py +++ b/gear_tui.py @@ -11,20 +11,29 @@ """ import argparse +import copy import os from datetime import date from typing import Optional +from rich.text import Text from textual import on from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical, VerticalScroll -from textual.screen import Screen, ModalScreen +from textual.screen import ModalScreen, Screen from textual.widgets import ( - Header, Footer, DataTable, Input, Select, Button, Static, Label, - TabbedContent, TabPane, + Button, + DataTable, + Footer, + Header, + Input, + Label, + Select, + Static, + TabbedContent, + TabPane, ) -from rich.text import Text import gear_core as gc @@ -123,14 +132,16 @@ background: #182015; border: thick #4A7856; padding: 1 2; - width: 64; + width: 90%; + max-width: 64; height: auto; max-height: 90%; overflow-y: auto; } .picker-dialog { - width: 76; + width: 95%; + max-width: 76; height: 34; } @@ -214,7 +225,10 @@ def colored_bar(percent: float, width: int = 20) -> Text: class ConfirmScreen(ModalScreen[bool]): - BINDINGS = [Binding("escape", "cancel", "Cancel")] + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("enter", "confirm", "Confirm"), + ] def __init__(self, message: str, danger: bool = False): super().__init__() @@ -231,6 +245,9 @@ def compose(self) -> ComposeResult: def action_cancel(self) -> None: self.dismiss(False) + def action_confirm(self) -> None: + self.dismiss(True) + @on(Button.Pressed, "#c-cancel") def _cancel(self) -> None: self.dismiss(False) @@ -241,7 +258,10 @@ def _confirm(self) -> None: class GearFormScreen(ModalScreen[Optional[dict]]): - BINDINGS = [Binding("escape", "cancel", "Cancel")] + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("ctrl+s", "save", "Save"), + ] def __init__(self, mode: str = "add", initial: Optional[dict] = None): super().__init__() @@ -289,6 +309,9 @@ def on_mount(self) -> None: def action_cancel(self) -> None: self.dismiss(None) + def action_save(self) -> None: + self._save() + @on(Button.Pressed, "#f-cancel") def _cancel(self) -> None: self.dismiss(None) @@ -301,11 +324,14 @@ def _save(self) -> None: return try: weight = float(self.query_one("#f-weight", Input).value or 0) - qty = max(1, int(self.query_one("#f-qty", Input).value or 1)) + qty = int(self.query_one("#f-qty", Input).value or 1) cost = float(self.query_one("#f-cost", Input).value or 0) except ValueError: self.app.notify("Weight, quantity, and cost must be numbers", severity="error") return + if weight < 0 or cost < 0 or qty < 1: + self.app.notify("Weight/cost cannot be negative and quantity must be at least 1", severity="error") + return result = { "category": self.query_one("#f-category", Select).value, "name": name, @@ -324,7 +350,10 @@ def _save(self) -> None: class TripFormScreen(ModalScreen[Optional[dict]]): - BINDINGS = [Binding("escape", "cancel", "Cancel")] + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("ctrl+s", "save", "Save"), + ] def __init__(self, mode: str = "add", initial: Optional[dict] = None): super().__init__() @@ -353,6 +382,9 @@ def on_mount(self) -> None: def action_cancel(self) -> None: self.dismiss(None) + def action_save(self) -> None: + self._save() + @on(Button.Pressed, "#t-cancel") def _cancel(self) -> None: self.dismiss(None) @@ -369,6 +401,9 @@ def _save(self) -> None: except ValueError: self.app.notify("Target base weight must be a number", severity="error") return + if target is not None and target < 0: + self.app.notify("Target base weight cannot be negative", severity="error") + return result = { "name": name, "dates": self.query_one("#t-dates", Input).value.strip(), @@ -383,7 +418,10 @@ class GearPickerScreen(ModalScreen[Optional[tuple]]): immediately (using whatever note text is currently typed); the button is there for keyboard users too.""" - BINDINGS = [Binding("escape", "cancel", "Cancel")] + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("ctrl+s", "add", "Add selected"), + ] def __init__(self, all_gear: list, exclude_ids: list): super().__init__() @@ -425,6 +463,9 @@ def _search(self, event: Input.Changed) -> None: def action_cancel(self) -> None: self.dismiss(None) + def action_add(self) -> None: + self._add() + @on(Button.Pressed, "#gp-cancel") def _cancel(self) -> None: self.dismiss(None) @@ -453,13 +494,52 @@ def _add(self) -> None: self.dismiss((gear_id, note)) +class ShortcutHelpScreen(ModalScreen[None]): + BINDINGS = [ + Binding("escape", "close", "Close"), + Binding("question_mark", "close", "Close", show=False), + ] + + def compose(self) -> ComposeResult: + help_text = """[b]Keyboard shortcuts[/b] + +[b]Anywhere[/b] 1 / 2 / 3 Switch tabs / Search ? This help + Ctrl+B Backup data Q Quit + +[b]Gear[/b] A Add E Edit Delete Delete R Review filter +[b]Trips[/b] A Add Enter Open Delete Delete +[b]Trip dashboard[/b] A Add item E Edit trip Delete Remove X Export +[b]Dialogs[/b] Ctrl+S Save/add Esc Cancel +[b]Confirmations[/b] Enter Confirm Esc Cancel + +[dim]Arrow keys move through tables. Enter activates the selected row.[/dim]""" + with Vertical(id="dialog"): + yield Static(help_text) + with Horizontal(classes="dialog-buttons"): + yield Button("Close", id="help-close", variant="primary") + + def action_close(self) -> None: + self.dismiss(None) + + @on(Button.Pressed, "#help-close") + def _close(self) -> None: + self.dismiss(None) + + # --------------------------------------------------------------------------- # Trip dashboard (full screen, pushed when a trip is opened) # --------------------------------------------------------------------------- class TripDashboardScreen(Screen): - BINDINGS = [Binding("escape", "go_back", "Back"), Binding("b", "go_back", "Back")] + BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("b", "go_back", "Back"), + Binding("a", "add_item", "Add item"), + Binding("e", "edit_trip", "Edit trip"), + Binding("delete", "remove_item", "Remove"), + Binding("x", "export", "Export"), + ] def __init__(self, trip_id: str): super().__init__() @@ -542,6 +622,18 @@ def refresh_dashboard(self) -> None: def action_go_back(self) -> None: self.dismiss() + def action_add_item(self) -> None: + self._add_item() + + def action_edit_trip(self) -> None: + self._edit_trip() + + def action_remove_item(self) -> None: + self._remove_item() + + def action_export(self) -> None: + self._export() + @on(Button.Pressed, "#dash-back") def _back(self) -> None: self.dismiss() @@ -565,7 +657,8 @@ def handle(result): if result: gear_id, note = result trip["items"].append({"gear_id": gear_id, "note": note}) - app.save() + if not app.save(): + return self.refresh_dashboard() self.app.notify("Added to trip", severity="information", timeout=2) @@ -584,7 +677,9 @@ def _remove_item(self) -> None: def handle(confirmed): if confirmed: trip["items"] = [i for i in trip["items"] if i["gear_id"] != gear_id] - app.save() + if not app.save(): + self.refresh_dashboard() + return self.refresh_dashboard() self.app.push_screen(ConfirmScreen(f"Remove '{name}' from this trip?", danger=True), handle) @@ -597,7 +692,9 @@ def _edit_trip(self) -> None: def handle(result): if result: trip.update(result) - app.save() + if not app.save(): + self.refresh_dashboard() + return self.refresh_dashboard() self.app.push_screen(TripFormScreen(mode="edit", initial=trip), handle) @@ -607,12 +704,8 @@ def _export(self) -> None: app: "GearTrackerApp" = self.app # type: ignore trip = gc.find_trip(app.data, self.trip_id) md = gc.render_trip_markdown(app.data, trip) - os.makedirs(gc.DEFAULT_EXPORT_DIR, exist_ok=True) fname = f"{gc.safe_filename(trip['name'])}_{date.today().isoformat()}.md" - path = os.path.join(gc.DEFAULT_EXPORT_DIR, fname) - with open(path, "w", encoding="utf-8") as f: - f.write(md) - self.app.notify(f"Wrote {path}", title="Export complete", timeout=4) + app.write_export(fname, md) # --------------------------------------------------------------------------- @@ -621,6 +714,14 @@ def _export(self) -> None: class GearPane(Vertical): + BINDINGS = [ + Binding("a", "add", "Add"), + Binding("e", "edit", "Edit"), + Binding("delete", "delete", "Delete"), + Binding("r", "review", "Review"), + Binding("escape", "clear_search", "Clear search", show=False), + ] + def compose(self) -> ComposeResult: with Horizontal(classes="toolbar"): yield Input(placeholder="Search gear (name, brand, category, notes)...", id="gear-search") @@ -640,10 +741,12 @@ def on_mount(self) -> None: def refresh_table(self, filter_text: str = "", review_only: bool = False) -> None: app: "GearTrackerApp" = self.app # type: ignore table = self.query_one("#gear-table", DataTable) + selected_id = self._current_gear_id() table.clear() gear = sorted(app.data["gear"], key=lambda g: (g["category"], -gc.total_weight_oz(g))) t = filter_text.lower().strip() count = 0 + visible_ids = set() for g in gear: if review_only and not gc.is_review_flagged(g): continue @@ -653,12 +756,38 @@ def refresh_table(self, filter_text: str = "", review_only: bool = False) -> Non table.add_row(g["id"], g["category"], g["name"], f"{gc.total_weight_oz(g):.1f}", g["weight_type"], str(g["qty"]), f"{g['usefulness']}/5", flag, key=g["id"]) count += 1 + visible_ids.add(g["id"]) + if selected_id in visible_ids: + table.move_cursor(row=table.get_row_index(selected_id), animate=False) label = f"{count} item(s)" + (" · review filter on" if review_only else "") self.query_one("#gear-status", Static).update(label) @on(Input.Changed, "#gear-search") def _search_changed(self, event: Input.Changed) -> None: - self.refresh_table(event.value) + self.refresh_table(event.value, review_only=getattr(self, "_review_only", False)) + + def _refresh_current(self) -> None: + self.refresh_table( + self.query_one("#gear-search", Input).value, + review_only=getattr(self, "_review_only", False), + ) + + def action_add(self) -> None: + self._add() + + def action_edit(self) -> None: + self._edit_button() + + def action_delete(self) -> None: + self._delete() + + def action_review(self) -> None: + self._toggle_review() + + def action_clear_search(self) -> None: + search = self.query_one("#gear-search", Input) + search.value = "" + self.query_one("#gear-table", DataTable).focus() @on(Button.Pressed, "#gear-add") def _add(self) -> None: @@ -666,8 +795,10 @@ def handle(result): if result: result["id"] = gc.next_id(self.app.data["gear"], "G") # type: ignore self.app.data["gear"].append(result) # type: ignore - self.app.save() # type: ignore - self.refresh_table(self.query_one("#gear-search", Input).value) + if not self.app.save(): # type: ignore + self._refresh_current() + return + self._refresh_current() self.app.notify(f"Added {result['name']}", timeout=2) self.app.push_screen(GearFormScreen(mode="add"), handle) @@ -694,8 +825,10 @@ def handle(result): result.pop("id", None) result.pop("added", None) gear.update(result) - app.save() - self.refresh_table(self.query_one("#gear-search", Input).value) + if not app.save(): + self._refresh_current() + return + self._refresh_current() self.app.push_screen(GearFormScreen(mode="edit", initial=gear), handle) @@ -726,8 +859,10 @@ def handle(confirmed): for t in refs: t["items"] = [i for i in t["items"] if i["gear_id"] != gear_id] app.data["gear"] = [g for g in app.data["gear"] if g["id"] != gear_id] - app.save() - self.refresh_table(self.query_one("#gear-search", Input).value) + if not app.save(): + self._refresh_current() + return + self._refresh_current() self.app.push_screen(ConfirmScreen(msg, danger=True), handle) @@ -745,6 +880,13 @@ def _toggle_review(self) -> None: class TripsPane(Vertical): + BINDINGS = [ + Binding("a", "add", "Add"), + Binding("enter", "open", "Open"), + Binding("delete", "delete", "Delete"), + Binding("escape", "clear_search", "Clear search", show=False), + ] + def compose(self) -> ComposeResult: with Horizontal(classes="toolbar"): yield Input(placeholder="Search trips...", id="trip-search") @@ -763,9 +905,11 @@ def on_mount(self) -> None: def refresh_table(self, filter_text: str = "") -> None: app: "GearTrackerApp" = self.app # type: ignore table = self.query_one("#trip-table", DataTable) + selected_id = self._current_trip_id() table.clear() t = filter_text.lower().strip() count = 0 + visible_ids = set() for trip in app.data["trips"]: blob = f"{trip['name']} {trip.get('dates','')}".lower() if t and t not in blob: @@ -781,12 +925,29 @@ def refresh_table(self, filter_text: str = "") -> None: table.add_row(trip["id"], trip["name"], trip.get("dates", ""), str(len(trip["items"])), f"{s['base_lb']:.2f}", target, delta, key=trip["id"]) count += 1 + visible_ids.add(trip["id"]) + if selected_id in visible_ids: + table.move_cursor(row=table.get_row_index(selected_id), animate=False) self.query_one("#trip-status", Static).update(f"{count} trip(s)") @on(Input.Changed, "#trip-search") def _search_changed(self, event: Input.Changed) -> None: self.refresh_table(event.value) + def action_add(self) -> None: + self._add() + + def action_open(self) -> None: + self._open_button() + + def action_delete(self) -> None: + self._delete() + + def action_clear_search(self) -> None: + search = self.query_one("#trip-search", Input) + search.value = "" + self.query_one("#trip-table", DataTable).focus() + @on(Button.Pressed, "#trip-add") def _add(self) -> None: def handle(result): @@ -795,7 +956,9 @@ def handle(result): result["created"] = date.today().isoformat() result["items"] = [] self.app.data["trips"].append(result) # type: ignore - self.app.save() # type: ignore + if not self.app.save(): # type: ignore + self.refresh_table() + return self.refresh_table() self.app.notify(f"Added trip {result['name']}", timeout=2) @@ -835,7 +998,9 @@ def _delete(self) -> None: def handle(confirmed): if confirmed: app.data["trips"] = [t for t in app.data["trips"] if t["id"] != trip_id] - app.save() + if not app.save(): + self.refresh_table() + return self.refresh_table() self.app.push_screen( @@ -859,7 +1024,8 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.query_one("#report-trip-table", DataTable).add_columns("ID", "Name", "Dates", "Items") self.refresh_table() - self.query_one("#report-status", Static).update(f"Exports are written to: {gc.DEFAULT_EXPORT_DIR}") + app: "GearTrackerApp" = self.app # type: ignore + self.query_one("#report-status", Static).update(f"Exports are written to: {app.export_dir}") def refresh_table(self) -> None: app: "GearTrackerApp" = self.app # type: ignore @@ -878,25 +1044,19 @@ def _export_trip(self) -> None: app: "GearTrackerApp" = self.app # type: ignore trip = gc.find_trip(app.data, trip_id) md = gc.render_trip_markdown(app.data, trip) - os.makedirs(gc.DEFAULT_EXPORT_DIR, exist_ok=True) fname = f"{gc.safe_filename(trip['name'])}_{date.today().isoformat()}.md" - path = os.path.join(gc.DEFAULT_EXPORT_DIR, fname) - with open(path, "w", encoding="utf-8") as f: - f.write(md) - self.query_one("#report-status", Static).update(f"Wrote {path}") - self.app.notify(f"Wrote {path}", title="Export complete", timeout=4) + path = app.write_export(fname, md) + if path: + self.query_one("#report-status", Static).update(f"Wrote {path}") @on(Button.Pressed, "#report-export-inventory") def _export_inventory(self) -> None: app: "GearTrackerApp" = self.app # type: ignore md = gc.render_inventory_markdown(app.data) - os.makedirs(gc.DEFAULT_EXPORT_DIR, exist_ok=True) fname = f"gear_inventory_{date.today().isoformat()}.md" - path = os.path.join(gc.DEFAULT_EXPORT_DIR, fname) - with open(path, "w", encoding="utf-8") as f: - f.write(md) - self.query_one("#report-status", Static).update(f"Wrote {path}") - self.app.notify(f"Wrote {path}", title="Export complete", timeout=4) + path = app.write_export(fname, md) + if path: + self.query_one("#report-status", Static).update(f"Wrote {path}") # --------------------------------------------------------------------------- @@ -908,16 +1068,25 @@ class GearTrackerApp(App): CSS = APP_CSS TITLE = "Backpacking Gear Tracker" BINDINGS = [ + Binding("1", "show_tab('gear')", "Gear"), + Binding("2", "show_tab('trips')", "Trips"), + Binding("3", "show_tab('reports')", "Reports"), + Binding("slash", "search", "Search"), + Binding("question_mark", "show_help", "Help"), + Binding("ctrl+b", "backup", "Backup"), Binding("q", "quit", "Quit"), Binding("ctrl+c", "quit", "Quit", show=False), ] def __init__(self, data_path: str): super().__init__() - self.data_path = data_path - self.data = gc.load_data(data_path) - if not os.path.exists(data_path): - gc.save_data(data_path, self.data) + self.data_path = os.path.abspath(os.path.expanduser(data_path)) + self.export_dir = gc.export_dir_for_data(self.data_path) + self.data = gc.load_data(self.data_path) + if not os.path.exists(self.data_path): + gc.save_data(self.data_path, self.data) + self._data_signature = gc.file_signature(self.data_path) + self._last_saved_data = copy.deepcopy(self.data) def compose(self) -> ComposeResult: yield Header(show_clock=True, time_format="%I:%M %p") @@ -930,8 +1099,72 @@ def compose(self) -> ComposeResult: yield ReportsPane() yield Footer() - def save(self) -> None: - gc.save_data(self.data_path, self.data) + def action_show_tab(self, tab_id: str) -> None: + tabs = self.query_one(TabbedContent) + tabs.active = tab_id + self._refresh_tab(tab_id) + + def action_search(self) -> None: + active = self.query_one(TabbedContent).active + selector = "#gear-search" if active == "gear" else "#trip-search" if active == "trips" else None + if selector: + search = self.query_one(selector, Input) + search.focus() + search.select_all() + else: + self.notify("Search is available in Gear and Trips", severity="information", timeout=2) + + def action_show_help(self) -> None: + self.push_screen(ShortcutHelpScreen()) + + def action_backup(self) -> None: + try: + path = gc.backup_data(self.data_path) + except OSError as exc: + self.notify(f"Backup failed: {exc}", severity="error", timeout=5) + return + self.notify(f"Wrote {path}", title="Backup complete", timeout=4) + + @on(TabbedContent.TabActivated) + def _tab_activated(self, event: TabbedContent.TabActivated) -> None: + self._refresh_tab(event.pane.id or "") + + def _refresh_tab(self, tab_id: str) -> None: + if tab_id == "gear": + pane = self.query_one(GearPane) + pane.refresh_table( + pane.query_one("#gear-search", Input).value, + review_only=getattr(pane, "_review_only", False), + ) + elif tab_id == "trips": + pane = self.query_one(TripsPane) + pane.refresh_table(pane.query_one("#trip-search", Input).value) + elif tab_id == "reports": + self.query_one(ReportsPane).refresh_table() + + def save(self) -> bool: + try: + self._data_signature = gc.save_data( + self.data_path, + self.data, + expected_signature=self._data_signature, + ) + except (OSError, gc.DataValidationError) as exc: + self.data = copy.deepcopy(self._last_saved_data) + self.notify(f"Save failed; changes were rolled back: {exc}", severity="error", timeout=7) + return False + self._last_saved_data = copy.deepcopy(self.data) + return True + + def write_export(self, filename: str, content: str) -> Optional[str]: + path = os.path.join(self.export_dir, filename) + try: + gc.write_export(path, content) + except OSError as exc: + self.notify(f"Export failed: {exc}", severity="error", timeout=6) + return None + self.notify(f"Wrote {path}", title="Export complete", timeout=4) + return path def main(): @@ -939,7 +1172,10 @@ def main(): parser.add_argument("--data", default=gc.DEFAULT_DATA_PATH, help="Path to the JSON data file (default: gear_data.json next to this script)") args = parser.parse_args() - app = GearTrackerApp(args.data) + try: + app = GearTrackerApp(args.data) + except (OSError, gc.DataValidationError) as exc: + parser.error(f"could not load data file: {exc}") app.run() diff --git a/pyproject.toml b/pyproject.toml index 9e59dda..513ae23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "gear-tracker" version = "0.1.0" -description = "Add your description here" +description = "A portable terminal app for backpacking gear and trip pack lists" requires-python = ">=3.9" dependencies = [ "textual>=0.60.0", @@ -12,4 +12,3 @@ dev = [ "textual-dev>=1.8.0", ] - diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..a3cac6e --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,85 @@ +import copy +import json +import os +import tempfile +import unittest +from pathlib import Path + +import gear_core as gc + + +class DataValidationTests(unittest.TestCase): + def setUp(self): + self.data = gc.example_data() + + def test_example_data_is_valid(self): + self.assertIs(gc.validate_data(self.data), self.data) + + def test_rejects_invalid_values(self): + cases = [] + + negative_weight = copy.deepcopy(self.data) + negative_weight["gear"][0]["weight_oz"] = -1 + cases.append(negative_weight) + + bad_usefulness = copy.deepcopy(self.data) + bad_usefulness["gear"][0]["usefulness"] = 6 + cases.append(bad_usefulness) + + duplicate_id = copy.deepcopy(self.data) + duplicate_id["gear"][1]["id"] = duplicate_id["gear"][0]["id"] + cases.append(duplicate_id) + + for invalid_data in cases: + with self.subTest(data=invalid_data), self.assertRaises(gc.DataValidationError): + gc.validate_data(invalid_data) + + def test_load_reports_json_location(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "gear.json" + path.write_text('{"gear": [}', encoding="utf-8") + with self.assertRaisesRegex(gc.DataValidationError, r"line 1, column"): + gc.load_data(path) + + +class PersistenceTests(unittest.TestCase): + def test_save_is_atomic_keeps_backup_and_detects_conflict(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "gear.json" + original = gc.example_data() + first_signature = gc.save_data(path, original) + + changed = copy.deepcopy(original) + changed["gear"][0]["name"] = "Changed in Packrat" + second_signature = gc.save_data(path, changed, expected_signature=first_signature) + + backup = Path(f"{path}.bak") + self.assertTrue(backup.exists()) + self.assertEqual(json.loads(backup.read_text(encoding="utf-8")), original) + self.assertEqual(gc.load_data(path)["gear"][0]["name"], "Changed in Packrat") + self.assertNotEqual(first_signature, second_signature) + self.assertFalse(any(p.suffix == ".tmp" for p in Path(directory).iterdir())) + + path.write_text(path.read_text(encoding="utf-8") + " ", encoding="utf-8") + with self.assertRaises(gc.DataConflictError): + gc.save_data(path, changed, expected_signature=second_signature) + + def test_exports_follow_custom_data_path(self): + path = os.path.join("somewhere", "portable", "gear.json") + self.assertEqual( + gc.export_dir_for_data(path), + os.path.abspath(os.path.join("somewhere", "portable", "exports")), + ) + + +class SummaryTests(unittest.TestCase): + def test_missing_gear_is_reported_without_breaking_summary(self): + data = gc.example_data() + data["trips"][0]["items"].append({"gear_id": "G999", "note": "missing"}) + summary = gc.compute_trip_summary(data, data["trips"][0]) + self.assertEqual(summary["missing_gear_ids"], ["G999"]) + self.assertIn("no longer exists", gc.render_trip_markdown(data, data["trips"][0])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 0000000..becac19 --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,45 @@ +import tempfile +import unittest +from pathlib import Path + +from textual.widgets import Input, TabbedContent + +from gear_tui import GearFormScreen, GearTrackerApp, ShortcutHelpScreen + + +class KeyboardWorkflowTests(unittest.IsolatedAsyncioTestCase): + async def test_global_navigation_search_help_and_backup(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "gear.json" + app = GearTrackerApp(str(path)) + async with app.run_test(size=(120, 40)) as pilot: + await pilot.press("2") + self.assertEqual(app.query_one(TabbedContent).active, "trips") + await pilot.press("1", "/") + self.assertEqual(app.query_one(TabbedContent).active, "gear") + self.assertTrue(app.query_one("#gear-search", Input).has_focus) + + await pilot.press("a") + self.assertEqual(app.query_one("#gear-search", Input).value, "a") + self.assertNotIsInstance(app.screen, GearFormScreen) + + await pilot.press("escape", "?") + self.assertIsInstance(app.screen, ShortcutHelpScreen) + await pilot.press("escape", "ctrl+b") + self.assertTrue(Path(f"{path}.bak").exists()) + + async def test_gear_hotkey_and_ctrl_s_add_an_item(self): + with tempfile.TemporaryDirectory() as directory: + app = GearTrackerApp(str(Path(directory) / "gear.json")) + starting_count = len(app.data["gear"]) + async with app.run_test(size=(120, 40)) as pilot: + app.query_one("#gear-table").focus() + await pilot.press("a") + self.assertIsInstance(app.screen, GearFormScreen) + await pilot.press("t", "e", "s", "t", "ctrl+s") + self.assertEqual(len(app.data["gear"]), starting_count + 1) + self.assertEqual(app.data["gear"][-1]["name"], "test") + + +if __name__ == "__main__": + unittest.main()