AI-2.0 #6
12 changed files with 559 additions and 147 deletions
168
PROJECT_DOCUMENTATION.md
Normal file
168
PROJECT_DOCUMENTATION.md
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
# Pen Tracker Project Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`pen-tracker` is a small Python package for tracking fountain pens and inks using CSV storage. It provides a single command-line interface (`pen-tracker`) for interactive data management, plus direct CLI commands for adding, listing, exporting, and maintaining the collection.
|
||||||
|
|
||||||
|
This repository is structured as a standard Python package with source under `src/pen_tracker`, package metadata in `pyproject.toml`, and a small test suite under `tests`.
|
||||||
|
|
||||||
|
## Key goals
|
||||||
|
|
||||||
|
- Maintain a pen collection with fields such as make, model, nib, ink, purchase date, and notes.
|
||||||
|
- Maintain an ink collection separately with vendor, name, color, purchase date, size, and notes.
|
||||||
|
- Store data in CSV files for compatibility and simplicity.
|
||||||
|
- Use a single CLI entry point for developer and automation workflows.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
- `pyproject.toml` — packaging metadata, dependencies, and entry points.
|
||||||
|
- `README.md` — user-facing usage documentation.
|
||||||
|
- `PROJECT_DOCUMENTATION.md` — developer/AI reference documentation.
|
||||||
|
- `import_csv_to_sqlite.py` — standalone CSV-to-SQLite migration helper.
|
||||||
|
- `src/pen_tracker/engine.py` — domain logic, data models, SQLite persistence, history tracking, and storage defaults.
|
||||||
|
- `src/pen_tracker/cli.py` — command-line interface, interactive menus, and command parsing.
|
||||||
|
- `tests/test_engine.py` — basic test coverage for core engine behavior.
|
||||||
|
|
||||||
|
## Core components
|
||||||
|
|
||||||
|
### `src/pen_tracker/engine.py`
|
||||||
|
|
||||||
|
Primary responsibilities:
|
||||||
|
|
||||||
|
- Define `Pen` and `Ink` dataclasses.
|
||||||
|
- Provide `PenTracker` and `InkTracker` classes that:
|
||||||
|
- Decide storage path from environment or default XDG data directories.
|
||||||
|
- Load CSV files into Python objects.
|
||||||
|
- Normalize data on load.
|
||||||
|
- Persist sorted CSV output.
|
||||||
|
|
||||||
|
Important details:
|
||||||
|
|
||||||
|
- Default pen storage path: `~/.local/share/pen-tracker/pens.csv`
|
||||||
|
- Default ink storage path: `~/.local/share/pen-tracker/inks.csv`
|
||||||
|
- Environment overrides:
|
||||||
|
- `PEN_TRACKER_CSV` for pen storage.
|
||||||
|
- `INK_TRACKER_CSV` for ink storage.
|
||||||
|
- `PenTracker.load_data()` maps CSV headers like `Date-Purchased` and `Current-Ink` to dataclass fields like `Date_Purchased` and `Current_Ink`.
|
||||||
|
- CSV headers are written using the original hyphenated naming convention.
|
||||||
|
|
||||||
|
### `src/pen_tracker/cli.py`
|
||||||
|
|
||||||
|
Primary responsibilities:
|
||||||
|
|
||||||
|
- Parse command-line arguments with `argparse`.
|
||||||
|
- Provide commands for:
|
||||||
|
- `add` — add a new pen from CLI inputs.
|
||||||
|
- `list` — display all pens.
|
||||||
|
- `export` — export pens to JSON.
|
||||||
|
- `add-ink` — add a new ink.
|
||||||
|
- `list-inks` — list saved inks.
|
||||||
|
- Provide an interactive text menu when no command is supplied.
|
||||||
|
- Display one-based IDs for pens and inks.
|
||||||
|
- Validate user input and dates.
|
||||||
|
|
||||||
|
## Usage patterns
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run interactive CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pen-tracker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add a pen via CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pen-tracker add --make "Pilot" --model "Metropolitan" --nib "F"
|
||||||
|
```
|
||||||
|
|
||||||
|
### List saved pens
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pen-tracker list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export pens to JSON
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pen-tracker export --output my_pens.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add an ink via CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pen-tracker add-ink --vendor "Diamine" --name "Majestic Blue" --color "Blue"
|
||||||
|
```
|
||||||
|
|
||||||
|
### List inks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pen-tracker list-inks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### Pen fields
|
||||||
|
|
||||||
|
- `Make`
|
||||||
|
- `Model`
|
||||||
|
- `Date_Purchased` / `Date-Purchased`
|
||||||
|
- `Vendor`
|
||||||
|
- `Nib`
|
||||||
|
- `Nib_Material` / `Nib-Material`
|
||||||
|
- `Body`
|
||||||
|
- `Cap`
|
||||||
|
- `Post`
|
||||||
|
- `Current_Ink` / `Current-Ink`
|
||||||
|
- `Inked_date` / `Inked-date`
|
||||||
|
- `Notes`
|
||||||
|
|
||||||
|
### Ink fields
|
||||||
|
|
||||||
|
- `Vendor`
|
||||||
|
- `Name`
|
||||||
|
- `Color`
|
||||||
|
- `Purchased`
|
||||||
|
- `Size`
|
||||||
|
- `Notes`
|
||||||
|
|
||||||
|
## Packaging and scripts
|
||||||
|
|
||||||
|
The package is configured with `pyproject.toml` and uses `setuptools.build_meta` as the build backend. There are no runtime dependencies.
|
||||||
|
|
||||||
|
Entry point:
|
||||||
|
|
||||||
|
- `pen-tracker = pen_tracker.cli:main`
|
||||||
|
|
||||||
|
## Developer notes
|
||||||
|
|
||||||
|
- The CLI is the only supported interface. The repository no longer includes any TUI or graphical code.
|
||||||
|
- Data is now persisted in SQLite, with default path `~/.local/share/pen-tracker/pen_tracker.db`.
|
||||||
|
- Ink changes for each pen are recorded in `pen_ink_history`, including transitions to `N/A`.
|
||||||
|
- When modifying storage columns, update both `engine.py` and `cli.py` to maintain field mappings and database behavior.
|
||||||
|
- One-based IDs are used consistently for user-facing selection and display.
|
||||||
|
- SQLite persistence always sorts data before saving.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `tests/test_engine.py` contains test coverage for the core engine layer.
|
||||||
|
- Add tests for new fields, storage behavior, and CLI data validation if extending features.
|
||||||
|
|
||||||
|
## Notes for AI agents
|
||||||
|
|
||||||
|
- Focus on `src/pen_tracker/engine.py` for the data model and persistence logic.
|
||||||
|
- Focus on `src/pen_tracker/cli.py` for user-facing behavior, commands, and interactive menu flow.
|
||||||
|
- The default storage paths are derived from XDG conventions and environment variables.
|
||||||
|
- There is no GUI, web service, or database; all data is stored in CSV files.
|
||||||
|
- The project version is `0.5.0` and the package name is `pen-tracker`.
|
||||||
|
|
||||||
|
## Extension suggestions
|
||||||
|
|
||||||
|
- Add unit tests for the CLI parser and interactive menu flows.
|
||||||
|
- Add a dedicated `docs/` folder if more formal design docs or API references are needed.
|
||||||
|
- Add JSON schema validation or stronger date handling if data robustness is required.
|
||||||
15
README.md
15
README.md
|
|
@ -10,9 +10,9 @@ pip install .
|
||||||
|
|
||||||
## Data Storage
|
## Data Storage
|
||||||
|
|
||||||
Pen data is stored in `~/.local/share/pen-tracker/pens.csv` by default, following XDG Base Directory specification. The directory is created automatically if it doesn't exist.
|
Data is stored in a SQLite database by default at `~/.local/share/pen-tracker/pen_tracker.db`, following the XDG Base Directory specification. The directory is created automatically if it doesn't exist.
|
||||||
|
|
||||||
You can override the location by setting the `PEN_TRACKER_CSV` environment variable.
|
You can override the location by setting the `PEN_TRACKER_DB` environment variable or using `--db` when running the CLI.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|
@ -25,11 +25,20 @@ pen-tracker
|
||||||
|
|
||||||
Command-line mode:
|
Command-line mode:
|
||||||
```bash
|
```bash
|
||||||
pen-tracker add --make "Pilot" --model "Metropolitan" --nib "F"
|
pen-tracker --db ~/.local/share/pen-tracker/pen_tracker.db add --make "Pilot" --model "Metropolitan" --nib "F"
|
||||||
pen-tracker list
|
pen-tracker list
|
||||||
pen-tracker export --output my_pens.json
|
pen-tracker export --output my_pens.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Import CSV data
|
||||||
|
|
||||||
|
If you are migrating from the old CSV storage format, run:
|
||||||
|
```bash
|
||||||
|
python import_csv_to_sqlite.py --db ~/.local/share/pen-tracker/pen_tracker.db
|
||||||
|
```
|
||||||
|
|
||||||
|
The import script will initialize the SQLite database if it does not already exist.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Track fountain pens with details like make, model, nib, ink, etc.
|
- Track fountain pens with details like make, model, nib, ink, etc.
|
||||||
|
|
|
||||||
BIN
__pycache__/import_csv_to_sqlite.cpython-313.pyc
Normal file
BIN
__pycache__/import_csv_to_sqlite.cpython-313.pyc
Normal file
Binary file not shown.
84
import_csv_to_sqlite.py
Executable file
84
import_csv_to_sqlite.py
Executable file
|
|
@ -0,0 +1,84 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||||
|
|
||||||
|
from pen_tracker.engine import Ink, InkTracker, Pen, PenTracker
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_pens_csv() -> str:
|
||||||
|
env_path = os.getenv('PEN_TRACKER_CSV')
|
||||||
|
if env_path:
|
||||||
|
return env_path
|
||||||
|
data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
|
||||||
|
app_data_dir = os.path.join(data_home, 'pen-tracker')
|
||||||
|
os.makedirs(app_data_dir, exist_ok=True)
|
||||||
|
return os.path.join(app_data_dir, 'pens.csv')
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_inks_csv() -> str:
|
||||||
|
env_path = os.getenv('INK_TRACKER_CSV')
|
||||||
|
if env_path:
|
||||||
|
return env_path
|
||||||
|
data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
|
||||||
|
app_data_dir = os.path.join(data_home, 'pen-tracker')
|
||||||
|
os.makedirs(app_data_dir, exist_ok=True)
|
||||||
|
return os.path.join(app_data_dir, 'inks.csv')
|
||||||
|
|
||||||
|
|
||||||
|
def load_csv_rows(path: str, fieldnames: List[str]) -> List[Dict[str, str]]:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return []
|
||||||
|
|
||||||
|
with open(path, mode='r', encoding='utf-8-sig', newline='') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
if reader.fieldnames:
|
||||||
|
reader.fieldnames = [h.strip() for h in reader.fieldnames]
|
||||||
|
rows: List[Dict[str, str]] = []
|
||||||
|
for row in reader:
|
||||||
|
clean_row = {k.strip(): (v.strip() if v else 'N/A') for k, v in row.items() if k is not None}
|
||||||
|
for field in fieldnames:
|
||||||
|
clean_row.setdefault(field, 'N/A')
|
||||||
|
rows.append(clean_row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def import_data(db_path: str, pens_csv: str, inks_csv: str) -> None:
|
||||||
|
ink_tracker = InkTracker(db_path)
|
||||||
|
pen_tracker = PenTracker(db_path)
|
||||||
|
|
||||||
|
ink_rows = load_csv_rows(inks_csv, ink_tracker.headers)
|
||||||
|
inks = [Ink(**row) for row in ink_rows]
|
||||||
|
ink_tracker.inks = inks
|
||||||
|
ink_tracker.save_data()
|
||||||
|
|
||||||
|
pen_rows = load_csv_rows(pens_csv, pen_tracker.headers)
|
||||||
|
pens = [Pen(**{pen_tracker.key_map.get(k, k): v for k, v in row.items()}) for row in pen_rows]
|
||||||
|
pen_tracker.pens = pens
|
||||||
|
pen_tracker.save_data()
|
||||||
|
|
||||||
|
print(f'Imported {len(ink_tracker.inks)} inks from {inks_csv}')
|
||||||
|
print(f'Imported {len(pen_tracker.pens)} pens from {pens_csv}')
|
||||||
|
print(f'SQLite database initialized at: {db_path}')
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description='Import CSV data into pen-tracker SQLite database')
|
||||||
|
parser.add_argument('--db', default=None, help='Path to the SQLite database file')
|
||||||
|
parser.add_argument('--pens-csv', default=None, help='Path to pens CSV file')
|
||||||
|
parser.add_argument('--inks-csv', default=None, help='Path to inks CSV file')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db_path = args.db
|
||||||
|
pens_csv = args.pens_csv or get_default_pens_csv()
|
||||||
|
inks_csv = args.inks_csv or get_default_inks_csv()
|
||||||
|
|
||||||
|
import_data(db_path, pens_csv, inks_csv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pen-tracker"
|
name = "pen-tracker"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
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.5.0
|
Version: 0.5.1
|
||||||
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
|
||||||
|
|
@ -20,9 +20,9 @@ pip install .
|
||||||
|
|
||||||
## Data Storage
|
## Data Storage
|
||||||
|
|
||||||
Pen data is stored in `~/.local/share/pen-tracker/pens.csv` by default, following XDG Base Directory specification. The directory is created automatically if it doesn't exist.
|
Data is stored in a SQLite database by default at `~/.local/share/pen-tracker/pen_tracker.db`, following the XDG Base Directory specification. The directory is created automatically if it doesn't exist.
|
||||||
|
|
||||||
You can override the location by setting the `PEN_TRACKER_CSV` environment variable.
|
You can override the location by setting the `PEN_TRACKER_DB` environment variable or using `--db` when running the CLI.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|
@ -35,11 +35,20 @@ pen-tracker
|
||||||
|
|
||||||
Command-line mode:
|
Command-line mode:
|
||||||
```bash
|
```bash
|
||||||
pen-tracker add --make "Pilot" --model "Metropolitan" --nib "F"
|
pen-tracker --db ~/.local/share/pen-tracker/pen_tracker.db add --make "Pilot" --model "Metropolitan" --nib "F"
|
||||||
pen-tracker list
|
pen-tracker list
|
||||||
pen-tracker export --output my_pens.json
|
pen-tracker export --output my_pens.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Import CSV data
|
||||||
|
|
||||||
|
If you are migrating from the old CSV storage format, run:
|
||||||
|
```bash
|
||||||
|
python import_csv_to_sqlite.py --db ~/.local/share/pen-tracker/pen_tracker.db
|
||||||
|
```
|
||||||
|
|
||||||
|
The import script will initialize the SQLite database if it does not already exist.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Track fountain pens with details like make, model, nib, ink, etc.
|
- Track fountain pens with details like make, model, nib, ink, etc.
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,5 +1,4 @@
|
||||||
import os
|
import os
|
||||||
import csv
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -148,6 +147,23 @@ class CLITracker(PenTracker):
|
||||||
field = self.key_map.get(header, header)
|
field = self.key_map.get(header, header)
|
||||||
value = getattr(pen, field)
|
value = getattr(pen, field)
|
||||||
print(f"{header:<20}: {value}")
|
print(f"{header:<20}: {value}")
|
||||||
|
|
||||||
|
history = []
|
||||||
|
if pen.id is not None:
|
||||||
|
history = self.get_ink_history(pen.id)
|
||||||
|
|
||||||
|
print("═"*45)
|
||||||
|
print("\nInk Change History:")
|
||||||
|
if history:
|
||||||
|
print(f"{'When':<20} | {'Ink':<20}")
|
||||||
|
print("-" * 45)
|
||||||
|
for row in history:
|
||||||
|
changed_at = row.get('changed_at', 'N/A')
|
||||||
|
ink_name = row.get('ink_name', 'N/A')
|
||||||
|
print(f"{changed_at:<20} | {ink_name:<20}")
|
||||||
|
else:
|
||||||
|
print("No ink history available.")
|
||||||
|
|
||||||
print("═"*45)
|
print("═"*45)
|
||||||
input("\nPress Enter to return to menu...")
|
input("\nPress Enter to return to menu...")
|
||||||
|
|
||||||
|
|
@ -192,7 +208,7 @@ class CLITracker(PenTracker):
|
||||||
new_ink = Ink(**new_ink_data)
|
new_ink = Ink(**new_ink_data)
|
||||||
self.ink_tracker.inks.append(new_ink)
|
self.ink_tracker.inks.append(new_ink)
|
||||||
self.ink_tracker.save_data()
|
self.ink_tracker.save_data()
|
||||||
print("\n[✔] Ink added successfully to inks.csv!")
|
print("\n[✔] Ink added successfully!")
|
||||||
|
|
||||||
def view_all_inks(self):
|
def view_all_inks(self):
|
||||||
if not self.ink_tracker.inks:
|
if not self.ink_tracker.inks:
|
||||||
|
|
@ -237,7 +253,7 @@ def clear_screen():
|
||||||
def main():
|
def main():
|
||||||
# This is the entry point defined in pyproject.toml
|
# This is the entry point defined in pyproject.toml
|
||||||
parser = argparse.ArgumentParser(description="Fountain Pen Tracker")
|
parser = argparse.ArgumentParser(description="Fountain Pen Tracker")
|
||||||
parser.add_argument('--csv', default=None, help='Path to CSV file')
|
parser.add_argument('--db', default=None, help='Path to SQLite database file')
|
||||||
subparsers = parser.add_subparsers(dest='command', help='Commands')
|
subparsers = parser.add_subparsers(dest='command', help='Commands')
|
||||||
|
|
||||||
# Add command
|
# Add command
|
||||||
|
|
@ -275,7 +291,7 @@ def main():
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
tracker = CLITracker(args.csv)
|
tracker = CLITracker(args.db)
|
||||||
|
|
||||||
if args.command == 'add':
|
if args.command == 'add':
|
||||||
# Validate dates
|
# Validate dates
|
||||||
|
|
@ -331,7 +347,7 @@ def main():
|
||||||
else:
|
else:
|
||||||
# Interactive mode
|
# Interactive mode
|
||||||
while True:
|
while True:
|
||||||
print("\n🖋️ FOUNTAIN PEN TRACKER (CSV Edition)")
|
print("\n🖋️ FOUNTAIN PEN TRACKER (SQLite Edition)")
|
||||||
print("1. View Collection Summary")
|
print("1. View Collection Summary")
|
||||||
print("2. Add New Pen")
|
print("2. Add New Pen")
|
||||||
print("3. Edit a Pen")
|
print("3. Edit a Pen")
|
||||||
|
|
|
||||||
|
|
@ -1,163 +1,278 @@
|
||||||
import csv
|
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass
|
||||||
from typing import List
|
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_db_path(db_path: Optional[str] = None) -> str:
|
||||||
|
if db_path:
|
||||||
|
return db_path
|
||||||
|
env_path = os.getenv('PEN_TRACKER_DB')
|
||||||
|
if env_path:
|
||||||
|
return env_path
|
||||||
|
data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
|
||||||
|
app_data_dir = os.path.join(data_home, 'pen-tracker')
|
||||||
|
os.makedirs(app_data_dir, exist_ok=True)
|
||||||
|
return os.path.join(app_data_dir, 'pen_tracker.db')
|
||||||
|
|
||||||
|
|
||||||
|
class SqliteStore:
|
||||||
|
def __init__(self, db_path: Optional[str] = None):
|
||||||
|
self.db_path = get_default_db_path(db_path)
|
||||||
|
self.conn = sqlite3.connect(self.db_path)
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
self.conn.execute('PRAGMA foreign_keys = ON')
|
||||||
|
self.initialize_db()
|
||||||
|
|
||||||
|
def initialize_db(self):
|
||||||
|
self.conn.execute(
|
||||||
|
'''CREATE TABLE IF NOT EXISTS inks (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
purchased TEXT NOT NULL,
|
||||||
|
size TEXT NOT NULL,
|
||||||
|
notes TEXT NOT NULL
|
||||||
|
)'''
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
'''CREATE TABLE IF NOT EXISTS pens (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
make TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
date_purchased TEXT NOT NULL,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
nib TEXT NOT NULL,
|
||||||
|
nib_material TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
cap TEXT NOT NULL,
|
||||||
|
post TEXT NOT NULL,
|
||||||
|
current_ink TEXT NOT NULL,
|
||||||
|
inked_date TEXT NOT NULL,
|
||||||
|
notes TEXT NOT NULL
|
||||||
|
)'''
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
'''CREATE TABLE IF NOT EXISTS pen_ink_history (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
pen_id INTEGER NOT NULL,
|
||||||
|
ink_name TEXT NOT NULL,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY(pen_id) REFERENCES pens(id) ON DELETE CASCADE
|
||||||
|
)'''
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def execute(self, query: str, params: Tuple[Any, ...] = ()):
|
||||||
|
return self.conn.execute(query, params)
|
||||||
|
|
||||||
|
def executemany(self, query: str, seq_of_params: Sequence[Tuple[Any, ...]]):
|
||||||
|
return self.conn.executemany(query, seq_of_params)
|
||||||
|
|
||||||
|
def fetchall(self, query: str, params: Tuple[Any, ...] = ()) -> List[Dict[str, Any]]:
|
||||||
|
cursor = self.conn.execute(query, params)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Ink:
|
class Ink:
|
||||||
Vendor: str = "N/A"
|
Vendor: str = 'N/A'
|
||||||
Name: str = "N/A"
|
Name: str = 'N/A'
|
||||||
Color: str = "N/A"
|
Color: str = 'N/A'
|
||||||
Purchased: str = "N/A"
|
Purchased: str = 'N/A'
|
||||||
Size: str = "N/A"
|
Size: str = 'N/A'
|
||||||
Notes: str = "N/A"
|
Notes: str = 'N/A'
|
||||||
|
id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Pen:
|
class Pen:
|
||||||
Make: str = "N/A"
|
Make: str = 'N/A'
|
||||||
Model: str = "N/A"
|
Model: str = 'N/A'
|
||||||
Date_Purchased: str = "N/A"
|
Date_Purchased: str = 'N/A'
|
||||||
Vendor: str = "N/A"
|
Vendor: str = 'N/A'
|
||||||
Nib: str = "N/A"
|
Nib: str = 'N/A'
|
||||||
Nib_Material: str = "N/A"
|
Nib_Material: str = 'N/A'
|
||||||
Body: str = "N/A"
|
Body: str = 'N/A'
|
||||||
Cap: str = "N/A"
|
Cap: str = 'N/A'
|
||||||
Post: str = "N/A"
|
Post: str = 'N/A'
|
||||||
Current_Ink: str = "N/A"
|
Current_Ink: str = 'N/A'
|
||||||
Inked_date: str = "N/A"
|
Inked_date: str = 'N/A'
|
||||||
Notes: str = "N/A"
|
Notes: str = 'N/A'
|
||||||
|
id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class InkTracker:
|
class InkTracker:
|
||||||
def __init__(self, storage_file: str = None):
|
def __init__(self, db_path: Optional[str] = None):
|
||||||
if storage_file is None:
|
self.db = SqliteStore(db_path)
|
||||||
storage_file = os.getenv('INK_TRACKER_CSV')
|
|
||||||
if storage_file is None:
|
|
||||||
data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
|
|
||||||
app_data_dir = os.path.join(data_home, 'pen-tracker')
|
|
||||||
os.makedirs(app_data_dir, exist_ok=True)
|
|
||||||
storage_file = os.path.join(app_data_dir, 'inks.csv')
|
|
||||||
self.storage_file = storage_file
|
|
||||||
self.headers = ['Vendor', 'Name', 'Color', 'Purchased', 'Size', 'Notes']
|
self.headers = ['Vendor', 'Name', 'Color', 'Purchased', 'Size', 'Notes']
|
||||||
self.inks: List[Ink] = self.load_data()
|
self.inks: List[Ink] = self.load_data()
|
||||||
|
|
||||||
def _sort_inks(self):
|
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()))
|
self.inks.sort(key=lambda x: (x.Vendor.lower(), x.Name.lower()))
|
||||||
|
|
||||||
def load_data(self) -> List[Ink]:
|
def load_data(self) -> List[Ink]:
|
||||||
"""Loads data from the CSV file."""
|
rows = self.db.fetchall(
|
||||||
if not os.path.exists(self.storage_file):
|
'SELECT * FROM inks ORDER BY vendor COLLATE NOCASE, name COLLATE NOCASE'
|
||||||
self._create_empty_csv()
|
)
|
||||||
return []
|
inks: List[Ink] = []
|
||||||
|
for row in rows:
|
||||||
inks = []
|
inks.append(
|
||||||
try:
|
Ink(
|
||||||
with open(self.storage_file, mode='r', encoding='utf-8-sig') as f:
|
Vendor=row['vendor'],
|
||||||
reader = csv.DictReader(f)
|
Name=row['name'],
|
||||||
if reader.fieldnames:
|
Color=row['color'],
|
||||||
reader.fieldnames = [h.strip() for h in reader.fieldnames]
|
Purchased=row['purchased'],
|
||||||
for row in reader:
|
Size=row['size'],
|
||||||
if None in row:
|
Notes=row['notes'],
|
||||||
extras = row.pop(None)
|
id=row['id'],
|
||||||
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
|
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):
|
def save_data(self):
|
||||||
"""Sorts the data before writing to ensure persistent alphabetical order."""
|
|
||||||
self._sort_inks()
|
self._sort_inks()
|
||||||
with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f:
|
self.db.execute('DELETE FROM inks')
|
||||||
writer = csv.DictWriter(f, fieldnames=self.headers)
|
for ink in self.inks:
|
||||||
writer.writeheader()
|
cursor = self.db.execute(
|
||||||
for ink in self.inks:
|
'INSERT INTO inks (vendor, name, color, purchased, size, notes) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
writer.writerow(asdict(ink))
|
(ink.Vendor, ink.Name, ink.Color, ink.Purchased, ink.Size, ink.Notes),
|
||||||
|
)
|
||||||
|
ink.id = cursor.lastrowid
|
||||||
|
self.db.commit()
|
||||||
|
self.inks = self.load_data()
|
||||||
|
|
||||||
|
|
||||||
class PenTracker:
|
class PenTracker:
|
||||||
def __init__(self, storage_file: str = None):
|
def __init__(self, db_path: Optional[str] = None):
|
||||||
if storage_file is None:
|
self.db = SqliteStore(db_path)
|
||||||
storage_file = os.getenv('PEN_TRACKER_CSV')
|
|
||||||
if storage_file is None:
|
|
||||||
data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
|
|
||||||
app_data_dir = os.path.join(data_home, 'pen-tracker')
|
|
||||||
os.makedirs(app_data_dir, exist_ok=True)
|
|
||||||
storage_file = os.path.join(app_data_dir, 'pens.csv')
|
|
||||||
self.storage_file = storage_file
|
|
||||||
self.headers = [
|
self.headers = [
|
||||||
'Make', 'Model', 'Date-Purchased', 'Vendor', 'Nib',
|
'Make', 'Model', 'Date-Purchased', 'Vendor', 'Nib',
|
||||||
'Nib-Material', 'Body', 'Cap', 'Post',
|
'Nib-Material', 'Body', 'Cap', 'Post',
|
||||||
'Current-Ink', 'Inked-date', 'Notes'
|
'Current-Ink', 'Inked-date', 'Notes',
|
||||||
]
|
]
|
||||||
self.key_map = {
|
self.key_map = {
|
||||||
'Date-Purchased': 'Date_Purchased',
|
'Date-Purchased': 'Date_Purchased',
|
||||||
'Inked-date': 'Inked_date',
|
'Inked-date': 'Inked_date',
|
||||||
'Nib-Material': 'Nib_Material',
|
'Nib-Material': 'Nib_Material',
|
||||||
'Current-Ink': 'Current_Ink'
|
'Current-Ink': 'Current_Ink',
|
||||||
}
|
}
|
||||||
self.reverse_key_map = {v: k for k, v in self.key_map.items()}
|
self.reverse_key_map = {v: k for k, v in self.key_map.items()}
|
||||||
self.pens: List[Pen] = self.load_data()
|
self.pens: List[Pen] = self.load_data()
|
||||||
|
|
||||||
def _sort_pens(self):
|
def _sort_pens(self):
|
||||||
"""Sorts the pens list by Make, then by Model alphabetically."""
|
|
||||||
self.pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower()))
|
self.pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower()))
|
||||||
|
|
||||||
def load_data(self) -> List[Pen]:
|
def load_data(self) -> List[Pen]:
|
||||||
"""Loads data from the CSV file."""
|
rows = self.db.fetchall(
|
||||||
if not os.path.exists(self.storage_file):
|
'SELECT * FROM pens ORDER BY make COLLATE NOCASE, model COLLATE NOCASE'
|
||||||
self._create_empty_csv()
|
)
|
||||||
return []
|
pens: List[Pen] = []
|
||||||
|
for row in rows:
|
||||||
pens = []
|
pens.append(
|
||||||
try:
|
Pen(
|
||||||
# Standard, clean way to open the file
|
Make=row['make'],
|
||||||
with open(self.storage_file, mode='r', encoding='utf-8-sig') as f:
|
Model=row['model'],
|
||||||
reader = csv.DictReader(f)
|
Date_Purchased=row['date_purchased'],
|
||||||
for row in reader:
|
Vendor=row['vendor'],
|
||||||
# Strip whitespace from keys and values, handle empty values
|
Nib=row['nib'],
|
||||||
clean_row = {k.strip(): (v.strip() if v else "N/A") for k, v in row.items()}
|
Nib_Material=row['nib_material'],
|
||||||
# Map keys to dataclass field names
|
Body=row['body'],
|
||||||
mapped_row = {self.key_map.get(k, k): v for k, v in clean_row.items()}
|
Cap=row['cap'],
|
||||||
pens.append(Pen(**mapped_row))
|
Post=row['post'],
|
||||||
pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower()))
|
Current_Ink=row['current_ink'],
|
||||||
except Exception as e:
|
Inked_date=row['inked_date'],
|
||||||
logger.error(f"Error loading CSV: {e}")
|
Notes=row['notes'],
|
||||||
|
id=row['id'],
|
||||||
|
)
|
||||||
|
)
|
||||||
return pens
|
return pens
|
||||||
|
|
||||||
def _create_empty_csv(self):
|
def _insert_pen(self, pen: Pen) -> int:
|
||||||
"""Creates the CSV file with headers if it is missing."""
|
cursor = self.db.execute(
|
||||||
with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f:
|
'INSERT INTO pens (make, model, date_purchased, vendor, nib, nib_material, body, cap, post, current_ink, inked_date, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
writer = csv.DictWriter(f, fieldnames=self.headers)
|
(
|
||||||
writer.writeheader()
|
pen.Make,
|
||||||
|
pen.Model,
|
||||||
|
pen.Date_Purchased,
|
||||||
|
pen.Vendor,
|
||||||
|
pen.Nib,
|
||||||
|
pen.Nib_Material,
|
||||||
|
pen.Body,
|
||||||
|
pen.Cap,
|
||||||
|
pen.Post,
|
||||||
|
pen.Current_Ink,
|
||||||
|
pen.Inked_date,
|
||||||
|
pen.Notes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row_id = cursor.lastrowid
|
||||||
|
return int(row_id) if row_id is not None else 0
|
||||||
|
|
||||||
|
def _update_pen(self, pen: Pen):
|
||||||
|
self.db.execute(
|
||||||
|
'UPDATE pens SET make = ?, model = ?, date_purchased = ?, vendor = ?, nib = ?, nib_material = ?, body = ?, cap = ?, post = ?, current_ink = ?, inked_date = ?, notes = ? WHERE id = ?',
|
||||||
|
(
|
||||||
|
pen.Make,
|
||||||
|
pen.Model,
|
||||||
|
pen.Date_Purchased,
|
||||||
|
pen.Vendor,
|
||||||
|
pen.Nib,
|
||||||
|
pen.Nib_Material,
|
||||||
|
pen.Body,
|
||||||
|
pen.Cap,
|
||||||
|
pen.Post,
|
||||||
|
pen.Current_Ink,
|
||||||
|
pen.Inked_date,
|
||||||
|
pen.Notes,
|
||||||
|
pen.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _record_history(self, pen_id: int, ink_name: str):
|
||||||
|
self.db.execute(
|
||||||
|
'INSERT INTO pen_ink_history (pen_id, ink_name) VALUES (?, ?)',
|
||||||
|
(pen_id, ink_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_ink_history(self, pen_id: int) -> List[Dict[str, str]]:
|
||||||
|
return self.db.fetchall(
|
||||||
|
'SELECT * FROM pen_ink_history WHERE pen_id = ? ORDER BY changed_at',
|
||||||
|
(pen_id,),
|
||||||
|
)
|
||||||
|
|
||||||
def save_data(self):
|
def save_data(self):
|
||||||
"""Sorts the data before writing to ensure persistent alphabetical order."""
|
|
||||||
self._sort_pens()
|
self._sort_pens()
|
||||||
with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f:
|
existing_rows = {row['id']: row for row in self.db.fetchall('SELECT * FROM pens')}
|
||||||
writer = csv.DictWriter(f, fieldnames=self.headers)
|
seen_ids: Set[int] = set()
|
||||||
writer.writeheader()
|
|
||||||
for pen in self.pens:
|
for pen in self.pens:
|
||||||
row = asdict(pen)
|
if pen.id is None:
|
||||||
# Map back to CSV keys
|
pen.id = self._insert_pen(pen)
|
||||||
csv_row = {k.replace('_', '-'): v for k, v in row.items()}
|
self._record_history(pen.id, pen.Current_Ink)
|
||||||
writer.writerow(csv_row)
|
else:
|
||||||
|
old_row = existing_rows.pop(pen.id, None)
|
||||||
|
if old_row is None:
|
||||||
|
pen.id = self._insert_pen(pen)
|
||||||
|
self._record_history(pen.id, pen.Current_Ink)
|
||||||
|
else:
|
||||||
|
if old_row['current_ink'] != pen.Current_Ink:
|
||||||
|
self._record_history(pen.id, pen.Current_Ink)
|
||||||
|
self._update_pen(pen)
|
||||||
|
seen_ids.add(pen.id)
|
||||||
|
|
||||||
|
for orphan_id in existing_rows.keys():
|
||||||
|
self.db.execute('DELETE FROM pens WHERE id = ?', (orphan_id,))
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.pens = self.load_data()
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,11 +1,11 @@
|
||||||
import unittest
|
|
||||||
import tempfile
|
|
||||||
import os
|
import os
|
||||||
from pen_tracker.engine import PenTracker, Pen, InkTracker, Ink
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pen_tracker.engine import Ink, InkTracker, Pen, PenTracker
|
||||||
|
|
||||||
class TestPenTracker(unittest.TestCase):
|
class TestPenTracker(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.csv')
|
self.temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.db')
|
||||||
self.temp_file.close()
|
self.temp_file.close()
|
||||||
self.tracker = PenTracker(self.temp_file.name)
|
self.tracker = PenTracker(self.temp_file.name)
|
||||||
|
|
||||||
|
|
@ -33,11 +33,28 @@ 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")
|
||||||
|
|
||||||
|
def test_pen_ink_history_records_changes(self):
|
||||||
|
pen = Pen(Make="Pilot", Model="Metropolitan", Nib="F", Current_Ink="Blue")
|
||||||
|
self.tracker.pens.append(pen)
|
||||||
|
self.tracker.save_data()
|
||||||
|
|
||||||
|
persisted = self.tracker.pens[0]
|
||||||
|
history = self.tracker.get_ink_history(persisted.id)
|
||||||
|
self.assertEqual(len(history), 1)
|
||||||
|
self.assertEqual(history[0]['ink_name'], "Blue")
|
||||||
|
|
||||||
|
persisted.Current_Ink = "N/A"
|
||||||
|
self.tracker.save_data()
|
||||||
|
|
||||||
|
history = self.tracker.get_ink_history(persisted.id)
|
||||||
|
self.assertEqual(len(history), 2)
|
||||||
|
self.assertEqual(history[-1]['ink_name'], "N/A")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestInkTracker(unittest.TestCase):
|
class TestInkTracker(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.csv')
|
self.temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.db')
|
||||||
self.temp_file.close()
|
self.temp_file.close()
|
||||||
self.tracker = InkTracker(self.temp_file.name)
|
self.tracker = InkTracker(self.temp_file.name)
|
||||||
|
|
||||||
|
|
@ -64,20 +81,14 @@ class TestInkTracker(unittest.TestCase):
|
||||||
self.assertEqual(new_tracker.inks[0].Vendor, "A")
|
self.assertEqual(new_tracker.inks[0].Vendor, "A")
|
||||||
self.assertEqual(new_tracker.inks[1].Vendor, "Z")
|
self.assertEqual(new_tracker.inks[1].Vendor, "Z")
|
||||||
|
|
||||||
def test_loads_row_with_extra_trailing_field(self):
|
def test_save_and_load_ink(self):
|
||||||
csv_content = (
|
self.tracker.inks.append(Ink(Vendor='Waterman', Name='Intense Black', Color='Black', Notes='Good ink'))
|
||||||
"Vendor,Name,Color,Purchased,Size,Notes\n"
|
self.tracker.save_data()
|
||||||
"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)
|
new_tracker = InkTracker(self.temp_file.name)
|
||||||
self.assertEqual(len(new_tracker.inks), 2)
|
self.assertEqual(len(new_tracker.inks), 1)
|
||||||
self.assertEqual(new_tracker.inks[0].Vendor, "Pilot")
|
self.assertEqual(new_tracker.inks[0].Vendor, 'Waterman')
|
||||||
self.assertEqual(new_tracker.inks[1].Vendor, "Waterman")
|
self.assertEqual(new_tracker.inks[0].Name, 'Intense Black')
|
||||||
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