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.
- libro_book-0.1.0/.cursor/rules/ja-rule.mdc +18 -0
- libro_book-0.1.0/.gitignore +7 -0
- libro_book-0.1.0/LICENSE +21 -0
- libro_book-0.1.0/PKG-INFO +132 -0
- libro_book-0.1.0/justfile +28 -0
- libro_book-0.1.0/pyproject.toml +36 -0
- libro_book-0.1.0/readme.md +113 -0
- libro_book-0.1.0/src/libro/actions/add.py +54 -0
- libro_book-0.1.0/src/libro/actions/db.py +28 -0
- libro_book-0.1.0/src/libro/actions/importer.py +94 -0
- libro_book-0.1.0/src/libro/actions/report.py +92 -0
- libro_book-0.1.0/src/libro/actions/show.py +133 -0
- libro_book-0.1.0/src/libro/config.py +87 -0
- libro_book-0.1.0/src/libro/main.py +60 -0
- libro_book-0.1.0/src/libro/models.py +68 -0
- libro_book-0.1.0/src/libro/utils.py +67 -0
|
@@ -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
|
+
|
libro_book-0.1.0/LICENSE
ADDED
|
@@ -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)
|