Merge branch 'feature/ink'
This commit is contained in:
commit
4d919c9746
14 changed files with 595 additions and 61 deletions
24
Pens.csv~
24
Pens.csv~
|
|
@ -1,24 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -3,7 +3,7 @@ import csv
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .engine import PenTracker, Pen
|
from .engine import PenTracker, Pen, InkTracker, Ink
|
||||||
|
|
||||||
def validate_date(date_str: str) -> bool:
|
def validate_date(date_str: str) -> bool:
|
||||||
"""Validate date in YYYY-MM-DD format."""
|
"""Validate date in YYYY-MM-DD format."""
|
||||||
|
|
@ -17,6 +17,10 @@ def validate_date(date_str: str) -> bool:
|
||||||
|
|
||||||
class CLITracker(PenTracker):
|
class CLITracker(PenTracker):
|
||||||
|
|
||||||
|
def __init__(self, storage_file=None):
|
||||||
|
super().__init__(storage_file)
|
||||||
|
self.ink_tracker = InkTracker()
|
||||||
|
|
||||||
def add_pen(self):
|
def add_pen(self):
|
||||||
print("\n--- Add New Fountain Pen ---")
|
print("\n--- Add New Fountain Pen ---")
|
||||||
new_pen_data = {}
|
new_pen_data = {}
|
||||||
|
|
@ -29,6 +33,9 @@ class CLITracker(PenTracker):
|
||||||
|
|
||||||
for label, key in fields:
|
for label, key in fields:
|
||||||
while True:
|
while True:
|
||||||
|
if key == 'Current-Ink':
|
||||||
|
value = self.select_ink()
|
||||||
|
break
|
||||||
value = input(f"Enter {label}: ").strip()
|
value = input(f"Enter {label}: ").strip()
|
||||||
if not value:
|
if not value:
|
||||||
value = "N/A"
|
value = "N/A"
|
||||||
|
|
@ -74,6 +81,15 @@ class CLITracker(PenTracker):
|
||||||
field = self.key_map.get(header, header)
|
field = self.key_map.get(header, header)
|
||||||
current_val = getattr(pen, field)
|
current_val = getattr(pen, field)
|
||||||
while True:
|
while True:
|
||||||
|
if header == 'Current-Ink':
|
||||||
|
print(f"Current Ink: {current_val}")
|
||||||
|
change_ink = input("Change ink? (y/n): ").strip().lower()
|
||||||
|
if change_ink == 'y':
|
||||||
|
new_val = self.select_ink()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
new_val = ""
|
||||||
|
break
|
||||||
new_val = input(f"{header} [{current_val}]: ").strip()
|
new_val = input(f"{header} [{current_val}]: ").strip()
|
||||||
if not new_val:
|
if not new_val:
|
||||||
break
|
break
|
||||||
|
|
@ -149,6 +165,69 @@ class CLITracker(PenTracker):
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
print("[!] Invalid ID.")
|
print("[!] Invalid ID.")
|
||||||
|
|
||||||
|
def add_ink(self):
|
||||||
|
print("\n--- Add New Ink ---")
|
||||||
|
new_ink_data = {}
|
||||||
|
fields = [
|
||||||
|
("Vendor", "Vendor"), ("Name", "Name"), ("Color", "Color"),
|
||||||
|
("Purchased (YYYY-MM-DD)", "Purchased"), ("Size", "Size"), ("Notes", "Notes")
|
||||||
|
]
|
||||||
|
|
||||||
|
for label, key in fields:
|
||||||
|
while True:
|
||||||
|
value = input(f"Enter {label}: ").strip()
|
||||||
|
if not value:
|
||||||
|
value = "N/A"
|
||||||
|
break
|
||||||
|
if key == 'Purchased':
|
||||||
|
if validate_date(value):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print("Invalid date format. Use YYYY-MM-DD.")
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
new_ink_data[key] = value
|
||||||
|
|
||||||
|
new_ink = Ink(**new_ink_data)
|
||||||
|
self.ink_tracker.inks.append(new_ink)
|
||||||
|
self.ink_tracker.save_data()
|
||||||
|
print("\n[✔] Ink added successfully to inks.csv!")
|
||||||
|
|
||||||
|
def view_all_inks(self):
|
||||||
|
if not self.ink_tracker.inks:
|
||||||
|
print("\n[!] Your ink collection is currently empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print(f"{'ID':<4} | {'VENDOR':<12} | {'NAME':<20} | {'COLOR':<15} | {'SIZE':<10}")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
for idx, ink in enumerate(self.ink_tracker.inks):
|
||||||
|
vendor = ink.Vendor[:12]
|
||||||
|
name = ink.Name[:20]
|
||||||
|
color = ink.Color[:15]
|
||||||
|
size = ink.Size[:10]
|
||||||
|
print(f"{idx:<4} | {vendor:<12} | {name:<20} | {color:<15} | {size:<10}")
|
||||||
|
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
def select_ink(self):
|
||||||
|
"""Helper method to select an ink from the list."""
|
||||||
|
if not self.ink_tracker.inks:
|
||||||
|
print("[!] No inks available. Add some inks first.")
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
self.view_all_inks()
|
||||||
|
try:
|
||||||
|
idx = int(input("\nEnter the ID of the ink to select (or -1 for N/A): "))
|
||||||
|
if idx == -1:
|
||||||
|
return "N/A"
|
||||||
|
ink = self.ink_tracker.inks[idx]
|
||||||
|
return ink.Name
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
print("[!] Invalid ID.")
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
def clear_screen():
|
def clear_screen():
|
||||||
os.system('cls' if os.name == 'nt' else 'clear')
|
os.system('cls' if os.name == 'nt' else 'clear')
|
||||||
|
|
||||||
|
|
@ -180,6 +259,17 @@ def main():
|
||||||
export_parser = subparsers.add_parser('export', help='Export to JSON')
|
export_parser = subparsers.add_parser('export', help='Export to JSON')
|
||||||
export_parser.add_argument('--output', default='pens.json', help='Output file')
|
export_parser.add_argument('--output', default='pens.json', help='Output file')
|
||||||
|
|
||||||
|
# Ink commands
|
||||||
|
add_ink_parser = subparsers.add_parser('add-ink', help='Add a new ink')
|
||||||
|
add_ink_parser.add_argument('--vendor', required=True)
|
||||||
|
add_ink_parser.add_argument('--name', required=True)
|
||||||
|
add_ink_parser.add_argument('--color')
|
||||||
|
add_ink_parser.add_argument('--purchased')
|
||||||
|
add_ink_parser.add_argument('--size')
|
||||||
|
add_ink_parser.add_argument('--notes')
|
||||||
|
|
||||||
|
list_inks_parser = subparsers.add_parser('list-inks', help='List all inks')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
tracker = CLITracker(args.csv)
|
tracker = CLITracker(args.csv)
|
||||||
|
|
@ -217,6 +307,24 @@ def main():
|
||||||
with open(args.output, 'w') as f:
|
with open(args.output, 'w') as f:
|
||||||
json.dump([asdict(p) for p in tracker.pens], f, indent=2)
|
json.dump([asdict(p) for p in tracker.pens], f, indent=2)
|
||||||
print(f"Exported to {args.output}")
|
print(f"Exported to {args.output}")
|
||||||
|
elif args.command == 'add-ink':
|
||||||
|
if args.purchased and not validate_date(args.purchased):
|
||||||
|
print("Invalid purchased date format.")
|
||||||
|
return
|
||||||
|
ink_data = {
|
||||||
|
'Vendor': args.vendor,
|
||||||
|
'Name': args.name,
|
||||||
|
'Color': args.color or 'N/A',
|
||||||
|
'Purchased': args.purchased or 'N/A',
|
||||||
|
'Size': args.size or 'N/A',
|
||||||
|
'Notes': args.notes or 'N/A'
|
||||||
|
}
|
||||||
|
new_ink = Ink(**ink_data)
|
||||||
|
tracker.ink_tracker.inks.append(new_ink)
|
||||||
|
tracker.ink_tracker.save_data()
|
||||||
|
print("Ink added successfully.")
|
||||||
|
elif args.command == 'list-inks':
|
||||||
|
tracker.view_all_inks()
|
||||||
else:
|
else:
|
||||||
# Interactive mode
|
# Interactive mode
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -225,7 +333,9 @@ def main():
|
||||||
print("2. Add New Pen")
|
print("2. Add New Pen")
|
||||||
print("3. Edit a Pen")
|
print("3. Edit a Pen")
|
||||||
print("4. Delete a Pen")
|
print("4. Delete a Pen")
|
||||||
print("5. Exit")
|
print("5. View Ink Collection")
|
||||||
|
print("6. Add New Ink")
|
||||||
|
print("7. Exit")
|
||||||
|
|
||||||
choice = input("\nSelect an option: ")
|
choice = input("\nSelect an option: ")
|
||||||
|
|
||||||
|
|
@ -242,6 +352,12 @@ def main():
|
||||||
clear_screen()
|
clear_screen()
|
||||||
tracker.delete_pen()
|
tracker.delete_pen()
|
||||||
elif choice == '5':
|
elif choice == '5':
|
||||||
|
clear_screen()
|
||||||
|
tracker.view_all_inks()
|
||||||
|
elif choice == '6':
|
||||||
|
clear_screen()
|
||||||
|
tracker.add_ink()
|
||||||
|
elif choice == '7':
|
||||||
print("Goodbye! Happy writing! ✒️")
|
print("Goodbye! Happy writing! ✒️")
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ from typing import List
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Ink:
|
||||||
|
Vendor: str = "N/A"
|
||||||
|
Name: str = "N/A"
|
||||||
|
Color: str = "N/A"
|
||||||
|
Purchased: str = "N/A"
|
||||||
|
Size: str = "N/A"
|
||||||
|
Notes: str = "N/A"
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Pen:
|
class Pen:
|
||||||
Make: str = "N/A"
|
Make: str = "N/A"
|
||||||
|
|
@ -21,6 +30,65 @@ class Pen:
|
||||||
Inked_date: str = "N/A"
|
Inked_date: str = "N/A"
|
||||||
Notes: str = "N/A"
|
Notes: str = "N/A"
|
||||||
|
|
||||||
|
class InkTracker:
|
||||||
|
def __init__(self, storage_file: str = None):
|
||||||
|
if storage_file is None:
|
||||||
|
storage_file = os.path.abspath('inks.csv')
|
||||||
|
self.storage_file = os.path.abspath(storage_file)
|
||||||
|
self.headers = ['Vendor', 'Name', 'Color', 'Purchased', 'Size', 'Notes']
|
||||||
|
self.inks: List[Ink] = self.load_data()
|
||||||
|
|
||||||
|
def _sort_inks(self):
|
||||||
|
"""Sorts the inks list by Vendor, then by Name alphabetically."""
|
||||||
|
self.inks.sort(key=lambda x: (x.Vendor.lower(), x.Name.lower()))
|
||||||
|
|
||||||
|
def load_data(self) -> List[Ink]:
|
||||||
|
"""Loads data from the CSV file."""
|
||||||
|
if not os.path.exists(self.storage_file):
|
||||||
|
self._create_empty_csv()
|
||||||
|
return []
|
||||||
|
|
||||||
|
inks = []
|
||||||
|
try:
|
||||||
|
with open(self.storage_file, mode='r', encoding='utf-8-sig') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
if reader.fieldnames:
|
||||||
|
reader.fieldnames = [h.strip() for h in reader.fieldnames]
|
||||||
|
for row in reader:
|
||||||
|
if None in row:
|
||||||
|
extras = row.pop(None)
|
||||||
|
if extras:
|
||||||
|
extra_text = ",".join(extras).strip()
|
||||||
|
if extra_text:
|
||||||
|
row['Notes'] = (row.get('Notes') or '')
|
||||||
|
if row['Notes']:
|
||||||
|
row['Notes'] += ", "
|
||||||
|
row['Notes'] += extra_text
|
||||||
|
clean_row = {
|
||||||
|
k.strip(): (v.strip() if v else "N/A")
|
||||||
|
for k, v in row.items() if k is not None
|
||||||
|
}
|
||||||
|
inks.append(Ink(**clean_row))
|
||||||
|
inks.sort(key=lambda x: (x.Vendor.lower(), x.Name.lower()))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading inks CSV: {e}")
|
||||||
|
return inks
|
||||||
|
|
||||||
|
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_inks()
|
||||||
|
with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=self.headers)
|
||||||
|
writer.writeheader()
|
||||||
|
for ink in self.inks:
|
||||||
|
writer.writerow(asdict(ink))
|
||||||
|
|
||||||
class PenTracker:
|
class PenTracker:
|
||||||
def __init__(self, storage_file: str = None):
|
def __init__(self, storage_file: str = None):
|
||||||
if storage_file is None:
|
if storage_file is None:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from textual.screen import Screen
|
||||||
from textual.widgets import Label, Input, Button, Header, Footer, DataTable
|
from textual.widgets import Label, Input, Button, Header, Footer, DataTable
|
||||||
from textual.containers import Vertical, Horizontal
|
from textual.containers import Vertical, Horizontal
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from .engine import PenTracker, Pen
|
from .engine import PenTracker, Pen, InkTracker, Ink
|
||||||
# --- TUI SCREENS ---
|
# --- TUI SCREENS ---
|
||||||
|
|
||||||
class PenFormScreen(Screen):
|
class PenFormScreen(Screen):
|
||||||
|
|
@ -54,6 +54,50 @@ class PenFormScreen(Screen):
|
||||||
self.tracker.save_data()
|
self.tracker.save_data()
|
||||||
self.dismiss(new_pen)
|
self.dismiss(new_pen)
|
||||||
|
|
||||||
|
class InkFormScreen(Screen):
|
||||||
|
"""A screen for adding or editing an ink."""
|
||||||
|
|
||||||
|
def __init__(self, ink_tracker, existing_ink=None):
|
||||||
|
super().__init__()
|
||||||
|
self.ink_tracker = ink_tracker
|
||||||
|
self.existing_ink = existing_ink
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="form-container"):
|
||||||
|
yield Label("🖋️ NEW INK" if not self.existing_ink else "✏️ EDIT INK")
|
||||||
|
|
||||||
|
for header in self.ink_tracker.headers:
|
||||||
|
val = getattr(self.existing_ink, header) if self.existing_ink 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.ink_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"
|
||||||
|
|
||||||
|
new_ink = Ink(**new_data)
|
||||||
|
|
||||||
|
if self.existing_ink is not None and self.existing_ink in self.ink_tracker.inks:
|
||||||
|
idx = self.ink_tracker.inks.index(self.existing_ink)
|
||||||
|
self.ink_tracker.inks[idx] = new_ink
|
||||||
|
else:
|
||||||
|
self.ink_tracker.inks.append(new_ink)
|
||||||
|
|
||||||
|
self.ink_tracker.save_data()
|
||||||
|
self.dismiss(new_ink)
|
||||||
|
|
||||||
class PenTrackerApp(App):
|
class PenTrackerApp(App):
|
||||||
"""The Main TUI Application."""
|
"""The Main TUI Application."""
|
||||||
|
|
||||||
|
|
@ -89,7 +133,8 @@ class PenTrackerApp(App):
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("d", "delete_selected", "Delete Selected"),
|
Binding("d", "delete_selected", "Delete Selected"),
|
||||||
Binding("a", "add_new", "Add New Pen"),
|
Binding("a", "add_new", "Add New"),
|
||||||
|
Binding("i", "toggle_mode", "Toggle Pens/Inks"),
|
||||||
Binding("q", "quit", "Quit"),
|
Binding("q", "quit", "Quit"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -100,29 +145,46 @@ class PenTrackerApp(App):
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.tracker = PenTracker()
|
self.tracker = PenTracker()
|
||||||
|
self.ink_tracker = InkTracker()
|
||||||
|
self.mode = "pens" # "pens" or "inks"
|
||||||
self._refresh_table()
|
self._refresh_table()
|
||||||
|
|
||||||
def _refresh_table(self):
|
def _refresh_table(self):
|
||||||
table = self.query_one("#pen_table", DataTable)
|
table = self.query_one("#pen_table", DataTable)
|
||||||
table.clear(columns=True)
|
table.clear(columns=True)
|
||||||
|
|
||||||
display_cols = ['Make', 'Model', 'Nib', 'Nib-Material', 'Body','Cap', 'Current-Ink', 'Inked-date', 'Notes']
|
if self.mode == "pens":
|
||||||
table.add_columns(*display_cols)
|
display_cols = ['Make', 'Model', 'Nib', 'Nib-Material', 'Body','Cap', 'Current-Ink', 'Inked-date', 'Notes']
|
||||||
|
table.add_columns(*display_cols)
|
||||||
|
items = self.tracker.pens
|
||||||
|
else: # inks
|
||||||
|
display_cols = ['Vendor', 'Name', 'Color', 'Purchased', 'Size', 'Notes']
|
||||||
|
table.add_columns(*display_cols)
|
||||||
|
items = self.ink_tracker.inks
|
||||||
|
|
||||||
for idx, pen in enumerate(self.tracker.pens):
|
for idx, item in enumerate(items):
|
||||||
row_values = [getattr(pen, c.replace('-','_')) for c in display_cols]
|
if self.mode == "pens":
|
||||||
# We use the index as the row key to track items accurately
|
row_values = [getattr(item, c.replace('-','_')) for c in display_cols]
|
||||||
|
else:
|
||||||
|
row_values = [getattr(item, c) for c in display_cols]
|
||||||
table.add_row(*row_values, key=str(idx))
|
table.add_row(*row_values, key=str(idx))
|
||||||
|
|
||||||
def action_add_new(self) -> None:
|
def action_add_new(self) -> None:
|
||||||
form = PenFormScreen(self.tracker)
|
if self.mode == "pens":
|
||||||
|
form = PenFormScreen(self.tracker)
|
||||||
|
else:
|
||||||
|
form = InkFormScreen(self.ink_tracker)
|
||||||
self.push_screen(form, self.handle_form_result)
|
self.push_screen(form, self.handle_form_result)
|
||||||
|
|
||||||
def action_edit_selected(self, index_str: str):
|
def action_edit_selected(self, index_str: str):
|
||||||
try:
|
try:
|
||||||
idx = int(index_str)
|
idx = int(index_str)
|
||||||
existing_pen = self.tracker.pens[idx]
|
if self.mode == "pens":
|
||||||
form = PenFormScreen(self.tracker, existing_pen=existing_pen)
|
existing_item = self.tracker.pens[idx]
|
||||||
|
form = PenFormScreen(self.tracker, existing_item)
|
||||||
|
else:
|
||||||
|
existing_item = self.ink_tracker.inks[idx]
|
||||||
|
form = InkFormScreen(self.ink_tracker, existing_item)
|
||||||
self.push_screen(form, self.handle_form_result)
|
self.push_screen(form, self.handle_form_result)
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
pass
|
pass
|
||||||
|
|
@ -135,16 +197,26 @@ class PenTrackerApp(App):
|
||||||
def action_delete_selected(self) -> None:
|
def action_delete_selected(self) -> None:
|
||||||
table = self.query_one("#pen_table", DataTable)
|
table = self.query_one("#pen_table", DataTable)
|
||||||
try:
|
try:
|
||||||
# Accessing via the table's current cursor
|
|
||||||
table = self.query_one("#pen_table", DataTable)
|
table = self.query_one("#pen_table", DataTable)
|
||||||
row_node = table.get_row_at_cursor()
|
row_node = table.get_row_at_cursor()
|
||||||
idx = int(row_node.key.value)
|
idx = int(row_node.key.value)
|
||||||
removed = self.tracker.pens.pop(idx)
|
if self.mode == "pens":
|
||||||
self.tracker.save_data()
|
removed = self.tracker.pens.pop(idx)
|
||||||
|
self.tracker.save_data()
|
||||||
|
self.notify(f"Deleted {removed.Make}", title="Removed")
|
||||||
|
else:
|
||||||
|
removed = self.ink_tracker.inks.pop(idx)
|
||||||
|
self.ink_tracker.save_data()
|
||||||
|
self.notify(f"Deleted {removed.Name}", title="Removed")
|
||||||
self._refresh_table()
|
self._refresh_table()
|
||||||
self.notify(f"Deleted {removed.Make}", title="Removed")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.notify("No pen selected to delete", title="Error", severity="error")
|
self.notify("No item selected to delete", title="Error", severity="error")
|
||||||
|
|
||||||
|
def action_toggle_mode(self) -> None:
|
||||||
|
self.mode = "inks" if self.mode == "pens" else "pens"
|
||||||
|
self._refresh_table()
|
||||||
|
mode_name = "Pens" if self.mode == "pens" else "Inks"
|
||||||
|
self.notify(f"Switched to {mode_name} mode", title="Mode Changed")
|
||||||
|
|
||||||
def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None:
|
def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pen-tracker"
|
name = "pen-tracker"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
authors = [
|
authors = [
|
||||||
{ name="Don Harper", email="don@donharper.org" },
|
{ name="Don Harper", email="don@donharper.org" },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
Metadata-Version: 2.4
|
Metadata-Version: 2.4
|
||||||
Name: pen-tracker
|
Name: pen-tracker
|
||||||
Version: 0.3.0
|
Version: 0.4.0
|
||||||
Summary: A fountain pen collection tracker.
|
Summary: A fountain pen collection tracker.
|
||||||
Author-email: Don Harper <don@donharper.org>
|
Author-email: Don Harper <don@donharper.org>
|
||||||
Requires-Python: >=3.8
|
Requires-Python: >=3.8
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -3,7 +3,7 @@ import csv
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .engine import PenTracker, Pen
|
from .engine import PenTracker, Pen, InkTracker, Ink
|
||||||
|
|
||||||
def validate_date(date_str: str) -> bool:
|
def validate_date(date_str: str) -> bool:
|
||||||
"""Validate date in YYYY-MM-DD format."""
|
"""Validate date in YYYY-MM-DD format."""
|
||||||
|
|
@ -17,6 +17,10 @@ def validate_date(date_str: str) -> bool:
|
||||||
|
|
||||||
class CLITracker(PenTracker):
|
class CLITracker(PenTracker):
|
||||||
|
|
||||||
|
def __init__(self, storage_file=None):
|
||||||
|
super().__init__(storage_file)
|
||||||
|
self.ink_tracker = InkTracker()
|
||||||
|
|
||||||
def add_pen(self):
|
def add_pen(self):
|
||||||
print("\n--- Add New Fountain Pen ---")
|
print("\n--- Add New Fountain Pen ---")
|
||||||
new_pen_data = {}
|
new_pen_data = {}
|
||||||
|
|
@ -29,6 +33,9 @@ class CLITracker(PenTracker):
|
||||||
|
|
||||||
for label, key in fields:
|
for label, key in fields:
|
||||||
while True:
|
while True:
|
||||||
|
if key == 'Current-Ink':
|
||||||
|
value = self.select_ink()
|
||||||
|
break
|
||||||
value = input(f"Enter {label}: ").strip()
|
value = input(f"Enter {label}: ").strip()
|
||||||
if not value:
|
if not value:
|
||||||
value = "N/A"
|
value = "N/A"
|
||||||
|
|
@ -74,6 +81,15 @@ class CLITracker(PenTracker):
|
||||||
field = self.key_map.get(header, header)
|
field = self.key_map.get(header, header)
|
||||||
current_val = getattr(pen, field)
|
current_val = getattr(pen, field)
|
||||||
while True:
|
while True:
|
||||||
|
if header == 'Current-Ink':
|
||||||
|
print(f"Current Ink: {current_val}")
|
||||||
|
change_ink = input("Change ink? (y/n): ").strip().lower()
|
||||||
|
if change_ink == 'y':
|
||||||
|
new_val = self.select_ink()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
new_val = ""
|
||||||
|
break
|
||||||
new_val = input(f"{header} [{current_val}]: ").strip()
|
new_val = input(f"{header} [{current_val}]: ").strip()
|
||||||
if not new_val:
|
if not new_val:
|
||||||
break
|
break
|
||||||
|
|
@ -149,6 +165,69 @@ class CLITracker(PenTracker):
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
print("[!] Invalid ID.")
|
print("[!] Invalid ID.")
|
||||||
|
|
||||||
|
def add_ink(self):
|
||||||
|
print("\n--- Add New Ink ---")
|
||||||
|
new_ink_data = {}
|
||||||
|
fields = [
|
||||||
|
("Vendor", "Vendor"), ("Name", "Name"), ("Color", "Color"),
|
||||||
|
("Purchased (YYYY-MM-DD)", "Purchased"), ("Size", "Size"), ("Notes", "Notes")
|
||||||
|
]
|
||||||
|
|
||||||
|
for label, key in fields:
|
||||||
|
while True:
|
||||||
|
value = input(f"Enter {label}: ").strip()
|
||||||
|
if not value:
|
||||||
|
value = "N/A"
|
||||||
|
break
|
||||||
|
if key == 'Purchased':
|
||||||
|
if validate_date(value):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print("Invalid date format. Use YYYY-MM-DD.")
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
new_ink_data[key] = value
|
||||||
|
|
||||||
|
new_ink = Ink(**new_ink_data)
|
||||||
|
self.ink_tracker.inks.append(new_ink)
|
||||||
|
self.ink_tracker.save_data()
|
||||||
|
print("\n[✔] Ink added successfully to inks.csv!")
|
||||||
|
|
||||||
|
def view_all_inks(self):
|
||||||
|
if not self.ink_tracker.inks:
|
||||||
|
print("\n[!] Your ink collection is currently empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print(f"{'ID':<4} | {'VENDOR':<12} | {'NAME':<20} | {'COLOR':<15} | {'SIZE':<10}")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
for idx, ink in enumerate(self.ink_tracker.inks):
|
||||||
|
vendor = ink.Vendor[:12]
|
||||||
|
name = ink.Name[:20]
|
||||||
|
color = ink.Color[:15]
|
||||||
|
size = ink.Size[:10]
|
||||||
|
print(f"{idx:<4} | {vendor:<12} | {name:<20} | {color:<15} | {size:<10}")
|
||||||
|
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
def select_ink(self):
|
||||||
|
"""Helper method to select an ink from the list."""
|
||||||
|
if not self.ink_tracker.inks:
|
||||||
|
print("[!] No inks available. Add some inks first.")
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
self.view_all_inks()
|
||||||
|
try:
|
||||||
|
idx = int(input("\nEnter the ID of the ink to select (or -1 for N/A): "))
|
||||||
|
if idx == -1:
|
||||||
|
return "N/A"
|
||||||
|
ink = self.ink_tracker.inks[idx]
|
||||||
|
return ink.Name
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
print("[!] Invalid ID.")
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
def clear_screen():
|
def clear_screen():
|
||||||
os.system('cls' if os.name == 'nt' else 'clear')
|
os.system('cls' if os.name == 'nt' else 'clear')
|
||||||
|
|
||||||
|
|
@ -180,6 +259,17 @@ def main():
|
||||||
export_parser = subparsers.add_parser('export', help='Export to JSON')
|
export_parser = subparsers.add_parser('export', help='Export to JSON')
|
||||||
export_parser.add_argument('--output', default='pens.json', help='Output file')
|
export_parser.add_argument('--output', default='pens.json', help='Output file')
|
||||||
|
|
||||||
|
# Ink commands
|
||||||
|
add_ink_parser = subparsers.add_parser('add-ink', help='Add a new ink')
|
||||||
|
add_ink_parser.add_argument('--vendor', required=True)
|
||||||
|
add_ink_parser.add_argument('--name', required=True)
|
||||||
|
add_ink_parser.add_argument('--color')
|
||||||
|
add_ink_parser.add_argument('--purchased')
|
||||||
|
add_ink_parser.add_argument('--size')
|
||||||
|
add_ink_parser.add_argument('--notes')
|
||||||
|
|
||||||
|
list_inks_parser = subparsers.add_parser('list-inks', help='List all inks')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
tracker = CLITracker(args.csv)
|
tracker = CLITracker(args.csv)
|
||||||
|
|
@ -217,6 +307,24 @@ def main():
|
||||||
with open(args.output, 'w') as f:
|
with open(args.output, 'w') as f:
|
||||||
json.dump([asdict(p) for p in tracker.pens], f, indent=2)
|
json.dump([asdict(p) for p in tracker.pens], f, indent=2)
|
||||||
print(f"Exported to {args.output}")
|
print(f"Exported to {args.output}")
|
||||||
|
elif args.command == 'add-ink':
|
||||||
|
if args.purchased and not validate_date(args.purchased):
|
||||||
|
print("Invalid purchased date format.")
|
||||||
|
return
|
||||||
|
ink_data = {
|
||||||
|
'Vendor': args.vendor,
|
||||||
|
'Name': args.name,
|
||||||
|
'Color': args.color or 'N/A',
|
||||||
|
'Purchased': args.purchased or 'N/A',
|
||||||
|
'Size': args.size or 'N/A',
|
||||||
|
'Notes': args.notes or 'N/A'
|
||||||
|
}
|
||||||
|
new_ink = Ink(**ink_data)
|
||||||
|
tracker.ink_tracker.inks.append(new_ink)
|
||||||
|
tracker.ink_tracker.save_data()
|
||||||
|
print("Ink added successfully.")
|
||||||
|
elif args.command == 'list-inks':
|
||||||
|
tracker.view_all_inks()
|
||||||
else:
|
else:
|
||||||
# Interactive mode
|
# Interactive mode
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -225,7 +333,9 @@ def main():
|
||||||
print("2. Add New Pen")
|
print("2. Add New Pen")
|
||||||
print("3. Edit a Pen")
|
print("3. Edit a Pen")
|
||||||
print("4. Delete a Pen")
|
print("4. Delete a Pen")
|
||||||
print("5. Exit")
|
print("5. View Ink Collection")
|
||||||
|
print("6. Add New Ink")
|
||||||
|
print("7. Exit")
|
||||||
|
|
||||||
choice = input("\nSelect an option: ")
|
choice = input("\nSelect an option: ")
|
||||||
|
|
||||||
|
|
@ -242,6 +352,12 @@ def main():
|
||||||
clear_screen()
|
clear_screen()
|
||||||
tracker.delete_pen()
|
tracker.delete_pen()
|
||||||
elif choice == '5':
|
elif choice == '5':
|
||||||
|
clear_screen()
|
||||||
|
tracker.view_all_inks()
|
||||||
|
elif choice == '6':
|
||||||
|
clear_screen()
|
||||||
|
tracker.add_ink()
|
||||||
|
elif choice == '7':
|
||||||
print("Goodbye! Happy writing! ✒️")
|
print("Goodbye! Happy writing! ✒️")
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ from typing import List
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Ink:
|
||||||
|
Vendor: str = "N/A"
|
||||||
|
Name: str = "N/A"
|
||||||
|
Color: str = "N/A"
|
||||||
|
Purchased: str = "N/A"
|
||||||
|
Size: str = "N/A"
|
||||||
|
Notes: str = "N/A"
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Pen:
|
class Pen:
|
||||||
Make: str = "N/A"
|
Make: str = "N/A"
|
||||||
|
|
@ -21,6 +30,65 @@ class Pen:
|
||||||
Inked_date: str = "N/A"
|
Inked_date: str = "N/A"
|
||||||
Notes: str = "N/A"
|
Notes: str = "N/A"
|
||||||
|
|
||||||
|
class InkTracker:
|
||||||
|
def __init__(self, storage_file: str = None):
|
||||||
|
if storage_file is None:
|
||||||
|
storage_file = os.path.abspath('inks.csv')
|
||||||
|
self.storage_file = os.path.abspath(storage_file)
|
||||||
|
self.headers = ['Vendor', 'Name', 'Color', 'Purchased', 'Size', 'Notes']
|
||||||
|
self.inks: List[Ink] = self.load_data()
|
||||||
|
|
||||||
|
def _sort_inks(self):
|
||||||
|
"""Sorts the inks list by Vendor, then by Name alphabetically."""
|
||||||
|
self.inks.sort(key=lambda x: (x.Vendor.lower(), x.Name.lower()))
|
||||||
|
|
||||||
|
def load_data(self) -> List[Ink]:
|
||||||
|
"""Loads data from the CSV file."""
|
||||||
|
if not os.path.exists(self.storage_file):
|
||||||
|
self._create_empty_csv()
|
||||||
|
return []
|
||||||
|
|
||||||
|
inks = []
|
||||||
|
try:
|
||||||
|
with open(self.storage_file, mode='r', encoding='utf-8-sig') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
if reader.fieldnames:
|
||||||
|
reader.fieldnames = [h.strip() for h in reader.fieldnames]
|
||||||
|
for row in reader:
|
||||||
|
if None in row:
|
||||||
|
extras = row.pop(None)
|
||||||
|
if extras:
|
||||||
|
extra_text = ",".join(extras).strip()
|
||||||
|
if extra_text:
|
||||||
|
row['Notes'] = (row.get('Notes') or '')
|
||||||
|
if row['Notes']:
|
||||||
|
row['Notes'] += ", "
|
||||||
|
row['Notes'] += extra_text
|
||||||
|
clean_row = {
|
||||||
|
k.strip(): (v.strip() if v else "N/A")
|
||||||
|
for k, v in row.items() if k is not None
|
||||||
|
}
|
||||||
|
inks.append(Ink(**clean_row))
|
||||||
|
inks.sort(key=lambda x: (x.Vendor.lower(), x.Name.lower()))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading inks CSV: {e}")
|
||||||
|
return inks
|
||||||
|
|
||||||
|
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_inks()
|
||||||
|
with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=self.headers)
|
||||||
|
writer.writeheader()
|
||||||
|
for ink in self.inks:
|
||||||
|
writer.writerow(asdict(ink))
|
||||||
|
|
||||||
class PenTracker:
|
class PenTracker:
|
||||||
def __init__(self, storage_file: str = None):
|
def __init__(self, storage_file: str = None):
|
||||||
if storage_file is None:
|
if storage_file is None:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from textual.screen import Screen
|
||||||
from textual.widgets import Label, Input, Button, Header, Footer, DataTable
|
from textual.widgets import Label, Input, Button, Header, Footer, DataTable
|
||||||
from textual.containers import Vertical, Horizontal
|
from textual.containers import Vertical, Horizontal
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from .engine import PenTracker, Pen
|
from .engine import PenTracker, Pen, InkTracker, Ink
|
||||||
# --- TUI SCREENS ---
|
# --- TUI SCREENS ---
|
||||||
|
|
||||||
class PenFormScreen(Screen):
|
class PenFormScreen(Screen):
|
||||||
|
|
@ -54,6 +54,50 @@ class PenFormScreen(Screen):
|
||||||
self.tracker.save_data()
|
self.tracker.save_data()
|
||||||
self.dismiss(new_pen)
|
self.dismiss(new_pen)
|
||||||
|
|
||||||
|
class InkFormScreen(Screen):
|
||||||
|
"""A screen for adding or editing an ink."""
|
||||||
|
|
||||||
|
def __init__(self, ink_tracker, existing_ink=None):
|
||||||
|
super().__init__()
|
||||||
|
self.ink_tracker = ink_tracker
|
||||||
|
self.existing_ink = existing_ink
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="form-container"):
|
||||||
|
yield Label("🖋️ NEW INK" if not self.existing_ink else "✏️ EDIT INK")
|
||||||
|
|
||||||
|
for header in self.ink_tracker.headers:
|
||||||
|
val = getattr(self.existing_ink, header) if self.existing_ink 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.ink_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"
|
||||||
|
|
||||||
|
new_ink = Ink(**new_data)
|
||||||
|
|
||||||
|
if self.existing_ink is not None and self.existing_ink in self.ink_tracker.inks:
|
||||||
|
idx = self.ink_tracker.inks.index(self.existing_ink)
|
||||||
|
self.ink_tracker.inks[idx] = new_ink
|
||||||
|
else:
|
||||||
|
self.ink_tracker.inks.append(new_ink)
|
||||||
|
|
||||||
|
self.ink_tracker.save_data()
|
||||||
|
self.dismiss(new_ink)
|
||||||
|
|
||||||
class PenTrackerApp(App):
|
class PenTrackerApp(App):
|
||||||
"""The Main TUI Application."""
|
"""The Main TUI Application."""
|
||||||
|
|
||||||
|
|
@ -89,7 +133,8 @@ class PenTrackerApp(App):
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("d", "delete_selected", "Delete Selected"),
|
Binding("d", "delete_selected", "Delete Selected"),
|
||||||
Binding("a", "add_new", "Add New Pen"),
|
Binding("a", "add_new", "Add New"),
|
||||||
|
Binding("i", "toggle_mode", "Toggle Pens/Inks"),
|
||||||
Binding("q", "quit", "Quit"),
|
Binding("q", "quit", "Quit"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -100,29 +145,46 @@ class PenTrackerApp(App):
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.tracker = PenTracker()
|
self.tracker = PenTracker()
|
||||||
|
self.ink_tracker = InkTracker()
|
||||||
|
self.mode = "pens" # "pens" or "inks"
|
||||||
self._refresh_table()
|
self._refresh_table()
|
||||||
|
|
||||||
def _refresh_table(self):
|
def _refresh_table(self):
|
||||||
table = self.query_one("#pen_table", DataTable)
|
table = self.query_one("#pen_table", DataTable)
|
||||||
table.clear(columns=True)
|
table.clear(columns=True)
|
||||||
|
|
||||||
display_cols = ['Make', 'Model', 'Nib', 'Nib-Material', 'Body','Cap', 'Current-Ink', 'Inked-date', 'Notes']
|
if self.mode == "pens":
|
||||||
table.add_columns(*display_cols)
|
display_cols = ['Make', 'Model', 'Nib', 'Nib-Material', 'Body','Cap', 'Current-Ink', 'Inked-date', 'Notes']
|
||||||
|
table.add_columns(*display_cols)
|
||||||
|
items = self.tracker.pens
|
||||||
|
else: # inks
|
||||||
|
display_cols = ['Vendor', 'Name', 'Color', 'Purchased', 'Size', 'Notes']
|
||||||
|
table.add_columns(*display_cols)
|
||||||
|
items = self.ink_tracker.inks
|
||||||
|
|
||||||
for idx, pen in enumerate(self.tracker.pens):
|
for idx, item in enumerate(items):
|
||||||
row_values = [getattr(pen, c.replace('-','_')) for c in display_cols]
|
if self.mode == "pens":
|
||||||
# We use the index as the row key to track items accurately
|
row_values = [getattr(item, c.replace('-','_')) for c in display_cols]
|
||||||
|
else:
|
||||||
|
row_values = [getattr(item, c) for c in display_cols]
|
||||||
table.add_row(*row_values, key=str(idx))
|
table.add_row(*row_values, key=str(idx))
|
||||||
|
|
||||||
def action_add_new(self) -> None:
|
def action_add_new(self) -> None:
|
||||||
form = PenFormScreen(self.tracker)
|
if self.mode == "pens":
|
||||||
|
form = PenFormScreen(self.tracker)
|
||||||
|
else:
|
||||||
|
form = InkFormScreen(self.ink_tracker)
|
||||||
self.push_screen(form, self.handle_form_result)
|
self.push_screen(form, self.handle_form_result)
|
||||||
|
|
||||||
def action_edit_selected(self, index_str: str):
|
def action_edit_selected(self, index_str: str):
|
||||||
try:
|
try:
|
||||||
idx = int(index_str)
|
idx = int(index_str)
|
||||||
existing_pen = self.tracker.pens[idx]
|
if self.mode == "pens":
|
||||||
form = PenFormScreen(self.tracker, existing_pen=existing_pen)
|
existing_item = self.tracker.pens[idx]
|
||||||
|
form = PenFormScreen(self.tracker, existing_item)
|
||||||
|
else:
|
||||||
|
existing_item = self.ink_tracker.inks[idx]
|
||||||
|
form = InkFormScreen(self.ink_tracker, existing_item)
|
||||||
self.push_screen(form, self.handle_form_result)
|
self.push_screen(form, self.handle_form_result)
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
pass
|
pass
|
||||||
|
|
@ -135,16 +197,26 @@ class PenTrackerApp(App):
|
||||||
def action_delete_selected(self) -> None:
|
def action_delete_selected(self) -> None:
|
||||||
table = self.query_one("#pen_table", DataTable)
|
table = self.query_one("#pen_table", DataTable)
|
||||||
try:
|
try:
|
||||||
# Accessing via the table's current cursor
|
|
||||||
table = self.query_one("#pen_table", DataTable)
|
table = self.query_one("#pen_table", DataTable)
|
||||||
row_node = table.get_row_at_cursor()
|
row_node = table.get_row_at_cursor()
|
||||||
idx = int(row_node.key.value)
|
idx = int(row_node.key.value)
|
||||||
removed = self.tracker.pens.pop(idx)
|
if self.mode == "pens":
|
||||||
self.tracker.save_data()
|
removed = self.tracker.pens.pop(idx)
|
||||||
|
self.tracker.save_data()
|
||||||
|
self.notify(f"Deleted {removed.Make}", title="Removed")
|
||||||
|
else:
|
||||||
|
removed = self.ink_tracker.inks.pop(idx)
|
||||||
|
self.ink_tracker.save_data()
|
||||||
|
self.notify(f"Deleted {removed.Name}", title="Removed")
|
||||||
self._refresh_table()
|
self._refresh_table()
|
||||||
self.notify(f"Deleted {removed.Make}", title="Removed")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.notify("No pen selected to delete", title="Error", severity="error")
|
self.notify("No item selected to delete", title="Error", severity="error")
|
||||||
|
|
||||||
|
def action_toggle_mode(self) -> None:
|
||||||
|
self.mode = "inks" if self.mode == "pens" else "pens"
|
||||||
|
self._refresh_table()
|
||||||
|
mode_name = "Pens" if self.mode == "pens" else "Inks"
|
||||||
|
self.notify(f"Switched to {mode_name} mode", title="Mode Changed")
|
||||||
|
|
||||||
def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None:
|
def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,7 +1,7 @@
|
||||||
import unittest
|
import unittest
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
from pen_tracker.engine import PenTracker, Pen
|
from pen_tracker.engine import PenTracker, Pen, InkTracker, Ink
|
||||||
|
|
||||||
class TestPenTracker(unittest.TestCase):
|
class TestPenTracker(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
@ -33,5 +33,51 @@ class TestPenTracker(unittest.TestCase):
|
||||||
self.assertEqual(new_tracker.pens[0].Make, "A")
|
self.assertEqual(new_tracker.pens[0].Make, "A")
|
||||||
self.assertEqual(new_tracker.pens[1].Make, "Z")
|
self.assertEqual(new_tracker.pens[1].Make, "Z")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TestInkTracker(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.csv')
|
||||||
|
self.temp_file.close()
|
||||||
|
self.tracker = InkTracker(self.temp_file.name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
os.unlink(self.temp_file.name)
|
||||||
|
|
||||||
|
def test_add_and_load_ink(self):
|
||||||
|
ink = Ink(Vendor="Diamine", Name="Pumpkin", Color="Orange")
|
||||||
|
self.tracker.inks.append(ink)
|
||||||
|
self.tracker.save_data()
|
||||||
|
|
||||||
|
new_tracker = InkTracker(self.temp_file.name)
|
||||||
|
self.assertEqual(len(new_tracker.inks), 1)
|
||||||
|
self.assertEqual(new_tracker.inks[0].Vendor, "Diamine")
|
||||||
|
self.assertEqual(new_tracker.inks[0].Name, "Pumpkin")
|
||||||
|
|
||||||
|
def test_sorting(self):
|
||||||
|
self.tracker.inks = [
|
||||||
|
Ink(Vendor="Z", Name="A"),
|
||||||
|
Ink(Vendor="A", Name="B")
|
||||||
|
]
|
||||||
|
self.tracker.save_data()
|
||||||
|
new_tracker = InkTracker(self.temp_file.name)
|
||||||
|
self.assertEqual(new_tracker.inks[0].Vendor, "A")
|
||||||
|
self.assertEqual(new_tracker.inks[1].Vendor, "Z")
|
||||||
|
|
||||||
|
def test_loads_row_with_extra_trailing_field(self):
|
||||||
|
csv_content = (
|
||||||
|
"Vendor,Name,Color,Purchased,Size,Notes\n"
|
||||||
|
"Waterman,Intense Black,Black,,\"Cartridge,International Short\",\n"
|
||||||
|
"Pilot,Black Cartridge,Black,2024-01-01,Cartridge,Good ink\n"
|
||||||
|
)
|
||||||
|
with open(self.temp_file.name, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(csv_content)
|
||||||
|
|
||||||
|
new_tracker = InkTracker(self.temp_file.name)
|
||||||
|
self.assertEqual(len(new_tracker.inks), 2)
|
||||||
|
self.assertEqual(new_tracker.inks[0].Vendor, "Pilot")
|
||||||
|
self.assertEqual(new_tracker.inks[1].Vendor, "Waterman")
|
||||||
|
self.assertEqual(new_tracker.inks[1].Notes, "N/A")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue