diff --git a/Pens.csv~ b/Pens.csv~ new file mode 100644 index 0000000..23aa7de --- /dev/null +++ b/Pens.csv~ @@ -0,0 +1,24 @@ +Make,Model,Date-Purchased,Vendor,Nib,Nib-Material,Body,Cap,Post,Current-Ink,Inked-date,Notes +Andibro,PenShort,2026-02-02,Amazon,M,Steel,Wood,Brass,Y,N/A,N/A,Cartridge can leak +duckland,drake,2026-04-12,Ducks R Us,F,Steel,Al,Al,y,squid ink,2026-04-12,This is cool +Ensso,Bolt2,2025-08-05,Kickstart,F,N/A,AL – Black,Retractable,N/A,N/A,N/A,N/A +Ensso,Piuma AL,2017-05-01,Kickstart,F,N/A,AL – Black,AL – Black,Y,N/A,N/A,N/A +Ensso,Piuma Brass,2017-05-01,Kickstart,F,N/A,Brass,Brass,Y,Colorverse 2026 Red Horse,N/A,N/A +Hongdian,M1,2025-12-25,Amazon,F,N/A,AL,AL,Y,N/A,N/A,N/A +Jinhao,10 Press,2026-02-02,Amazon,EF,N/A,AL – Orange,Retractable,N/A,Colorverse Sea of Tranquillity,2026-04-15,N/A +Jinhao,159,2026-04-15,Amazon,M,N/A,Steel - Red,Steel - Red,Y,N/A,N/A,N/A +Jinhao,601 Steel Cap Vacumatic,2026-03-03,Amazon,F,N/A,Resin – Lt Blue,Steel,Y,Hongdian Blue,N/A,N/A +Jinhao,75,2026-04-08,Amazon,F,N/A,Black Brass,Black Brass,Y,Mangdian Red,2026-04-08,N/A +Jinhao,82 Acrylic,2026-03-26,Amazon,F,N/A,Brown Acrylic,Green Acrylic,Y,Diamine Pumpkin,2026-04-01,N/A +Jinhao,8802 Rosewood,2022-08-25,Amazon,F,N/A,Brass – Black,Rosewood,Y,N/A,N/A,N/A +Jinhao,X159,2026-04-20,Amazon,F,N/A,Resin - Orange,Resin - Orange,Y,N/A,N/A,N/A +Kaweco,Skyline Sport DIY Fountain Pen,2026-03-13,Dromgoole’s,F,N/A,Plastic - Glow Green,Plastic,Y,Diamine Pumpkin,2026-03-28,N/A +Lamy,Safari,2016-12-28,Amazon,M,N/A,Black,Black,Y,N/A,N/A,N/A +Monteverde USA,Ritma,2026-04-22,Dromgoole’s,F,N/A,Walnut,Metal - Gun Metal,Y,Platinum Lavendar Black,2026-04-22,N/A +Pilot,Kakuno,2026-02-06,Goldspot,M,N/A,Clear,Clear,Y,N/A,N/A,N/A +Pilot,Kakuno,2026-03-24,Goulet Pens,F,N/A,Clear,Clear,Y,N/A,N/A,Eyedropper mod +Pilot,Metropolitan 91111,2026-02-24,Amazon,F,N/A,Brass – Black,Brass – Black,Y,N/A,N/A,N/A +Platinum,Curdus,2017-12-25,Gift,F,Steele,Green,Retractable,N/A,N/A,N/A,Feed/Nib broken +Platinum,Preppy,2026-03-20,Goulet Pens,F,N/A,Clear,Clear,Y,N/A,N/A,N/A +Retro 51,Tornado Fountain,2026-04-21,Goldspot,F,N/A,AL - Orange,AL - Orange,Y,Diamine Pumpkin,2026-04-22,Escape ACES Suit Orange +ZenZoi,Bamboo,2017-04-11,Amazon,M,N/A,Bamboo,Bamboo,Y,N/A,N/A,N/A diff --git a/README.md b/README.md index e69de29..da2e4ae 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,47 @@ +# Pen Tracker + +A simple fountain pen collection tracker. + +## Installation + +```bash +pip install . +``` + +## Data Storage + +Pen data is stored in `~/.local/share/pen-tracker/pens.csv` by default, following XDG Base Directory specification. The directory is created automatically if it doesn't exist. + +You can override the location by setting the `PEN_TRACKER_CSV` environment variable. + +## Usage + +### CLI + +Interactive mode: +```bash +pen-tracker +``` + +Command-line mode: +```bash +pen-tracker add --make "Pilot" --model "Metropolitan" --nib "F" +pen-tracker list +pen-tracker export --output my_pens.json +``` + +### TUI + +```bash +pen-tui +``` + +## Features + +- Track fountain pens with details like make, model, nib, ink, etc. +- CLI interface with interactive and command-line modes +- TUI interface using Textual +- Data stored in CSV format +- Export to JSON +- Input validation for dates +- Configurable CSV path via PEN_TRACKER_CSV environment variable \ No newline at end of file diff --git a/build/lib/pen_tracker/__init__.py b/build/lib/pen_tracker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/pen_tracker/cli.py b/build/lib/pen_tracker/cli.py new file mode 100644 index 0000000..6052306 --- /dev/null +++ b/build/lib/pen_tracker/cli.py @@ -0,0 +1,248 @@ +import os +import csv +import argparse +import json +from datetime import datetime +from .engine import PenTracker, Pen + +def validate_date(date_str: str) -> bool: + """Validate date in YYYY-MM-DD format.""" + if date_str == "N/A": + return True + try: + datetime.strptime(date_str, '%Y-%m-%d') + return True + except ValueError: + return False + +class CLITracker(PenTracker): + + def add_pen(self): + print("\n--- Add New Fountain Pen ---") + new_pen_data = {} + fields = [ + ("Make", "Make"), ("Model", "Model"), ("Date Purchased (YYYY-MM-DD)", "Date-Purchased"), + ("Vendor", "Vendor"), ("Nib Size", "Nib"), ("Nib Material", "Nib-Material"), + ("Body Material", "Body"), ("Cap Material", "Cap"), ("Postable", "Post"), + ("Current Ink", "Current-Ink"), ("Inked Date (YYYY-MM-DD)", "Inked-date"), ("Notes", "Notes") + ] + + for label, key in fields: + while True: + value = input(f"Enter {label}: ").strip() + if not value: + value = "N/A" + break + if key in ['Date-Purchased', 'Inked-date']: + if validate_date(value): + break + else: + print("Invalid date format. Use YYYY-MM-DD.") + else: + break + new_pen_data[key] = value + + # Map to Pen fields + mapped_data = {self.key_map.get(k, k): v for k, v in new_pen_data.items()} + new_pen = Pen(**mapped_data) + self.pens.append(new_pen) + self.save_data() + print("\n[✔] Pen added successfully to Pens.csv!") + + def edit_pen(self): + """Allows the user to select a pen and modify specific fields.""" + if not self.pens: + print("\n[!] No pens available to edit.") + return + + # Show summary so user knows which ID to pick + print("\n--- Select Pen to Edit ---") + self.show_summary_list() + + try: + idx = int(input("\nEnter the ID of the pen to edit: ")) + pen = self.pens[idx] + except (ValueError, IndexError): + print("[!] Invalid ID.") + return + + print("\n--- Editing Pen Details ---") + print("(Press ENTER without typing to keep the current value)\n") + + # We iterate through headers so we don't miss any column + for header in self.headers: + field = self.key_map.get(header, header) + current_val = getattr(pen, field) + while True: + new_val = input(f"{header} [{current_val}]: ").strip() + if not new_val: + break + if header in ['Date-Purchased', 'Inked-date']: + if validate_date(new_val): + break + else: + print("Invalid date format. Use YYYY-MM-DD.") + else: + break + if new_val: # If the user actually typed something new + setattr(pen, field, new_val) + + self.save_data() + print("\n[✔] Pen updated successfully!") + + def show_summary_list(self): + """Helper to print a list without the interactive menu logic.""" + print(f"{'ID':<4} | {'MAKE':<12} | {'MODEL':<12} | {'INK':<15}") + print("-" * 55) + for idx, pen in enumerate(self.pens): + make = pen.Make[:12] + model = pen.Model[:12] + ink = pen.Current_Ink[:15] + print(f"{idx:<4} | {make:<12} | {model:<12} | {ink:<15}") + + def view_all_pens(self): + if not self.pens: + print("\n[!] Your collection is currently empty.") + return + + print("\n" + "="*85) + print(f"{'ID':<4} | {'MAKE':<12} | {'MODEL':<12} | {'INK':<15} | {'INKED DATE':<12}") + print("-" * 85) + + for idx, pen in enumerate(self.pens): + make = pen.Make[:12] + model = pen.Model[:12] + ink = pen.Current_Ink[:15] + inkdate = pen.Inked_date[:12] + print(f"{idx:<4} | {make:<12} | {model:<12} | {ink:<15} | {inkdate:<12}") + + print("="*85) + + choice = input("\nEnter ID to see full details (or 'b' to go back): ") + if choice.lower() != 'b': + try: + self.view_pen_details(int(choice)) + except (ValueError, IndexError): + print("[!] Invalid ID.") + + def view_pen_details(self, index): + pen = self.pens[index] + print("\n" + "═"*45) + print(f"{' FOUNTAIN PEN DETAILS ':=^45}") + for header in self.headers: + field = self.key_map.get(header, header) + value = getattr(pen, field) + print(f"{header:<20}: {value}") + print("═"*45) + input("\nPress Enter to return to menu...") + + def delete_pen(self): + if not self.pens: + print("\n[!] Nothing to delete.") + return + self.show_summary_list() + try: + idx = int(input("\nEnter the ID of the pen to delete: ")) + removed = self.pens.pop(idx) + self.save_data() + print(f"\n[!] Removed: {removed.Make} {removed.Model}") + except (ValueError, IndexError): + print("[!] Invalid ID.") + +def clear_screen(): + os.system('cls' if os.name == 'nt' else 'clear') + +def main(): + # This is the entry point defined in pyproject.toml + parser = argparse.ArgumentParser(description="Fountain Pen Tracker") + parser.add_argument('--csv', default=None, help='Path to CSV file') + subparsers = parser.add_subparsers(dest='command', help='Commands') + + # Add command + add_parser = subparsers.add_parser('add', help='Add a new pen') + add_parser.add_argument('--make', required=True) + add_parser.add_argument('--model', required=True) + add_parser.add_argument('--date-purchased') + add_parser.add_argument('--vendor') + add_parser.add_argument('--nib') + add_parser.add_argument('--nib-material') + add_parser.add_argument('--body') + add_parser.add_argument('--cap') + add_parser.add_argument('--post') + add_parser.add_argument('--current-ink') + add_parser.add_argument('--inked-date') + add_parser.add_argument('--notes') + + # List command + list_parser = subparsers.add_parser('list', help='List all pens') + + # Export command + export_parser = subparsers.add_parser('export', help='Export to JSON') + export_parser.add_argument('--output', default='pens.json', help='Output file') + + args = parser.parse_args() + + tracker = CLITracker(args.csv) + + if args.command == 'add': + # Validate dates + if args.date_purchased and not validate_date(args.date_purchased): + print("Invalid date-purchased format.") + return + if args.inked_date and not validate_date(args.inked_date): + print("Invalid inked-date format.") + return + pen_data = { + 'Make': args.make, + 'Model': args.model, + 'Date-Purchased': args.date_purchased or 'N/A', + 'Vendor': args.vendor or 'N/A', + 'Nib': args.nib or 'N/A', + 'Nib-Material': args.nib_material or 'N/A', + 'Body': args.body or 'N/A', + 'Cap': args.cap or 'N/A', + 'Post': args.post or 'N/A', + 'Current-Ink': args.current_ink or 'N/A', + 'Inked-date': args.inked_date or 'N/A', + 'Notes': args.notes or 'N/A' + } + mapped_data = {tracker.key_map.get(k, k): v for k, v in pen_data.items()} + new_pen = Pen(**mapped_data) + tracker.pens.append(new_pen) + tracker.save_data() + print("Pen added successfully.") + elif args.command == 'list': + tracker.view_all_pens() + elif args.command == 'export': + with open(args.output, 'w') as f: + json.dump([asdict(p) for p in tracker.pens], f, indent=2) + print(f"Exported to {args.output}") + else: + # Interactive mode + while True: + print("\n🖋️ FOUNTAIN PEN TRACKER (CSV Edition)") + print("1. View Collection Summary") + print("2. Add New Pen") + print("3. Edit a Pen") + print("4. Delete a Pen") + print("5. Exit") + + choice = input("\nSelect an option: ") + + if choice == '1': + clear_screen() + tracker.view_all_pens() + elif choice == '2': + clear_screen() + tracker.add_pen() + elif choice == '3': + clear_screen() + tracker.edit_pen() + elif choice == '4': + clear_screen() + tracker.delete_pen() + elif choice == '5': + print("Goodbye! Happy writing! ✒️") + break + else: + print("[!] Invalid selection. Please try again.") diff --git a/build/lib/pen_tracker/engine.py b/build/lib/pen_tracker/engine.py new file mode 100644 index 0000000..90f7fbb --- /dev/null +++ b/build/lib/pen_tracker/engine.py @@ -0,0 +1,85 @@ +import csv +import os +import logging +from dataclasses import dataclass, asdict +from typing import List + +logger = logging.getLogger(__name__) + +@dataclass +class Pen: + Make: str = "N/A" + Model: str = "N/A" + Date_Purchased: str = "N/A" + Vendor: str = "N/A" + Nib: str = "N/A" + Nib_Material: str = "N/A" + Body: str = "N/A" + Cap: str = "N/A" + Post: str = "N/A" + Current_Ink: str = "N/A" + Inked_date: str = "N/A" + Notes: str = "N/A" + +class PenTracker: + def __init__(self, storage_file: str = None): + if storage_file is None: + storage_file = os.getenv('PEN_TRACKER_CSV', 'Pens.csv') + self.storage_file = storage_file + self.headers = [ + 'Make', 'Model', 'Date-Purchased', 'Vendor', 'Nib', + 'Nib-Material', 'Body', 'Cap', 'Post', + 'Current-Ink', 'Inked-date', 'Notes' + ] + self.key_map = { + 'Date-Purchased': 'Date_Purchased', + 'Inked-date': 'Inked_date', + 'Nib-Material': 'Nib_Material', + 'Current-Ink': 'Current_Ink' + } + self.reverse_key_map = {v: k for k, v in self.key_map.items()} + self.pens: List[Pen] = self.load_data() + + def _sort_pens(self): + """Sorts the pens list by Make, then by Model alphabetically.""" + self.pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower())) + + def load_data(self) -> List[Pen]: + """Loads data from the CSV file.""" + if not os.path.exists(self.storage_file): + self._create_empty_csv() + return [] + + pens = [] + try: + # Standard, clean way to open the file + with open(self.storage_file, mode='r', encoding='utf-8-sig') as f: + reader = csv.DictReader(f) + for row in reader: + # Strip whitespace from keys and values, handle empty values + clean_row = {k.strip(): (v.strip() if v else "N/A") for k, v in row.items()} + # Map keys to dataclass field names + mapped_row = {self.key_map.get(k, k): v for k, v in clean_row.items()} + pens.append(Pen(**mapped_row)) + pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower())) + except Exception as e: + logger.error(f"Error loading CSV: {e}") + return pens + + def _create_empty_csv(self): + """Creates the CSV file with headers if it is missing.""" + with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=self.headers) + writer.writeheader() + + def save_data(self): + """Sorts the data before writing to ensure persistent alphabetical order.""" + self._sort_pens() + with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=self.headers) + writer.writeheader() + for pen in self.pens: + row = asdict(pen) + # Map back to CSV keys + csv_row = {k.replace('_', '-'): v for k, v in row.items()} + writer.writerow(csv_row) diff --git a/build/lib/pen_tracker/tui.py b/build/lib/pen_tracker/tui.py new file mode 100644 index 0000000..8de1846 --- /dev/null +++ b/build/lib/pen_tracker/tui.py @@ -0,0 +1,159 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Label, Input, Button, Header, Footer, DataTable +from textual.containers import Vertical, Horizontal +from textual.binding import Binding +from .engine import PenTracker, Pen +# --- TUI SCREENS --- + +class PenFormScreen(Screen): + """A screen for adding or editing a pen.""" + + def __init__(self, tracker, existing_pen=None): + super().__init__() + self.tracker = tracker + self.existing_pen = existing_pen + + def compose(self) -> ComposeResult: + with Vertical(id="form-container"): + yield Label("📝 NEW PEN" if not self.existing_pen else "✏️ EDIT PEN") + + for header in self.tracker.headers: + field = self.tracker.key_map.get(header, header) + val = getattr(self.existing_pen, field) if self.existing_pen else "" + yield Input(value=val, placeholder=header, id=header) + + with Horizontal(): + yield Button("Save", variant="success", id="save_cmd") + yield Button("Cancel", variant="error", id="cancel_cmd") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel_cmd": + self.dismiss() + return + + new_data = {} + for header in self.tracker.headers: + try: + input_widget = self.query_one(f"#{header}", Input) + new_data[header] = input_widget.value if input_widget.value.strip() else "N/A" + except Exception: + new_data[header] = "N/A" + + # Map to Pen fields + mapped_data = {self.tracker.key_map.get(k, k): v for k, v in new_data.items()} + new_pen = Pen(**mapped_data) + + if self.existing_pen is not None and self.existing_pen in self.tracker.pens: + idx = self.tracker.pens.index(self.existing_pen) + self.tracker.pens[idx] = new_pen + else: + self.tracker.pens.append(new_pen) + + # save_data() handles the sorting internally + self.tracker.save_data() + self.dismiss(new_pen) + +class PenTrackerApp(App): + """The Main TUI Application.""" + + CSS = """ + #form-container { + width: 60%; + height: auto; + border: heavy white; + padding: 1 2; + margin: 5 10; + background: $panel; + } + Label { + width: 100%; + text-align: center; + text-style: bold; + margin-bottom: 1; + } + Input { + margin-bottom: 1; + } + Horizontal { + align: center middle; + height: auto; + } + Button { + margin: 0 1; + } + DataTable { + height: 1fr; + } + """ + + BINDINGS = [ + Binding("d", "delete_selected", "Delete Selected"), + Binding("a", "add_new", "Add New Pen"), + Binding("q", "quit", "Quit"), + ] + + def compose(self) -> ComposeResult: + yield Header() + yield DataTable(id="pen_table") + yield Footer() + + def on_mount(self) -> None: + self.tracker = PenTracker('Pens.csv') + self._refresh_table() + + def _refresh_table(self): + table = self.query_one("#pen_table", DataTable) + table.clear(columns=True) + + display_cols = ['Make', 'Model', 'Nib', 'Nib-Material', 'Body','Cap', 'Current-Ink', 'Inked-date', 'Notes'] + table.add_columns(*display_cols) + + for idx, pen in enumerate(self.tracker.pens): + row_values = [getattr(pen, c.replace('-','_')) for c in display_cols] + # We use the index as the row key to track items accurately + table.add_row(*row_values, key=str(idx)) + + def action_add_new(self) -> None: + form = PenFormScreen(self.tracker) + self.push_screen(form, self.handle_form_result) + + def action_edit_selected(self, index_str: str): + try: + idx = int(index_str) + existing_pen = self.tracker.pens[idx] + form = PenFormScreen(self.tracker, existing_pen=existing_pen) + self.push_screen(form, self.handle_form_result) + except (ValueError, IndexError): + pass + + def handle_form_result(self, updated_pen_data) -> None: + if updated_pen_data: + self._refresh_table() + self.notify("Collection Updated & Sorted!", title="Success") + + def action_delete_selected(self) -> None: + table = self.query_one("#pen_table", DataTable) + try: + # Accessing via the table's current cursor + table = self.query_one("#pen_table", DataTable) + row_node = table.get_row_at_cursor() + idx = int(row_node.key.value) + removed = self.tracker.pens.pop(idx) + self.tracker.save_data() + self._refresh_table() + self.notify(f"Deleted {removed.Make}", title="Removed") + except Exception: + self.notify("No pen selected to delete", title="Error", severity="error") + + def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: + try: + idx = int(event.cell_key.row_key.value) + self.action_edit_selected(str(idx)) + except (AttributeError, ValueError): + self.notify("Error selecting row", title="Error", severity="error") + +def main(): + # This is the entry point defined in pyproject.toml + app = PenTrackerApp() + app.run() diff --git a/pyproject.toml b/pyproject.toml index 5a7f7c3..6495605 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pen-tracker" -version = "0.1.2" +version = "0.2.0" authors = [ { name="Don Harper", email="don@donharper.org" }, ] diff --git a/src/pen_tracker.egg-info/PKG-INFO b/src/pen_tracker.egg-info/PKG-INFO index 1343b95..f5ef874 100644 --- a/src/pen_tracker.egg-info/PKG-INFO +++ b/src/pen_tracker.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: pen-tracker -Version: 0.1.2 +Version: 0.2.0 Summary: A fountain pen collection tracker. Author-email: Don Harper Requires-Python: >=3.8 @@ -8,3 +8,45 @@ Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: textual Dynamic: license-file + +# Pen Tracker + +A simple fountain pen collection tracker. + +## Installation + +```bash +pip install . +``` + +## Usage + +### CLI + +Interactive mode: +```bash +pen-tracker +``` + +Command-line mode: +```bash +pen-tracker add --make "Pilot" --model "Metropolitan" --nib "F" +pen-tracker list +pen-tracker export --output my_pens.json +``` + +### TUI + +```bash +pen-tui +``` + +## Features + +- Track fountain pens with details like make, model, nib, ink, etc. +- CLI interface with interactive and command-line modes +- TUI interface using Textual +- Data stored in CSV format +- Export to JSON +- Input validation for dates +- Configurable CSV path via PEN_TRACKER_CSV environment variable diff --git a/src/pen_tracker.egg-info/SOURCES.txt b/src/pen_tracker.egg-info/SOURCES.txt index 4d050c2..4c88beb 100644 --- a/src/pen_tracker.egg-info/SOURCES.txt +++ b/src/pen_tracker.egg-info/SOURCES.txt @@ -10,4 +10,5 @@ src/pen_tracker.egg-info/SOURCES.txt src/pen_tracker.egg-info/dependency_links.txt src/pen_tracker.egg-info/entry_points.txt src/pen_tracker.egg-info/requires.txt -src/pen_tracker.egg-info/top_level.txt \ No newline at end of file +src/pen_tracker.egg-info/top_level.txt +tests/test_engine.py \ No newline at end of file diff --git a/src/pen_tracker/__pycache__/cli.cpython-313.pyc b/src/pen_tracker/__pycache__/cli.cpython-313.pyc index 442256e..b92abeb 100644 Binary files a/src/pen_tracker/__pycache__/cli.cpython-313.pyc and b/src/pen_tracker/__pycache__/cli.cpython-313.pyc differ diff --git a/src/pen_tracker/__pycache__/engine.cpython-313.pyc b/src/pen_tracker/__pycache__/engine.cpython-313.pyc index 8b9c1b6..4e87b3d 100644 Binary files a/src/pen_tracker/__pycache__/engine.cpython-313.pyc and b/src/pen_tracker/__pycache__/engine.cpython-313.pyc differ diff --git a/src/pen_tracker/__pycache__/tui.cpython-313.pyc b/src/pen_tracker/__pycache__/tui.cpython-313.pyc index 894ebbc..3d3c8aa 100644 Binary files a/src/pen_tracker/__pycache__/tui.cpython-313.pyc and b/src/pen_tracker/__pycache__/tui.cpython-313.pyc differ diff --git a/src/pen_tracker/cli.py b/src/pen_tracker/cli.py index ab79293..6052306 100644 --- a/src/pen_tracker/cli.py +++ b/src/pen_tracker/cli.py @@ -1,60 +1,25 @@ import os -from .engine import PenTracker +import csv +import argparse +import json +from datetime import datetime +from .engine import PenTracker, Pen + +def validate_date(date_str: str) -> bool: + """Validate date in YYYY-MM-DD format.""" + if date_str == "N/A": + return True + try: + datetime.strptime(date_str, '%Y-%m-%d') + return True + except ValueError: + return False class CLITracker(PenTracker): - # ... (Copy all the methods: add_pen, edit_pen, view_all_pens, etc., from your original script) - # Ensure you change "self.storage_file" references if they were hardcoded. - # IMPORTANT: Replace "from pen-tracker import PenTracker" with "from .engine import PenTracker" - # def __init__(self, storage_file='Pens.csv'): - # self.storage_file = storage_file - # # These headers must match your CSV exactly - # self.headers = [ - # 'Make', 'Model', 'Date-Purchased', 'Vendor', 'Nib', - # 'Nib-Material', 'Body', 'Cap', 'Post', - # 'Current-Ink', 'Inked-date', 'Notes' - # ] - # self.pens = self.load_data() - - def load_data(self): - """Loads data from the CSV file.""" - if not os.path.exists(self.storage_file): - self._create_empty_csv() - return [] - - pens = [] - try: - with open(self.sstorage_file, mode='r', encoding='utf-8-sig') as f: # Fixed typo in logic here for safety - pass - except: pass # Fallback - - # Re-implementing clean load - if os.path.exists(self.storage_file): - try: - with open(self.storage_file, mode='r', encoding='utf-8-sig') as f: - reader = csv.DictReader(f) - for row in reader: - clean_row = {k.strip(): (v.strip() if v else "N/A") for k, v in row.items()} - pens.append(clean_row) - except Exception as e: - print(f"[!] Error loading CSV: {e}") - return pens - - def _create_empty_csv(self): - """Creates the CSV file with headers if it is missing.""" - with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: - writer = csv.DictWriter(f, fieldnames=self.headers) - writer.writeheader() - - def save_data(self): - """Saves the current list of pens back to the CSV file.""" - with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: - writer = csv.DictWriter(f, fieldnames=self.headers) - writer.writeheader() - writer.writerows(self.pens) def add_pen(self): print("\n--- Add New Fountain Pen ---") - new_pen = {} + new_pen_data = {} fields = [ ("Make", "Make"), ("Model", "Model"), ("Date Purchased (YYYY-MM-DD)", "Date-Purchased"), ("Vendor", "Vendor"), ("Nib Size", "Nib"), ("Nib Material", "Nib-Material"), @@ -63,9 +28,23 @@ class CLITracker(PenTracker): ] for label, key in fields: - value = input(f"Enter {label}: ").strip() - new_pen[key] = value if value else "N/A" + while True: + value = input(f"Enter {label}: ").strip() + if not value: + value = "N/A" + break + if key in ['Date-Purchased', 'Inked-date']: + if validate_date(value): + break + else: + print("Invalid date format. Use YYYY-MM-DD.") + else: + break + new_pen_data[key] = value + # Map to Pen fields + mapped_data = {self.key_map.get(k, k): v for k, v in new_pen_data.items()} + new_pen = Pen(**mapped_data) self.pens.append(new_pen) self.save_data() print("\n[✔] Pen added successfully to Pens.csv!") @@ -91,23 +70,34 @@ class CLITracker(PenTracker): print("(Press ENTER without typing to keep the current value)\n") # We iterate through headers so we don't miss any column - for key in self.headers: - current_val = pen.get(key, "N/A") - new_val = input(f"{key} [{current_val}]: ").strip() + for header in self.headers: + field = self.key_map.get(header, header) + current_val = getattr(pen, field) + while True: + new_val = input(f"{header} [{current_val}]: ").strip() + if not new_val: + break + if header in ['Date-Purchased', 'Inked-date']: + if validate_date(new_val): + break + else: + print("Invalid date format. Use YYYY-MM-DD.") + else: + break if new_val: # If the user actually typed something new - pen[key] = new_val + setattr(pen, field, new_val) self.save_data() print("\n[✔] Pen updated successfully!") def show_summary_list(self): """Helper to print a list without the interactive menu logic.""" - print(f"{'ID':<4} | {'MAKE':<12} | {'MODEL':<1s} | {'INK':<15}") - print("-" * 40) + print(f"{'ID':<4} | {'MAKE':<12} | {'MODEL':<12} | {'INK':<15}") + print("-" * 55) for idx, pen in enumerate(self.pens): - make = pen.get('Make', 'N/A')[:12] - model = pen.get('Model', 'N/A')[:12] - ink = pen.get('Current-Ink', 'N/A')[:15] + make = pen.Make[:12] + model = pen.Model[:12] + ink = pen.Current_Ink[:15] print(f"{idx:<4} | {make:<12} | {model:<12} | {ink:<15}") def view_all_pens(self): @@ -120,10 +110,10 @@ class CLITracker(PenTracker): print("-" * 85) for idx, pen in enumerate(self.pens): - make = pen.get('Make', 'N/A')[:12] - model = pen.get('Model', 'N/A')[:12] - ink = pen.get('Current-Ink', 'N/A')[:15] - inkdate = pen.get('Inked-date', '----------------')[:12] + make = pen.Make[:12] + model = pen.Model[:12] + ink = pen.Current_Ink[:15] + inkdate = pen.Inked_date[:12] print(f"{idx:<4} | {make:<12} | {model:<12} | {ink:<15} | {inkdate:<12}") print("="*85) @@ -139,9 +129,10 @@ class CLITracker(PenTracker): pen = self.pens[index] print("\n" + "═"*45) print(f"{' FOUNTAIN PEN DETAILS ':=^45}") - for key in self.headers: - value = pen.get(key, "N/A") - print(f"{key:<20}: {value}") + for header in self.headers: + field = self.key_map.get(header, header) + value = getattr(pen, field) + print(f"{header:<20}: {value}") print("═"*45) input("\nPress Enter to return to menu...") @@ -154,7 +145,7 @@ class CLITracker(PenTracker): idx = int(input("\nEnter the ID of the pen to delete: ")) removed = self.pens.pop(idx) self.save_data() - print(f"\n[!] Removed: {removed.get('Make', 'Unknown')} {removed.get('Model', '')}") + print(f"\n[!] Removed: {removed.Make} {removed.Model}") except (ValueError, IndexError): print("[!] Invalid ID.") @@ -163,32 +154,95 @@ def clear_screen(): def main(): # This is the entry point defined in pyproject.toml - import sys - tracker = CLITracker('Pens.csv') - while True: - print("\n🖋️ FOUNTAIN PEN TRACKER (CSV Edition)") - print("1. View Collection Summary") - print("2. Add New Pen") - print("3. Edit a Pen") - print("4. Delete a Pen") - print("5. Exit") - - choice = input("\nSelect an option: ") + parser = argparse.ArgumentParser(description="Fountain Pen Tracker") + parser.add_argument('--csv', default=None, help='Path to CSV file') + subparsers = parser.add_subparsers(dest='command', help='Commands') - if choice == '1': - clear_append = clear_screen() - tracker.view_all_pens() - elif choice == '2': - clear_screen() - tracker.add_pen() - elif choice == '3': - clear_screen() - tracker.edit_pen() - elif choice == '4': - clear_screen() - tracker.delete_pen() - elif choice == '5': - print("Goodbye! Happy writing! ✒️") - break - else: - print("[!] Invalid selection. Please try again.") + # Add command + add_parser = subparsers.add_parser('add', help='Add a new pen') + add_parser.add_argument('--make', required=True) + add_parser.add_argument('--model', required=True) + add_parser.add_argument('--date-purchased') + add_parser.add_argument('--vendor') + add_parser.add_argument('--nib') + add_parser.add_argument('--nib-material') + add_parser.add_argument('--body') + add_parser.add_argument('--cap') + add_parser.add_argument('--post') + add_parser.add_argument('--current-ink') + add_parser.add_argument('--inked-date') + add_parser.add_argument('--notes') + + # List command + list_parser = subparsers.add_parser('list', help='List all pens') + + # Export command + export_parser = subparsers.add_parser('export', help='Export to JSON') + export_parser.add_argument('--output', default='pens.json', help='Output file') + + args = parser.parse_args() + + tracker = CLITracker(args.csv) + + if args.command == 'add': + # Validate dates + if args.date_purchased and not validate_date(args.date_purchased): + print("Invalid date-purchased format.") + return + if args.inked_date and not validate_date(args.inked_date): + print("Invalid inked-date format.") + return + pen_data = { + 'Make': args.make, + 'Model': args.model, + 'Date-Purchased': args.date_purchased or 'N/A', + 'Vendor': args.vendor or 'N/A', + 'Nib': args.nib or 'N/A', + 'Nib-Material': args.nib_material or 'N/A', + 'Body': args.body or 'N/A', + 'Cap': args.cap or 'N/A', + 'Post': args.post or 'N/A', + 'Current-Ink': args.current_ink or 'N/A', + 'Inked-date': args.inked_date or 'N/A', + 'Notes': args.notes or 'N/A' + } + mapped_data = {tracker.key_map.get(k, k): v for k, v in pen_data.items()} + new_pen = Pen(**mapped_data) + tracker.pens.append(new_pen) + tracker.save_data() + print("Pen added successfully.") + elif args.command == 'list': + tracker.view_all_pens() + elif args.command == 'export': + with open(args.output, 'w') as f: + json.dump([asdict(p) for p in tracker.pens], f, indent=2) + print(f"Exported to {args.output}") + else: + # Interactive mode + while True: + print("\n🖋️ FOUNTAIN PEN TRACKER (CSV Edition)") + print("1. View Collection Summary") + print("2. Add New Pen") + print("3. Edit a Pen") + print("4. Delete a Pen") + print("5. Exit") + + choice = input("\nSelect an option: ") + + if choice == '1': + clear_screen() + tracker.view_all_pens() + elif choice == '2': + clear_screen() + tracker.add_pen() + elif choice == '3': + clear_screen() + tracker.edit_pen() + elif choice == '4': + clear_screen() + tracker.delete_pen() + elif choice == '5': + print("Goodbye! Happy writing! ✒️") + break + else: + print("[!] Invalid selection. Please try again.") diff --git a/src/pen_tracker/engine.py b/src/pen_tracker/engine.py index b952ce5..8444a3f 100644 --- a/src/pen_tracker/engine.py +++ b/src/pen_tracker/engine.py @@ -1,26 +1,60 @@ import csv import os +import logging +from dataclasses import dataclass, asdict +from typing import List + +logger = logging.getLogger(__name__) + +@dataclass +class Pen: + Make: str = "N/A" + Model: str = "N/A" + Date_Purchased: str = "N/A" + Vendor: str = "N/A" + Nib: str = "N/A" + Nib_Material: str = "N/A" + Body: str = "N/A" + Cap: str = "N/A" + Post: str = "N/A" + Current_Ink: str = "N/A" + Inked_date: str = "N/A" + Notes: str = "N/A" class PenTracker: - def __init__(self, storage_file='Pens.csv'): + def __init__(self, storage_file: str = None): + if storage_file is None: + storage_file = os.getenv('PEN_TRACKER_CSV') + if storage_file is None: + data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share')) + app_data_dir = os.path.join(data_home, 'pen-tracker') + os.makedirs(app_data_dir, exist_ok=True) + storage_file = os.path.join(app_data_dir, 'pens.csv') self.storage_file = storage_file self.headers = [ 'Make', 'Model', 'Date-Purchased', 'Vendor', 'Nib', 'Nib-Material', 'Body', 'Cap', 'Post', 'Current-Ink', 'Inked-date', 'Notes' ] - self.pens = self.load_data() + self.key_map = { + 'Date-Purchased': 'Date_Purchased', + 'Inked-date': 'Inked_date', + 'Nib-Material': 'Nib_Material', + 'Current-Ink': 'Current_Ink' + } + self.reverse_key_map = {v: k for k, v in self.key_map.items()} + self.pens: List[Pen] = self.load_data() def _sort_pens(self): """Sorts the pens list by Make, then by Model alphabetically.""" - self.pens.sort(key=lambda x: (x.get('Make', '').lower(), x.get('Model', '').lower())) + self.pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower())) - def load_data(self): + def load_data(self) -> List[Pen]: """Loads data from the CSV file.""" if not os.path.exists(self.storage_file): self._create_empty_csv() return [] - + pens = [] try: # Standard, clean way to open the file @@ -29,10 +63,12 @@ class PenTracker: for row in reader: # Strip whitespace from keys and values, handle empty values clean_row = {k.strip(): (v.strip() if v else "N/A") for k, v in row.items()} - pens.append(clean_row) - self._sort_pens() + # Map keys to dataclass field names + mapped_row = {self.key_map.get(k, k): v for k, v in clean_row.items()} + pens.append(Pen(**mapped_row)) + pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower())) except Exception as e: - print(f"[!] Error loading CSV: {e}") + logger.error(f"Error loading CSV: {e}") return pens def _create_empty_csv(self): @@ -47,4 +83,8 @@ class PenTracker: with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: writer = csv.DictWriter(f, fieldnames=self.headers) writer.writeheader() - writer.writerows(self.pens) + for pen in self.pens: + row = asdict(pen) + # Map back to CSV keys + csv_row = {k.replace('_', '-'): v for k, v in row.items()} + writer.writerow(csv_row) diff --git a/src/pen_tracker/engine.py.bork b/src/pen_tracker/engine.py.bork deleted file mode 100644 index 127b3ad..0000000 --- a/src/pen_tracker/engine.py.bork +++ /dev/null @@ -1,46 +0,0 @@ -import csv -import os - -class PenTracker: - def __run_sort_pens(self): - """Internal sort method used by both interfaces.""" - self.pens.sort(key=lambda x: (x.get('Make', '').lower(), x.get('Model', '').lower())) - - def __init__(self, storage_file='Pens.csv'): - self.storage_file = storage_file - self.headers = [ - 'Make', 'Model', 'Date-Purchased', 'Vendor', 'Nib', - 'Nib-Material', 'Body', 'Cap', 'Post', - 'Current-Ink', 'Inked-date', 'Notes' - ] - self.pens = self.load_data() - - def load_data(self): - if not os.path.exists(self.storage_file): - self._create_empty_csv() - return [] - - pens = [] - try: - with open(self.dat_file := self.storage_file, mode='r', encoding='utf-8-sig') as f: - reader = csv.DictReader(f) - for row in reader: - clean_row = {k.strip(): (v.strip() if v else "N/A") for k, v in row.items()} - pens.append(clean_row) - self.__run_sort_pens() - except Exception as e: - print(f"[!] Error loading CSV: {e}") - return pens - - def _create_empty_csv(self): - with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: - writer = csv.DictWriter(f, fieldnames=self.headers) - writer.writeheader() - - def save_data(self): - self.__run_sort_pens() - with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: - writer = csv.DictReader(f, fieldnames=self.headers) # Fixed logic from original - writer = csv.DictWriter(f, fieldnames=self.headers) - writer.writeheader() - writer.writerows(self.pens) diff --git a/src/pen_tracker/tui.py b/src/pen_tracker/tui.py index 92cd264..ced7649 100644 --- a/src/pen_tracker/tui.py +++ b/src/pen_tracker/tui.py @@ -1,5 +1,9 @@ -from textual.app import App -from .engine import PenTracker +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Label, Input, Button, Header, Footer, DataTable +from textual.containers import Vertical, Horizontal +from textual.binding import Binding +from .engine import PenTracker, Pen # --- TUI SCREENS --- class PenFormScreen(Screen): @@ -15,7 +19,8 @@ class PenFormScreen(Screen): yield Label("📝 NEW PEN" if not self.existing_pen else "✏️ EDIT PEN") for header in self.tracker.headers: - val = self.existing_pen.get(header, "") if self.existing_pen else "" + field = self.tracker.key_map.get(header, header) + val = getattr(self.existing_pen, field) if self.existing_pen else "" yield Input(value=val, placeholder=header, id=header) with Horizontal(): @@ -35,15 +40,19 @@ class PenFormScreen(Screen): except Exception: new_data[header] = "N/A" + # Map to Pen fields + mapped_data = {self.tracker.key_map.get(k, k): v for k, v in new_data.items()} + new_pen = Pen(**mapped_data) + if self.existing_pen is not None and self.existing_pen in self.tracker.pens: idx = self.tracker.pens.index(self.existing_pen) - self.tracker.pens[idx] = new_data + self.tracker.pens[idx] = new_pen else: - self.tracker.pens.append(new_data) + self.tracker.pens.append(new_pen) # save_data() handles the sorting internally self.tracker.save_data() - self.dismiss(new_data) + self.dismiss(new_pen) class PenTrackerApp(App): """The Main TUI Application.""" @@ -90,7 +99,7 @@ class PenTrackerApp(App): yield Footer() def on_mount(self) -> None: - self.tracker = PenTracker('Pens.csv') + self.tracker = PenTracker() self._refresh_table() def _refresh_table(self): @@ -101,7 +110,7 @@ class PenTrackerApp(App): table.add_columns(*display_cols) for idx, pen in enumerate(self.tracker.pens): - row_values = [pen.get(c, "N/A") for c in display_cols] + row_values = [getattr(pen, c.replace('-','_')) for c in display_cols] # We use the index as the row key to track items accurately table.add_row(*row_values, key=str(idx)) @@ -124,7 +133,7 @@ class PenTrackerApp(App): self.notify("Collection Updated & Sorted!", title="Success") def action_delete_selected(self) -> None: - table = self.int_query_one("#pen_table", DataTable) # Corrected reference + table = self.query_one("#pen_table", DataTable) try: # Accessing via the table's current cursor table = self.query_one("#pen_table", DataTable) @@ -133,7 +142,7 @@ class PenTrackerApp(App): removed = self.tracker.pens.pop(idx) self.tracker.save_data() self._refresh_table() - self.notify(f"Deleted {removed['Make']}", title="Removed") + self.notify(f"Deleted {removed.Make}", title="Removed") except Exception: self.notify("No pen selected to delete", title="Error", severity="error") diff --git a/tests/__pycache__/test_engine.cpython-313.pyc b/tests/__pycache__/test_engine.cpython-313.pyc new file mode 100644 index 0000000..7c794c1 Binary files /dev/null and b/tests/__pycache__/test_engine.cpython-313.pyc differ diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..503c79d --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,37 @@ +import unittest +import tempfile +import os +from pen_tracker.engine import PenTracker, Pen + +class TestPenTracker(unittest.TestCase): + def setUp(self): + self.temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.csv') + self.temp_file.close() + self.tracker = PenTracker(self.temp_file.name) + + def tearDown(self): + os.unlink(self.temp_file.name) + + def test_add_and_load_pen(self): + pen = Pen(Make="Pilot", Model="Metropolitan", Nib="F") + self.tracker.pens.append(pen) + self.tracker.save_data() + + # Load in new tracker + new_tracker = PenTracker(self.temp_file.name) + self.assertEqual(len(new_tracker.pens), 1) + self.assertEqual(new_tracker.pens[0].Make, "Pilot") + self.assertEqual(new_tracker.pens[0].Model, "Metropolitan") + + def test_sorting(self): + self.tracker.pens = [ + Pen(Make="Z", Model="A"), + Pen(Make="A", Model="B") + ] + self.tracker.save_data() + new_tracker = PenTracker(self.temp_file.name) + self.assertEqual(new_tracker.pens[0].Make, "A") + self.assertEqual(new_tracker.pens[1].Make, "Z") + +if __name__ == '__main__': + unittest.main() \ No newline at end of file