From c236facb4d4c277773c83f1a4ee85b48833d7e67 Mon Sep 17 00:00:00 2001 From: Joris Date: Sat, 16 Sep 2023 18:28:54 +0200 Subject: Add CLI command to insert book in library --- README.md | 4 +++ src/main.py | 15 +++++++++ src/new/__init__.py | 0 src/new/command.py | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/new/format.py | 76 +++++++++++++++++++++++++++++++++++++++++++ src/new/reader.py | 55 +++++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+) create mode 100644 src/new/__init__.py create mode 100644 src/new/command.py create mode 100644 src/new/format.py create mode 100644 src/new/reader.py 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 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) -- cgit v1.2.3