From bf3b1fc456a61129b8d70a5f2820e8b4b0de2de8 Mon Sep 17 00:00:00 2001 From: Don Harper Date: Sat, 2 May 2026 22:25:35 -0500 Subject: [PATCH] task | add ink support --- build/lib/pen_tracker/cli.py | 120 +++++++++++++++++- build/lib/pen_tracker/engine.py | 68 ++++++++++ build/lib/pen_tracker/tui.py | 102 ++++++++++++--- pyproject.toml | 2 +- src/pen_tracker.egg-info/PKG-INFO | 2 +- .../__pycache__/cli.cpython-313.pyc | Bin 13260 -> 18831 bytes .../__pycache__/engine.cpython-313.pyc | Bin 6508 -> 11317 bytes .../__pycache__/tui.cpython-313.pyc | Bin 9481 -> 13979 bytes src/pen_tracker/cli.py | 120 +++++++++++++++++- src/pen_tracker/engine.py | 68 ++++++++++ src/pen_tracker/tui.py | 102 ++++++++++++--- tests/__pycache__/test_engine.cpython-313.pyc | Bin 2944 -> 6535 bytes tests/test_engine.py | 48 ++++++- 13 files changed, 595 insertions(+), 37 deletions(-) diff --git a/build/lib/pen_tracker/cli.py b/build/lib/pen_tracker/cli.py index 6052306..a73c052 100644 --- a/build/lib/pen_tracker/cli.py +++ b/build/lib/pen_tracker/cli.py @@ -3,7 +3,7 @@ import csv import argparse import json from datetime import datetime -from .engine import PenTracker, Pen +from .engine import PenTracker, Pen, InkTracker, Ink def validate_date(date_str: str) -> bool: """Validate date in YYYY-MM-DD format.""" @@ -17,6 +17,10 @@ def validate_date(date_str: str) -> bool: class CLITracker(PenTracker): + def __init__(self, storage_file=None): + super().__init__(storage_file) + self.ink_tracker = InkTracker() + def add_pen(self): print("\n--- Add New Fountain Pen ---") new_pen_data = {} @@ -29,6 +33,9 @@ class CLITracker(PenTracker): for label, key in fields: while True: + if key == 'Current-Ink': + value = self.select_ink() + break value = input(f"Enter {label}: ").strip() if not value: value = "N/A" @@ -74,6 +81,15 @@ class CLITracker(PenTracker): field = self.key_map.get(header, header) current_val = getattr(pen, field) 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() if not new_val: break @@ -149,6 +165,69 @@ class CLITracker(PenTracker): except (ValueError, IndexError): 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(): 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.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() tracker = CLITracker(args.csv) @@ -217,6 +307,24 @@ def main(): 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}") + 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: # Interactive mode while True: @@ -225,7 +333,9 @@ def main(): print("2. Add New Pen") print("3. Edit 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: ") @@ -242,6 +352,12 @@ def main(): clear_screen() tracker.delete_pen() elif choice == '5': + clear_screen() + tracker.view_all_inks() + elif choice == '6': + clear_screen() + tracker.add_ink() + elif choice == '7': print("Goodbye! Happy writing! ✒️") break else: diff --git a/build/lib/pen_tracker/engine.py b/build/lib/pen_tracker/engine.py index 8444a3f..cfc6601 100644 --- a/build/lib/pen_tracker/engine.py +++ b/build/lib/pen_tracker/engine.py @@ -6,6 +6,15 @@ from typing import List 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 class Pen: Make: str = "N/A" @@ -21,6 +30,65 @@ class Pen: Inked_date: 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: def __init__(self, storage_file: str = None): if storage_file is None: diff --git a/build/lib/pen_tracker/tui.py b/build/lib/pen_tracker/tui.py index ced7649..ed32fb1 100644 --- a/build/lib/pen_tracker/tui.py +++ b/build/lib/pen_tracker/tui.py @@ -3,7 +3,7 @@ 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 +from .engine import PenTracker, Pen, InkTracker, Ink # --- TUI SCREENS --- class PenFormScreen(Screen): @@ -54,6 +54,50 @@ class PenFormScreen(Screen): self.tracker.save_data() 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): """The Main TUI Application.""" @@ -89,7 +133,8 @@ class PenTrackerApp(App): BINDINGS = [ 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"), ] @@ -100,29 +145,46 @@ class PenTrackerApp(App): def on_mount(self) -> None: self.tracker = PenTracker() + self.ink_tracker = InkTracker() + self.mode = "pens" # "pens" or "inks" 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) + if self.mode == "pens": + 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): - row_values = [getattr(pen, c.replace('-','_')) for c in display_cols] - # We use the index as the row key to track items accurately + for idx, item in enumerate(items): + if self.mode == "pens": + 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)) 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) 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) + if self.mode == "pens": + 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) except (ValueError, IndexError): pass @@ -135,16 +197,26 @@ class PenTrackerApp(App): 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() + if self.mode == "pens": + 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.notify(f"Deleted {removed.Make}", title="Removed") 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: try: diff --git a/pyproject.toml b/pyproject.toml index e5ccc2e..6c8f6e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pen-tracker" -version = "0.3.0" +version = "0.4.0" authors = [ { name="Don Harper", email="don@donharper.org" }, ] diff --git a/src/pen_tracker.egg-info/PKG-INFO b/src/pen_tracker.egg-info/PKG-INFO index 83ea7ac..78eb99e 100644 --- a/src/pen_tracker.egg-info/PKG-INFO +++ b/src/pen_tracker.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: pen-tracker -Version: 0.3.0 +Version: 0.4.0 Summary: A fountain pen collection tracker. Author-email: Don Harper Requires-Python: >=3.8 diff --git a/src/pen_tracker/__pycache__/cli.cpython-313.pyc b/src/pen_tracker/__pycache__/cli.cpython-313.pyc index b92abebea7d5d49c7215353647538b7626d0f8dc..3860351dd39fed5ca7ac581aaea6b09d7372c6eb 100644 GIT binary patch delta 7114 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 CB2VQ2 delta 3072 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 delta 1458 zcmY+E-D@0G6u{@s?9P509)zRK?vitO{fGanX<+DUE6?A4Z17j!af8szHCO79&`%dGKp zCWD%%M)jq6`KvLIR^|%#-2mj@#%xX{t3kxYzE-t5}O*h#Wob% z5+)`q#~%z*-n4Cdz8BfHobT^qJzuW&>+-ww$wJFG({P%%T<49_(AK$*&ffy$ScR$eW`3A9+p;T(C5A(YAQdLqK|%dow28qSk0DI)Mfc!gvG z8>mbyAxW!8W``ZZotyQ)_U?w^6CG*v*i;vCO!j zd2Drn7_2Vb0;)R`sU>zMvPX-|`Jo%Rh_q|hm!Z#O)j!@Ib` zR%F*a-f(@V$wk#NVFk7ED8MRtXBWI$vn7F3PX1Uv}f{)ZkY33nbAC1e!7{58Wiw&jfl1y@Wo(EFr#BlB9Sd zCP-Q)+#?JSXwV=Y#<-|Mv?NeWF(%pQcs{n04)d+b*GK0kc7O8^;jduNBIW;Zg;N$ diff --git a/src/pen_tracker/__pycache__/tui.cpython-313.pyc b/src/pen_tracker/__pycache__/tui.cpython-313.pyc index 3d3c8aaa903a5e1f20c4e030be48f5a080967b87..4d88dd4ca041394eec7415644348efaccc8be33e 100644 GIT binary patch delta 5822 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 delta 2816 zcmZ8jYfN0n6`r|wA9t6#yTCr!2e8YV%VY8KG%h6OVaE=zHC%(OK!w-47kk69i{~zO zKx&;hRl80i88k|RRky*ZDmzNlSoI(OsVX;W6{&x`!7 z$L&b-&6#uN&YU@~xj&t|_^kb*&1OZ=zWn5iWS4uz?!`Y}shiy!LS9sfWWkSQ%aoym zXG;(wE}d%yZow3O%yrA69}&+fn`B@4>r2I)W<8>$kCIq?Rv{V>^v&1sL2UA|9sKk@ z7qxhS)I1=eD}~OR{rn*gFVRe&pTk9i{_dkKJTc3* zDWRQ{?SAyUfmmd}T&Po4xd_}=0P|vfF3Qd+8?1BbGdu9zI^_V$qf>=)$yCu;HSMc) z*4ku8yFvEqe5dRqu2T*vpxM6leBb>1-@0$&z(KC8pf8aKMU`_QkZSmhW;nBu%xZ>F zKoX)C%6@<=DK0N*V%F5J9NM`uGmqx}pDn}j%-noNRZh^s^4>6uOV%F_0=$kUnYr{P zolGg@BuI8O zqP@YI{R(3+r@n|DlvJ!Wtd}(B46ljz1#v?NAG8bWA+LukHzoO7ZHqsp})6{thy z2&Ija4z@3A#}AAZF^BL**+hFP0_RO><2W8G-jP0um1x`~n!60R6Jk*9fLS8V_1%IIdYq=G?SiBUF`zd}ycbM2tfBRaKahz*b+Ov8t-`e=bpT z;2qnB;QLVUt@h-F`p>-;Yt`49t~PB{w&g3^?s?lU9?A`TJa}Z?H~PEbTW7A+tyNuH zyt;V9c|%^W>%QmTcY8SZ>>c0eXJXN%kyl1m_uUcO-)+0DzO(nP*#4Cn`8xik`9@U} zoO-*WroZ0wt@G*WYSEPd!IQx`j>b5uH3TrG!4eO5ypW!WxM^99Xwz$iVJFpn^gvCW zlcj`an8}bi6@mdSOUmf^nn=q6Ow?)sh(kx|>d`l+E>EpH8gd3b61{oByXx8qcIJbf z9|>I~NWZBG(0|ukxci7!)V9*^MO>b7&IsS9`N`eFfpo=I3{EpI<8Cf(pqC;Z!}0xN zR%)#&r@xN`Xu7T_3E2bXiRFMKXnYciv0g)Uzv)HlB@mGd0Gf4z)$#*`WQb-xoK7e& z=(J=cdg1<gaTN??zq*N@Y6f9)*kI`rh6}#+(Lt5p8iMOVNX3_i~bka|8DS!8dy@ z_iO~3@`0x7ay}5r4L@*()?C+0ua@2#&N~n12I+hCk-|P_p9kIc6!uB{?RwlN(XIMl zwy`|cOzBKEIkPBnx;k3n!aNjkB{9w7ot5&(CWpo30{uX$!58Qk(jFY7!3H09k7e^7 z`h3G%^kk!(z9%@Ty~%+^ns+(rH;qoHHC8-MTbepCN8fD>*m&8{4XJesRiH^f{Z&)l z(~z{XSr)qa5DEf+2KDkmG=n8el&~OP5(br&0xE?x^O!Q1ISUWOCC2wfGa=BD`kMp3 zCSbhHk!2V(K~>HwB$-{5?4*}Qn@eYNz$Pk#8U~MD1^JYjCxeW~Y(Z8S%rbZyVAn%S zKx+caxHKfW?kmkqb~Jg?Rcx7uI>H|MpXOTAk726*7V^(+-r}s53H%v-Cfs`7<9{Qy zlG^apeyen42d_xT8#o%4Bg2{$14z1R& zbzSSZ+OrnE!gtF1%$b^3Yh_iM3kXNap12&0mlL}4V~WpISSD1$M&(pDDNe*tFL zTzA6{8O36r=%=$RB0pnL0HBEjN-8BoNW>u~$S)ZGD1&_r^uGfASK%0&GU$d5h0xPp zH*8xM!XFEcWqx^Lbz)tp-ZC2sO)nnW5_x0EYS}h|VcW_X%fAw=#*S?-XWY9bn2kl( zvD& bool: """Validate date in YYYY-MM-DD format.""" @@ -17,6 +17,10 @@ def validate_date(date_str: str) -> bool: class CLITracker(PenTracker): + def __init__(self, storage_file=None): + super().__init__(storage_file) + self.ink_tracker = InkTracker() + def add_pen(self): print("\n--- Add New Fountain Pen ---") new_pen_data = {} @@ -29,6 +33,9 @@ class CLITracker(PenTracker): for label, key in fields: while True: + if key == 'Current-Ink': + value = self.select_ink() + break value = input(f"Enter {label}: ").strip() if not value: value = "N/A" @@ -74,6 +81,15 @@ class CLITracker(PenTracker): field = self.key_map.get(header, header) current_val = getattr(pen, field) 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() if not new_val: break @@ -149,6 +165,69 @@ class CLITracker(PenTracker): except (ValueError, IndexError): 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(): 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.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() tracker = CLITracker(args.csv) @@ -217,6 +307,24 @@ def main(): 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}") + 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: # Interactive mode while True: @@ -225,7 +333,9 @@ def main(): print("2. Add New Pen") print("3. Edit 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: ") @@ -242,6 +352,12 @@ def main(): clear_screen() tracker.delete_pen() elif choice == '5': + clear_screen() + tracker.view_all_inks() + elif choice == '6': + clear_screen() + tracker.add_ink() + elif choice == '7': print("Goodbye! Happy writing! ✒️") break else: diff --git a/src/pen_tracker/engine.py b/src/pen_tracker/engine.py index 8444a3f..cfc6601 100644 --- a/src/pen_tracker/engine.py +++ b/src/pen_tracker/engine.py @@ -6,6 +6,15 @@ from typing import List 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 class Pen: Make: str = "N/A" @@ -21,6 +30,65 @@ class Pen: Inked_date: 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: def __init__(self, storage_file: str = None): if storage_file is None: diff --git a/src/pen_tracker/tui.py b/src/pen_tracker/tui.py index ced7649..ed32fb1 100644 --- a/src/pen_tracker/tui.py +++ b/src/pen_tracker/tui.py @@ -3,7 +3,7 @@ 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 +from .engine import PenTracker, Pen, InkTracker, Ink # --- TUI SCREENS --- class PenFormScreen(Screen): @@ -54,6 +54,50 @@ class PenFormScreen(Screen): self.tracker.save_data() 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): """The Main TUI Application.""" @@ -89,7 +133,8 @@ class PenTrackerApp(App): BINDINGS = [ 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"), ] @@ -100,29 +145,46 @@ class PenTrackerApp(App): def on_mount(self) -> None: self.tracker = PenTracker() + self.ink_tracker = InkTracker() + self.mode = "pens" # "pens" or "inks" 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) + if self.mode == "pens": + 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): - row_values = [getattr(pen, c.replace('-','_')) for c in display_cols] - # We use the index as the row key to track items accurately + for idx, item in enumerate(items): + if self.mode == "pens": + 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)) 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) 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) + if self.mode == "pens": + 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) except (ValueError, IndexError): pass @@ -135,16 +197,26 @@ class PenTrackerApp(App): 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() + if self.mode == "pens": + 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.notify(f"Deleted {removed.Make}", title="Removed") 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: try: diff --git a/tests/__pycache__/test_engine.cpython-313.pyc b/tests/__pycache__/test_engine.cpython-313.pyc index 7c794c1da50f659d8d3b2828227eb4dc3474f500..69504fe1b6c77e4b9e827f4c2c50d6fc253f43bc 100644 GIT binary patch literal 6535 zcmeHLO>7&-6`tkpl0%A;YgzuGe-ui!9W$k*Shga|w)_+M2eBc>)mpV11=wuF6-|iR zRcBXGqLWdetx}}0dob!l4@xhw(2EZ}_*kGl^i+T`1sYQWJv0~JRLentp8DP_mn+dr zRYj6Ri;Tp#vpa9z?#z5|-uGs$wKYMYtljx%@j(kA|G*o+sI|)4BdFXX8quVygn3tq zN#Z`jB5;jdm9HpFxuP=lN|Z&f#8@m%_KWyi3Nu=Nk>K1F~0VaiOV#X7oR3 zCf;q)Vkd7BVzd(ix_bg^eyy7f)T(JxkP1?UKEwN5M&XHTrgfbelhY>SGSqqEEo-{| z0P2&z5xh#Zo3$)d?!nt|#4uWk$y#JDVG53oshR>Zs=?Q@m=bQXZg@8iLzDtpR&tjXl-^0_gG_`+^Jd9(O&)dTrlyJ?W@4PW=FIF= zv1Ic0oH1kOufwg)4EE6_d=|%6T{NqcCEGC>&IMO2P~|b#TcTx&D~?&3a`63W-rsl2 zo-zCKw$ZvLA%uW~jT$pf2NZqswMa%4+{fMiM>5k9xj*Y9)E1u#!t(^vO zn>GTmRHolkdnEgk%<_tV~L_dvOO z;PLJ^7L{Km*P#E#g~fr0g$0mqVPTsrLw%yrzE}KZ092D@DYvZfi0$y`oK-4X(+u5< zflmZlEV&gES3{b#t~ z49A#BGTen2&J0(;D*=w{$w~p?W9!yKdz3x?$z@l5OO5>~QcUh=*ux14GX5K>*&{ zw%?s!vL1cE+;(PBMxRf9oUGDRnWm~VU8d=!9Tj@0{=BnHJ1aE(B1&L4==iMj)6Qzw z@p9Mk$GZj=TR^Ky_mt_L-|o0SQ9aO8KG5?x)%#GY(EfVU{e9I`rku(==qjhO)zr~) z>gdD43O&_Kp}(B!f0$dLBb&J3d!a5k6j|q#5ep_8_4S>&j@fNYZ91$>QN1^WEl>5L=3Sc`nbBk7i?+e3otr0h6=MxaO z$rs6OceN$CoILonURzb=1Bpci0bQY~RZ8Nm9=K~De!45&9KvBZJyxNE&2TD!d~Agd z3Y@~=L_EShA(x4Bs_Pipb)6@4ea6num9U=Fb@cyQ%QhYO0CQZ3W~OE9I@=AMSsFz@ ziXjkur>;A$;T9)#!*yBl{W+K{U0){Rtzu(f6qQk6h;q=#&&l(MtPVa;Nb0N4qjB|u z^yMz9p78ph<|O>;#gAlb_|~nmKHz>-i?Mit!e|Be3y9^j!(eDb@cf_WehfovCdM#= zGW0cXxiMfe9Gpi)aP=G6h*(vRj|DU2@i7qrQrPDuwt-HBzqN0ZQ?dQ{?gJ5v!p1q*7# zaE%2aFIdNdoTVM*C$&hc-dyUYHv=N|P)p;OF72Jd^>$ zDG(;t5dtY{B#f7@Pn+Rj4*mipY&iYFHEJkfZ>%$Q8NRZfQU@I;TK)RCyje=!kP?Sj z3h!S_iNhFrEErk%w>7ayn`RO5uVoRBUmA**2rL#z7s(RLak1oqXii1a^n=!2HTb2uHx0!yk z1V@`$PiDJE3;1=jUOx)|*`VpN zZRgVvED~IrUr^`Vsh$%ViBr>>wDXXwJQGeY#6cLJaDPtyk6e&*edjX?uGo;K2MG~Z z=UEYkA);Rp^TbYp8v<9wSTl-uD9{4OjrydG<8`?-4hD{h{0R>P9G%(o z`h3y7rJEnZAp#yG6d}DRK%!xm@_Z0;tHxbr3mr$`ea;Gq+hkRVv|f=`WfJdrksxjD z>(9-WoQF3)Zyo;Q_9w~KJC{GceD`W4nf^*9ZD+o*;{&nZ+^EpuO*f=>D)d}41#!M` zafP1zY8!mmO6%~y9Q4fJwU3;Z|8zQWArWJz;69g0Fgz+{!zj+6IEy04(Z=u=_hR-Q zigPH=qd-#ZWo9^U32GA4_>cdcoPA8rzEJi{C+^;??mS%HdH4l^n-}M#*Q8UyBe+?;DG?>P ZxbRp`JyDXs82#z!&)x>$D0{_7{|$g4TkHS; delta 610 zcmYk3&ubGw6vtfM@4%U#6x8Q_T<5f1ngbk+d^>}KD?jbd2jZ3@$g#lkK@<`zITJ) z?N7W{tkRdgxrXbJDw!rNRwFIGK^_%U$r$fFw@M=$R_n_0Iw8>*A&?mBeVnP8zffV- zH?;jQPS!;9WGfb$f!#0C2Bp4QHcqHie~tGye7fp$ZR|*Gc9Tv!*%A))icv%fASu8T zShn5_lq?LZ){G_U2-lnd)Z%zl79XcOL8H@DZvu@PUtP>}~L!l{v&jgG}G~106 zoloMuFufQ|G6;Jf+=1z`Iy1}ai#ebb6*v?9&m+6^u?LwK@+Uh3lG>v5{@>p6`AF5La+FFCi-3VSN6IjG9jy+-tt1k7m z)!g2B+U>+EVg(ZT2jnflkWoqpWHdXuGjj8sU#ClN)=$gzujTqV0cY4GyzpxO8=Lsf S3&+7x@Z!Oc5ncmvSot5Q@QXqK diff --git a/tests/test_engine.py b/tests/test_engine.py index 503c79d..dfdce76 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,7 +1,7 @@ import unittest import tempfile import os -from pen_tracker.engine import PenTracker, Pen +from pen_tracker.engine import PenTracker, Pen, InkTracker, Ink class TestPenTracker(unittest.TestCase): def setUp(self): @@ -33,5 +33,51 @@ class TestPenTracker(unittest.TestCase): self.assertEqual(new_tracker.pens[0].Make, "A") 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__': unittest.main() \ No newline at end of file