feat: Implement CLI and TUI for Fountain Pen Tracker

- Added CLI functionality for adding, editing, viewing, and deleting fountain pens.
- Introduced TUI using Textual for a more interactive experience.
- Created Pen and PenTracker classes to manage pen data and CSV storage.
- Implemented input validation for date fields.
- Added export functionality to JSON format.
- Updated project version to 0.2.0.
- Added unit tests for PenTracker functionality.
This commit is contained in:
Don Harper 2026-04-26 23:00:52 -05:00
parent 1a12e6d3c5
commit 51a1697c83
18 changed files with 866 additions and 166 deletions

View file

@ -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)