From 1d16056f4f122f9312b2eb2c32141eaf019b83aa Mon Sep 17 00:00:00 2001 From: Don Harper Date: Sat, 13 Jun 2026 23:56:07 -0500 Subject: [PATCH 1/2] Add PROJECT_DOCUMENTATION.md --- PROJECT_DOCUMENTATION.md | 165 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 PROJECT_DOCUMENTATION.md diff --git a/PROJECT_DOCUMENTATION.md b/PROJECT_DOCUMENTATION.md new file mode 100644 index 0000000..71ec940 --- /dev/null +++ b/PROJECT_DOCUMENTATION.md @@ -0,0 +1,165 @@ +# 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. +- `src/pen_tracker/engine.py` — domain logic, data models, CSV persistence, 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. +- When modifying storage columns, update both `engine.py` and `cli.py` to maintain header mapping and CSV serialization. +- One-based IDs are used consistently for user-facing selection and display. +- CSV 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. From c38a3cabc4906bbb819e9e791cea79d4ac341b81 Mon Sep 17 00:00:00 2001 From: Don Harper Date: Sun, 14 Jun 2026 00:24:13 -0500 Subject: [PATCH 2/2] Covert storage to sqlite backed, added ink history --- PROJECT_DOCUMENTATION.md | 9 +- README.md | 15 +- .../import_csv_to_sqlite.cpython-313.pyc | Bin 0 -> 5528 bytes import_csv_to_sqlite.py | 84 +++++ pyproject.toml | 2 +- src/pen_tracker.egg-info/PKG-INFO | 17 +- .../__pycache__/cli.cpython-313.pyc | Bin 18831 -> 19508 bytes .../__pycache__/engine.cpython-313.pyc | Bin 11317 -> 15287 bytes src/pen_tracker/cli.py | 26 +- src/pen_tracker/engine.py | 349 ++++++++++++------ tests/__pycache__/test_engine.cpython-313.pyc | Bin 6535 -> 7401 bytes tests/test_engine.py | 45 ++- 12 files changed, 397 insertions(+), 150 deletions(-) create mode 100644 __pycache__/import_csv_to_sqlite.cpython-313.pyc create mode 100755 import_csv_to_sqlite.py diff --git a/PROJECT_DOCUMENTATION.md b/PROJECT_DOCUMENTATION.md index 71ec940..3154064 100644 --- a/PROJECT_DOCUMENTATION.md +++ b/PROJECT_DOCUMENTATION.md @@ -18,7 +18,8 @@ This repository is structured as a standard Python package with source under `sr - `pyproject.toml` — packaging metadata, dependencies, and entry points. - `README.md` — user-facing usage documentation. - `PROJECT_DOCUMENTATION.md` — developer/AI reference documentation. -- `src/pen_tracker/engine.py` — domain logic, data models, CSV persistence, and storage defaults. +- `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. @@ -141,9 +142,11 @@ Entry point: ## Developer notes - The CLI is the only supported interface. The repository no longer includes any TUI or graphical code. -- When modifying storage columns, update both `engine.py` and `cli.py` to maintain header mapping and CSV serialization. +- 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. -- CSV persistence always sorts data before saving. +- SQLite persistence always sorts data before saving. ## Testing diff --git a/README.md b/README.md index ad359b5..92f7cbb 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ pip install . ## Data Storage -Pen data is stored in `~/.local/share/pen-tracker/pens.csv` by default, following XDG Base Directory specification. The directory is created automatically if it doesn't exist. +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 @@ -25,11 +25,20 @@ pen-tracker Command-line mode: ```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 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 - Track fountain pens with details like make, model, nib, ink, etc. diff --git a/__pycache__/import_csv_to_sqlite.cpython-313.pyc b/__pycache__/import_csv_to_sqlite.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9a2f0de4025fcbb99bf67f97259351263a0b823d GIT binary patch literal 5528 zcmeHLZA@F&89vwd`ugMA*kA}T&4)us8wUvaAR(j)8O0<42SV*!LI#p`u`l3YuHAD@ zns{pEPMQi;rO;|s-0sK7G%ZA$v`CvK`Y%aaO`Rsq#>;aQ{I+g8ws)7|vTg@B%xUs^Xz(MX6smt2p{PdqWoj?H&HkK&T_wuh<8CXF|UA3+Q83ddh_+?^Ul6>*NUs^ zM$3CGH%`2FV%9QuGVf|lpIc$rxBWl&&)8;@3rxcbXTRPu-E#fJ^ofjZ_Hv#(m}L&G z!I98KwQ91kSTLi8dtRySCASv2u;QCcF6o#od{S} zr`=FhO&aVZ!b1e%a>Y6l6=M-em=NXgMs6Z^kZA6j1f?Hrys;Zm03abiIC)dxk;X6@ zpGBZ$6X7$Rk9c6dpdm;HnO4StB*P#ObRh(uvfo;t63x>An}%MGwXuXI;Lef=7wTtZ zLlcq>E_4>QFzySG-Q5&46VYersYHkaQz089hNokM+11d5BqL8lA8#Tgu{>MK;A*|V zn5KTSOdlwWdI+IO*o(wf-bD7i2iD4)o;3^Jtgj1nfHyx2d!7m!Lx!H_!mK5WWRlE0 z1@naCmaM!bP{P>r)}T>iR}bm|Js~URjRB$}n*wjq&H#|<0uqco{H^!E8|z@Dg*_W7 zq-e=3nMTkq)S%n92w0#IK<$LFJm?~{IZ5wCBb1lzRVbXYOeRMfk2T8CQ3ZKvg_(#) zM8db>cqA%~Dke#MJr09YvkN}?F$f^|A`iDsw`J;NiEPfa|IzmjwCfD@XpKeg%hV5c zQnhF~Rjhq1!%anhJnGv+-}AbCHSB#uEu`<)S$sRK_YXFJ{ABxKDEYL8BXW<`*KATu zk>P^XRc$xfQ$^)Pk_r>r(gN{Mb^uGJ`s)4eSo+*IsLn zbae+BFv10Aby5Mwme@hvi3u7HAfEM6M=2l^xW43S!KGveL4ia%t26<2-veh|yC+kQ#hJOT!+PFd7TDl)5utn>0J=R)5KQvE0wI7B@P-oE4@WRlDkkyG0_Mn(%$bLvr1C*Dt zbgBNK81bYy53$>x5geZ&8R8QbGoIk9=YX?3#Je08WD$VTWK@VnQ_xOG9{1cv-ZD%K zEG7!5l8j;+Q@KmVM2i)M^sQKA;fkm_shAjDe1KG$#>H!)2_d1-;F5y>3*#!r1ddAZ z9EypO*M?6LY#R}$NCOV~3KgAdg?QL{hFnL&h%#;==njG=m$guKjFhz!aR!7;47tXY zBrfnJS9F-kT5&Hxz-}Vh16LT>vSM52V!mQe+P|Lq*0C)!u=LXL+)Kyvj+fJCzpCAr z=jwm6H*@&ij_K~1-S2ka+?)1gx%%bm7Z#byWyZ0@xO0qqk$K^Xrk8{-xZ#CMLN>s^ z+mW;N+@=3wxo7#5TdX*DpZl1Zr)FMWa=n;yy}0Dsmvil#JD7JJxU*})b@(2)P;qY2 z)|0y=q&I2-7nWWy^S?Yn7 z$uV(r3(rV2Cc+OHCe52+#>7}6Wo>LEcexR)W2#DPPD<3dkTwKDc;UwoQldk=R>dsf z(S(3yQ7LQ3qmvV&lz;#**Xc zqNh3UIhsEEWXZRTW5zUl>k3l=~n zc47u#j3icnQ7G&^aXr-oP&bqGNBRwga675+5TQOL%_qR=o{WiYI09ur4oLnC#41fu z)HlfS09hX(3&?+>?O!AMZSz~^w;gXeW~iA%`i~BVTc(?)TaKHKJLbHnb^b`+)xL;c z`4>8sN2gX<6LlyPeuyAit=yO{qnsIf6+tvtITya|R+D#<^S-;TyWzWAKkm)7_#cw$ z)mkTYEaO^55Y0ggi0;U0G@pD(at~kRsK(W8h_Owj^0fOa#{7%Zt2AP2)Mfq$0P`^y literal 0 HcmV?d00001 diff --git a/import_csv_to_sqlite.py b/import_csv_to_sqlite.py new file mode 100755 index 0000000..fb7f518 --- /dev/null +++ b/import_csv_to_sqlite.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index b2763bb..24017fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pen-tracker" -version = "0.5.0" +version = "0.5.1" 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 26d8654..57bafce 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.5.0 +Version: 0.5.1 Summary: A fountain pen collection tracker. Author-email: Don Harper Requires-Python: >=3.8 @@ -20,9 +20,9 @@ pip install . ## Data Storage -Pen data is stored in `~/.local/share/pen-tracker/pens.csv` by default, following XDG Base Directory specification. The directory is created automatically if it doesn't exist. +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 @@ -35,11 +35,20 @@ pen-tracker Command-line mode: ```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 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 - Track fountain pens with details like make, model, nib, ink, etc. diff --git a/src/pen_tracker/__pycache__/cli.cpython-313.pyc b/src/pen_tracker/__pycache__/cli.cpython-313.pyc index 3860351dd39fed5ca7ac581aaea6b09d7372c6eb..4fab42132cee0bbfe06fa25da574ae78b06d7e9f 100644 GIT binary patch delta 3652 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 T?P7<9(z?6qu!4qZnrHq81JQcI delta 3024 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 diff --git a/src/pen_tracker/__pycache__/engine.cpython-313.pyc b/src/pen_tracker/__pycache__/engine.cpython-313.pyc index 507df11c117ee97cd79e75fd91b8f8bcb13b2db9..4ff440f6f78d0e9245346659b3cd7a54b4f1aba5 100644 GIT binary patch literal 15287 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 literal 11317 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 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 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" + Vendor: str = 'N/A' + Name: str = 'N/A' + Color: str = 'N/A' + Purchased: str = 'N/A' + Size: str = 'N/A' + Notes: str = 'N/A' + id: Optional[int] = None + @dataclass class Pen: - Make: str = "N/A" - Model: str = "N/A" - Date_Purchased: str = "N/A" - Vendor: str = "N/A" - Nib: str = "N/A" - Nib_Material: str = "N/A" - Body: str = "N/A" - Cap: str = "N/A" - Post: str = "N/A" - Current_Ink: str = "N/A" - Inked_date: str = "N/A" - Notes: str = "N/A" + Make: str = 'N/A' + Model: str = 'N/A' + Date_Purchased: str = 'N/A' + Vendor: str = 'N/A' + Nib: str = 'N/A' + Nib_Material: str = 'N/A' + Body: str = 'N/A' + Cap: str = 'N/A' + Post: str = 'N/A' + Current_Ink: str = 'N/A' + Inked_date: str = 'N/A' + Notes: str = 'N/A' + id: Optional[int] = None + class InkTracker: - def __init__(self, storage_file: str = None): - if storage_file is None: - 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 + def __init__(self, db_path: Optional[str] = None): + self.db = SqliteStore(db_path) 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}") + rows = self.db.fetchall( + 'SELECT * FROM inks ORDER BY vendor COLLATE NOCASE, name COLLATE NOCASE' + ) + inks: List[Ink] = [] + for row in rows: + inks.append( + Ink( + Vendor=row['vendor'], + Name=row['name'], + Color=row['color'], + Purchased=row['purchased'], + Size=row['size'], + Notes=row['notes'], + id=row['id'], + ) + ) 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)) + self.db.execute('DELETE FROM inks') + for ink in self.inks: + cursor = self.db.execute( + 'INSERT INTO inks (vendor, name, color, purchased, size, notes) VALUES (?, ?, ?, ?, ?, ?)', + (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: - def __init__(self, storage_file: str = None): - if storage_file is None: - storage_file = os.getenv('PEN_TRACKER_CSV') - if storage_file is None: - data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share')) - app_data_dir = os.path.join(data_home, 'pen-tracker') - os.makedirs(app_data_dir, exist_ok=True) - storage_file = os.path.join(app_data_dir, 'pens.csv') - self.storage_file = storage_file + def __init__(self, db_path: Optional[str] = None): + self.db = SqliteStore(db_path) self.headers = [ 'Make', 'Model', 'Date-Purchased', 'Vendor', 'Nib', 'Nib-Material', 'Body', 'Cap', 'Post', - 'Current-Ink', 'Inked-date', 'Notes' + 'Current-Ink', 'Inked-date', 'Notes', ] self.key_map = { 'Date-Purchased': 'Date_Purchased', 'Inked-date': 'Inked_date', '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.pens: List[Pen] = self.load_data() def _sort_pens(self): - """Sorts the pens list by Make, then by Model alphabetically.""" self.pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower())) def load_data(self) -> List[Pen]: - """Loads data from the CSV file.""" - if not os.path.exists(self.storage_file): - self._create_empty_csv() - return [] - - pens = [] - try: - # Standard, clean way to open the file - with open(self.storage_file, mode='r', encoding='utf-8-sig') as f: - reader = csv.DictReader(f) - for row in reader: - # Strip whitespace from keys and values, handle empty values - clean_row = {k.strip(): (v.strip() if v else "N/A") for k, v in row.items()} - # Map keys to dataclass field names - mapped_row = {self.key_map.get(k, k): v for k, v in clean_row.items()} - pens.append(Pen(**mapped_row)) - pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower())) - except Exception as e: - logger.error(f"Error loading CSV: {e}") + rows = self.db.fetchall( + 'SELECT * FROM pens ORDER BY make COLLATE NOCASE, model COLLATE NOCASE' + ) + pens: List[Pen] = [] + for row in rows: + pens.append( + Pen( + Make=row['make'], + Model=row['model'], + Date_Purchased=row['date_purchased'], + Vendor=row['vendor'], + Nib=row['nib'], + Nib_Material=row['nib_material'], + Body=row['body'], + Cap=row['cap'], + Post=row['post'], + Current_Ink=row['current_ink'], + Inked_date=row['inked_date'], + Notes=row['notes'], + id=row['id'], + ) + ) 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 _insert_pen(self, pen: Pen) -> int: + cursor = self.db.execute( + 'INSERT INTO pens (make, model, date_purchased, vendor, nib, nib_material, body, cap, post, current_ink, inked_date, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ( + 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): - """Sorts the data before writing to ensure persistent alphabetical order.""" self._sort_pens() - with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: - writer = csv.DictWriter(f, fieldnames=self.headers) - writer.writeheader() - for pen in self.pens: - row = asdict(pen) - # Map back to CSV keys - csv_row = {k.replace('_', '-'): v for k, v in row.items()} - writer.writerow(csv_row) + existing_rows = {row['id']: row for row in self.db.fetchall('SELECT * FROM pens')} + seen_ids: Set[int] = set() + + for pen in self.pens: + if pen.id is None: + pen.id = self._insert_pen(pen) + self._record_history(pen.id, pen.Current_Ink) + 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() diff --git a/tests/__pycache__/test_engine.cpython-313.pyc b/tests/__pycache__/test_engine.cpython-313.pyc index 69504fe1b6c77e4b9e827f4c2c50d6fc253f43bc..1af50c58522b5937f1b1e5038a118a332219f044 100644 GIT binary patch delta 1832 zcmbVMO-vg{6yDig+v9)ZUrd0Q6w^2*1~noWH4RA!N+Fb{HYnw%SsA8U{PrT(RE-X0^U--7EPUuo5Gz)`1 zk{q3oi6&f;70tq6UqKznPphnF=`i|P6r!mS+QD}sBfZ7Xi(xf*W!Wb*3CKV@jmMC2 zdEEFb?=!Ic!~^gez)1ijO`3OKA4P0_ASN~u*#Ei0bKD(nsANl^1fLKRV#3fS(mDGa zeIN#?!|S121|zTV)QJND>p_(A*w4pp>`SY7MWCNzzkjIWz}7Fc><3Sn==Zp;R!A7( zkO{1#*uo9~65fq^Do zn4Fd)B6$@w#{nXswsuStA}e``T@!($k`tiDX=OrEk{NkM=xsW`4TkN%nM5oEa>5PF z?q5M8tl=Is=a`(AVB>f?m(P+L8lO&)D43f7-ekZI;DdQH1I;`olN@uEQ<`aiOHO2l z5EJ4DkNUiv%S%(TayYC+3ymA@sOpX`-dMW%@aCHP z%)Dt6+ct2eiYveL+`IZkb@8Kh94YDU1&S5-tBT!fu<4P19mlpp;d%2qu3-+J+itOq zwJNS%$Dyr%1;6~?qr2}PDGsW^=11YDI7X`22R-D8iw!z0dLtH^vUKsJlYVV!LDh82 z;bZ^#L72z!bn#gb$be)k$1uH#@+%aOpq<^r$4cW;V*+@0{3e8YBDdAOj4P# zcPV*U$;qJ$lk8d34EivGFJ-f-5bJ){;^j&Vu{fHcH_Q51Czf87i0x1D0Bryt0Q3O74FIjH)115mGXQ9uTs57kJQ6h-E}}gbFlbnkGRd?eNfA4# z1$T$8omSF$_(-}^8JpF^z+p5Id|$|-;WL?RYI;&WOESy{9_<`dVn;v-J>$&JI4Ar4 y$hEI=?YrVh)VA>6hOa^OHSBV1u{(n5(M43&!D^=maiVR0W=#lgiZ*&Z==ulvn!F_d delta 1481 zcmb7@T}&KR6vywKk7ah)57^yd`BDZ_u-gvMqO~C3(gvwvrEHNJ4daBJg$c_{y)#Hd zVzzy;H72&}jq$bd!RV7DJosc{;;Wjhg_KNVeDT4?7fjbQ`s6)BV2z0;-ktr;IcNUo z-g9rxeKd6I+1TS~R7Pw(zVn;)S$#S78jt21C>E3DcTzP|Dt{4UUNe1*PnCCDI^{Xj zzCw)UHIw)}vweBSUONsjo5pYx)@UVuIz*(Ex{O5Mz`uw>-?IBQeTln>dHMl&Tj;5@ zT;6Hr`*|$TOQ9~z@2rP@<`jX|Ly~~gfL;Jk3*l<~bB;6|eavRUv>`r2m*N9BP0Mjb z{JlvM2kCa)!adZ+75ZyKI~J+bIEbI2lZ{_uZRd|hjcW@X&EN}`z2*|_coC6Kq?$vs z;AiPlY6+67PBCP{H4Ec?Hzo(DYnpWn^N6%=b(HxRJ4HR|6QG|lVBgL8E%Tn z?(qN2N+C_g*#ayhF6Cv2JQp zPLY{&8)SLaaLv4yv$jkv>v*OsFB+an)(u;mu|3mvP4&ehYe(&nTGy08JW{gqD`p@d zw&7WhZ4}kqszbakO`fxgj&~?m196}|OdmKi@Lcbiv+U7opQarrud;rTeZ09PZg_>> z!3_2#(_VJ+mc8;QXo>(Q*d~MaDEZP+nOl6ee=;Ncf^*HZYrWx%o5W%p2|6a|lYU4J zS_zuY7lYkq1fqaf&#sTY?7)sa9~a137IfjZsBDlXkep@y;y7NP2Mp)dHM(M25OfR=} zmIf*<1KSt(q!*sVnPx9K`lk!+`AK4Gg#Ttlz983;VdiHuG8tiSG71<2j04gD6)+DN z0=x;B089c}0dPzN_7I?l8EzZHyY$DlbZ=A~K2(9gmvnvIuxwq=M15()wmdi@5`)~Q z8E9AgU@4dh>hT58{$n9u8d-Pp8%1-BtS}90d*niS2Rz33A&Li2_$M@eAB|Uqc073Z p>I0>xqV!Y|bE*^g6dtaHm~(IyBO!8o>ptJIFGOz5(ueJ_zW@`nW}yH8 diff --git a/tests/test_engine.py b/tests/test_engine.py index dfdce76..88e0b13 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,11 +1,11 @@ -import unittest -import tempfile 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): 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.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[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): 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.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[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) + def test_save_and_load_ink(self): + self.tracker.inks.append(Ink(Vendor='Waterman', Name='Intense Black', Color='Black', Notes='Good ink')) + self.tracker.save_data() 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") + self.assertEqual(len(new_tracker.inks), 1) + self.assertEqual(new_tracker.inks[0].Vendor, 'Waterman') + self.assertEqual(new_tracker.inks[0].Name, 'Intense Black') if __name__ == '__main__': unittest.main() \ No newline at end of file