From 1ebc55c72a1a17293bbf4ad86e0177a10a794750 Mon Sep 17 00:00:00 2001 From: Joris Date: Sun, 17 Sep 2023 12:23:47 +0200 Subject: Make app packageable --- .gitignore | 2 +- Makefile | 10 +- README.md | 5 +- bin/dev-server | 8 +- books | 6 + cli/__init__.py | 0 cli/library/__init__.py | 0 cli/library/command.py | 21 ++ cli/main.py | 80 ++++++ cli/new/__init__.py | 0 cli/new/command.py | 88 +++++++ cli/new/format.py | 68 ++++++ cli/new/reader.py | 55 +++++ cli/view/__init__.py | 0 cli/view/command.py | 16 ++ flake.nix | 2 +- library/client/book.ts | 15 ++ library/client/lib/functions.ts | 7 + library/client/lib/i18n.ts | 14 ++ library/client/lib/rx.ts | 404 +++++++++++++++++++++++++++++++ library/client/lib/search.ts | 17 ++ library/client/main.ts | 46 ++++ library/client/view/books.ts | 116 +++++++++ library/client/view/components/modal.ts | 38 +++ library/client/view/filters.ts | 90 +++++++ library/public/index.html | 7 + library/public/main.css | 235 ++++++++++++++++++ library/tsconfig.json | 13 + setup.py | 12 + src/library/__init__.py | 0 src/library/command.py | 21 -- src/main.py | 71 ------ src/new/__init__.py | 0 src/new/command.py | 94 ------- src/new/format.py | 76 ------ src/new/reader.py | 55 ----- src/view/__init__.py | 0 src/view/client/book.ts | 15 -- src/view/client/lib/functions.ts | 7 - src/view/client/lib/i18n.ts | 14 -- src/view/client/lib/rx.ts | 404 ------------------------------- src/view/client/lib/search.ts | 17 -- src/view/client/main.ts | 46 ---- src/view/client/view/books.ts | 116 --------- src/view/client/view/components/modal.ts | 38 --- src/view/client/view/filters.ts | 90 ------- src/view/command.py | 17 -- src/view/public/index.html | 7 - src/view/public/main.css | 235 ------------------ src/view/tsconfig.json | 13 - 50 files changed, 1363 insertions(+), 1348 deletions(-) create mode 100755 books create mode 100644 cli/__init__.py create mode 100644 cli/library/__init__.py create mode 100644 cli/library/command.py create mode 100644 cli/main.py create mode 100644 cli/new/__init__.py create mode 100644 cli/new/command.py create mode 100644 cli/new/format.py create mode 100644 cli/new/reader.py create mode 100644 cli/view/__init__.py create mode 100644 cli/view/command.py create mode 100644 library/client/book.ts create mode 100644 library/client/lib/functions.ts create mode 100644 library/client/lib/i18n.ts create mode 100644 library/client/lib/rx.ts create mode 100644 library/client/lib/search.ts create mode 100644 library/client/main.ts create mode 100644 library/client/view/books.ts create mode 100644 library/client/view/components/modal.ts create mode 100644 library/client/view/filters.ts create mode 100644 library/public/index.html create mode 100644 library/public/main.css create mode 100644 library/tsconfig.json create mode 100644 setup.py delete mode 100644 src/library/__init__.py delete mode 100644 src/library/command.py delete mode 100644 src/main.py delete mode 100644 src/new/__init__.py delete mode 100644 src/new/command.py delete mode 100644 src/new/format.py delete mode 100644 src/new/reader.py delete mode 100644 src/view/__init__.py delete mode 100644 src/view/client/book.ts delete mode 100644 src/view/client/lib/functions.ts delete mode 100644 src/view/client/lib/i18n.ts delete mode 100644 src/view/client/lib/rx.ts delete mode 100644 src/view/client/lib/search.ts delete mode 100644 src/view/client/main.ts delete mode 100644 src/view/client/view/books.ts delete mode 100644 src/view/client/view/components/modal.ts delete mode 100644 src/view/client/view/filters.ts delete mode 100644 src/view/command.py delete mode 100644 src/view/public/index.html delete mode 100644 src/view/public/main.css delete mode 100644 src/view/tsconfig.json diff --git a/.gitignore b/.gitignore index f7a3017..674e9cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ __pycache__ -src/view/public/*.js +library/public/*.js diff --git a/Makefile b/Makefile index 593752d..fc2acc6 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,11 @@ -build: +build: library/public/main.js + +library/public/main.js: @esbuild \ - --bundle src/main.ts \ + --bundle library/client/main.ts \ --minify \ --target=es2017 \ - --outdir=public + --outdir=library/public clean: - @rm -f public/main.js + @rm -f library/public/*.js diff --git a/README.md b/README.md index b45f1bc..d676bf2 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,9 @@ In nix shell (`nix develop`), run: ## Show library - BOOK_LIBRARY=path-to-books python src/main.py library + make + BOOKS_LIBRARY=path-to-books BOOKS_BROWSER=firefox python src/main.py library ## Add book - BOOK_LIBRARY=path-to-books python src/main.py new optional-path-to-ebook + BOOKS_LIBRARY=path-to-books python src/main.py new optional-path-to-ebook diff --git a/bin/dev-server b/bin/dev-server index c98fdc5..7351209 100755 --- a/bin/dev-server +++ b/bin/dev-server @@ -3,7 +3,7 @@ set -euo pipefail cd $(dirname "$0")/.. if [ "$#" == 1 ]; then - BOOK_LIBRARY="$1" + BOOKS_LIBRARY="$1" else echo "usage: $0 path-to-book-directory" exit 1 @@ -11,14 +11,14 @@ fi # Watch books -BUILD_BOOKS_CMD="echo \"const bookLibrary=\" > src/view/public/books.js && python src/main.py library >> src/view/public/books.js && echo src/view/public/books.js updated." +BUILD_BOOKS_CMD="echo \"const bookLibrary=\" > library/public/books.js && ./books library >> library/public/books.js && echo library/public/books.js updated." watchexec \ - --watch "$BOOK_LIBRARY" \ + --watch "$BOOKS_LIBRARY" \ -- "$BUILD_BOOKS_CMD" & # Watch TypeScript -cd src/view +cd library CHECK="echo -e 'Checking TypeScript…\n' && tsc --checkJs" BUILD="esbuild --bundle main.ts --target=es2017 --outdir=public" SHOW="echo -e '\nOpen $PWD/public/index.html'" diff --git a/books b/books new file mode 100755 index 0000000..dbdc846 --- /dev/null +++ b/books @@ -0,0 +1,6 @@ +#!/usr/bin/env python +import cli.main +import os + +bin_dir = os.path.dirname(os.path.realpath(__file__)) +cli.main.main(bin_dir) diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/library/__init__.py b/cli/library/__init__.py new file mode 100644 index 0000000..e69de29 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 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 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(' ')) diff --git a/flake.nix b/flake.nix index 10c1430..97d9261 100644 --- a/flake.nix +++ b/flake.nix @@ -23,8 +23,8 @@ # CLI python311 - python311Packages.requests python311Packages.pillow + python311Packages.requests ebook-convert ]; }; diff --git a/library/client/book.ts b/library/client/book.ts new file mode 100644 index 0000000..680cc11 --- /dev/null +++ b/library/client/book.ts @@ -0,0 +1,15 @@ +export interface Book { + title: string + subtitle?: string + authors: Array + authorsSort: string + genres: Array + date: number + summary?: string + read: ReadStatus, + cover: string +} + +export type ReadStatus = 'Read' | 'Unread' | 'Reading' | 'Stopped' + +export const readStatuses: Array = ['Read', 'Unread', 'Reading', 'Stopped' ] diff --git a/library/client/lib/functions.ts b/library/client/lib/functions.ts new file mode 100644 index 0000000..21fdad9 --- /dev/null +++ b/library/client/lib/functions.ts @@ -0,0 +1,7 @@ +export function debounce(func: Function, timeout: number): any { + let timer: any + return (...args: any) => { + clearTimeout(timer) + timer = setTimeout(() => { func.apply(this, args) }, timeout) + } +} diff --git a/library/client/lib/i18n.ts b/library/client/lib/i18n.ts new file mode 100644 index 0000000..3716367 --- /dev/null +++ b/library/client/lib/i18n.ts @@ -0,0 +1,14 @@ +export function unit( + n: number, + singular: string, + plural: string, + f: (n: number, unit: string) => string = format +): string { + return n > 1 + ? f(n, plural) + : f(n, singular) +} + +function format(n: number, unit: string): string { + return `${n} ${unit}` +} diff --git a/library/client/lib/rx.ts b/library/client/lib/rx.ts new file mode 100644 index 0000000..bf01b6d --- /dev/null +++ b/library/client/lib/rx.ts @@ -0,0 +1,404 @@ +// [1.0.1] 2023-02-13 + +// Html + +export type Html + = false + | undefined + | string + | number + | Tag + | WithVar + | Array + | Rx + +interface Tag { + type: 'Tag' + tagName: string + attributes: Attributes + children?: Array + onmount?: (element: Element) => void + onunmount?: (element: Element) => void +} + +interface WithVar { + type: 'WithVar' + init: A + getChildren: (v: Var, update: (f: (value: A) => A) => void) => Html +} + +interface Attributes { + [key: string]: Rx | AttributeValue +} + +type AttributeValue + = string + | number + | boolean + | ((event: Event) => void) + | ((element: Element) => void) + +function isHtml(x: any): x is Html { + return (typeof x === 'string' + || typeof x === 'number' + || isTag(x) + || isWithVar(x) + || isRx(x) + || Array.isArray(x)) +} + +type ValueOrArray = T | Array> + +export function h( + tagName: string, + x?: Attributes | Html, + ...children: Array +): Tag { + if (x === undefined || x === false) { + return { + type: 'Tag', + tagName, + attributes: {} + } + } else if (isHtml(x)) { + return { + type: 'Tag', + tagName, + attributes: {}, + children: [x, ...children], + } + } else { + let attributes = x as Attributes + let onmount, onunmount + if ('onmount' in attributes) { + onmount = attributes['onmount'] as (element: Element) => void + delete attributes['onmount'] + } + if ('onunmount' in attributes) { + onunmount = attributes['onunmount'] as (element: Element) => void + delete attributes['onunmount'] + } + return { + type: 'Tag', + tagName, + attributes, + children, + onmount, + onunmount + } + } +} + +export function withVar(init: A, getChildren: (v: Var, update: (f: (value: A) => A) => void) => Html): WithVar { + return { + type: 'WithVar', + init, + getChildren + } +} + +// Rx + +export type RxAble = Rx | A + +export class Rx { + map(f: (value: A) => B): Rx { + return new Map(this, f) + } + + flatMap(f: (value: A) => Rx): Rx { + return new FlatMap(this, f) + } +} + +class Var extends Rx { + readonly type: 'Var' + readonly id: string + + constructor(id: string) { + super() + this.id = id + this.type = 'Var' + } +} + +class Map extends Rx { + readonly type: 'Map' + readonly rx: Rx + readonly f: (value: A) => B + + constructor(rx: Rx, f: (value: A) => B) { + super() + this.type = 'Map' + this.rx = rx + this.f = f + } +} + +class FlatMap extends Rx { + readonly type: 'FlatMap' + readonly rx: Rx + readonly f: (value: A) => Rx + + constructor(rx: Rx, f: (value: A) => Rx) { + super() + this.type = 'FlatMap' + this.rx = rx + this.f = f + } +} + +// Mount + +export function mount(html: Html): Cancelable { + const state = new State() + let appendRes = appendChild(state, document.body, html) + return appendRes.cancel +} + +interface StateEntry { + value: A + subscribers: Array<(value: A) => void> +} + +class State { + readonly state: {[key: string]: StateEntry} + varCounter: bigint + + constructor() { + this.state = {} + this.varCounter = BigInt(0) + } + + register(initValue: A) { + const v = new Var(this.varCounter.toString()) + this.varCounter += BigInt(1) + this.state[v.id] = { + value: initValue, + subscribers: [] + } + return v + } + + unregister(v: Var) { + delete this.state[v.id] + } + + get(v: Var) { + return this.state[v.id].value + } + + update(v: Var, f: (value: A) => A) { + const value = f(this.state[v.id].value) + this.state[v.id].value = value + this.state[v.id].subscribers.forEach(notify => { + // Don’t notify if it has been removed from a precedent notifier + if (this.state[v.id].subscribers.indexOf(notify) !== -1) { + notify(value) + } + }) + } + + subscribe(v: Var, notify: (value: A) => void): Cancelable { + this.state[v.id].subscribers.push(notify) + return () => this.state[v.id].subscribers = this.state[v.id].subscribers.filter(n => n !== notify) + } +} + +// Cancelable + +type Cancelable = () => void + +const voidCancel = () => {} + +// Removable + +type Removable = () => void + +const voidRemove = () => {} + +// Rx run + +function rxRun(state: State, rx: Rx, effect: (value: A) => void): Cancelable { + if (isVar(rx)) { + const cancel = state.subscribe(rx, effect) + effect(state.get(rx)) + return cancel + } else if (isMap(rx)) { + return rxRun(state, rx.rx, value => effect(rx.f(value))) + } else if (isFlatMap(rx)) { + let cancel1 = voidCancel + const cancel2 = rxRun(state, rx.rx, (value: A) => { + cancel1() + cancel1 = rxRun(state, rx.f(value), effect) + }) + return () => { + cancel2() + cancel1() + } + } else { + throw new Error(`Unrecognized rx: ${rx}`) + } +} + +function isRx(x: any): x is Rx { + return x !== undefined && x.type !== undefined && (x.type === "Var" || x.type === "Map" || x.type === "FlatMap") +} + +function isVar(x: any): x is Var { + return x.type === "Var" +} + +function isMap(x: any): x is Map { + return x.type === "Map" +} + +function isFlatMap(x: any): x is FlatMap { + return x.type === "FlatMap" +} + +// Append + +interface AppendResult { + cancel: Cancelable + remove: Removable + lastAdded?: Node +} + +function appendChild(state: State, element: Element, child: Html, lastAdded?: Node): AppendResult { + if (Array.isArray(child)) { + let cancels: Array = [] + let removes: Array = [] + child.forEach((o) => { + const appendResult = appendChild(state, element, o, lastAdded) + cancels.push(appendResult.cancel) + removes.push(appendResult.remove) + lastAdded = appendResult.lastAdded + }) + return { + cancel: () => cancels.forEach((o) => o()), + remove: () => removes.forEach((o) => o()), + lastAdded + } + } else if (typeof child == "string") { + const node = document.createTextNode(child) + appendNode(element, node, lastAdded) + return { + cancel: voidCancel, + remove: () => element.removeChild(node), + lastAdded: node + } + } else if (typeof child == "number") { + return appendChild(state, element, child.toString(), lastAdded) + } else if (isTag(child)) { + const { tagName, attributes, children, onmount, onunmount } = child + + const childElement = document.createElement(tagName) + const cancelAttributes = Object.entries(attributes).map(([key, value]) => { + if (isRx(value)) { + return rxRun(state, value, newValue => setAttribute(state, childElement, key, newValue)) + } else { + setAttribute(state, childElement, key, value) + } + }) + + const appendChildrenRes = appendChild(state, childElement, children) + + appendNode(element, childElement, lastAdded) + + if (onmount !== undefined) { + onmount(childElement) + } + + return { + cancel: () => { + cancelAttributes.forEach(cancel => cancel !== undefined ? cancel() : {}) + appendChildrenRes.cancel() + if (onunmount !== undefined) { + onunmount(childElement) + } + }, + remove: () => element.removeChild(childElement), + lastAdded: childElement, + } + } else if (isWithVar(child)) { + const { init, getChildren } = child + const v = state.register(init) + const children = getChildren(v, f => state.update(v, f)) + const appendRes = appendChild(state, element, children) + return { + cancel: () => { + appendRes.cancel() + state.unregister(v) + }, + remove: () => appendRes.remove(), + lastAdded: appendRes.lastAdded + } + } else if (isRx(child)) { + const rxBase = document.createTextNode('') + appendNode(element, rxBase, lastAdded) + let appendRes: AppendResult = { + cancel: voidCancel, + remove: voidRemove, + lastAdded: rxBase + } + const cancelRx = rxRun(state, child, (value: Html) => { + appendRes.cancel() + appendRes.remove() + appendRes = appendChild(state, element, value, rxBase) + }) + return { + cancel: () => { + appendRes.cancel() + cancelRx() + }, + remove: () => { + appendRes.remove() + element.removeChild(rxBase) + }, + lastAdded: appendRes.lastAdded, + } + } else if (child === undefined || child === false) { + return { + cancel: voidCancel, + remove: voidRemove, + lastAdded + } + } else { + throw new Error(`Unrecognized child: ${child}`) + } +} + +function isTag(x: any): x is Tag { + return x !== undefined && x.type === "Tag" +} + +function isWithVar(x: any): x is WithVar { + return x !== undefined && x.type === "WithVar" +} + +function appendNode(base: Element, node: Node, lastAdded?: Node) { + if (lastAdded !== undefined) { + base.insertBefore(node, lastAdded.nextSibling) + } else { + base.append(node) + } +} + +function setAttribute(state: State, element: Element, key: string, attribute: AttributeValue) { + if (typeof attribute == "boolean") { + if (attribute) { + // @ts-ignore + element[key] = "true" + } + } else if (typeof attribute == "number") { + // @ts-ignore + element[key] = attribute.toString() + } else if (typeof attribute == "string") { + // @ts-ignore + element[key] = attribute + } else { + // @ts-ignore + element[key] = (event: Event) => attribute(event) + } +} diff --git a/library/client/lib/search.ts b/library/client/lib/search.ts new file mode 100644 index 0000000..026cb94 --- /dev/null +++ b/library/client/lib/search.ts @@ -0,0 +1,17 @@ +export function match(search: string, ...targets: Array): boolean { + const formattedTargets = targets + .filter(t => t !== undefined) + .map(format) + + return search.split(/\s+/).every(subSearch => + formattedTargets.some(target => target.includes(format(subSearch))) + ) +} + +export function format(str: string): string { + return unaccent(str.toLowerCase()) +} + +function unaccent(str: string): string { + return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "") +} diff --git a/library/client/main.ts b/library/client/main.ts new file mode 100644 index 0000000..5885871 --- /dev/null +++ b/library/client/main.ts @@ -0,0 +1,46 @@ +import { h, withVar, mount, Html } from 'lib/rx' +import * as Search from 'lib/search' +import * as Functions from 'lib/functions' +import * as I18n from 'lib/i18n' +import * as Filters from 'view/filters' +import * as Books from 'view/books' +import * as Book from 'book' + +// @ts-ignore +const sortedBookLibrary: Array = (bookLibrary as Array).sort((a, b) => + a.authorsSort == b.authorsSort + ? a.date > b.date + : a.authorsSort > b.authorsSort) + +mount(withVar({}, (filters, updateFilters) => + withVar('', (search, updateSearch) => { + const filteredBooks = filters.flatMap(f => search.map(s => + sortedBookLibrary.filter(book => + (f.read === undefined || book.read === f.read) + && (s === '' || matchSearch(book, s)) + ) + )) + + return [ + h('aside', Filters.view({ filteredBooks, filters, updateFilters })), + h('main', + h('header', + h('input', + { className: 'g-Search', + oninput: Functions.debounce( + (event: Event) => updateSearch(_ => (event.target as HTMLInputElement).value), + 500 + ) + } + ), + filteredBooks.map(fb => I18n.unit(fb.length, 'livre', 'livres')) + ), + Books.view(filteredBooks) + ) + ] + }) +)) + +function matchSearch(book: Book.Book, search: string): boolean { + return Search.match(search, book.title, book.subtitle, ...book.authors) +} diff --git a/library/client/view/books.ts b/library/client/view/books.ts new file mode 100644 index 0000000..aba55c1 --- /dev/null +++ b/library/client/view/books.ts @@ -0,0 +1,116 @@ +import { h, withVar, mount, Html, Rx } from 'lib/rx' +import * as Book from 'book' +import * as Modal from 'view/components/modal' + +export function view(books: Rx>): Html { + return h('div', + { className: 'g-Books' }, + withVar(undefined, (focusBook, updateFocusBook) => [ + books.map(bs => [ + focusBook.map(book => { + if (book !== undefined) { + let onKeyup = keyupHandler({ + books: bs, + book, + onUpdate: (book: Book.Book) => updateFocusBook(_ => book) + }) + + return bookDetailModal({ + book, + onClose: () => updateFocusBook(_ => undefined), + onmount: () => addEventListener('keyup', onKeyup), + onunmount: () => removeEventListener('keyup', onKeyup) + }) + } + }), + bs.map(book => viewBook({ + book, + onSelect: (book) => updateFocusBook(_ => book) + })) + ]) + ]) + ) +} + +interface KeyupHandlerParams { + books: Array + book: Book.Book + onUpdate: (book: Book.Book) => void +} + +function keyupHandler({ books, book, onUpdate }: KeyupHandlerParams): ((e: KeyboardEvent) => void) { + return (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + const indexedBooks = books.map((b, i) => ({ b, i })) + const focus = indexedBooks.find(({ b }) => b == book) + if (focus !== undefined && focus.i > 0) { + onUpdate(books[focus.i - 1]) + } + } else if (e.key === 'ArrowRight') { + const indexedBooks = books.map((b, i) => ({ b, i })) + const focus = indexedBooks.find(({ b }) => b == book) + if (focus !== undefined && focus.i < books.length - 1) { + onUpdate(books[focus.i + 1]) + } + } + } +} + +interface ViewBookParams { + book: Book.Book + onSelect: (book: Book.Book) => void +} + +function viewBook({ book, onSelect }: ViewBookParams): Html { + return h('button', + { className: 'g-Book' }, + h('img', + { src: book.cover, + alt: book.title, + className: 'g-Book__Image', + onclick: () => onSelect(book) + } + ) + ) +} + +interface BookDetailModalParams { + book: Book.Book + onClose: () => void + onmount: () => void + onunmount: () => void +} + +function bookDetailModal({ book, onClose, onmount, onunmount }: BookDetailModalParams): Html { + return Modal.view({ + header: h('div', + h('div', { className: 'g-BookDetail__Title' }, `${book.title}, ${book.date}`), + book.subtitle && h('div', { className: 'g-BookDetail__Subtitle' }, book.subtitle) + ), + body: h('div', + { className: 'g-BookDetail' }, + h('img', { src: book.cover }), + h('div', + h('dl', + metadata('Auteur', book.authors), + metadata('Genre', book.genres) + ), + book.summary && book.summary + .split('\n') + .map(str => str.trim()) + .filter(str => str != '') + .map(str => h('p', str)) + ) + ), + onClose, + onmount, + onunmount + }) +} + +function metadata(term: string, descriptions: Array): Html { + return h('div', + h('dt', term, descriptions.length > 1 && 's', ' :'), + h('dd', ' ', descriptions.join(', ')) + ) +} diff --git a/library/client/view/components/modal.ts b/library/client/view/components/modal.ts new file mode 100644 index 0000000..5e845e1 --- /dev/null +++ b/library/client/view/components/modal.ts @@ -0,0 +1,38 @@ +import { h, Html } from 'lib/rx' + +interface Params { + header: Html + body: Html + onClose: () => void + onmount?: (element: Element) => void + onunmount?: (element: Element) => void +} + +export function view({ header, body, onClose, onmount, onunmount }: Params): Html { + return h('div', + { className: 'g-Modal', + onclick: () => onClose(), + onmount: (element: Element) => onmount && onmount(element), + onunmount: (element: Element) => onunmount && onunmount(element) + }, + h('div', + { className: 'g-Modal__Content', + onclick: (e: Event) => e.stopPropagation() + }, + h('div', + { className: 'g-Modal__Header' }, + header, + h('button', + { className: 'g-Modal__Close', + onclick: () => onClose() + }, + '✕' + ) + ), + h('div', + { className: 'g-Modal__Body' }, + body + ) + ) + ) +} diff --git a/library/client/view/filters.ts b/library/client/view/filters.ts new file mode 100644 index 0000000..efe4115 --- /dev/null +++ b/library/client/view/filters.ts @@ -0,0 +1,90 @@ +import { h, Rx, Html } from 'lib/rx' +import * as Book from 'book' +import * as I18n from 'lib/i18n' + +// Model + +export interface Model { + read?: Book.ReadStatus +} + +const init: Model = {} + +// View + +interface ViewFiltersParams { + filteredBooks: Rx> + filters: Rx + updateFilters: (f: (filters: Model) => Model) => void +} + +export function view({ filteredBooks, filters, updateFilters }: ViewFiltersParams): Html { + return h('ul', + h('li', [ + h('div', { className: 'g-FilterTitle' }, 'Lecture'), + readFilter({ + filteredBooks, + readStatus: filters.map(fs => fs.read), + update: (status?: Book.ReadStatus) => updateFilters(fs => { + fs.read = status + return fs + }) + }) + ]) + ) +} + +interface ReadFilterParams { + filteredBooks: Rx> + readStatus: Rx + update: (status?: Book.ReadStatus) => void +} + +function readFilter({ filteredBooks, readStatus, update }: ReadFilterParams): Html { + return h('ul', + { className: 'g-Filters' }, + readStatus.map(currentStatus => { + if (currentStatus !== undefined) { + return h('li', + { className: 'g-Filter g-Filter--Selected' }, + h('button', + { onclick: () => update(undefined) }, + filteredBooks.map(xs => unit(xs.length, readStatusLabels(currentStatus))) + ) + ) + } else { + return Book.readStatuses.map(status => + filteredBooks.map(xs => { + const count = xs.filter(b => b.read === status).length + + return count !== 0 + ? h('li', + { className: 'g-Filter g-Filter--Unselected' }, + h('button', + { onclick: () => update(status) }, + unit(count, readStatusLabels(status)) + ) + ) + : undefined + }) + ) + } + }) + ) +} + +function unit(n: number, labels: Array): string { + return I18n.unit(n, labels[0], labels[1], (n, str) => `${str} (${n})`) +} + +function readStatusLabels(status: Book.ReadStatus): Array { + if (status === 'Read') { + return ['lu', 'lus'] + } else if (status === 'Unread') { + return ['non lu', 'non lus'] + } else if (status === 'Reading') { + return ['en cours', 'en cours'] + } else { + return ['arrêté', 'arrêtés'] + } +} diff --git a/library/public/index.html b/library/public/index.html new file mode 100644 index 0000000..ce4d568 --- /dev/null +++ b/library/public/index.html @@ -0,0 +1,7 @@ + + + +Bibliothèque + + + diff --git a/library/public/main.css b/library/public/main.css new file mode 100644 index 0000000..f361cbe --- /dev/null +++ b/library/public/main.css @@ -0,0 +1,235 @@ +/* Variables */ + +:root { + --color-focus: #888833; + + --font-size-dog: 1.5rem; + + --spacing-mouse: 0.25rem; + --spacing-cat: 0.5rem; + --spacing-dog: 1rem; + --spacing-horse: 2rem; + + --width-close-button: 3rem; + --width-book: 13rem; +} + +/* Top level */ + +html { + height: 100%; +} + +body { + margin: 0; + display: flex; + height: 100%; + font-family: sans-serif; +} + +dl { + margin: 0; +} + +dd { + margin-left: 0; +} + +p { + margin: 0; +} + +/* Modal */ + +.g-Modal { + width: 100vw; + height: 100vh; + position: fixed; + top: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1; +} + +.g-Modal__Content { + position: relative; + background-color: white; + width: 50%; + border-radius: 0.2rem; + border: 1px solid #EEE; +} + +.g-Modal__Header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-dog) var(--spacing-horse); + border-bottom: 1px solid #EEE; +} + +.g-Modal__Body { + padding: var(--spacing-horse); + max-height: 50vh; + overflow-y: scroll; +} + +.g-Modal__Close { + cursor: pointer; + font-weight: bold; + border-radius: 50%; + border: 1px solid #EEE; + background-color: transparent; + width: var(--width-close-button); + height: var(--width-close-button); + font-size: 1.7rem; + flex-shrink: 0; +} + +.g-Modal__Close:hover, .g-Modal__Close:focus { + background-color: #EEE; +} + +/* Filters */ + +aside { + background-color: #333; + color: white; + width: 200px; + overflow-y: auto; +} + +ul { + margin: 0; + padding: 0; + list-style-type: none; +} + +.g-FilterTitle { + padding: 0.5rem 1rem; + background-color: #555; + border-left: 8px solid transparent; + font-weight: bold; +} + +.g-Filter--Unselected { + border-left: 8px solid #555; +} + +.g-Filter--Selected { + border-left: 8px solid #883333; +} + +.g-Filter button { + border: none; + background-color: transparent; + color: inherit; + cursor: pointer; + padding: 0.5rem 1rem; + width: 100%; + text-align: left; +} + +.g-Filter button:hover { + background-color: var(--color-focus); +} + +/* Books */ + +main { + width: 100%; + padding: 1rem; + overflow-y: auto; +} + +header { + display: flex; + font-size: 120%; + margin-bottom: 1rem; +} + +.g-Search { + margin-right: 1rem; +} + +.g-Books { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(var(--width-book), 1fr)); + grid-gap: 1rem; +} + +.g-Book { + align-self: center; + border: 1px solid #DDDDDD; + padding: 0; + width: fit-content; +} + +.g-Book:hover { + cursor: pointer; + outline: none; +} + +.g-Book:hover .g-Book__Image { + transform: scale(105%); + opacity: 0.9; +} + +.g-Book__Image { + display: flex; + width: var(--width-book); + transition: all 0.2s ease-in-out; +} + +/* Book detail */ + +.g-BookDetail { + display: flex; + align-items: flex-start; +} + +.g-BookDetail img { + width: var(--width-book); + margin-right: var(--spacing-horse); + border: 1px solid #EEE; +} + +.g-BookDetail__Title { + font-size: var(--font-size-dog); + margin-bottom: var(--spacing-mouse); +} + +.g-BookDetail__Subtitle { + font-style: italic; + margin-bottom: var(--spacing-mouse); +} + +.g-BookDetail dl { + display: flex; + flex-direction: column; + gap: var(--spacing-cat); + margin-bottom: var(--spacing-dog); +} + +.g-BookDetail dt { + display: inline; + text-decoration: underline; +} + +.g-BookDetail dd { + display: inline; +} + +.g-BookDetail p { + font-family: serif; + text-align: justify; + line-height: 1.4rem; + font-size: 1.1rem; + text-indent: 1.5rem; +} + +.g-BookDetail p:not(:last-child) { + margin-bottom: var(--spacing-dog); +} diff --git a/library/tsconfig.json b/library/tsconfig.json new file mode 100644 index 0000000..1e07c37 --- /dev/null +++ b/library/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "amd", + "target": "es2020", + "baseUrl": "client", + "outFile": "public/main.js", + "noImplicitAny": true, + "strictNullChecks": true, + "removeComments": true, + "preserveConstEnums": true + }, + "include": ["client/**/*"] +} diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c2bae59 --- /dev/null +++ b/setup.py @@ -0,0 +1,12 @@ +import setuptools + +setuptools.setup( + name="books", + version="1.0.0", + author="Joris Guyonvarch", + description="Visualize a book library", + long_description_content_type="text/markdown", + url="https://git.guyonvarch.me/books", + packages=setuptools.find_packages(), + scripts=['./books'] +) diff --git a/src/library/__init__.py b/src/library/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/library/command.py b/src/library/command.py deleted file mode 100644 index 6b8577e..0000000 --- a/src/library/command.py +++ /dev/null @@ -1,21 +0,0 @@ -import glob -import json -import os -import tomllib - -def run(book_library): - print(get(book_library)) - -def get(book_library): - metadatas = [] - - for path in glob.glob(f'{book_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/src/main.py b/src/main.py deleted file mode 100644 index 1f07785..0000000 --- a/src/main.py +++ /dev/null @@ -1,71 +0,0 @@ -# Manage book library. -# -# Required dependencies: -# -# - python >= 3.11 -# - requests -# - pillow -# - ebook-convert CLI (from calibre) - -import sys -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 - -- View books in web page: - - $ python {sys.argv[0]} view browser-cmd - -Environment variables: - - BOOK_LIBRARY: path to book library.""") - -def get_book_library(): - path = os.getenv('BOOK_LIBRARY') - if path is None or not os.path.isdir(path): - print_help(title='BOOK_LIBRARY environment variable is required.') - exit(1) - else: - return path - -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) - case [ _, 'view', browser_cmd ]: - book_library = get_book_library() - view.command.run(book_library, browser_cmd) - case [ _, '--help' ]: - print_help() - case [ _, '-h' ]: - print_help() - case _: - print_help('Command not found.') - exit(1) - -if __name__ == "__main__": - main() diff --git a/src/new/__init__.py b/src/new/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/new/command.py b/src/new/command.py deleted file mode 100644 index fe706c2..0000000 --- a/src/new/command.py +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index a712544..0000000 --- a/src/new/format.py +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index eacd70b..0000000 --- a/src/new/reader.py +++ /dev/null @@ -1,55 +0,0 @@ -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/src/view/__init__.py b/src/view/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/view/client/book.ts b/src/view/client/book.ts deleted file mode 100644 index 680cc11..0000000 --- a/src/view/client/book.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface Book { - title: string - subtitle?: string - authors: Array - authorsSort: string - genres: Array - date: number - summary?: string - read: ReadStatus, - cover: string -} - -export type ReadStatus = 'Read' | 'Unread' | 'Reading' | 'Stopped' - -export const readStatuses: Array = ['Read', 'Unread', 'Reading', 'Stopped' ] diff --git a/src/view/client/lib/functions.ts b/src/view/client/lib/functions.ts deleted file mode 100644 index 21fdad9..0000000 --- a/src/view/client/lib/functions.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function debounce(func: Function, timeout: number): any { - let timer: any - return (...args: any) => { - clearTimeout(timer) - timer = setTimeout(() => { func.apply(this, args) }, timeout) - } -} diff --git a/src/view/client/lib/i18n.ts b/src/view/client/lib/i18n.ts deleted file mode 100644 index 3716367..0000000 --- a/src/view/client/lib/i18n.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function unit( - n: number, - singular: string, - plural: string, - f: (n: number, unit: string) => string = format -): string { - return n > 1 - ? f(n, plural) - : f(n, singular) -} - -function format(n: number, unit: string): string { - return `${n} ${unit}` -} diff --git a/src/view/client/lib/rx.ts b/src/view/client/lib/rx.ts deleted file mode 100644 index bf01b6d..0000000 --- a/src/view/client/lib/rx.ts +++ /dev/null @@ -1,404 +0,0 @@ -// [1.0.1] 2023-02-13 - -// Html - -export type Html - = false - | undefined - | string - | number - | Tag - | WithVar - | Array - | Rx - -interface Tag { - type: 'Tag' - tagName: string - attributes: Attributes - children?: Array - onmount?: (element: Element) => void - onunmount?: (element: Element) => void -} - -interface WithVar { - type: 'WithVar' - init: A - getChildren: (v: Var, update: (f: (value: A) => A) => void) => Html -} - -interface Attributes { - [key: string]: Rx | AttributeValue -} - -type AttributeValue - = string - | number - | boolean - | ((event: Event) => void) - | ((element: Element) => void) - -function isHtml(x: any): x is Html { - return (typeof x === 'string' - || typeof x === 'number' - || isTag(x) - || isWithVar(x) - || isRx(x) - || Array.isArray(x)) -} - -type ValueOrArray = T | Array> - -export function h( - tagName: string, - x?: Attributes | Html, - ...children: Array -): Tag { - if (x === undefined || x === false) { - return { - type: 'Tag', - tagName, - attributes: {} - } - } else if (isHtml(x)) { - return { - type: 'Tag', - tagName, - attributes: {}, - children: [x, ...children], - } - } else { - let attributes = x as Attributes - let onmount, onunmount - if ('onmount' in attributes) { - onmount = attributes['onmount'] as (element: Element) => void - delete attributes['onmount'] - } - if ('onunmount' in attributes) { - onunmount = attributes['onunmount'] as (element: Element) => void - delete attributes['onunmount'] - } - return { - type: 'Tag', - tagName, - attributes, - children, - onmount, - onunmount - } - } -} - -export function withVar(init: A, getChildren: (v: Var, update: (f: (value: A) => A) => void) => Html): WithVar { - return { - type: 'WithVar', - init, - getChildren - } -} - -// Rx - -export type RxAble = Rx | A - -export class Rx { - map(f: (value: A) => B): Rx { - return new Map(this, f) - } - - flatMap(f: (value: A) => Rx): Rx { - return new FlatMap(this, f) - } -} - -class Var extends Rx { - readonly type: 'Var' - readonly id: string - - constructor(id: string) { - super() - this.id = id - this.type = 'Var' - } -} - -class Map extends Rx { - readonly type: 'Map' - readonly rx: Rx - readonly f: (value: A) => B - - constructor(rx: Rx, f: (value: A) => B) { - super() - this.type = 'Map' - this.rx = rx - this.f = f - } -} - -class FlatMap extends Rx { - readonly type: 'FlatMap' - readonly rx: Rx - readonly f: (value: A) => Rx - - constructor(rx: Rx, f: (value: A) => Rx) { - super() - this.type = 'FlatMap' - this.rx = rx - this.f = f - } -} - -// Mount - -export function mount(html: Html): Cancelable { - const state = new State() - let appendRes = appendChild(state, document.body, html) - return appendRes.cancel -} - -interface StateEntry { - value: A - subscribers: Array<(value: A) => void> -} - -class State { - readonly state: {[key: string]: StateEntry} - varCounter: bigint - - constructor() { - this.state = {} - this.varCounter = BigInt(0) - } - - register(initValue: A) { - const v = new Var(this.varCounter.toString()) - this.varCounter += BigInt(1) - this.state[v.id] = { - value: initValue, - subscribers: [] - } - return v - } - - unregister(v: Var) { - delete this.state[v.id] - } - - get(v: Var) { - return this.state[v.id].value - } - - update(v: Var, f: (value: A) => A) { - const value = f(this.state[v.id].value) - this.state[v.id].value = value - this.state[v.id].subscribers.forEach(notify => { - // Don’t notify if it has been removed from a precedent notifier - if (this.state[v.id].subscribers.indexOf(notify) !== -1) { - notify(value) - } - }) - } - - subscribe(v: Var, notify: (value: A) => void): Cancelable { - this.state[v.id].subscribers.push(notify) - return () => this.state[v.id].subscribers = this.state[v.id].subscribers.filter(n => n !== notify) - } -} - -// Cancelable - -type Cancelable = () => void - -const voidCancel = () => {} - -// Removable - -type Removable = () => void - -const voidRemove = () => {} - -// Rx run - -function rxRun(state: State, rx: Rx, effect: (value: A) => void): Cancelable { - if (isVar(rx)) { - const cancel = state.subscribe(rx, effect) - effect(state.get(rx)) - return cancel - } else if (isMap(rx)) { - return rxRun(state, rx.rx, value => effect(rx.f(value))) - } else if (isFlatMap(rx)) { - let cancel1 = voidCancel - const cancel2 = rxRun(state, rx.rx, (value: A) => { - cancel1() - cancel1 = rxRun(state, rx.f(value), effect) - }) - return () => { - cancel2() - cancel1() - } - } else { - throw new Error(`Unrecognized rx: ${rx}`) - } -} - -function isRx(x: any): x is Rx { - return x !== undefined && x.type !== undefined && (x.type === "Var" || x.type === "Map" || x.type === "FlatMap") -} - -function isVar(x: any): x is Var { - return x.type === "Var" -} - -function isMap(x: any): x is Map { - return x.type === "Map" -} - -function isFlatMap(x: any): x is FlatMap { - return x.type === "FlatMap" -} - -// Append - -interface AppendResult { - cancel: Cancelable - remove: Removable - lastAdded?: Node -} - -function appendChild(state: State, element: Element, child: Html, lastAdded?: Node): AppendResult { - if (Array.isArray(child)) { - let cancels: Array = [] - let removes: Array = [] - child.forEach((o) => { - const appendResult = appendChild(state, element, o, lastAdded) - cancels.push(appendResult.cancel) - removes.push(appendResult.remove) - lastAdded = appendResult.lastAdded - }) - return { - cancel: () => cancels.forEach((o) => o()), - remove: () => removes.forEach((o) => o()), - lastAdded - } - } else if (typeof child == "string") { - const node = document.createTextNode(child) - appendNode(element, node, lastAdded) - return { - cancel: voidCancel, - remove: () => element.removeChild(node), - lastAdded: node - } - } else if (typeof child == "number") { - return appendChild(state, element, child.toString(), lastAdded) - } else if (isTag(child)) { - const { tagName, attributes, children, onmount, onunmount } = child - - const childElement = document.createElement(tagName) - const cancelAttributes = Object.entries(attributes).map(([key, value]) => { - if (isRx(value)) { - return rxRun(state, value, newValue => setAttribute(state, childElement, key, newValue)) - } else { - setAttribute(state, childElement, key, value) - } - }) - - const appendChildrenRes = appendChild(state, childElement, children) - - appendNode(element, childElement, lastAdded) - - if (onmount !== undefined) { - onmount(childElement) - } - - return { - cancel: () => { - cancelAttributes.forEach(cancel => cancel !== undefined ? cancel() : {}) - appendChildrenRes.cancel() - if (onunmount !== undefined) { - onunmount(childElement) - } - }, - remove: () => element.removeChild(childElement), - lastAdded: childElement, - } - } else if (isWithVar(child)) { - const { init, getChildren } = child - const v = state.register(init) - const children = getChildren(v, f => state.update(v, f)) - const appendRes = appendChild(state, element, children) - return { - cancel: () => { - appendRes.cancel() - state.unregister(v) - }, - remove: () => appendRes.remove(), - lastAdded: appendRes.lastAdded - } - } else if (isRx(child)) { - const rxBase = document.createTextNode('') - appendNode(element, rxBase, lastAdded) - let appendRes: AppendResult = { - cancel: voidCancel, - remove: voidRemove, - lastAdded: rxBase - } - const cancelRx = rxRun(state, child, (value: Html) => { - appendRes.cancel() - appendRes.remove() - appendRes = appendChild(state, element, value, rxBase) - }) - return { - cancel: () => { - appendRes.cancel() - cancelRx() - }, - remove: () => { - appendRes.remove() - element.removeChild(rxBase) - }, - lastAdded: appendRes.lastAdded, - } - } else if (child === undefined || child === false) { - return { - cancel: voidCancel, - remove: voidRemove, - lastAdded - } - } else { - throw new Error(`Unrecognized child: ${child}`) - } -} - -function isTag(x: any): x is Tag { - return x !== undefined && x.type === "Tag" -} - -function isWithVar(x: any): x is WithVar { - return x !== undefined && x.type === "WithVar" -} - -function appendNode(base: Element, node: Node, lastAdded?: Node) { - if (lastAdded !== undefined) { - base.insertBefore(node, lastAdded.nextSibling) - } else { - base.append(node) - } -} - -function setAttribute(state: State, element: Element, key: string, attribute: AttributeValue) { - if (typeof attribute == "boolean") { - if (attribute) { - // @ts-ignore - element[key] = "true" - } - } else if (typeof attribute == "number") { - // @ts-ignore - element[key] = attribute.toString() - } else if (typeof attribute == "string") { - // @ts-ignore - element[key] = attribute - } else { - // @ts-ignore - element[key] = (event: Event) => attribute(event) - } -} diff --git a/src/view/client/lib/search.ts b/src/view/client/lib/search.ts deleted file mode 100644 index 026cb94..0000000 --- a/src/view/client/lib/search.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function match(search: string, ...targets: Array): boolean { - const formattedTargets = targets - .filter(t => t !== undefined) - .map(format) - - return search.split(/\s+/).every(subSearch => - formattedTargets.some(target => target.includes(format(subSearch))) - ) -} - -export function format(str: string): string { - return unaccent(str.toLowerCase()) -} - -function unaccent(str: string): string { - return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "") -} diff --git a/src/view/client/main.ts b/src/view/client/main.ts deleted file mode 100644 index 5885871..0000000 --- a/src/view/client/main.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { h, withVar, mount, Html } from 'lib/rx' -import * as Search from 'lib/search' -import * as Functions from 'lib/functions' -import * as I18n from 'lib/i18n' -import * as Filters from 'view/filters' -import * as Books from 'view/books' -import * as Book from 'book' - -// @ts-ignore -const sortedBookLibrary: Array = (bookLibrary as Array).sort((a, b) => - a.authorsSort == b.authorsSort - ? a.date > b.date - : a.authorsSort > b.authorsSort) - -mount(withVar({}, (filters, updateFilters) => - withVar('', (search, updateSearch) => { - const filteredBooks = filters.flatMap(f => search.map(s => - sortedBookLibrary.filter(book => - (f.read === undefined || book.read === f.read) - && (s === '' || matchSearch(book, s)) - ) - )) - - return [ - h('aside', Filters.view({ filteredBooks, filters, updateFilters })), - h('main', - h('header', - h('input', - { className: 'g-Search', - oninput: Functions.debounce( - (event: Event) => updateSearch(_ => (event.target as HTMLInputElement).value), - 500 - ) - } - ), - filteredBooks.map(fb => I18n.unit(fb.length, 'livre', 'livres')) - ), - Books.view(filteredBooks) - ) - ] - }) -)) - -function matchSearch(book: Book.Book, search: string): boolean { - return Search.match(search, book.title, book.subtitle, ...book.authors) -} diff --git a/src/view/client/view/books.ts b/src/view/client/view/books.ts deleted file mode 100644 index aba55c1..0000000 --- a/src/view/client/view/books.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { h, withVar, mount, Html, Rx } from 'lib/rx' -import * as Book from 'book' -import * as Modal from 'view/components/modal' - -export function view(books: Rx>): Html { - return h('div', - { className: 'g-Books' }, - withVar(undefined, (focusBook, updateFocusBook) => [ - books.map(bs => [ - focusBook.map(book => { - if (book !== undefined) { - let onKeyup = keyupHandler({ - books: bs, - book, - onUpdate: (book: Book.Book) => updateFocusBook(_ => book) - }) - - return bookDetailModal({ - book, - onClose: () => updateFocusBook(_ => undefined), - onmount: () => addEventListener('keyup', onKeyup), - onunmount: () => removeEventListener('keyup', onKeyup) - }) - } - }), - bs.map(book => viewBook({ - book, - onSelect: (book) => updateFocusBook(_ => book) - })) - ]) - ]) - ) -} - -interface KeyupHandlerParams { - books: Array - book: Book.Book - onUpdate: (book: Book.Book) => void -} - -function keyupHandler({ books, book, onUpdate }: KeyupHandlerParams): ((e: KeyboardEvent) => void) { - return (e: KeyboardEvent) => { - if (e.key === 'ArrowLeft') { - const indexedBooks = books.map((b, i) => ({ b, i })) - const focus = indexedBooks.find(({ b }) => b == book) - if (focus !== undefined && focus.i > 0) { - onUpdate(books[focus.i - 1]) - } - } else if (e.key === 'ArrowRight') { - const indexedBooks = books.map((b, i) => ({ b, i })) - const focus = indexedBooks.find(({ b }) => b == book) - if (focus !== undefined && focus.i < books.length - 1) { - onUpdate(books[focus.i + 1]) - } - } - } -} - -interface ViewBookParams { - book: Book.Book - onSelect: (book: Book.Book) => void -} - -function viewBook({ book, onSelect }: ViewBookParams): Html { - return h('button', - { className: 'g-Book' }, - h('img', - { src: book.cover, - alt: book.title, - className: 'g-Book__Image', - onclick: () => onSelect(book) - } - ) - ) -} - -interface BookDetailModalParams { - book: Book.Book - onClose: () => void - onmount: () => void - onunmount: () => void -} - -function bookDetailModal({ book, onClose, onmount, onunmount }: BookDetailModalParams): Html { - return Modal.view({ - header: h('div', - h('div', { className: 'g-BookDetail__Title' }, `${book.title}, ${book.date}`), - book.subtitle && h('div', { className: 'g-BookDetail__Subtitle' }, book.subtitle) - ), - body: h('div', - { className: 'g-BookDetail' }, - h('img', { src: book.cover }), - h('div', - h('dl', - metadata('Auteur', book.authors), - metadata('Genre', book.genres) - ), - book.summary && book.summary - .split('\n') - .map(str => str.trim()) - .filter(str => str != '') - .map(str => h('p', str)) - ) - ), - onClose, - onmount, - onunmount - }) -} - -function metadata(term: string, descriptions: Array): Html { - return h('div', - h('dt', term, descriptions.length > 1 && 's', ' :'), - h('dd', ' ', descriptions.join(', ')) - ) -} diff --git a/src/view/client/view/components/modal.ts b/src/view/client/view/components/modal.ts deleted file mode 100644 index 5e845e1..0000000 --- a/src/view/client/view/components/modal.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { h, Html } from 'lib/rx' - -interface Params { - header: Html - body: Html - onClose: () => void - onmount?: (element: Element) => void - onunmount?: (element: Element) => void -} - -export function view({ header, body, onClose, onmount, onunmount }: Params): Html { - return h('div', - { className: 'g-Modal', - onclick: () => onClose(), - onmount: (element: Element) => onmount && onmount(element), - onunmount: (element: Element) => onunmount && onunmount(element) - }, - h('div', - { className: 'g-Modal__Content', - onclick: (e: Event) => e.stopPropagation() - }, - h('div', - { className: 'g-Modal__Header' }, - header, - h('button', - { className: 'g-Modal__Close', - onclick: () => onClose() - }, - '✕' - ) - ), - h('div', - { className: 'g-Modal__Body' }, - body - ) - ) - ) -} diff --git a/src/view/client/view/filters.ts b/src/view/client/view/filters.ts deleted file mode 100644 index efe4115..0000000 --- a/src/view/client/view/filters.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { h, Rx, Html } from 'lib/rx' -import * as Book from 'book' -import * as I18n from 'lib/i18n' - -// Model - -export interface Model { - read?: Book.ReadStatus -} - -const init: Model = {} - -// View - -interface ViewFiltersParams { - filteredBooks: Rx> - filters: Rx - updateFilters: (f: (filters: Model) => Model) => void -} - -export function view({ filteredBooks, filters, updateFilters }: ViewFiltersParams): Html { - return h('ul', - h('li', [ - h('div', { className: 'g-FilterTitle' }, 'Lecture'), - readFilter({ - filteredBooks, - readStatus: filters.map(fs => fs.read), - update: (status?: Book.ReadStatus) => updateFilters(fs => { - fs.read = status - return fs - }) - }) - ]) - ) -} - -interface ReadFilterParams { - filteredBooks: Rx> - readStatus: Rx - update: (status?: Book.ReadStatus) => void -} - -function readFilter({ filteredBooks, readStatus, update }: ReadFilterParams): Html { - return h('ul', - { className: 'g-Filters' }, - readStatus.map(currentStatus => { - if (currentStatus !== undefined) { - return h('li', - { className: 'g-Filter g-Filter--Selected' }, - h('button', - { onclick: () => update(undefined) }, - filteredBooks.map(xs => unit(xs.length, readStatusLabels(currentStatus))) - ) - ) - } else { - return Book.readStatuses.map(status => - filteredBooks.map(xs => { - const count = xs.filter(b => b.read === status).length - - return count !== 0 - ? h('li', - { className: 'g-Filter g-Filter--Unselected' }, - h('button', - { onclick: () => update(status) }, - unit(count, readStatusLabels(status)) - ) - ) - : undefined - }) - ) - } - }) - ) -} - -function unit(n: number, labels: Array): string { - return I18n.unit(n, labels[0], labels[1], (n, str) => `${str} (${n})`) -} - -function readStatusLabels(status: Book.ReadStatus): Array { - if (status === 'Read') { - return ['lu', 'lus'] - } else if (status === 'Unread') { - return ['non lu', 'non lus'] - } else if (status === 'Reading') { - return ['en cours', 'en cours'] - } else { - return ['arrêté', 'arrêtés'] - } -} diff --git a/src/view/command.py b/src/view/command.py deleted file mode 100644 index e50027f..0000000 --- a/src/view/command.py +++ /dev/null @@ -1,17 +0,0 @@ -import os -import shutil -import subprocess -import tempfile -import time - -import library.command - -def run(book_library, browser_cmd): - tmp_dir = tempfile.mkdtemp() - directory = os.path.dirname(os.path.realpath(__file__)) - shutil.copytree(f'{directory}/public', tmp_dir, dirs_exist_ok=True) - with open(f'{tmp_dir}/books.js', 'w') as f: - json = library.command.get(book_library) - f.write(f'const bookLibrary = {json}') - browser_cmd = f'{browser_cmd} {tmp_dir}/index.html' - subprocess.run(browser_cmd.split(' ')) diff --git a/src/view/public/index.html b/src/view/public/index.html deleted file mode 100644 index ce4d568..0000000 --- a/src/view/public/index.html +++ /dev/null @@ -1,7 +0,0 @@ - - - -Bibliothèque - - - diff --git a/src/view/public/main.css b/src/view/public/main.css deleted file mode 100644 index f361cbe..0000000 --- a/src/view/public/main.css +++ /dev/null @@ -1,235 +0,0 @@ -/* Variables */ - -:root { - --color-focus: #888833; - - --font-size-dog: 1.5rem; - - --spacing-mouse: 0.25rem; - --spacing-cat: 0.5rem; - --spacing-dog: 1rem; - --spacing-horse: 2rem; - - --width-close-button: 3rem; - --width-book: 13rem; -} - -/* Top level */ - -html { - height: 100%; -} - -body { - margin: 0; - display: flex; - height: 100%; - font-family: sans-serif; -} - -dl { - margin: 0; -} - -dd { - margin-left: 0; -} - -p { - margin: 0; -} - -/* Modal */ - -.g-Modal { - width: 100vw; - height: 100vh; - position: fixed; - top: 0; - left: 0; - display: flex; - justify-content: center; - align-items: center; - background-color: rgba(0, 0, 0, 0.5); - z-index: 1; -} - -.g-Modal__Content { - position: relative; - background-color: white; - width: 50%; - border-radius: 0.2rem; - border: 1px solid #EEE; -} - -.g-Modal__Header { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--spacing-dog) var(--spacing-horse); - border-bottom: 1px solid #EEE; -} - -.g-Modal__Body { - padding: var(--spacing-horse); - max-height: 50vh; - overflow-y: scroll; -} - -.g-Modal__Close { - cursor: pointer; - font-weight: bold; - border-radius: 50%; - border: 1px solid #EEE; - background-color: transparent; - width: var(--width-close-button); - height: var(--width-close-button); - font-size: 1.7rem; - flex-shrink: 0; -} - -.g-Modal__Close:hover, .g-Modal__Close:focus { - background-color: #EEE; -} - -/* Filters */ - -aside { - background-color: #333; - color: white; - width: 200px; - overflow-y: auto; -} - -ul { - margin: 0; - padding: 0; - list-style-type: none; -} - -.g-FilterTitle { - padding: 0.5rem 1rem; - background-color: #555; - border-left: 8px solid transparent; - font-weight: bold; -} - -.g-Filter--Unselected { - border-left: 8px solid #555; -} - -.g-Filter--Selected { - border-left: 8px solid #883333; -} - -.g-Filter button { - border: none; - background-color: transparent; - color: inherit; - cursor: pointer; - padding: 0.5rem 1rem; - width: 100%; - text-align: left; -} - -.g-Filter button:hover { - background-color: var(--color-focus); -} - -/* Books */ - -main { - width: 100%; - padding: 1rem; - overflow-y: auto; -} - -header { - display: flex; - font-size: 120%; - margin-bottom: 1rem; -} - -.g-Search { - margin-right: 1rem; -} - -.g-Books { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(var(--width-book), 1fr)); - grid-gap: 1rem; -} - -.g-Book { - align-self: center; - border: 1px solid #DDDDDD; - padding: 0; - width: fit-content; -} - -.g-Book:hover { - cursor: pointer; - outline: none; -} - -.g-Book:hover .g-Book__Image { - transform: scale(105%); - opacity: 0.9; -} - -.g-Book__Image { - display: flex; - width: var(--width-book); - transition: all 0.2s ease-in-out; -} - -/* Book detail */ - -.g-BookDetail { - display: flex; - align-items: flex-start; -} - -.g-BookDetail img { - width: var(--width-book); - margin-right: var(--spacing-horse); - border: 1px solid #EEE; -} - -.g-BookDetail__Title { - font-size: var(--font-size-dog); - margin-bottom: var(--spacing-mouse); -} - -.g-BookDetail__Subtitle { - font-style: italic; - margin-bottom: var(--spacing-mouse); -} - -.g-BookDetail dl { - display: flex; - flex-direction: column; - gap: var(--spacing-cat); - margin-bottom: var(--spacing-dog); -} - -.g-BookDetail dt { - display: inline; - text-decoration: underline; -} - -.g-BookDetail dd { - display: inline; -} - -.g-BookDetail p { - font-family: serif; - text-align: justify; - line-height: 1.4rem; - font-size: 1.1rem; - text-indent: 1.5rem; -} - -.g-BookDetail p:not(:last-child) { - margin-bottom: var(--spacing-dog); -} diff --git a/src/view/tsconfig.json b/src/view/tsconfig.json deleted file mode 100644 index 1e07c37..0000000 --- a/src/view/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "module": "amd", - "target": "es2020", - "baseUrl": "client", - "outFile": "public/main.js", - "noImplicitAny": true, - "strictNullChecks": true, - "removeComments": true, - "preserveConstEnums": true - }, - "include": ["client/**/*"] -} -- cgit v1.2.3