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:
parent
1a12e6d3c5
commit
51a1697c83
18 changed files with 866 additions and 166 deletions
24
Pens.csv~
Normal file
24
Pens.csv~
Normal file
|
|
@ -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
|
||||
47
README.md
47
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
|
||||
0
build/lib/pen_tracker/__init__.py
Normal file
0
build/lib/pen_tracker/__init__.py
Normal file
248
build/lib/pen_tracker/cli.py
Normal file
248
build/lib/pen_tracker/cli.py
Normal file
|
|
@ -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.")
|
||||
85
build/lib/pen_tracker/engine.py
Normal file
85
build/lib/pen_tracker/engine.py
Normal 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)
|
||||
159
build/lib/pen_tracker/tui.py
Normal file
159
build/lib/pen_tracker/tui.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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" },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 <don@donharper.org>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
src/pen_tracker.egg-info/top_level.txt
|
||||
tests/test_engine.py
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
BIN
tests/__pycache__/test_engine.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_engine.cpython-313.pyc
Normal file
Binary file not shown.
37
tests/test_engine.py
Normal file
37
tests/test_engine.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue