diff options
53 files changed, 1156 insertions, 1461 deletions
@@ -1,2 +1,2 @@ +library/ __pycache__ -library/public/*.js diff --git a/Makefile b/Makefile deleted file mode 100644 index fc2acc6..0000000 --- a/Makefile +++ /dev/null @@ -1,11 +0,0 @@ -build: library/public/main.js - -library/public/main.js: - @esbuild \ - --bundle library/client/main.ts \ - --minify \ - --target=es2017 \ - --outdir=library/public - -clean: - @rm -f library/public/*.js @@ -1,41 +1,25 @@ -# Books +Manage a book library. -Visualize a book library. +- Group by progress +- Associate any number of ebooks to each book +- Transfer individual books to ereader -## Book library +# Running -Organize folders as you wish, only `metadata.toml` files matter: - -```toml -title = "Title of the Book" -subtitle = "Optional subtitle" -authors = [ "Author 1", "Author 2" ] -authorsSort = "Author sorting" -genres = [ "Foo", "Bar", "Baz" ] -year = 1234 -summarry = """ -First paragraph -Second paragraph -""" -read = "Read" +```sh +nix develop --command books ``` -`read` is one of: `Read`, `Unread`, `Reading`, `Stopped`. - -Each `metadata.toml` file correspond to a book, and there **must** be a cover -named `cover.ext` in the same directory. Any extension works. - -## Dev server - -In nix shell (`nix develop`), run: +# Testing - ./bin/dev-server path-to-books - -## Show library - - make - BOOKS_LIBRARY=path-to-books BOOKS_BROWSER=firefox python src/main.py library +```sh +pytest +``` -## Add book +# Improvements - BOOKS_LIBRARY=path-to-books python src/main.py new optional-path-to-ebook +- book flow: keep header bar visible when scrolling down +- filters: + - textual search + https://stackoverflow.com/questions/55828169/how-to-filter-gtk-flowbox-children-with-gtk-entrysearch + - select by genre diff --git a/bin/dev-server b/bin/dev-server deleted file mode 100755 index 7351209..0000000 --- a/bin/dev-server +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd $(dirname "$0")/.. - -if [ "$#" == 1 ]; then - BOOKS_LIBRARY="$1" -else - echo "usage: $0 path-to-book-directory" - exit 1 -fi - -# Watch books - -BUILD_BOOKS_CMD="echo \"const bookLibrary=\" > library/public/books.js && ./books library >> library/public/books.js && echo library/public/books.js updated." -watchexec \ - --watch "$BOOKS_LIBRARY" \ - -- "$BUILD_BOOKS_CMD" & - -# Watch TypeScript - -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'" -watchexec \ - --clear \ - --watch client \ - -- "$CHECK && $BUILD && $SHOW" diff --git a/bin/migrate/1-read-status b/bin/migrate/1-read-status deleted file mode 100755 index 5f4b61d..0000000 --- a/bin/migrate/1-read-status +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [ "$#" == 1 ]; then - BOOK_DIR="$1" -else - echo "usage: $0 path-to-book-directory" - exit 1 -fi - -for FILE in $(find "$BOOK_DIR" -name 'metadata.json'); do - METADATA=$(cat "$FILE") - READ=$(echo "$METADATA" | jq .read) - if [ "$READ" == "true" ]; then - echo "$METADATA" | jq '.read = "Read"' > "$FILE" - else - echo "$METADATA" | jq '.read = "Unread"' > "$FILE" - fi -done diff --git a/bin/migrate/2-compress-covers b/bin/migrate/2-compress-covers deleted file mode 100755 index 0398689..0000000 --- a/bin/migrate/2-compress-covers +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env nix-shell -#!nix-shell -i python3 -p python3Packages.pillow - -import PIL.Image -import glob -import os -import sys - -if len(sys.argv) == 2 and os.path.exists(sys.argv[1]): - book_directory = sys.argv[1] -else: - print(f'Usage: {sys.argv[0]} book-directory') - exit(1) - -def compress(path): - directory = os.path.dirname(os.path.realpath(path)) - image = PIL.Image.open(path) - width, height = image.size - if width > 300: - image = image.resize((300, int(300 * height / width)), PIL.Image.LANCZOS) - image = image.convert('RGB') - image.save(f'{directory}/tmp.webp', 'WEBP', optimize=True, quality=85) - os.remove(path) - os.rename(f'{directory}/tmp.webp', f'{directory}/cover.webp') - -for path in glob.glob(f'{book_directory}/**/cover.*', recursive=True): - compress(path) diff --git a/bin/test b/bin/test deleted file mode 100755 index cd6c0de..0000000 --- a/bin/test +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname $0)/.." -python -m pytest @@ -1,10 +1,6 @@ #!/usr/bin/env python -import cli.main -import os -import sys +from src.main import main +import os.path -bin_dir = os.path.dirname(os.path.realpath(__file__)) -try: - cli.main.main(bin_dir) -except KeyboardInterrupt: - sys.exit(1) +resources = f'{os.path.dirname(__file__)}/resources' +main(resources) diff --git a/cli/library/command.py b/cli/library/command.py deleted file mode 100644 index 1c4d20c..0000000 --- a/cli/library/command.py +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 01434fa..0000000 --- a/cli/main.py +++ /dev/null @@ -1,80 +0,0 @@ -# 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 deleted file mode 100644 index e69de29..0000000 --- a/cli/new/__init__.py +++ /dev/null diff --git a/cli/new/command.py b/cli/new/command.py deleted file mode 100644 index 17b686c..0000000 --- a/cli/new/command.py +++ /dev/null @@ -1,88 +0,0 @@ -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 book_source is not 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.list(authors)} - authorsSort = "{author_sort}" - genres = {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 book_source is not 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 book_source is not 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/reader.py b/cli/new/reader.py deleted file mode 100644 index eacd70b..0000000 --- a/cli/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/cli/view/__init__.py b/cli/view/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/cli/view/__init__.py +++ /dev/null diff --git a/cli/view/command.py b/cli/view/command.py deleted file mode 100644 index 72e44dd..0000000 --- a/cli/view/command.py +++ /dev/null @@ -1,16 +0,0 @@ -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(' ')) @@ -1,27 +1,12 @@ { "nodes": { - "flake-utils": { - "locked": { - "lastModified": 1667395993, - "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1674290375, - "narHash": "sha256-yRD5bQWzu6UpDC6cybXwFizWHJCHcJTw35Ms7Q/nzU4=", + "lastModified": 1766769513, + "narHash": "sha256-Cp/cu3mBzrkZqAZ9SjezaSFo9YkUB0YAp/PttWo7MZE=", "owner": "nixos", "repo": "nixpkgs", - "rev": "68403fe04f6c85853ddd389c9e58dd9b9c8b0a36", + "rev": "2d0f3cecb3a2e77f5604e82fd43c030796095ca3", "type": "github" }, "original": { @@ -32,7 +17,6 @@ }, "root": { "inputs": { - "flake-utils": "flake-utils", "nixpkgs": "nixpkgs" } } @@ -1,34 +1,24 @@ { inputs = { - nixpkgs.url = "github:nixos/nixpkgs"; - flake-utils.url = "github:numtide/flake-utils"; + nixpkgs.url = "github:nixos/nixpkgs"; }; - outputs = { self, nixpkgs, flake-utils, ... }: - flake-utils.lib.eachDefaultSystem (system: - let - pkgs = import nixpkgs { inherit system; }; - - ebook-convert = with pkgs; writeShellScriptBin "ebook-convert" '' - set -euo pipefail - ${calibre}/bin/ebook-convert "$@" - ''; - in with pkgs; { - devShell = mkShell { - buildInputs = [ - esbuild - nodePackages.typescript - psmisc # fuser - watchexec - - # CLI - python311 - python311Packages.pillow - python311Packages.pytest - python311Packages.requests - ebook-convert - ]; - }; - } + outputs = { self, nixpkgs, ... }: + let + eachSystem = with nixpkgs.lib; f: foldAttrs mergeAttrs {} + (map (s: mapAttrs (_: v: { ${s} = v; }) (f s)) systems.flakeExposed); + in eachSystem (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in { devShell = pkgs.mkShell { + buildInputs = with pkgs; [ + gtk4 + sqlite + libadwaita + gobject-introspection + (python3.withPackages (subpkgs: with subpkgs; [ + pygobject3 nanoid requests pillow numpy calibre pytest + ])) + ]; + }; } ); } diff --git a/library/client/book.ts b/library/client/book.ts deleted file mode 100644 index 680cc11..0000000 --- a/library/client/book.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface Book { - title: string - subtitle?: string - authors: Array<string> - authorsSort: string - genres: Array<string> - date: number - summary?: string - read: ReadStatus, - cover: string -} - -export type ReadStatus = 'Read' | 'Unread' | 'Reading' | 'Stopped' - -export const readStatuses: Array<ReadStatus> = ['Read', 'Unread', 'Reading', 'Stopped' ] diff --git a/library/client/lib/functions.ts b/library/client/lib/functions.ts deleted file mode 100644 index 21fdad9..0000000 --- a/library/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/library/client/lib/i18n.ts b/library/client/lib/i18n.ts deleted file mode 100644 index 3716367..0000000 --- a/library/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/library/client/lib/rx.ts b/library/client/lib/rx.ts deleted file mode 100644 index bf01b6d..0000000 --- a/library/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<any> - | Array<Html> - | Rx<Html> - -interface Tag { - type: 'Tag' - tagName: string - attributes: Attributes - children?: Array<Html> - onmount?: (element: Element) => void - onunmount?: (element: Element) => void -} - -interface WithVar<A> { - type: 'WithVar' - init: A - getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Html -} - -interface Attributes { - [key: string]: Rx<AttributeValue> | 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> = T | Array<ValueOrArray<T>> - -export function h( - tagName: string, - x?: Attributes | Html, - ...children: Array<Html> -): 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<A>(init: A, getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Html): WithVar<A> { - return { - type: 'WithVar', - init, - getChildren - } -} - -// Rx - -export type RxAble<A> = Rx<A> | A - -export class Rx<A> { - map<B>(f: (value: A) => B): Rx<B> { - return new Map<A, B>(this, f) - } - - flatMap<B>(f: (value: A) => Rx<B>): Rx<B> { - return new FlatMap<A, B>(this, f) - } -} - -class Var<A> extends Rx<A> { - readonly type: 'Var' - readonly id: string - - constructor(id: string) { - super() - this.id = id - this.type = 'Var' - } -} - -class Map<A, B> extends Rx<B> { - readonly type: 'Map' - readonly rx: Rx<A> - readonly f: (value: A) => B - - constructor(rx: Rx<A>, f: (value: A) => B) { - super() - this.type = 'Map' - this.rx = rx - this.f = f - } -} - -class FlatMap<A, B> extends Rx<B> { - readonly type: 'FlatMap' - readonly rx: Rx<A> - readonly f: (value: A) => Rx<B> - - constructor(rx: Rx<A>, f: (value: A) => Rx<B>) { - 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<A> { - value: A - subscribers: Array<(value: A) => void> -} - -class State { - readonly state: {[key: string]: StateEntry<any>} - varCounter: bigint - - constructor() { - this.state = {} - this.varCounter = BigInt(0) - } - - register<A>(initValue: A) { - const v = new Var(this.varCounter.toString()) - this.varCounter += BigInt(1) - this.state[v.id] = { - value: initValue, - subscribers: [] - } - return v - } - - unregister<A>(v: Var<A>) { - delete this.state[v.id] - } - - get<A>(v: Var<A>) { - return this.state[v.id].value - } - - update<A>(v: Var<A>, 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<A>(v: Var<A>, 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<A>(state: State, rx: Rx<A>, effect: (value: A) => void): Cancelable { - if (isVar(rx)) { - const cancel = state.subscribe(rx, effect) - effect(state.get(rx)) - return cancel - } else if (isMap<A, any>(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<A>(x: any): x is Rx<A> { - return x !== undefined && x.type !== undefined && (x.type === "Var" || x.type === "Map" || x.type === "FlatMap") -} - -function isVar<A>(x: any): x is Var<A> { - return x.type === "Var" -} - -function isMap<A, B>(x: any): x is Map<A, B> { - return x.type === "Map" -} - -function isFlatMap<A, B>(x: any): x is FlatMap<A, B> { - 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<Cancelable> = [] - let removes: Array<Removable> = [] - 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<AttributeValue>(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<A>(x: any): x is Tag { - return x !== undefined && x.type === "Tag" -} - -function isWithVar<A>(x: any): x is WithVar<A> { - 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 deleted file mode 100644 index 026cb94..0000000 --- a/library/client/lib/search.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function match(search: string, ...targets: Array<string | undefined>): 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 deleted file mode 100644 index 5885871..0000000 --- a/library/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<Book> = (bookLibrary as Array<Book.Book>).sort((a, b) => - a.authorsSort == b.authorsSort - ? a.date > b.date - : a.authorsSort > b.authorsSort) - -mount(withVar<Filters.Model>({}, (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 deleted file mode 100644 index aba55c1..0000000 --- a/library/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<Array<Book.Book>>): Html { - return h('div', - { className: 'g-Books' }, - withVar<Book.Book | undefined>(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: 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<string>): 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 deleted file mode 100644 index 5e845e1..0000000 --- a/library/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/library/client/view/filters.ts b/library/client/view/filters.ts deleted file mode 100644 index efe4115..0000000 --- a/library/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<Array<Book.Book>> - filters: Rx<Model> - 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<Array<Book.Book>> - readStatus: Rx<Book.ReadStatus | undefined> - 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>): string { - return I18n.unit(n, labels[0], labels[1], (n, str) => `${str} (${n})`) -} - -function readStatusLabels(status: Book.ReadStatus): Array<string> { - 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 deleted file mode 100644 index ce4d568..0000000 --- a/library/public/index.html +++ /dev/null @@ -1,7 +0,0 @@ -<!DOCTYPE html> -<meta charset="UTF-8"> -<link rel="stylesheet" href="main.css"> -<title>Bibliothèque</title> -<body></body> -<script src="books.js"></script> -<script src="main.js"></script> diff --git a/library/public/main.css b/library/public/main.css deleted file mode 100644 index f361cbe..0000000 --- a/library/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/library/tsconfig.json b/library/tsconfig.json deleted file mode 100644 index 1e07c37..0000000 --- a/library/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/**/*"] -} diff --git a/resources/blank-cover.png b/resources/blank-cover.png Binary files differnew file mode 100644 index 0000000..37f03b7 --- /dev/null +++ b/resources/blank-cover.png diff --git a/resources/migrations/01.sql b/resources/migrations/01.sql new file mode 100644 index 0000000..1acccdd --- /dev/null +++ b/resources/migrations/01.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS "books" ( + "id" TEXT PRIMARY KEY, + "created_at" TEXT NOT NULL DEFAULT (datetime()), + "updated_at" TEXT NOT NULL DEFAULT (datetime()), + "data" BLOB NOT NULL +) STRICT; @@ -7,6 +7,7 @@ setuptools.setup( description="Visualize a book library", long_description_content_type="text/markdown", url="https://git.guyonvarch.me/books", + # package_dir = {"": "src"}, packages=setuptools.find_packages(), - scripts=['./books'] + scripts = ["./books"] ) diff --git a/cli/__init__.py b/src/__init__.py index e69de29..e69de29 100644 --- a/cli/__init__.py +++ b/src/__init__.py diff --git a/src/application.py b/src/application.py new file mode 100644 index 0000000..46ab901 --- /dev/null +++ b/src/application.py @@ -0,0 +1,67 @@ +# To add CSS +# https://github.com/Taiko2k/GTK4PythonTutorial/blob/main/README.md#adding-your-custom-css-stylesheet + +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +from gi.repository import Gtk, Adw + +from src.book_flow import BookFlow +from src.filters import Filters +from src.book_form import BookForm +import src.utils as utils + +class MainWindow(Gtk.ApplicationWindow): + def __init__(self, resources, library, ereader, conn, *args, **kwargs): + super().__init__(*args, **kwargs) + + utils.set_header_bar(self, 'Books') + + scrolled_window = Gtk.ScrolledWindow() + self.set_child(scrolled_window) + + init_progress = 'Reading' + + add_book_button = Gtk.Button(label='Ajouter un livre') + add_book_button.connect('clicked', lambda _: BookForm(self, resources, library, conn, self._filters.get_progress(), self._on_book_added).present()) + + header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + utils.set_margin(header, 20) + self._filters = Filters(init_progress, self._update_book_flow_progress) + self._filters.set_hexpand(True) + header.append(self._filters) + header.append(add_book_button) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + box.append(header) + self._book_flow = BookFlow(self, resources, library, ereader, conn, init_progress, self._update_filters_progress) + box.append(self._book_flow) + scrolled_window.set_child(box) + + def _update_book_flow_progress(self, progress): + self._book_flow.update_progress(progress) + + def _update_filters_progress(self, progress): + self._filters.update_progress(progress) + + def _on_book_added(self, book_id, data): + self._update_filters_progress(data['progress']) + self._book_flow.add_book(book_id, data) + +class Application(Adw.Application): + def __init__(self, resources, library, ereader, conn, **kwargs): + super().__init__(**kwargs) + self.connect('activate', self.on_activate) + + self._resources = resources + self._library = library + self._ereader = ereader + self._conn = conn + + # Dark theme + sm = self.get_style_manager() + sm.set_color_scheme(Adw.ColorScheme.PREFER_DARK) + + def on_activate(self, app): + self.win = MainWindow(self._resources, self._library, self._ereader, self._conn, application=app) + self.win.present() diff --git a/src/book_delete.py b/src/book_delete.py new file mode 100644 index 0000000..c7e789f --- /dev/null +++ b/src/book_delete.py @@ -0,0 +1,35 @@ +import gi +gi.require_version('Gtk', '4.0') +from gi.repository import Gtk +import os + +import src.utils as utils +import src.book_files as book_files + +class BookDelete(Gtk.Window): + + def __init__(self, parent_window, library, book_id, data, on_parent_confirm): + Gtk.Window.__init__(self) + + self._on_parent_confirm = on_parent_confirm + + utils.configure_dialog(self, parent_window, data['title'], width=None, height=None) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) + self.set_child(box) + utils.set_margin(box, 20) + + picture = Gtk.Picture.new_for_filename(f'{library}/{book_id}/cover.png') + picture.set_can_shrink(False) + box.append(picture) + + for path in book_files.get(library, book_id): + box.append(utils.label(os.path.basename(path))) + + confirm_button = Gtk.Button(label='Supprimer') + confirm_button.connect('clicked', lambda _: self._on_confirm()) + box.append(confirm_button) + + def _on_confirm(self): + self._on_parent_confirm() + self.close() diff --git a/src/book_detail.py b/src/book_detail.py new file mode 100644 index 0000000..168a212 --- /dev/null +++ b/src/book_detail.py @@ -0,0 +1,57 @@ +import gi +gi.require_version('Gtk', '4.0') +from gi.repository import Gtk +import os +import subprocess + +import src.utils as utils +import src.book_files as book_files + +class BookDetail(Gtk.Window): + + def __init__(self, parent_window, library, book_id, data): + Gtk.Window.__init__(self) + + utils.configure_dialog(self, parent_window, data['title'], height=800) + + scrolled_window = Gtk.ScrolledWindow() + self.set_child(scrolled_window) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) + scrolled_window.set_child(box) + utils.set_margin(box, 20) + + if has_info(data, 'subtitle'): + box.append(utils.label(data['subtitle'])) + + if has_info(data, 'year'): + box.append(utils.label(str(data['year']))) + + if len(data['authors']): + box.append(utils.label(', '.join(data['authors']))) + + if len(data['genres']): + box.append(utils.label(', '.join(data['genres']))) + + picture = Gtk.Picture.new_for_filename(f'{library}/{book_id}/cover.png') + picture.set_can_shrink(False) + box.append(picture) + + box.append(utils.label(data['summary'])) + + for path in book_files.get(library, book_id): + box.append(book_line(path)) + +def has_info(data, key): + return key in data and data[key] + +def book_line(path): + box = Gtk.Box(spacing=20) + box.append(utils.label(os.path.basename(path))) + open_button = Gtk.Button(label='Ouvrir') + open_button.connect('clicked', lambda _: open_book(path)) + box.append(open_button) + return box + +def open_book(path): + subprocess.run(['xdg-open', path]) diff --git a/src/book_files.py b/src/book_files.py new file mode 100644 index 0000000..26253a8 --- /dev/null +++ b/src/book_files.py @@ -0,0 +1,13 @@ +import glob +import os +import pathlib + +def get(library, book_id): + directory = f'{library}/{book_id}' + + paths = [] + for path in glob.glob(f'{directory}/*'): + basename = os.path.basename(path) + if basename != 'cover.png' and basename != 'cover-min.png': + paths.append(pathlib.Path(path)) + return paths diff --git a/src/book_flow.py b/src/book_flow.py new file mode 100644 index 0000000..59b0218 --- /dev/null +++ b/src/book_flow.py @@ -0,0 +1,141 @@ +import gi +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, Gio +import glob +import shutil +import functools +import os +import subprocess + +import src.utils as utils +import src.db as db +from src.book_detail import BookDetail +from src.book_delete import BookDelete +from src.book_form import BookForm +from src.book_transfer import BookTransfer +from src.picture_cache import PictureCache +import src.book_files as book_files + +class BookFlow(Gtk.FlowBox): + + def __init__(self, window, resources, library, ereader, conn, progress, update_filter_progress): + Gtk.FlowBox.__init__(self) + + self._window = window + self._resources = resources + self._library = library + self._ereader = ereader + self._conn = conn + self._progress = progress + self._update_filter_progress = update_filter_progress + self._picture_cache = PictureCache() + + self._load_books() + + def add_book(self, book_id, data): + self._books[book_id] = data + self.update_progress(data['progress']) + + def update_progress(self, progress): + self._progress = progress + self._reset_view() + + # Private + + def _load_books(self): + """Get books from DB, but most importantly load covers to memory.""" + self._books = db.get_books(self._conn) + for book_id in self._books.keys(): + _ = self._picture_cache.get(f'{self._library}/{book_id}/cover-min.png') + self._reset_view() + + def _reset_view(self): + self.remove_all() + self._flow_box_children = {} + for book_id, data in sorted(self._books.items(), key=book_sort): + if data['progress'] == self._progress: + picture = self._picture_cache.get(f'{self._library}/{book_id}/cover-min.png') + picture.set_can_shrink(False) + + gesture_lclick = Gtk.GestureClick() + gesture_lclick.connect('released', self._on_left_click, book_id, data) + picture.add_controller(gesture_lclick) + + gesture_rclick = Gtk.GestureClick() + gesture_rclick.set_button(3) + gesture_rclick.connect('released', self._on_right_click, picture, book_id, data) + picture.add_controller(gesture_rclick) + + flow_box_child = Gtk.FlowBoxChild() + flow_box_child.set_child(picture) + self._flow_box_children[book_id] = flow_box_child + self.append(flow_box_child) + + def _on_left_click(self, gesture, n_press, x, y, book_id, data): + if n_press == 2: + self._see_book(book_id, data) + + def _on_right_click(self, gesture, n_press, x, y, picture, book_id, data): + if n_press == 1: + popover = Gtk.Popover() + popover.set_parent(picture) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + popover.set_child(box) + + see_button = Gtk.Button(label='Voir') + see_button.connect('clicked', lambda _: self._see_book(book_id, data)) + box.append(see_button) + + update_button = Gtk.Button(label='Modifier') + update_button.connect('clicked', lambda _: self._update_book(book_id, data)) + box.append(update_button) + + delete_button = Gtk.Button(label='Supprimer') + delete_button.connect('clicked', lambda _: self._confirm_delete_book(book_id, data)) + box.append(delete_button) + + if self._ereader and os.path.exists(self._ereader): + paths = book_files.get(self._library, book_id) + if paths: + transfer_button = Gtk.Button(label='Transférer') + transfer_button.connect('clicked', lambda _: self._transfer_book(book_id, data, paths)) + box.append(transfer_button) + + popover.popup() + + def _see_book(self, book_id, data): + BookDetail(self._window, self._library, book_id, data).present() + + def _update_book(self, book_id, data): + book = {'id': book_id, 'data': data } + BookForm(self._window, self._resources, self._library, self._conn, data['progress'], self._on_book_updated, book).present() + + def _on_book_updated(self, book_id, data): + self._picture_cache.invalidate(f'{self._library}/{book_id}/cover-min.png') + self._books[book_id] = data + self.update_progress(data['progress']) # Note: this redraws everything, this is overkill in some cases + self._update_filter_progress(data['progress']) + self.select_child(self._flow_box_children[book_id]) + + def _confirm_delete_book(self, book_id, data): + BookDelete(self._window, self._library, book_id, data, lambda: self._delete_book(book_id, data)).present() + + def _delete_book(self, book_id, data): + del self._books[book_id] + db.delete_book(self._conn, book_id) + self._reset_view() + + def _transfer_book(self, book_id, data, paths): + BookTransfer(self._window, self._ereader, book_id, data, paths).present() + +def book_sort(b): + key, data = b + author = author_key(data) + date = data['date'] if 'date' in data else '' + return f'{author}{date}' + +def author_key(data): + match data['authors']: + case [author, *_]: + return author.split()[-1] + return '' diff --git a/src/book_form.py b/src/book_form.py new file mode 100644 index 0000000..af213de --- /dev/null +++ b/src/book_form.py @@ -0,0 +1,167 @@ +import gi +gi.require_version('Gtk', '4.0') +from gi.repository import Gtk + +import src.utils as utils +import src.models as models +from src.ui.entry_list import EntryList +from src.ui.book_entries import BookEntries +from src.ui.cover_entry import CoverEntry +import src.book_store as book_store +import src.book_files as book_files +import src.str_format as str_format + +class BookForm(Gtk.Window): + + def __init__(self, parent_window, resources, library, conn, init_progress, on_book_saved, book = None): + Gtk.Window.__init__(self) + + self._book = book + self._library = library + self._conn = conn + self._on_book_saved = on_book_saved + + title = 'Modifier un livre' if book else 'Ajouter un livre' + utils.configure_dialog(self, parent_window, title, height=800) + + scrolled_window = Gtk.ScrolledWindow() + self.set_child(scrolled_window) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) + scrolled_window.set_child(box) + utils.set_margin(box, 20) + + # Cover and form + cover_and_form = Gtk.Box(spacing=20) + box.append(cover_and_form) + cover_path = f'{library}/{book['id']}/cover.png' if book else None + self._cover_entry = CoverEntry(parent_window, resources, cover_path) + cover_and_form.append(self._cover_entry) + top_form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) + cover_and_form.append(top_form) + + # Title and subtitle + self._title, self._subtitle = double_entry(top_form, 'Titre') + self._title.set_hexpand(True) + + # Year, lang and progress + year_lang_progress = Gtk.Box(spacing=10) + top_form.append(year_lang_progress) + self._year = labeled_entry(year_lang_progress, 'Année') + self._lang = dropdown(year_lang_progress, 'Langue', models.langs) + self._progress = dropdown(year_lang_progress, 'Progrès', models.all_progress, init_progress) + + # Authors, genres + authors_genres = Gtk.Box(spacing=10) + box.append(authors_genres) + self._authors = EntryList('Auteurs') + authors_genres.append(self._authors) + self._genres = EntryList('Genres') + authors_genres.append(self._genres) + + # Summary + summary_box = label_box(box, 'Résumé') + self._summary = Gtk.TextView() + self._summary.set_wrap_mode(Gtk.WrapMode.WORD) + self._summary.set_top_margin(10) + self._summary.set_right_margin(10) + self._summary.set_bottom_margin(10) + self._summary.set_left_margin(10) + summary_box.append(self._summary) + + # Books + self._book_entries = BookEntries(self) + box.append(self._book_entries) + + # Init values + if book: + book_id = book['id'] + data = book['data'] + + update_entry(self._title, data['title']) + if 'subtitle' in data: + update_entry(self._subtitle, data['subtitle']) + if 'year' in data: + update_entry(self._year, str(data['year'])) + if 'lang' in data: + self._lang.set_selected(models.langs.index(data['lang'])) + for author in data['authors']: + self._authors.add_entry(author) + for genre in data['genres']: + self._genres.add_entry(genre) + self._summary.get_buffer().set_text(data['summary']) + for path in book_files.get(self._library, book_id): + self._book_entries.add_book(path) + else: + self._authors.add_entry('') + self._genres.add_entry('') + + # Button + validate = Gtk.Button(label='Modifier' if book else 'Ajouter') + validate.connect('clicked', lambda _: self._on_validate()) + box.append(validate) + + def _on_validate(self): + data = {} + data['title'] = self._title.get_text() + data['subtitle'] = self._subtitle.get_text() + match safe_string_to_int(self._year.get_text()): + case None: pass + case n: data['year'] = n + data['lang'] = self._lang.get_selected_item().get_string() + data['progress'] = self._progress.get_selected_item().get_string() + data['authors'] = non_empty(self._authors.get_entry_texts()) + data['genres'] = non_empty(self._genres.get_entry_texts()) + data['summary'] = str_format.cleanup_text(text_view_text(self._summary), data['lang']) + cover = self._cover_entry.get_image() + books = self._book_entries.get_books() + + book_id = book_store.store(self._library, self._conn, data, cover, books, self._book) + if book_id: + self.close() + self._on_book_saved(book_id, data) + +def non_empty(xs): + return [x.strip() for x in xs if x.strip()] + +def safe_string_to_int(n): + try: + return int(n) + except ValueError: + return None + +def double_entry(parent, label): + box = label_box(parent, label) + entry1 = Gtk.Entry() + entry2 = Gtk.Entry() + box.append(entry1) + box.append(entry2) + return entry1, entry2 + +def labeled_entry(parent, label): + box = label_box(parent, label) + entry = Gtk.Entry() + box.append(entry) + return entry + +def dropdown(parent, label, values, selected = None): + box = label_box(parent, label) + dropdown = Gtk.DropDown.new_from_strings(values) + box.append(dropdown) + if selected: + dropdown.set_selected(values.index(selected)) + return dropdown + +def label_box(parent, label): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + box.append(utils.label(label)) + parent.append(box) + return box + +def text_view_text(text_view): + buffer = text_view.get_buffer() + start, end = buffer.get_bounds() + return buffer.get_text(start, end, False) + +def update_entry(entry, text): + entry.set_buffer(Gtk.EntryBuffer.new(text, len(text))) diff --git a/src/book_store.py b/src/book_store.py new file mode 100644 index 0000000..93836c7 --- /dev/null +++ b/src/book_store.py @@ -0,0 +1,102 @@ +import nanoid +import os +from PIL import Image +import pathlib +import shutil +import glob +import logging + +import src.db as db + +logger = logging.getLogger(__name__) + +def store(library, conn, data, cover, books, book = None): + book_id = book['id'] if book else nanoid.generate() + directory = f'{library}/{book_id}' + + try: + os.makedirs(directory, exist_ok=True) + + save_cover(cover, directory) + + if book: + if not already_exist(directory, books) or has_delete(directory, books): + update_books(directory, books) + else: + create_books(directory, books) + + if book: + db.update_book(conn, book_id, data) + else: + db.create_book(conn, book_id, data) + + return book_id + except Exception as err: + if book: + print(f'Error updating book: {err}') + else: + print(f'Error creating book: {err}') + shutil.rmtree(directory, ignore_errors=True) + db.delete_book(conn, book_id) + return None + +def already_exist(directory, books): + for path, name in books.items(): + ext = path.suffix + dest = f'{directory}/{name}{ext}' + if not dest == str(path): + return False + return True + +def has_delete(directory, books): + new_paths = books.keys() + for path in glob.glob(f'{directory}/*'): + if not path in new_paths: + return True + return False + +def update_books(directory, books): + try: + tmp_dir = f'{directory}/tmp' + os.makedirs(tmp_dir, exist_ok=True) + # Copy books to tmp directory + for i, path in enumerate(books.keys()): + dest = f'{tmp_dir}/{i}' + logger.info('Copying %s to %s', path, dest) + shutil.copyfile(path, dest) + # Remove existing files (except cover.png and tmp) + for path in glob.glob(f'{directory}/*'): + bn = os.path.basename(path) + if bn not in ['cover.png', 'cover-min.png', 'tmp']: + logger.info('Removing %s', path) + pathlib.Path.unlink(path) + # Move from tmp to directory + for i, path in enumerate(books.keys()): + src = f'{tmp_dir}/{i}' + ext = path.suffix + name = books[path] + dest = f'{directory}/{name}{ext}' + logger.info('Copying %s to %s', src, dest) + shutil.copyfile(src, dest) + finally: + # Remove tmp + shutil.rmtree(f'{directory}/tmp', ignore_errors=True) + +def create_books(directory, books): + for path, name in books.items(): + ext = path.suffix + shutil.copy(path, f'{directory}/{name}{ext}') + +# Save as PNG +def save_cover(image, directory): + image = image.convert('RGB') + image.save(f'{directory}/cover.png', 'Png', optimize=True, quality=85) + image_min = resize(image, 200) + image_min.save(f'{directory}/cover-min.png', 'Png', optimize=True, quality=85) + +def resize(image, new_width): + width, height = image.size + if width > new_width: + image = image.resize((new_width, int(new_width * height / width)), Image.LANCZOS) + return image + diff --git a/src/book_transfer.py b/src/book_transfer.py new file mode 100644 index 0000000..a89f779 --- /dev/null +++ b/src/book_transfer.py @@ -0,0 +1,66 @@ +import gi +gi.require_version('Gtk', '4.0') +from gi.repository import Gtk, GLib, GObject +import os +import subprocess +import shutil +import threading +import time + +import src.utils as utils +import src.str_format as str_format + +class BookTransfer(Gtk.Window): + + def __init__(self, parent_window, ereader, book_id, data, paths): + Gtk.Window.__init__(self) + + self._ereader = ereader + self._book_id = book_id + self._data = data + self._paths = paths + + utils.configure_dialog(self, parent_window, data['title'], width=None, height=None) + + self._box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) + self.set_child(self._box) + utils.set_margin(self._box, 20) + + for path in paths: + basename = os.path.basename(path) + self._box.append(utils.label(basename)) + + self._spinner = Gtk.Spinner() + self._spinner.start() + self._box.append(self._spinner) + + thread = threading.Thread(target=self._transfer_books_daemon) + thread.daemon = True + thread.start() + + def _complete_animation(self): + self._box.remove(self._spinner) + self._box.append(utils.label('✔')) + + def _transfer_books_daemon(self): + for path in self._paths: + self._transfer_book(path) + GLib.idle_add(self._complete_animation) + + def _transfer_book(self, path): + dest = self._dest_name(path) + if path.suffix in ['.mobi', '.azw3']: + # Copy + os.makedirs(os.path.dirname(dest), exist_ok=True) + shutil.copyfile(path, dest) + else: + # Convert + os.makedirs(os.path.dirname(dest), exist_ok=True) + subprocess.run(['ebook-convert', path, dest]) + + def _dest_name(self, path): + safe_author = str_format.safe_path(self._data['authors'][0]) if len(self._data['authors']) > 0 else self._book_id + safe_title = str_format.safe_path(self._data['title']) + basename_no_ext = os.path.splitext(os.path.basename(path))[0] + ext = path.suffix if path.suffix in ['.mobi', '.azw3'] else '.mobi' + return f'{self._ereader}/{safe_author}-{safe_title}-{basename_no_ext}{ext}' diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..a53ea82 --- /dev/null +++ b/src/db.py @@ -0,0 +1,60 @@ +import sqlite3 +import json +import logging +import sys +import glob + +logger = logging.getLogger(__name__) + +def get_connection(library): + conn = sqlite3.connect(f'{library}/db.sqlite3') + cursor = conn.cursor() + cursor.execute('PRAGMA foreign_keys = ON') + cursor.execute('PRAGMA journal_mode = WAL') + return conn + +def migrate(resources, conn): + current_version, = next(conn.cursor().execute('PRAGMA user_version'), (0, )) + for version, migration_path in enumerate(glob.glob(f'{resources}/migrations/*.sql'), start=1): + if version > current_version: + cur = conn.cursor() + try: + logger.info('Applying %s', migration_path) + with open(migration_path, 'r') as file: + migration_content = file.read() + cur.executescript(f'begin; PRAGMA user_version={version};' + migration_content) + except Exception as e: + logger.error('Failed migration %s: %s. Exiting', migration_path, e) + cur.execute('rollback') + sys.exit(1) + else: + cur.execute('commit') + +def get_books(conn): + books = {} + for r in conn.execute("SELECT id, json(data) FROM books"): + books[r[0]] = json.loads(r[1]) + return books + +def create_book(conn, book_id, data): + cursor = conn.cursor() + encoded_data = bytes(json.dumps(data), 'utf-8') + cursor.execute( + 'INSERT INTO books(id, created_at, updated_at, data) VALUES (?, datetime(), datetime(), ?)', + (book_id, encoded_data)) + cursor.execute('commit') + +def update_book(conn, book_id, data): + cursor = conn.cursor() + encoded_data = bytes(json.dumps(data), 'utf-8') + cursor.execute( + 'UPDATE books SET data = ?, updated_at = datetime() WHERE id = ?', + (encoded_data, book_id)) + cursor.execute('commit') + +def delete_book(conn, book_id): + cursor = conn.cursor() + cursor.execute( + 'DELETE FROM books WHERE id = ?', + (book_id,)) + cursor.execute('commit') diff --git a/src/filters.py b/src/filters.py new file mode 100644 index 0000000..f62eee1 --- /dev/null +++ b/src/filters.py @@ -0,0 +1,31 @@ +import gi +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk + +import src.utils as utils +import src.models as models + +class Filters(Gtk.Box): + + def __init__(self, init_progress, on_progress_updated): + Gtk.Box.__init__(self) + + self._progress = init_progress + self._on_progress_updated = on_progress_updated + + self._dropdown = Gtk.DropDown.new_from_strings(models.all_progress) + self._dropdown.set_selected(models.all_progress.index(init_progress)) + self._dropdown.connect('notify::selected-item', self._on_selected_item) + + self.append(self._dropdown) + + def get_progress(self): + return self._progress + + def update_progress(self, progress): + self._progress = progress + self._dropdown.set_selected(models.all_progress.index(progress)) + + def _on_selected_item(self, _dropdown, _data): + self._progress = self._dropdown.get_selected_item().get_string() + self._on_progress_updated(self._progress) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..2cf6075 --- /dev/null +++ b/src/main.py @@ -0,0 +1,36 @@ +import sys +import argparse +import os +import logging + +from src.application import Application +import src.db as db + +def parse_arguments(): + arg_parser = argparse.ArgumentParser(prog = 'Books', description = 'Manage book library') + arg_parser.add_argument('--library', help='path to book library') + arg_parser.add_argument('--ereader', help='path to ereader') + return arg_parser.parse_args() + +def main(resources): + # Args + args = parse_arguments() + library = args.library or f'{os.getcwd()}/library' + ereader = args.ereader + + # logging + logging.basicConfig(level=logging.INFO) + + # Create library directory if missing + os.makedirs(library, exist_ok=True) + + # Get connection + conn = db.get_connection(library) + db.migrate(resources, conn) + + # Start application + app = Application(resources, library, ereader, conn, application_id='fr.jorisg.books') + app.run() + +if __name__ == '__main__': + main() diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..d271b62 --- /dev/null +++ b/src/models.py @@ -0,0 +1,2 @@ +all_progress = ['Unread', 'Reading', 'Read', 'Stopped'] +langs = ['fr', 'en', 'de'] diff --git a/src/picture_cache.py b/src/picture_cache.py new file mode 100644 index 0000000..8da136b --- /dev/null +++ b/src/picture_cache.py @@ -0,0 +1,25 @@ +import gi +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk +import logging + +logger = logging.getLogger(__name__) + +class PictureCache: + + def __init__(self): + self._cache = {} + + def get(self, path): + if path in self._cache: + logger.debug('Loading from cache: %s', path) + return self._cache[path] + else: + picture = Gtk.Picture.new_for_filename(path) + logger.debug('Adding in cache: %s', path) + self._cache[path] = picture + return picture + + def invalidate(self, path): + logger.debug('Invalidating: %s', path) + del self._cache[path] diff --git a/cli/new/format.py b/src/str_format.py index 7f66f44..5d8c412 100644 --- a/cli/new/format.py +++ b/src/str_format.py @@ -2,10 +2,7 @@ import pathlib import re import unicodedata -def list(xs): - return '[' + ', '.join([f'"{x}"' for x in xs]) + ']' - -def path_part(name): +def safe_path(name): simplified = ''.join([alnum_or_space(c) for c in unaccent(name.lower())]) return '-'.join(simplified.split()) @@ -18,22 +15,21 @@ def alnum_or_space(c): else: return ' ' -def extension(path): - return pathlib.Path(path).suffix - def cleanup_text(s, lang): s = re.sub('\'', '’', s) s = re.sub(r'\.\.\.', '…', s) s = re.sub(r'\. \. \.', '…', s) s = cleanup_double_quotes(s, lang) + s = cleanup_paragraphs(s) 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(r' ([:?\!])', r' \1', s) s = re.sub('« ', '« ', s) + s = re.sub(' »', ' »', s) # Add missing insecable spaces s = re.sub(r'([^ ]):', r'\1 :', s) @@ -43,6 +39,8 @@ def cleanup_text(s, lang): s = re.sub(r'«([^ ])', r'« \1', s) elif lang == 'en': + s = re.sub('« ', '“', s) + s = re.sub(' »', '”', s) s = re.sub('«', '“', s) s = re.sub('»', '”', s) @@ -68,3 +66,7 @@ def cleanup_double_quotes(s, lang): else: res += c return res + +def cleanup_paragraphs(s): + ps = [f' {p.strip()}' for p in re.split(r'\n+', s) if p.strip()] + return '\n\n'.join(ps) diff --git a/cli/new/test_format.py b/src/test_str_format.py index d6269d0..57bec87 100644 --- a/cli/new/test_format.py +++ b/src/test_str_format.py @@ -1,8 +1,4 @@ -import cli.new.format as format - -def test_list(): - assert format.list([]) == '[]' - assert format.list(['a', 'b', 'c']) == '["a", "b", "c"]' +import str_format as format def test_unaccent(): assert format.unaccent('AuieTsrn') == 'AuieTsrn' @@ -10,16 +6,15 @@ def test_unaccent(): assert format.unaccent('ÂÀÉÈÊËÎÏÔÙ') == 'AAEEEEIIOU' def test_path_part(): - assert format.path_part('L’Homme à la béquille') == 'l-homme-a-la-bequille' - -def test_extension(): - assert format.extension('https://website.de/file.webp') == '.webp' - assert format.extension('/home/toto/extension-test/auie.notepad') == '.notepad' + assert format.safe_path('L’Homme à la béquille') == 'l-homme-a-la-bequille' def test_cleaneup_quotes(): assert format.cleanup_double_quotes('Bonjour, "ceci" où “cela”.', 'fr') == 'Bonjour, «ceci» où “cela”.' assert format.cleanup_double_quotes('Hello, "this" or «that».', 'en') == 'Hello, “this” or «that».' def test_cleaneup_text(): - assert format.cleanup_text('l\'"est": ici... Là? OK! Yes !', 'fr') == 'l’« est » : ici… Là ? OK ! Yes !' - assert format.cleanup_text('Is it "ok" or «not»?', 'en') == 'Is it “ok” or “not”?' + assert format.cleanup_text('l\'"est": ici... Là? OK! Yes !', 'fr') == ' l’« est » : ici… Là ? OK ! Yes !' + assert format.cleanup_text('Is it "ok" or «not»?', 'en') == ' Is it “ok” or “not”?' + +def test_cleanup_paragraphs(): + assert format.cleanup_paragraphs(' Foo\n\nBar\nBaz \n\n') == ' Foo\n\n Bar\n\n Baz' diff --git a/cli/library/__init__.py b/src/ui/__init__.py index e69de29..e69de29 100644 --- a/cli/library/__init__.py +++ b/src/ui/__init__.py diff --git a/src/ui/book_entries.py b/src/ui/book_entries.py new file mode 100644 index 0000000..edd9457 --- /dev/null +++ b/src/ui/book_entries.py @@ -0,0 +1,95 @@ +import gi +gi.require_version('Gtk', '4.0') +from gi.repository import Gtk, Gio, GLib +import pathlib +import os +import subprocess + +import src.utils as utils + +class BookEntries(Gtk.Box): + + def __init__(self, window): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL, spacing=10) + self._window = window + + self._books = {} # Dict {path: name} + + header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + header.append(get_label('Books')) + add_button = Gtk.Button(label='+') + add_button.connect('clicked', lambda _: self._open_dialog()) + header.append(add_button) + self.append(header) + + self._entries = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + self.append(self._entries) + + self._setup_filedialog() + + def get_books(self): + return self._books + + def _setup_filedialog(self): + self._filedialog = Gtk.FileDialog.new() + self._filedialog.set_title('Select a book') + + f = Gtk.FileFilter() + f.set_name('Book files') + f.add_mime_type('application/epub+zip') + f.add_mime_type('application/vnd.amazon.ebook') + + filters = Gio.ListStore.new(Gtk.FileFilter) + filters.append(f) + + self._filedialog.set_filters(filters) + self._filedialog.set_default_filter(f) + + def _open_dialog(self): + self._filedialog.open(self._window, None, self._open_dialog_callback) + + def _open_dialog_callback(self, dialog, result): + try: + file = dialog.open_finish(result) + if file is not None: + # https://github.com/GNOME/glib/blob/main/gio/glocalfile.c + path = pathlib.Path(file.get_path()) + self.add_book(path) + except GLib.Error as error: + print(f'Error opening file: {error.message}') + + def add_book(self, path): + name = os.path.splitext(os.path.basename(path))[0] + self._books[path] = name + + line = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + entry = Gtk.Entry() + entry.set_text(name) + entry.set_hexpand(True) + entry.connect('changed', self._update_book, path) + line.append(entry) + line.append(open_book_button(path)) + remove_button = Gtk.Button(label='-') + remove_button.connect('clicked', lambda _: self._remove_book(line, path)) + line.append(remove_button) + self._entries.append(line) + + def _update_book(self, entry, path): + self._books[path] = entry.get_text() + + def _remove_book(self, line, path): + del self._books[path] + self._entries.remove(line) + +def get_label(text): + label = Gtk.Label() + label.set_text(text) + return label + +def open_book_button(path): + open_button = Gtk.Button(label='Ouvrir') + open_button.connect('clicked', lambda _: open_book(path)) + return open_button + +def open_book(path): + subprocess.run(['xdg-open', path]) diff --git a/src/ui/cover_entry.py b/src/ui/cover_entry.py new file mode 100644 index 0000000..8f73a01 --- /dev/null +++ b/src/ui/cover_entry.py @@ -0,0 +1,91 @@ +import gi +gi.require_version('Gtk', '4.0') +from gi.repository import Gtk, GLib +import numpy +from PIL import Image +import requests +import io +import threading +import time +import logging + +import src.utils as utils + +logger = logging.getLogger(__name__) + +class CoverEntry(Gtk.Box): + + def __init__(self, parent_window, resources, filename=None): + Gtk.Box.__init__(self) + + self._parent_window = parent_window + + self._image = Image.open(filename or f'{resources}/blank-cover.png') + self._picture = Gtk.Picture.new_for_pixbuf(utils.image_to_pixbuf(self._image)) + self.append(self._picture) + + gesture_click = Gtk.GestureClick() + gesture_click.connect('released', self._on_open_dialog) + self._picture.add_controller(gesture_click) + + def get_image(self): + return self._image + + def _on_open_dialog(self, gesture, n_press, x, y): + self._dialog = Gtk.Window() + utils.configure_dialog(self._dialog, self._parent_window, 'Couverture', height=None) + + self._dialog_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) + self._dialog.set_child(self._dialog_box) + utils.set_margin(self._dialog_box, 20) + self._error_message = None + + self._dialog_box.append(utils.label('URL')) + + self._url_entry = Gtk.Entry() + self._dialog_box.append(self._url_entry) + + self._append_download_button() + + self._dialog.present() + + def _on_download_cover(self): + if(self._url_entry.get_text()): + thread = threading.Thread(target=self._download_cover_daemon) + thread.daemon = True + thread.start() + self._dialog_spinner = Gtk.Spinner() + self._dialog_spinner.start() + self._dialog_box.append(self._dialog_spinner) + self._dialog_box.remove(self._dialog_button) + if self._error_message: + self._dialog_box.remove(self._error_message) + + def _download_cover_daemon(self): + try: + self._image = download_image(self._url_entry.get_text()) + self._picture.set_pixbuf(utils.image_to_pixbuf(self._image)) + GLib.idle_add(self._complete_download) + except Exception as e: + logger.error('Failed downloading cover %s: %s', self._url_entry, e) + self._dialog_spinner.stop() + self._dialog_box.remove(self._dialog_spinner) + self._error_message = utils.label(f'{e}') + self._dialog_box.append(self._error_message) + self._append_download_button() + + def _append_download_button(self): + self._dialog_button = Gtk.Button(label='Télécharger') + self._dialog_button.connect('clicked', lambda _: self._on_download_cover()) + self._dialog_box.append(self._dialog_button) + + def _complete_download(self): + self._dialog.close() + +def download_image(url): + response = requests.get(url, headers={ 'User-Agent': 'python-script' }) + image = Image.open(io.BytesIO(response.content)) + width, height = image.size + if width > 400: + image = image.resize((400, int(400 * height / width)), Image.LANCZOS) + return image diff --git a/src/ui/entry_list.py b/src/ui/entry_list.py new file mode 100644 index 0000000..c0c355d --- /dev/null +++ b/src/ui/entry_list.py @@ -0,0 +1,42 @@ +import gi +gi.require_version('Gtk', '4.0') +from gi.repository import Gtk + +import src.utils as utils + +class EntryList(Gtk.Box): + + def __init__(self, name): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL, spacing=10) + + header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + header.append(get_label(name)) + add_button = Gtk.Button(label='+') + add_button.connect('clicked', lambda _: self.add_entry()) + header.append(add_button) + self.append(header) + + self._entries = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + self.append(self._entries) + + def get_entry_texts(self): + res = [] + for entry in self._entries: + res.append(entry.get_first_child().get_buffer().get_text()) + return res + + def add_entry(self, value=''): + line = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + entry = Gtk.Entry() + entry.set_text(value) + entry.set_hexpand(True) + line.append(entry) + remove_button = Gtk.Button(label='-') + remove_button.connect('clicked', lambda _: self._entries.remove(line)) + line.append(remove_button) + self._entries.append(line) + +def get_label(text): + label = Gtk.Label() + label.set_text(text) + return label diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..5ef3f37 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,58 @@ +import gi +gi.require_version('Gtk', '4.0') +from gi.repository import Gtk, GLib, GdkPixbuf, Gdk +import unicodedata + +def set_header_bar(window, title): + header_bar = Gtk.HeaderBar() + header_bar.set_show_title_buttons(True) + title_widget = Gtk.Label() + title_widget.set_text(title) + window.set_title(title) + header_bar.set_title_widget(title_widget) + window.set_titlebar(header_bar) + +def set_margin(widget, a, b = None, c = None, d = None): + if b == c == d == None: + b = c = d = a + elif c == d == None: + (c, d) = (a, b) + elif d == None: + d = b + widget.set_margin_top(a) + widget.set_margin_end(b) + widget.set_margin_bottom(c) + widget.set_margin_start(d) + +def configure_dialog(window, parent_window, title, width=600, height=400): + window.use_header_bar = True + window.set_modal(True) + window.set_transient_for(parent_window) + window.set_default_size(width or 0, height or 0) + set_header_bar(window, title) + + control_key = Gtk.EventControllerKey.new() + control_key.connect('key-pressed', on_dialog_key_pressed, window) + window.add_controller(control_key) + +def on_dialog_key_pressed(a, b, key, c, window): + if key == 9: # Escape + window.close() + +def label(text): + l = Gtk.Label() + l.set_text(text) + l.set_wrap(True) + l.set_halign(Gtk.Align.START) + return l + +def entry(): + return Gtk.Entry() + +# https://gitlab.gnome.org/GNOME/pygobject/-/issues/225 +# https://gist.github.com/mozbugbox/10cd35b2872628246140 +def image_to_pixbuf(image): + image = image.convert('RGB') + data = GLib.Bytes.new(image.tobytes()) + pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(data, GdkPixbuf.Colorspace.RGB, False, 8, image.width, image.height, len(image.getbands())*image.width) + return pixbuf.copy() |
