libro-book 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ ---
2
+ description:
3
+ globs:
4
+ alwaysApply: true
5
+ ---
6
+ You are an expert Python developer, skilled in the ability to create beautiful command-line tools utilizing libraries and frameworks such as rich.
7
+
8
+ Key Principles:
9
+ - Write concise, technical responses with accurate Python examples.
10
+ - Prioritize readability, efficiency, and maintainability.
11
+ - Use modular and reusable functions to handle common tasks.
12
+ - Follow PEP 8 style guidelines for Python code.
13
+
14
+ Data Handling:
15
+ - Data is stored in a sqlite3 database, only use functions and SQL that are compatibile
16
+ - Dates are stored in YYYY-MM-DD format
17
+
18
+
@@ -0,0 +1,7 @@
1
+ data/
2
+ libro.db
3
+ libro.bak
4
+
5
+ .ruff_cache
6
+ uv.lock
7
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Marcus Kazmierczak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: libro-book
3
+ Version: 0.1.0
4
+ Summary: A command-line tool to track books read
5
+ Author-email: Marcus Kazmierczak <marcus@mkaz.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: books,library,reading
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Natural Language :: English
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: appdirs>=1.4
17
+ Requires-Dist: rich>=13.3
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Libro
21
+
22
+ 📚 Libro: A simple command-line tool to track your reading history, with your data stored locally in a SQLite database.
23
+
24
+ ## Usage
25
+
26
+ Add new book: `libro add`
27
+
28
+ Show books read by year: `libro show --year 2024`
29
+
30
+ Show book details by id: `libro show 123`
31
+
32
+ Show books read by year: `libro report`
33
+
34
+ Show books read by author: `libro report --author`
35
+
36
+ ## Install
37
+
38
+ Libro is packaged as `libro-book` on PyPI.
39
+
40
+ ```
41
+ pip install libro-book
42
+ ```
43
+
44
+ You can also clone this repository and install it locally:
45
+
46
+ ```
47
+ git clone https://github.com/mkaz/libro.git
48
+ cd libro
49
+ pip install -e .
50
+ ```
51
+
52
+ ## Setup
53
+
54
+ On first run, libro will create a `libro.db` database file based on database location. It will prompt for confirmation to proceed which also shows the location where the file will be created.
55
+
56
+ **Database locations:**
57
+
58
+ The following order is used to determine the database location:
59
+
60
+ 1. Using the `--db` flag on command-line.
61
+
62
+ 2. `libro.db` in current directory
63
+
64
+ 3. Environment variable `LIBRO_DB` to specify custom file/location
65
+
66
+ 4. Finally, the user's platform-specific data directory
67
+ * Linux: `~/.local/share/libro/libro.db`
68
+ * macOS: `~/Library/Application Support/libro/libro.db`
69
+ * Windows: `%APPDATA%\libro\libro.db`
70
+
71
+
72
+ For example, if you want to create a new database file in the current directory, you can use the following command:
73
+
74
+ ```
75
+ libro --db ./libro.db
76
+ ```
77
+
78
+ ### Import from Goodreads
79
+
80
+ Libro can import your reading history from a Goodreads export CSV file.
81
+
82
+ ```
83
+ libro import goodreads_library_export.csv
84
+ ```
85
+
86
+ There is a `genre` field for fiction and nonfiction, but this data is not available in the Goodreads export. I still need to build the edit book functionality to change the genre.
87
+
88
+ # Database Schema
89
+
90
+ ## Books table
91
+
92
+ | Field | Type | Description |
93
+ |-------|------|-------------|
94
+ | id | primary key | Unique identifier |
95
+ | title | string | Book title |
96
+ | author | string | Book author |
97
+ | pages | int | Number of pages in book |
98
+ | pub_year | int | Year book was published |
99
+ | genre | string | Fiction or nonfiction |
100
+
101
+ ## Reviews table
102
+
103
+ | Field | Type | Description |
104
+ |-------|------|-------------|
105
+ | id | primary key | Unique identifier |
106
+ | book_id | foreign key | Book identifier |
107
+ | date_read | date | Date book was read |
108
+ | rating | float | Number between 0 and 5 |
109
+ | review | text | Review of book |
110
+
111
+ # Packaging
112
+
113
+ Notes to self, I forget how to do this stuff.
114
+
115
+ Libro is packaged as `libro-book` on PyPI.
116
+
117
+ Packaging is done with `hatchling`, [see Guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/)
118
+
119
+ ```
120
+ # install tools
121
+ py -m pip install --upgrade build twine
122
+ ```
123
+
124
+ ```
125
+ # build
126
+ py -m build
127
+ ```
128
+
129
+ ```
130
+ # upload
131
+ py -m twine upload dist/*
132
+ ```
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env just --justfile
2
+
3
+ # List all recipes
4
+ default:
5
+ @just --list
6
+
7
+ # Run pre-commit checks
8
+ lint:
9
+ ruff check src/libro/
10
+
11
+ # Clean Python artifacts
12
+ clean:
13
+ rm -rf build/
14
+ rm -rf dist/
15
+ rm -rf *.egg-info
16
+ find . -type d -name __pycache__ -exec rm -rf {} +
17
+ find . -type f -name "*.pyc" -delete
18
+
19
+ # Build the project
20
+ install:
21
+ uv sync
22
+
23
+ build: install
24
+ uv build
25
+
26
+ # Run the CLI application
27
+ run *args:
28
+ uv run src/libro/main.py {{args}}
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "libro-book"
3
+ version = "0.1.0"
4
+ description = "A command-line tool to track books read"
5
+ authors = [ {name = "Marcus Kazmierczak", email = "marcus@mkaz.com"} ]
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ readme = "readme.md"
9
+ keywords = ["books", "reading", "library"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: End Users/Desktop",
14
+ "Natural Language :: English",
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+ requires-python = ">=3.10"
19
+ dependencies = [
20
+ "appdirs>=1.4",
21
+ "rich>=13.3",
22
+ ]
23
+
24
+ [project.scripts]
25
+ libro = "libro.main:main"
26
+
27
+ [build-system]
28
+ requires = ["hatchling"]
29
+ build-backend = "hatchling.build"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/libro"]
33
+
34
+
35
+ [tool.ruff]
36
+ target-version = "py310"
@@ -0,0 +1,113 @@
1
+ # Libro
2
+
3
+ 📚 Libro: A simple command-line tool to track your reading history, with your data stored locally in a SQLite database.
4
+
5
+ ## Usage
6
+
7
+ Add new book: `libro add`
8
+
9
+ Show books read by year: `libro show --year 2024`
10
+
11
+ Show book details by id: `libro show 123`
12
+
13
+ Show books read by year: `libro report`
14
+
15
+ Show books read by author: `libro report --author`
16
+
17
+ ## Install
18
+
19
+ Libro is packaged as `libro-book` on PyPI.
20
+
21
+ ```
22
+ pip install libro-book
23
+ ```
24
+
25
+ You can also clone this repository and install it locally:
26
+
27
+ ```
28
+ git clone https://github.com/mkaz/libro.git
29
+ cd libro
30
+ pip install -e .
31
+ ```
32
+
33
+ ## Setup
34
+
35
+ On first run, libro will create a `libro.db` database file based on database location. It will prompt for confirmation to proceed which also shows the location where the file will be created.
36
+
37
+ **Database locations:**
38
+
39
+ The following order is used to determine the database location:
40
+
41
+ 1. Using the `--db` flag on command-line.
42
+
43
+ 2. `libro.db` in current directory
44
+
45
+ 3. Environment variable `LIBRO_DB` to specify custom file/location
46
+
47
+ 4. Finally, the user's platform-specific data directory
48
+ * Linux: `~/.local/share/libro/libro.db`
49
+ * macOS: `~/Library/Application Support/libro/libro.db`
50
+ * Windows: `%APPDATA%\libro\libro.db`
51
+
52
+
53
+ For example, if you want to create a new database file in the current directory, you can use the following command:
54
+
55
+ ```
56
+ libro --db ./libro.db
57
+ ```
58
+
59
+ ### Import from Goodreads
60
+
61
+ Libro can import your reading history from a Goodreads export CSV file.
62
+
63
+ ```
64
+ libro import goodreads_library_export.csv
65
+ ```
66
+
67
+ There is a `genre` field for fiction and nonfiction, but this data is not available in the Goodreads export. I still need to build the edit book functionality to change the genre.
68
+
69
+ # Database Schema
70
+
71
+ ## Books table
72
+
73
+ | Field | Type | Description |
74
+ |-------|------|-------------|
75
+ | id | primary key | Unique identifier |
76
+ | title | string | Book title |
77
+ | author | string | Book author |
78
+ | pages | int | Number of pages in book |
79
+ | pub_year | int | Year book was published |
80
+ | genre | string | Fiction or nonfiction |
81
+
82
+ ## Reviews table
83
+
84
+ | Field | Type | Description |
85
+ |-------|------|-------------|
86
+ | id | primary key | Unique identifier |
87
+ | book_id | foreign key | Book identifier |
88
+ | date_read | date | Date book was read |
89
+ | rating | float | Number between 0 and 5 |
90
+ | review | text | Review of book |
91
+
92
+ # Packaging
93
+
94
+ Notes to self, I forget how to do this stuff.
95
+
96
+ Libro is packaged as `libro-book` on PyPI.
97
+
98
+ Packaging is done with `hatchling`, [see Guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/)
99
+
100
+ ```
101
+ # install tools
102
+ py -m pip install --upgrade build twine
103
+ ```
104
+
105
+ ```
106
+ # build
107
+ py -m build
108
+ ```
109
+
110
+ ```
111
+ # upload
112
+ py -m twine upload dist/*
113
+ ```
@@ -0,0 +1,54 @@
1
+ import sqlite3
2
+
3
+ from libro.utils import get_valid_input, validate_and_convert_date
4
+ from libro.models import Book, Review
5
+
6
+
7
+ def add_book(db, args):
8
+ try:
9
+ print("Enter book details:")
10
+ title = get_valid_input("Title: ")
11
+ author = get_valid_input("Author: ")
12
+
13
+ pub_year = get_valid_input(
14
+ "Publication year: ",
15
+ lambda x: validate_and_convert_date(x, "pub_year"),
16
+ allow_empty=True,
17
+ )
18
+ pages = get_valid_input("Number of pages: ", allow_empty=True)
19
+ genre = get_genre()
20
+
21
+ date_read = get_valid_input(
22
+ "Date read (YYYY-MM-DD): ",
23
+ lambda x: validate_and_convert_date(x, "date_read"),
24
+ allow_empty=True,
25
+ )
26
+ rating = get_valid_input("Rating (1-5): ", allow_empty=True)
27
+ my_review = get_valid_input("Your review:", allow_empty=True, multiline=True)
28
+
29
+ # Create and insert book
30
+ book = Book(
31
+ title=title, author=author, pub_year=pub_year, pages=pages, genre=genre
32
+ )
33
+ book_id = book.insert(db)
34
+
35
+ # Create and insert review
36
+ review = Review(
37
+ book_id=book_id, date_read=date_read, rating=rating, review=my_review
38
+ )
39
+ review.insert(db)
40
+
41
+ print(f"\nSuccessfully added '{title}' to the database!")
42
+
43
+ except sqlite3.Error as e:
44
+ print(f"Database error: {e}")
45
+ except Exception as e:
46
+ print(f"Error: {e}")
47
+
48
+
49
+ def get_genre():
50
+ while True:
51
+ genre = input("Genre (fiction/nonfiction): ").strip().lower()
52
+ if genre in ["fiction", "nonfiction"]:
53
+ return genre
54
+ print("Please enter either 'fiction' or 'nonfiction'")
@@ -0,0 +1,28 @@
1
+ import sqlite3
2
+
3
+
4
+ def init_db(dbfile):
5
+ conn = sqlite3.connect(dbfile)
6
+ cursor = conn.cursor()
7
+ cursor.execute("""CREATE TABLE books (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ title TEXT NOT NULL,
10
+ author TEXT NOT NULL,
11
+ pub_year INTEGER,
12
+ pages INTEGER,
13
+ genre TEXT
14
+ )
15
+ """)
16
+ conn.commit()
17
+
18
+ cursor.execute("""CREATE TABLE reviews (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ book_id INTEGER,
21
+ date_read DATE,
22
+ rating INTEGER,
23
+ review TEXT,
24
+ FOREIGN KEY (book_id) REFERENCES books(id)
25
+ )
26
+ """)
27
+
28
+ conn.close()
@@ -0,0 +1,94 @@
1
+ from pathlib import Path
2
+ import csv
3
+ import re
4
+ from datetime import datetime
5
+ from libro.models import Book, Review
6
+
7
+
8
+ def import_books(db, args):
9
+ f = args["file"]
10
+ print(f"Importing books from {f}")
11
+
12
+ # check file exists
13
+ if not Path(f).is_file():
14
+ print(f"File {f} not found")
15
+ return
16
+
17
+ # read file
18
+ count = 0
19
+ with open(f, "r") as file:
20
+ reader = csv.DictReader(file)
21
+ for row in reader:
22
+ # Author field includes where spacing and tabs, so we need to clean it up
23
+ author = row["Author"].replace("\t", " ").replace(" ", " ").strip()
24
+ while " " in author:
25
+ author = author.replace(" ", " ")
26
+
27
+ # @TODO: Make this a import flag
28
+ # Title field includes series info that is not the title
29
+ # For example: Ender's Game (Ender's Saga, #1)
30
+ raw_title = row["Title"].strip()
31
+ # Regex to capture the title part before parenthesis *only if* the parenthesis contains '#'
32
+ series_pattern = re.compile(r"^(.*?)\s*\([^#]*#.*\)$")
33
+ match = series_pattern.match(raw_title)
34
+ if match:
35
+ # If it matches the series pattern (contains '#'), take the part before the parenthesis
36
+ title = match.group(1).strip()
37
+ else:
38
+ # Otherwise (no parenthesis or parenthesis without '#'), use the raw title as is
39
+ title = raw_title
40
+
41
+ # @TODO: Make this a import flag
42
+ # Moar cleanup - annoying non-fiction books have a colon and extra junk to promote.
43
+ # Remove colon and everything after it
44
+ # For example: Eats, Shoots & Leaves: The Zero Tolerance Approach to Punctuation
45
+ title = title.split(":")[0].strip()
46
+
47
+ pub_year = row["Original Publication Year"].strip()
48
+ pages = row["Number of Pages"].strip()
49
+ # Note: Ensure 'from datetime import datetime' is present at the top of the file.
50
+ raw_date_read = row["Date Read"].strip()
51
+ date_read = None # Default to None if empty or invalid
52
+ if raw_date_read:
53
+ try:
54
+ # Parse the date assuming Goodreads format YYYY/MM/DD
55
+ date_obj = datetime.strptime(raw_date_read, "%Y/%m/%d")
56
+ # Format to YYYY-MM-DD, which is suitable for SQLite and the Review model
57
+ date_read = date_obj.strftime("%Y-%m-%d")
58
+ except ValueError:
59
+ # Handle cases where the date format might be different or invalid
60
+ print(
61
+ f"Warning: Could not parse 'Date Read' field ('{raw_date_read}') for {title}. Setting date to None."
62
+ )
63
+ rating = row["My Rating"].strip()
64
+ review = row["My Review"].strip()
65
+
66
+ # There are many lets combine and look for "read"
67
+ # Bookshelves, Bookshelves with positions, Exclusive Shelf into a set
68
+ shelf1 = row["Bookshelves"]
69
+ shelf2 = row["Bookshelves with positions"]
70
+ shelf3 = row["Exclusive Shelf"]
71
+ shelf = ",".join([s.strip() for s in [shelf1, shelf2, shelf3] if s])
72
+ shelf = shelf.split(",")
73
+ shelf = set(shelf)
74
+
75
+ if "read" in shelf:
76
+ count += 1
77
+
78
+ # Create and insert book
79
+ book = Book(
80
+ title=title,
81
+ author=author,
82
+ pub_year=pub_year,
83
+ pages=pages,
84
+ genre="fiction", # Default to fiction, could be improved
85
+ )
86
+ book_id = book.insert(db)
87
+
88
+ # Create and insert review
89
+ review = Review(
90
+ book_id=book_id, date_read=date_read, rating=rating, review=review
91
+ )
92
+ review.insert(db)
93
+
94
+ print(f"Imported {count} books")
@@ -0,0 +1,92 @@
1
+ # Bar chart of books read by year
2
+
3
+ import sqlite3
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+ from rich import box
7
+
8
+
9
+ def get_books_by_year(db):
10
+ """Get count of books read per year."""
11
+ try:
12
+ cursor = db.cursor()
13
+ cursor.execute(
14
+ """
15
+ SELECT strftime('%Y', r.date_read) as year, COUNT(*) as count
16
+ FROM books b
17
+ JOIN reviews r ON b.id = r.book_id
18
+ WHERE r.date_read IS NOT NULL
19
+ GROUP BY year
20
+ ORDER BY year
21
+ """
22
+ )
23
+ return cursor.fetchall()
24
+ except sqlite3.Error as e:
25
+ print(f"Database error: {e}")
26
+ return None
27
+
28
+
29
+ def show_author_report(db):
30
+ """Display a report of most read authors."""
31
+ try:
32
+ cursor = db.cursor()
33
+ cursor.execute("""
34
+ SELECT b.author, COUNT(*) as count
35
+ FROM books b
36
+ JOIN reviews r ON b.id = r.book_id
37
+ WHERE r.date_read IS NOT NULL
38
+ GROUP BY b.author
39
+ HAVING count >= 3
40
+ ORDER BY count DESC
41
+ """)
42
+ authors = cursor.fetchall()
43
+
44
+ if not authors:
45
+ print("No authors found with more than 3 books read.")
46
+ return
47
+
48
+ console = Console()
49
+ table = Table(show_header=True, title="Most Read Authors", box=box.SIMPLE)
50
+ table.add_column("Author", style="cyan")
51
+ table.add_column("Books Read", style="green")
52
+
53
+ for author, count in authors:
54
+ table.add_row(author, str(count))
55
+
56
+ console.print(table)
57
+
58
+ except sqlite3.Error as e:
59
+ print(f"Database error: {e}")
60
+
61
+
62
+ def show_year_report(db):
63
+ """Display a bar chart of books read per year."""
64
+ books_by_year = get_books_by_year(db)
65
+ if not books_by_year:
66
+ print("No books found with read dates.")
67
+ return
68
+
69
+ console = Console()
70
+ table = Table(show_header=True, title="Books Read by Year", box=box.SIMPLE)
71
+ table.add_column("Year", style="cyan")
72
+ table.add_column("Count", style="green")
73
+ table.add_column("Bar", style="blue")
74
+
75
+ max_count = max(count for _, count in books_by_year)
76
+
77
+ for year, count in books_by_year:
78
+ # Create a bar using block characters
79
+ bar_length = int((count / max_count) * 50) # Scale to 50 characters
80
+ bar = "â–„" * bar_length
81
+
82
+ table.add_row(year, str(count), bar)
83
+
84
+ console.print(table)
85
+
86
+
87
+ def report(db, args):
88
+ """Main report function that routes to specific report types based on args."""
89
+ if args.get("author") is True:
90
+ show_author_report(db)
91
+ else:
92
+ show_year_report(db)
@@ -0,0 +1,133 @@
1
+ import sqlite3
2
+ from datetime import datetime
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+
7
+ def show_books(db, args={}):
8
+ # By year is default
9
+ # Current year is default year if not specified
10
+ year = args.get("year", datetime.now().year)
11
+
12
+ # if id is not none, show book detail
13
+ if args.get("id") is not None:
14
+ show_book_detail(db, args.get("id"))
15
+ return
16
+
17
+ books = get_books(db, year)
18
+ if not books:
19
+ print("No books found for the specified year.")
20
+ return
21
+
22
+ console = Console()
23
+ table = Table(show_header=True, title=f"Books Read in {year}")
24
+ table.add_column("id")
25
+ table.add_column("Title")
26
+ table.add_column("Author")
27
+ table.add_column("Rating")
28
+ table.add_column("Date Read")
29
+
30
+ # Sort books by genre (fiction first) and then by date
31
+ sorted_books = sorted(
32
+ books, key=lambda x: (x["genre"] != "fiction", x["date_read"] or "")
33
+ )
34
+
35
+ current_genre = None
36
+ for book in sorted_books:
37
+ # Add genre separator if genre changes
38
+ if book["genre"] != current_genre:
39
+ if current_genre is not None: # Don't add separator before first genre
40
+ table.add_row("", "", "", "", "", style="dim")
41
+ current_genre = book["genre"]
42
+ table.add_row(
43
+ f"[bold]{current_genre.title()}[/bold]",
44
+ "",
45
+ "",
46
+ "",
47
+ "",
48
+ style="bold cyan",
49
+ )
50
+
51
+ # Format the date
52
+ date_str = book["date_read"]
53
+ if date_str:
54
+ try:
55
+ date_obj = datetime.strptime(date_str, "%Y-%m-%d")
56
+ formatted_date = date_obj.strftime("%b %d, %Y")
57
+ except ValueError:
58
+ formatted_date = date_str
59
+ else:
60
+ formatted_date = ""
61
+
62
+ table.add_row(
63
+ str(book["id"]),
64
+ book["title"],
65
+ book["author"],
66
+ str(book["rating"]),
67
+ formatted_date,
68
+ )
69
+
70
+ console.print(table)
71
+
72
+
73
+ def show_book_detail(db, id):
74
+ cursor = db.cursor()
75
+ cursor.execute(
76
+ """SELECT b.id, b.title, b.author, b.pub_year, b.pages, b.genre,
77
+ r.rating, r.date_read, r.review
78
+ FROM books b
79
+ LEFT JOIN reviews r ON b.id = r.book_id
80
+ WHERE b.id = ?""",
81
+ (id,),
82
+ )
83
+ book = cursor.fetchone()
84
+
85
+ if not book:
86
+ print(f"No book found with ID {id}")
87
+ return
88
+
89
+ console = Console()
90
+ table = Table(show_header=True, title="Book Details")
91
+ table.add_column("Field", style="cyan")
92
+ table.add_column("Value", style="green")
93
+
94
+ # Map of column names to display names
95
+ display_names = [
96
+ "ID",
97
+ "Title",
98
+ "Author",
99
+ "Publication Year",
100
+ "Pages",
101
+ "Genre",
102
+ "Rating",
103
+ "Date Read",
104
+ "My Review",
105
+ ]
106
+
107
+ for col, value in zip(range(len(display_names)), book):
108
+ table.add_row(display_names[col], str(value))
109
+
110
+ console.print(table)
111
+
112
+
113
+ def get_books(db, year):
114
+ try:
115
+ cursor = db.cursor()
116
+ cursor.execute(
117
+ """
118
+ SELECT b.id, b.title, b.author, b.genre, r.rating, r.date_read
119
+ FROM books b
120
+ LEFT JOIN reviews r ON b.id = r.book_id
121
+ WHERE strftime('%Y', r.date_read) = ?
122
+ ORDER BY r.date_read ASC
123
+ """,
124
+ (str(year),),
125
+ )
126
+ books = cursor.fetchall()
127
+ return books
128
+ except sqlite3.Error as e:
129
+ print(f"Database error: {e}")
130
+ return None
131
+ except Exception as e:
132
+ print(f"Error: {e}")
133
+ return None
@@ -0,0 +1,87 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Dict
6
+ from datetime import datetime
7
+ from appdirs import AppDirs
8
+
9
+ __version__ = "0.1.0"
10
+
11
+
12
+ def init_args() -> Dict:
13
+ """Parse and return the arguments."""
14
+ parser = argparse.ArgumentParser(description="Book list")
15
+ parser.add_argument("--db", help="SQLite file")
16
+ parser.add_argument("-v", "--version", action="store_true")
17
+ parser.add_argument("-i", "--info", action="store_true")
18
+
19
+ # Create subparsers for commands
20
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
21
+
22
+ # Report command with its specific arguments
23
+ report_parser = subparsers.add_parser("report", help="Show reports")
24
+ report_parser.add_argument(
25
+ "--author", action="store_true", help="Show author report"
26
+ )
27
+ report_parser.add_argument("--year", type=int, help="Year to filter books")
28
+
29
+ # Show command with its specific arguments
30
+ show_parser = subparsers.add_parser("show", help="Show books")
31
+ show_parser.add_argument("--year", type=int, help="Year to filter books")
32
+ show_parser.add_argument(
33
+ "id", type=int, nargs="?", help="Show details for a specific book ID"
34
+ )
35
+
36
+ # Add command with its specific arguments
37
+ add_parser = subparsers.add_parser("add", help="Add a book")
38
+ add_parser.add_argument("--title", type=str, help="Title of the book")
39
+ add_parser.add_argument("--author", type=str, help="Author of the book")
40
+ add_parser.add_argument("--year", type=int, help="Year of the book")
41
+
42
+ # Add command with its specific arguments
43
+ import_parser = subparsers.add_parser("import", help="Import books")
44
+ import_parser.add_argument("file", type=str, help="Goodreads CSV export file")
45
+
46
+ args = vars(parser.parse_args())
47
+
48
+ if args["version"]:
49
+ print(f"libro v{__version__}")
50
+ sys.exit()
51
+
52
+ # if not specified on command-line figure it out
53
+ if args["db"] is None:
54
+ args["db"] = get_db_loc()
55
+
56
+ if args["command"] is None:
57
+ args["command"] = "show"
58
+
59
+ if args.get("year") is None:
60
+ args["year"] = datetime.now().year
61
+
62
+ return args
63
+
64
+
65
+ def get_db_loc() -> Path:
66
+ """Figure out where the libro.db file should be.
67
+ See README for spec"""
68
+
69
+ # check if tasks.db exists in current dir
70
+ cur_dir = Path(Path.cwd(), "libro.db")
71
+ if cur_dir.is_file():
72
+ return cur_dir
73
+
74
+ # check for env TASKS_DB
75
+ env_var = os.environ.get("LIBRO_DB")
76
+ if env_var is not None:
77
+ return Path(env_var)
78
+
79
+ # Finally use system specific data dir
80
+ dirs = AppDirs("Libro", "mkaz")
81
+
82
+ # No config file, default to data dir
83
+ data_dir = Path(dirs.user_data_dir)
84
+ if not data_dir.is_dir():
85
+ data_dir.mkdir()
86
+
87
+ return Path(dirs.user_data_dir, "libro.db")
@@ -0,0 +1,60 @@
1
+ import sqlite3
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from libro.config import init_args
6
+ from libro.actions.show import show_books
7
+ from libro.actions.report import report
8
+ from libro.actions.add import add_book
9
+ from libro.actions.db import init_db
10
+ from libro.actions.importer import import_books
11
+
12
+
13
+ def main():
14
+ print("") # give me some space
15
+ args = init_args()
16
+
17
+ dbfile = Path(args["db"])
18
+ if args["info"]:
19
+ print(f"Using libro.db {dbfile}")
20
+
21
+ # check if taskdb exists
22
+ is_new_db = not dbfile.is_file()
23
+ if is_new_db:
24
+ response = input(f"Create new database at {dbfile}? [Y/n] ").lower()
25
+ if response not in ["", "y", "yes"]:
26
+ print("No database created")
27
+ sys.exit(1)
28
+ init_db(dbfile)
29
+
30
+ # Check if database is empty
31
+ if is_new_db:
32
+ print("Database created")
33
+ sys.exit(0)
34
+
35
+ try:
36
+ db = sqlite3.connect(dbfile)
37
+ db.row_factory = sqlite3.Row
38
+
39
+ command = args["command"]
40
+ if command == "add":
41
+ print("Add new book read")
42
+ add_book(db, args)
43
+ elif command == "show":
44
+ show_books(db, args)
45
+ elif command == "report":
46
+ report(db, args)
47
+ elif command == "import":
48
+ import_books(db, args)
49
+ else:
50
+ print("Not yet implemented")
51
+
52
+ except sqlite3.Error as e:
53
+ print(f"Database error: {e}")
54
+ sys.exit(1)
55
+ finally:
56
+ db.close()
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
@@ -0,0 +1,68 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+ import sqlite3
4
+ from datetime import date
5
+
6
+
7
+ @dataclass
8
+ class Book:
9
+ """Represents a book in the database."""
10
+
11
+ title: str
12
+ author: str
13
+ pub_year: Optional[int] = None
14
+ pages: Optional[int] = None
15
+ genre: Optional[str] = None
16
+ id: Optional[int] = None
17
+
18
+ def insert(self, db: sqlite3.Connection) -> int:
19
+ """Insert the book into the database and return its ID."""
20
+ cursor = db.cursor()
21
+ cursor.execute(
22
+ """
23
+ INSERT INTO books (
24
+ title, author, pub_year, pages, genre
25
+ ) VALUES (?, ?, ?, ?, ?)
26
+ """,
27
+ (
28
+ self.title,
29
+ self.author,
30
+ self.pub_year,
31
+ self.pages,
32
+ self.genre,
33
+ ),
34
+ )
35
+ self.id = cursor.lastrowid
36
+ db.commit()
37
+ return self.id
38
+
39
+
40
+ @dataclass
41
+ class Review:
42
+ """Represents a book review in the database."""
43
+
44
+ book_id: int
45
+ date_read: Optional[date] = None
46
+ rating: Optional[int] = None
47
+ review: Optional[str] = None
48
+ id: Optional[int] = None
49
+
50
+ def insert(self, db: sqlite3.Connection) -> int:
51
+ """Insert the review into the database and return its ID."""
52
+ cursor = db.cursor()
53
+ cursor.execute(
54
+ """
55
+ INSERT INTO reviews (
56
+ book_id, date_read, rating, review
57
+ ) VALUES (?, ?, ?, ?)
58
+ """,
59
+ (
60
+ self.book_id,
61
+ self.date_read,
62
+ self.rating,
63
+ self.review,
64
+ ),
65
+ )
66
+ self.id = cursor.lastrowid
67
+ db.commit()
68
+ return self.id
@@ -0,0 +1,67 @@
1
+ from datetime import datetime
2
+
3
+
4
+ def validate_and_convert_date(date_str, field_name):
5
+ if not date_str: # Handle empty dates
6
+ return True, None
7
+
8
+ formats = [
9
+ "%m/%d/%y", # 1/24/15
10
+ "%m/%d/%Y", # 1/24/2015
11
+ "%Y-%m-%d", # 2015-01-24
12
+ "%Y", # Just the year
13
+ "%B %d, %Y", # March 14, 2024
14
+ "%d %B %Y", # 14 March 2024
15
+ ]
16
+
17
+ for fmt in formats:
18
+ try:
19
+ date_obj = datetime.strptime(date_str, fmt)
20
+ if fmt == "%m/%d/%y":
21
+ if date_obj.year > datetime.now().year:
22
+ date_obj = date_obj.replace(year=date_obj.year - 100)
23
+
24
+ if fmt == "%Y":
25
+ return True, f"{date_obj.year}-01-01"
26
+
27
+ return True, date_obj.strftime("%Y-%m-%d")
28
+ except ValueError:
29
+ continue
30
+
31
+ print(f"Error: Invalid date format in {field_name}: {date_str}")
32
+ return False, None
33
+
34
+
35
+ def get_valid_input(prompt, validator=None, allow_empty=False, multiline=False):
36
+ if not multiline:
37
+ while True:
38
+ value = input(prompt).strip()
39
+ if not value and not allow_empty:
40
+ print("This field cannot be empty. Please try again.")
41
+ continue
42
+ if validator and value:
43
+ is_valid, converted_value = validator(value)
44
+ if is_valid:
45
+ return converted_value if converted_value is not None else value
46
+ else:
47
+ return value
48
+ else:
49
+ print(prompt + " (Enter two consecutive blank lines to finish)")
50
+ lines = []
51
+ last_line_empty = False
52
+ while True:
53
+ line = input() # Don't strip here to preserve indentation
54
+ if not line.strip():
55
+ if last_line_empty:
56
+ if not lines and not allow_empty:
57
+ print("This field cannot be empty. Please try again.")
58
+ continue
59
+ break
60
+ last_line_empty = True
61
+ else:
62
+ last_line_empty = False
63
+ lines.append(line)
64
+ # Remove the last empty line that was used as a terminator
65
+ if lines and not lines[-1].strip():
66
+ lines.pop()
67
+ return "\n".join(lines)