aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoris Guyonvarch2025-12-26 18:41:26 +0100
committerJoris Guyonvarch2025-12-27 20:41:44 +0100
commita110c200e86d2325af07167531fac0f61d9681a0 (patch)
tree90e843f915a2e153ba735849afd83710d90560bf
parenta26d92ad5055fa057647158eb79511e7b1841162 (diff)
Switch to GUI to manage the library
Allow to regroup the CLI and the view into one unique tool.
-rw-r--r--.gitignore2
-rw-r--r--Makefile11
-rw-r--r--README.md50
-rwxr-xr-xbin/dev-server28
-rwxr-xr-xbin/migrate/1-read-status19
-rwxr-xr-xbin/migrate/2-compress-covers27
-rwxr-xr-xbin/test4
-rwxr-xr-xbooks12
-rw-r--r--cli/library/command.py21
-rw-r--r--cli/main.py80
-rw-r--r--cli/new/__init__.py0
-rw-r--r--cli/new/command.py88
-rw-r--r--cli/new/reader.py55
-rw-r--r--cli/view/__init__.py0
-rw-r--r--cli/view/command.py16
-rw-r--r--flake.lock22
-rw-r--r--flake.nix46
-rw-r--r--library/client/book.ts15
-rw-r--r--library/client/lib/functions.ts7
-rw-r--r--library/client/lib/i18n.ts14
-rw-r--r--library/client/lib/rx.ts404
-rw-r--r--library/client/lib/search.ts17
-rw-r--r--library/client/main.ts46
-rw-r--r--library/client/view/books.ts116
-rw-r--r--library/client/view/components/modal.ts38
-rw-r--r--library/client/view/filters.ts90
-rw-r--r--library/public/index.html7
-rw-r--r--library/public/main.css235
-rw-r--r--library/tsconfig.json13
-rw-r--r--resources/blank-cover.pngbin0 -> 6126 bytes
-rw-r--r--resources/migrations/01.sql6
-rw-r--r--setup.py3
-rw-r--r--src/__init__.py (renamed from cli/__init__.py)0
-rw-r--r--src/application.py67
-rw-r--r--src/book_delete.py35
-rw-r--r--src/book_detail.py57
-rw-r--r--src/book_files.py13
-rw-r--r--src/book_flow.py141
-rw-r--r--src/book_form.py167
-rw-r--r--src/book_store.py102
-rw-r--r--src/book_transfer.py66
-rw-r--r--src/db.py60
-rw-r--r--src/filters.py31
-rw-r--r--src/main.py36
-rw-r--r--src/models.py2
-rw-r--r--src/picture_cache.py25
-rw-r--r--src/str_format.py (renamed from cli/new/format.py)18
-rw-r--r--src/test_str_format.py (renamed from cli/new/test_format.py)19
-rw-r--r--src/ui/__init__.py (renamed from cli/library/__init__.py)0
-rw-r--r--src/ui/book_entries.py95
-rw-r--r--src/ui/cover_entry.py91
-rw-r--r--src/ui/entry_list.py42
-rw-r--r--src/utils.py58
53 files changed, 1156 insertions, 1461 deletions
diff --git a/.gitignore b/.gitignore
index 674e9cd..21c5029 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/README.md b/README.md
index d676bf2..d6c9651 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/books b/books
index 5ddbe71..44bfc57 100755
--- a/books
+++ b/books
@@ -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(' '))
diff --git a/flake.lock b/flake.lock
index 4775dd0..f76ff32 100644
--- a/flake.lock
+++ b/flake.lock
@@ -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"
}
}
diff --git a/flake.nix b/flake.nix
index 7d00ae7..9b69665 100644
--- a/flake.nix
+++ b/flake.nix
@@ -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
new file mode 100644
index 0000000..37f03b7
--- /dev/null
+++ b/resources/blank-cover.png
Binary files differ
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;
diff --git a/setup.py b/setup.py
index c2bae59..72729b9 100644
--- a/setup.py
+++ b/setup.py
@@ -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()