aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/__init__.py0
-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.py72
-rw-r--r--src/test_str_format.py20
-rw-r--r--src/ui/__init__.py0
-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
21 files changed, 1180 insertions, 0 deletions
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ 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/src/str_format.py b/src/str_format.py
new file mode 100644
index 0000000..5d8c412
--- /dev/null
+++ b/src/str_format.py
@@ -0,0 +1,72 @@
+import pathlib
+import re
+import unicodedata
+
+def safe_path(name):
+ simplified = ''.join([alnum_or_space(c) for c in unaccent(name.lower())])
+ return '-'.join(simplified.split())
+
+def unaccent(s):
+ return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
+
+def alnum_or_space(c):
+ if c.isalnum():
+ return c
+ else:
+ return ' '
+
+def 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('« ', '« ', s)
+ s = re.sub(' »', ' »', s)
+
+ # Add missing insecable spaces
+ s = re.sub(r'([^ ]):', r'\1 :', s)
+ s = re.sub(r'([^ ])\?', r'\1 ?', s)
+ s = re.sub(r'([^ ])\!', r'\1 !', s)
+ s = re.sub(r'([^ ])»', r'\1 »', s)
+ 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)
+
+ return s
+
+def cleanup_double_quotes(s, lang):
+ res = ''
+ quoted = False
+ for c in s:
+ if c == '"':
+ if quoted:
+ quoted = False
+ if lang == 'fr':
+ res += '»'
+ elif lang == 'en':
+ res += '”'
+ else:
+ quoted = True
+ if lang == 'fr':
+ res += '«'
+ elif lang == 'en':
+ res += '“'
+ else:
+ res += c
+ return res
+
+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/src/test_str_format.py b/src/test_str_format.py
new file mode 100644
index 0000000..57bec87
--- /dev/null
+++ b/src/test_str_format.py
@@ -0,0 +1,20 @@
+import str_format as format
+
+def test_unaccent():
+ assert format.unaccent('AuieTsrn') == 'AuieTsrn'
+ assert format.unaccent('âàéèêëîïôù') == 'aaeeeeiiou'
+ assert format.unaccent('ÂÀÉÈÊËÎÏÔÙ') == 'AAEEEEIIOU'
+
+def test_path_part():
+ 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”?'
+
+def test_cleanup_paragraphs():
+ assert format.cleanup_paragraphs(' Foo\n\nBar\nBaz \n\n') == ' Foo\n\n Bar\n\n Baz'
diff --git a/src/ui/__init__.py b/src/ui/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ 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()