commit 1a12e6d3c5571636cb30ef45829601c8ec1cf884 Author: Don Harper Date: Sun Apr 26 22:33:44 2026 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd5859d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.csv +*.code-workspace diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5a7f7c3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "pen-tracker" +version = "0.1.2" +authors = [ + { name="Don Harper", email="don@donharper.org" }, +] +description = "A fountain pen collection tracker." +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "textual", +] + +[project.scripts] +pen-tracker = "pen_tracker.cli:main" +pen-tui = "pen_tracker.tui:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/pen_tracker.egg-info/PKG-INFO b/src/pen_tracker.egg-info/PKG-INFO new file mode 100644 index 0000000..1343b95 --- /dev/null +++ b/src/pen_tracker.egg-info/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 2.4 +Name: pen-tracker +Version: 0.1.2 +Summary: A fountain pen collection tracker. +Author-email: Don Harper +Requires-Python: >=3.8 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: textual +Dynamic: license-file diff --git a/src/pen_tracker.egg-info/SOURCES.txt b/src/pen_tracker.egg-info/SOURCES.txt new file mode 100644 index 0000000..4d050c2 --- /dev/null +++ b/src/pen_tracker.egg-info/SOURCES.txt @@ -0,0 +1,13 @@ +LICENSE +README.md +pyproject.toml +src/pen_tracker/__init__.py +src/pen_tracker/cli.py +src/pen_tracker/engine.py +src/pen_tracker/tui.py +src/pen_tracker.egg-info/PKG-INFO +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 \ No newline at end of file diff --git a/src/pen_tracker.egg-info/dependency_links.txt b/src/pen_tracker.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/pen_tracker.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/pen_tracker.egg-info/entry_points.txt b/src/pen_tracker.egg-info/entry_points.txt new file mode 100644 index 0000000..5530550 --- /dev/null +++ b/src/pen_tracker.egg-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +pen-tracker = pen_tracker.cli:main +pen-tui = pen_tracker.tui:main diff --git a/src/pen_tracker.egg-info/requires.txt b/src/pen_tracker.egg-info/requires.txt new file mode 100644 index 0000000..a75a51d --- /dev/null +++ b/src/pen_tracker.egg-info/requires.txt @@ -0,0 +1 @@ +textual diff --git a/src/pen_tracker.egg-info/top_level.txt b/src/pen_tracker.egg-info/top_level.txt new file mode 100644 index 0000000..fcae849 --- /dev/null +++ b/src/pen_tracker.egg-info/top_level.txt @@ -0,0 +1 @@ +pen_tracker diff --git a/src/pen_tracker/__init__.py b/src/pen_tracker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pen_tracker/__pycache__/__init__.cpython-313.pyc b/src/pen_tracker/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..e6088d7 Binary files /dev/null and b/src/pen_tracker/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/pen_tracker/__pycache__/cli.cpython-313.pyc b/src/pen_tracker/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000..442256e Binary files /dev/null and b/src/pen_tracker/__pycache__/cli.cpython-313.pyc differ diff --git a/src/pen_tracker/__pycache__/engine.cpython-313.pyc b/src/pen_tracker/__pycache__/engine.cpython-313.pyc new file mode 100644 index 0000000..8b9c1b6 Binary files /dev/null and b/src/pen_tracker/__pycache__/engine.cpython-313.pyc differ diff --git a/src/pen_tracker/__pycache__/tui.cpython-313.pyc b/src/pen_tracker/__pycache__/tui.cpython-313.pyc new file mode 100644 index 0000000..894ebbc Binary files /dev/null and b/src/pen_tracker/__pycache__/tui.cpython-313.pyc differ diff --git a/src/pen_tracker/cli.py b/src/pen_tracker/cli.py new file mode 100644 index 0000000..ab79293 --- /dev/null +++ b/src/pen_tracker/cli.py @@ -0,0 +1,194 @@ +import os +from .engine import PenTracker + +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 = {} + 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: + value = input(f"Enter {label}: ").strip() + new_pen[key] = value if value else "N/A" + + 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 key in self.headers: + current_val = pen.get(key, "N/A") + new_val = input(f"{key} [{current_val}]: ").strip() + if new_val: # If the user actually typed something new + pen[key] = 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) + 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] + 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.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] + 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 key in self.headers: + value = pen.get(key, "N/A") + print(f"{key:<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.get('Make', 'Unknown')} {removed.get('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 + 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: ") + + 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.") diff --git a/src/pen_tracker/engine.py b/src/pen_tracker/engine.py new file mode 100644 index 0000000..b952ce5 --- /dev/null +++ b/src/pen_tracker/engine.py @@ -0,0 +1,50 @@ +import csv +import os + +class PenTracker: + 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 _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())) + + 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: + # 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()} + pens.append(clean_row) + self._sort_pens() + 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): + """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() + writer.writerows(self.pens) diff --git a/src/pen_tracker/engine.py.bork b/src/pen_tracker/engine.py.bork new file mode 100644 index 0000000..127b3ad --- /dev/null +++ b/src/pen_tracker/engine.py.bork @@ -0,0 +1,46 @@ +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) diff --git a/src/pen_tracker/tui.py b/src/pen_tracker/tui.py new file mode 100644 index 0000000..92cd264 --- /dev/null +++ b/src/pen_tracker/tui.py @@ -0,0 +1,150 @@ +from textual.app import App +from .engine import PenTracker +# --- 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: + val = self.existing_pen.get(header, "") 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" + + 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 + else: + self.tracker.pens.append(new_data) + + # save_data() handles the sorting internally + self.tracker.save_data() + self.dismiss(new_data) + +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 = [pen.get(c, "N/A") 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.int_query_one("#pen_table", DataTable) # Corrected reference + 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()