- 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.
248 lines
8.9 KiB
Python
248 lines
8.9 KiB
Python
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.")
|