aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoris2023-09-16 18:28:54 +0200
committerJoris2023-09-16 18:31:24 +0200
commitc236facb4d4c277773c83f1a4ee85b48833d7e67 (patch)
tree240e72821f7715e24c906e1a2e4081264d47d0ba
parent06f045e90bb57c36738e58ee6830e2a2391bc6a3 (diff)
Add CLI command to insert book in library
-rw-r--r--README.md4
-rw-r--r--src/main.py15
-rw-r--r--src/new/__init__.py0
-rw-r--r--src/new/command.py94
-rw-r--r--src/new/format.py76
-rw-r--r--src/new/reader.py55
6 files changed, 244 insertions, 0 deletions
diff --git a/README.md b/README.md
index d9ec3fd..b45f1bc 100644
--- a/README.md
+++ b/README.md
@@ -34,3 +34,7 @@ In nix shell (`nix develop`), run:
## Show library
BOOK_LIBRARY=path-to-books python src/main.py library
+
+## Add book
+
+ BOOK_LIBRARY=path-to-books python src/main.py new optional-path-to-ebook
diff --git a/src/main.py b/src/main.py
index 618cc5a..1f07785 100644
--- a/src/main.py
+++ b/src/main.py
@@ -12,10 +12,15 @@ import os
import library.command
import view.command
+import new.command
def print_help(title='Manage book library'):
print(f"""{title}
+- Insert book entry with optional ebook file:
+
+ $ python {sys.argv[0]} new [path-to-book]
+
- Print library metadata as json:
$ python {sys.argv[0]} library
@@ -38,6 +43,16 @@ def get_book_library():
def main():
match sys.argv:
+ case [ _, 'new' ]:
+ book_library = get_book_library()
+ new.command.run(book_library)
+ case [ _, 'new', book_source ]:
+ if os.path.isfile(book_source):
+ book_library = get_book_library()
+ new.command.run(book_library, book_source)
+ else:
+ print_help(title=f'File not found: {book_source}.')
+ exit(1)
case [ _, 'library' ]:
book_library = get_book_library()
library.command.run(book_library)
diff --git a/src/new/__init__.py b/src/new/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/new/__init__.py
diff --git a/src/new/command.py b/src/new/command.py
new file mode 100644
index 0000000..fe706c2
--- /dev/null
+++ b/src/new/command.py
@@ -0,0 +1,94 @@
+import PIL.Image
+import io
+import os
+import pathlib
+import re
+import requests
+import shutil
+import subprocess
+import sys
+import unicodedata
+import urllib.request
+
+import new.reader as reader
+import new.format as format
+
+def run(book_library, book_source=None):
+
+ # Get data
+
+ title = reader.required('Title')
+ subtitle = reader.optional('Subtitle')
+ authors = reader.non_empty_list('Authors')
+ author_sort = reader.required('Authors sorting')
+ genres = reader.non_empty_list('Genres')
+ year = reader.integer('Year')
+ lang = reader.choices('Lang', ['fr', 'en', 'de'])
+ summary = format.cleanup_text(reader.multi_line('Summary'), lang)
+ cover_url = reader.required('Cover url')
+ read = reader.choices('Read', ['Read', 'Unread', 'Reading', 'Stopped'])
+
+ # Output paths
+
+ author_path = format.path_part(author_sort)
+ title_path = format.path_part(title)
+
+ library_path = '/home/joris/documents/books'
+ output_dir = f'{library_path}/{author_path}/{title_path}'
+ metadata_path = f'{output_dir}/metadata.toml'
+ cover_path = f'{output_dir}/cover.webp'
+
+ if not book_source is None:
+ ext = format.extension(book_source)
+ book_path = f'{output_dir}/book{ext}'
+ book_source_dir = os.path.dirname(os.path.realpath(book_source))
+ book_source_new = f'{book_source_dir}/{author_path}-{title_path}.mobi'
+
+ # Metadata
+
+ metadata = f"""title = "{title}"
+ subtitle = "{subtitle}"
+ authors = {format.format_list(authors)}
+ authorsSort = "{author_sort}"
+ genres = {format.format_list(genres)}
+ date = {year}
+ summary = \"\"\"
+ {summary}
+ \"\"\"
+ read = "{read}"
+ """
+
+ # Ask for confirmation
+
+ print(f'About to create:\n\n- {metadata_path}\n- {cover_path}')
+ if not book_source is None:
+ print(f'- {book_path}')
+ print(f'\nAnd moving:\n\n {book_source},\n -> {book_source_new}.')
+ print()
+
+ reader.confirm('OK?')
+
+ # Create files
+
+ pathlib.Path(output_dir).mkdir(parents=True, exist_ok=True)
+ download_cover(cover_url, cover_path)
+ with open(metadata_path, 'w') as f:
+ f.write(metadata)
+ if not book_path is None:
+ shutil.copyfile(book_source, book_path)
+ if format.extension(book_source) in ['mobi', 'azw3']:
+ os.rename(book_source, book_source_new)
+ else:
+ subprocess.run(['ebook-convert', book_source, book_source_new])
+ os.remove(book_source)
+
+# Download cover as WEBP
+def download_cover(url, path):
+ response = requests.get(url, headers={ 'User-Agent': 'python-script' })
+ image = PIL.Image.open(io.BytesIO(response.content))
+ width, height = image.size
+ if width > 300:
+ image = image.resize((300, int(300 * height / width)), PIL.Image.LANCZOS)
+ image = image.convert('RGB')
+ image.save(path, 'WEBP', optimize=True, quality=85)
+
diff --git a/src/new/format.py b/src/new/format.py
new file mode 100644
index 0000000..a712544
--- /dev/null
+++ b/src/new/format.py
@@ -0,0 +1,76 @@
+import PIL.Image
+import io
+import os
+import pathlib
+import re
+import requests
+import shutil
+import subprocess
+import sys
+import unicodedata
+import urllib.request
+
+def format_list(xs):
+ return '[ ' + ', '.join([f'"{x}"' for x in xs]) + ' ]'
+
+def path_part(name):
+ simplified = ''.join([alnum_or_space(c) for c in unaccent(name.lower())])
+ return '-'.join(simplified.split())
+
+def unaccent(s):
+ return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
+
+def alnum_or_space(c):
+ if c.isalnum():
+ return c
+ else:
+ return ' '
+
+def extension(path):
+ return pathlib.Path(path).suffix
+
+def cleanup_text(s, lang):
+ s = re.sub('\'', '’', s)
+ s = re.sub('\.\.\.', '…', s)
+ s = re.sub('\. \. \.', '…', s)
+ s = cleanup_quotes(s, lang)
+
+ if lang == 'fr':
+ s = re.sub('“', '«', s)
+ s = re.sub('”', '»', s)
+
+ # Replace space by insecable spaces
+ s = re.sub(r' ([:?\!»])', r' \1', s)
+ s = re.sub('« ', '« ', s)
+
+ # Add missing insecable spaces
+ s = re.sub(r'([^ ])([:?\!»])', r'\1 \2', s)
+ s = re.sub(r'«([^ ])', r'« \1', s)
+
+ elif lang == 'en':
+ s = re.sub('«', '“', s)
+ s = re.sub('»', '”', s)
+
+ return s
+
+def cleanup_quotes(s, lang):
+ res = ''
+ quoted = False
+ for c in s:
+ if c == '"':
+ if quoted:
+ quoted = False
+ if lang == 'fr':
+ res += '»'
+ elif lang == 'en':
+ res += '”'
+ else:
+ quoted = True
+ if lang == 'fr':
+ res += '«'
+ elif lang == 'en':
+ res += '“'
+ else:
+ res += c
+ return res
+
diff --git a/src/new/reader.py b/src/new/reader.py
new file mode 100644
index 0000000..eacd70b
--- /dev/null
+++ b/src/new/reader.py
@@ -0,0 +1,55 @@
+def required(label):
+ value = input(f'{label}: ').strip()
+ if value:
+ print()
+ return value
+ else:
+ return required(label)
+
+def multi_line(label):
+ lines = ''
+ print(f'{label}, type [end] to finish:\n')
+ while True:
+ value = input()
+ if value.strip() == '[end]':
+ break
+ elif value.strip():
+ lines += f'{value.strip()}\n'
+ print()
+ return lines.strip()
+
+def optional(label):
+ value = input(f'{label} (optional): ').strip()
+ print()
+ return value
+
+def non_empty_list(label):
+ value = input(f'{label} (separated by commas): ')
+ values = [x.strip() for x in value.split(',') if x.strip()]
+ if len(values) > 0:
+ print()
+ return values
+ else:
+ return non_empty_list(label)
+
+def integer(label):
+ value = input(f'{label}: ').strip()
+ if value.isdigit():
+ print()
+ return int(value)
+ else:
+ return integer(label)
+
+def choices(label, xs):
+ pp_choices = '/'.join(xs)
+ value = input(f'{label} [{pp_choices}] ')
+ if value in xs:
+ print()
+ return value
+ else:
+ return choices(label, xs)
+
+def confirm(message):
+ if choices(message, ['y', 'n']) == 'n':
+ print('\nStopping.')
+ exit(1)