aboutsummaryrefslogtreecommitdiff
path: root/cli
diff options
context:
space:
mode:
authorJoris2023-09-17 12:23:47 +0200
committerJoris2023-09-17 12:23:47 +0200
commit1ebc55c72a1a17293bbf4ad86e0177a10a794750 (patch)
tree5fce0ea3a011ccbae85b0d3927f8ac33099585fb /cli
parentc236facb4d4c277773c83f1a4ee85b48833d7e67 (diff)
Make app packageable
Diffstat (limited to 'cli')
-rw-r--r--cli/__init__.py0
-rw-r--r--cli/library/__init__.py0
-rw-r--r--cli/library/command.py21
-rw-r--r--cli/main.py80
-rw-r--r--cli/new/__init__.py0
-rw-r--r--cli/new/command.py88
-rw-r--r--cli/new/format.py68
-rw-r--r--cli/new/reader.py55
-rw-r--r--cli/view/__init__.py0
-rw-r--r--cli/view/command.py16
10 files changed, 328 insertions, 0 deletions
diff --git a/cli/__init__.py b/cli/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/cli/__init__.py
diff --git a/cli/library/__init__.py b/cli/library/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/cli/library/__init__.py
diff --git a/cli/library/command.py b/cli/library/command.py
new file mode 100644
index 0000000..1c4d20c
--- /dev/null
+++ b/cli/library/command.py
@@ -0,0 +1,21 @@
+import glob
+import json
+import os
+import tomllib
+
+def run(books_library):
+ print(get(books_library))
+
+def get(books_library):
+ metadatas = []
+
+ for path in glob.glob(f'{books_library}/**/metadata.toml', recursive=True):
+ with open(path, 'rb') as f:
+ directory = os.path.dirname(os.path.realpath(path))
+ metadata = tomllib.load(f)
+ for p in glob.glob(f'{directory}/cover.*'):
+ metadata['cover'] = p
+ break
+ metadatas.append(metadata)
+
+ return json.dumps(metadatas)
diff --git a/cli/main.py b/cli/main.py
new file mode 100644
index 0000000..01434fa
--- /dev/null
+++ b/cli/main.py
@@ -0,0 +1,80 @@
+# Manage book library.
+#
+# Required dependencies:
+#
+# - python >= 3.11
+# - requests
+# - pillow
+# - ebook-convert CLI (from calibre)
+
+import cli.library.command
+import cli.new.command
+import cli.view.command
+import os
+import sys
+
+def main(bin_dir):
+ match sys.argv:
+ case [ _, 'new' ]:
+ books_library = get_books_library()
+ cli.new.command.run(books_library)
+ case [ _, 'new', book_source ]:
+ if os.path.isfile(book_source):
+ books_library = get_books_library()
+ cli.new.command.run(books_library, book_source)
+ else:
+ print_help(title=f'File not found: {book_source}.')
+ exit(1)
+ case [ _, 'library' ]:
+ books_library = get_books_library()
+ cli.library.command.run(books_library)
+ case [ _, 'view' ]:
+ books_library = get_books_library()
+ books_browser = get_env_var('BOOKS_BROWSER')
+ cli.view.command.run(books_library, books_browser, bin_dir)
+ case [ _, '--help' ]:
+ print_help()
+ case [ _, '-h' ]:
+ print_help()
+ case _:
+ print_help('Command not found.')
+ exit(1)
+
+def get_books_library():
+ books_library = get_env_var('BOOKS_LIBRARY')
+ if os.path.isdir(books_library):
+ return books_library
+ else:
+ print_help(title=f'BOOKS_LIBRARY {books_library} not found.')
+ exit(1)
+
+def get_env_var(key):
+ value = os.getenv(key)
+ if value:
+ return value
+ else:
+ print_help(title=f'{key} environment variable is required.')
+ exit(1)
+
+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
+
+- View books in web page:
+
+ $ python {sys.argv[0]} view
+
+Environment variables:
+
+ BOOKS_LIBRARY: path to book library,
+ BOOKS_BROWSER: browser command executed to view the library.""")
+
+if __name__ == "__main__":
+ main()
diff --git a/cli/new/__init__.py b/cli/new/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/cli/new/__init__.py
diff --git a/cli/new/command.py b/cli/new/command.py
new file mode 100644
index 0000000..9f5e5dc
--- /dev/null
+++ b/cli/new/command.py
@@ -0,0 +1,88 @@
+import PIL.Image
+import cli.new.format as format
+import cli.new.reader as reader
+import io
+import os
+import pathlib
+import requests
+import shutil
+import subprocess
+
+def run(books_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)
+
+ output_dir = f'{books_library}/{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/cli/new/format.py b/cli/new/format.py
new file mode 100644
index 0000000..c004f82
--- /dev/null
+++ b/cli/new/format.py
@@ -0,0 +1,68 @@
+import pathlib
+import re
+import unicodedata
+
+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/cli/new/reader.py b/cli/new/reader.py
new file mode 100644
index 0000000..eacd70b
--- /dev/null
+++ b/cli/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)
diff --git a/cli/view/__init__.py b/cli/view/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/cli/view/__init__.py
diff --git a/cli/view/command.py b/cli/view/command.py
new file mode 100644
index 0000000..72e44dd
--- /dev/null
+++ b/cli/view/command.py
@@ -0,0 +1,16 @@
+import cli.library.command
+import shutil
+import subprocess
+import subprocess
+import tempfile
+import time
+
+def run(books_library, books_browser, bin_dir):
+ tmp_dir = tempfile.mkdtemp()
+ shutil.copytree(f'{bin_dir}/library/public', tmp_dir, dirs_exist_ok=True)
+ subprocess.run(['chmod', 'u+w', tmp_dir])
+ with open(f'{tmp_dir}/books.js', 'w') as f:
+ json = cli.library.command.get(books_library)
+ f.write(f'const bookLibrary = {json}')
+ browser_cmd = f'{books_browser} {tmp_dir}/index.html'
+ subprocess.run(browser_cmd.split(' '))