9 Commits

25 changed files with 332 additions and 467 deletions
+4 -1
View File
@@ -3,7 +3,7 @@ debug = true
debug_sql = false debug_sql = false
[database] [database]
# One of (sqlite, postgres) # One of (sqlite, postgresql)
type = 'sqlite' type = 'sqlite'
[database.sqlite] [database.sqlite]
@@ -38,3 +38,6 @@ dryrun = false
warn_days_before_borrowing_deadline = [ 5, 1 ] warn_days_before_borrowing_deadline = [ 5, 1 ]
days_before_queue_position_expires = 14 days_before_queue_position_expires = 14
warn_days_before_expiring_queue_position_deadline = [ 3, 1 ] warn_days_before_expiring_queue_position_deadline = [ 3, 1 ]
[general]
quit_allowed = true
Generated
+4 -4
View File
@@ -7,11 +7,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1769338528, "lastModified": 1780178524,
"narHash": "sha256-t18ZoSt9kaI1yde26ok5s7aFLkap1Q9+/2icVh2zuaE=", "narHash": "sha256-2PcNyNqbGCWBpAMdCU1HxSQmhQiG6evdjxVnPA7w5bQ=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "7218348163fd8d84df4a6f682c634793e67a3fed", "rev": "2406de41ce9d0a1404cbf4e55537e3f720f37f23",
"revCount": 13, "revCount": 15,
"type": "git", "type": "git",
"url": "https://git.pvv.ntnu.no/Projects/libdib.git" "url": "https://git.pvv.ntnu.no/Projects/libdib.git"
}, },
+9 -1
View File
@@ -57,7 +57,15 @@
mkVm = name: mkApp "${self.nixosConfigurations.${name}.config.system.build.vm}/bin/run-nixos-vm"; mkVm = name: mkApp "${self.nixosConfigurations.${name}.config.system.build.vm}/bin/run-nixos-vm";
in { in {
default = self.apps.${system}.worblehat; default = self.apps.${system}.worblehat;
worblehat = mkApp (lib.getExe self.packages.${system}.worblehat) "Run worblehat without any setup"; worblehat = let
app = pkgs.writeShellApplication {
name = "worblehat-with-default-config";
runtimeInputs = [ self.packages.${system}.worblehat ];
text = ''
worblehat -c ${./config-template.toml} "$@"
'';
};
in mkApp (lib.getExe app) "Run the worblehat cli with its default config against an SQLite database";
vm = mkVm "vm" "Start a NixOS VM with worblehat installed in kiosk-mode"; vm = mkVm "vm" "Start a NixOS VM with worblehat installed in kiosk-mode";
vm-non-kiosk = mkVm "vm-non-kiosk" "Start a NixOS VM with worblehat installed in nonkiosk-mode"; vm-non-kiosk = mkVm "vm-non-kiosk" "Start a NixOS VM with worblehat installed in nonkiosk-mode";
}); });
+76 -19
View File
@@ -54,10 +54,43 @@ in {
freeformType = format.type; freeformType = format.type;
}; };
}; };
deadline-daemon = {
enable = lib.mkEnableOption "" // {
description = ''
Whether to enable the worblehat deadline-daemon service,
which periodically checks for upcoming deadlines and notifies users.
Note that this service is independent of the main worblehat service,
and must be enabled separately.
'';
};
onCalendar = lib.mkOption {
type = lib.types.str;
description = ''
How often to trigger rendering the map,
in the format of a systemd timer onCalendar configuration.
See {manpage}`systemd.timer(5)`.
'';
default = "*-*-* 10:15:00";
};
};
};
config = lib.mkMerge [
(lib.mkIf (cfg.enable || cfg.deadline-daemon.enable) {
environment.etc."worblehat/config.toml".source = format.generate "worblehat-config.toml" cfg.settings;
users = {
users.worblehat = {
group = "worblehat";
isNormalUser = true;
};
groups.worblehat = { };
}; };
config = lib.mkIf cfg.enable (lib.mkMerge [
{
services.worblehat.settings = lib.pipe ../config-template.toml [ services.worblehat.settings = lib.pipe ../config-template.toml [
builtins.readFile builtins.readFile
builtins.fromTOML builtins.fromTOML
@@ -69,20 +102,12 @@ in {
}) })
(lib.mapAttrsRecursive (_: lib.mkDefault)) (lib.mapAttrsRecursive (_: lib.mkDefault))
]; ];
} })
(lib.mkIf cfg.enable (lib.mkMerge [
{ {
environment.systemPackages = [ cfg.package ]; environment.systemPackages = [ cfg.package ];
environment.etc."worblehat/config.toml".source = format.generate "worblehat-config.toml" cfg.settings;
users = {
users.worblehat = {
group = "worblehat";
isNormalUser = true;
};
groups.worblehat = { };
};
services.worblehat.settings.database.type = "postgresql"; services.worblehat.settings.database.type = "postgresql";
services.worblehat.settings.database.postgresql = { services.worblehat.settings.database.postgresql = {
host = "/run/postgresql"; host = "/run/postgresql";
@@ -122,7 +147,7 @@ in {
users.users.worblehat = { users.users.worblehat = {
extraGroups = [ "lp" ]; extraGroups = [ "lp" ];
shell = (pkgs.writeShellScriptBin "login-shell" "${lib.getExe cfg.screenPackage} -x worblehat") // { shell = (pkgs.writeShellScriptBin "login-shell" "${lib.getExe' cfg.screenPackage "screen"} -x worblehat") // {
shellPath = "/bin/login-shell"; shellPath = "/bin/login-shell";
}; };
}; };
@@ -153,7 +178,7 @@ in {
User = "worblehat"; User = "worblehat";
Group = "worblehat"; Group = "worblehat";
ExecStartPre = "-${lib.getExe cfg.screenPackage} -X -S worblehat kill"; ExecStartPre = "-${lib.getExe' cfg.screenPackage "screen"} -X -S worblehat kill";
ExecStart = let ExecStart = let
screenArgs = lib.escapeShellArgs [ screenArgs = lib.escapeShellArgs [
# -dm creates the screen in detached mode without accessing it # -dm creates the screen in detached mode without accessing it
@@ -174,18 +199,50 @@ in {
config = "/etc/worblehat/config.toml"; config = "/etc/worblehat/config.toml";
}; };
in "${lib.getExe cfg.screenPackage} ${screenArgs} ${lib.getExe cfg.package} ${worblehatArgs} cli"; in "${lib.getExe' cfg.screenPackage "screen"} ${screenArgs} ${lib.getExe cfg.package} ${worblehatArgs} cli";
ExecStartPost = ExecStartPost =
lib.optionals (cfg.limitScreenWidth != null) [ lib.optionals (cfg.limitScreenWidth != null) [
"${lib.getExe cfg.screenPackage} -X -S worblehat width ${toString cfg.limitScreenWidth}" "${lib.getExe' cfg.screenPackage "screen"} -X -S worblehat width ${toString cfg.limitScreenWidth}"
] ]
++ lib.optionals (cfg.limitScreenHeight != null) [ ++ lib.optionals (cfg.limitScreenHeight != null) [
"${lib.getExe cfg.screenPackage} -X -S worblehat height ${toString cfg.limitScreenHeight}" "${lib.getExe' cfg.screenPackage "screen"} -X -S worblehat height ${toString cfg.limitScreenHeight}"
]; ];
}; };
}; };
services.getty.autologinUser = "worblehat"; services.getty.autologinUser = "worblehat";
}) })
]); ]))
(lib.mkIf cfg.deadline-daemon.enable {
systemd.timers.worblehat-deadline-daemon = {
description = "Worblehat Deadline Daemon";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.deadline-daemon.onCalendar;
Persistent = true;
};
};
systemd.services.worblehat-deadline-daemon = {
description = "Worblehat Deadline Daemon";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
Type = "oneshot";
CPUSchedulingPolicy = "idle";
IOSchedulingClass = "idle";
ExecStart = let
worblehatArgs = lib.cli.toCommandLineShellGNU { } {
config = "/etc/worblehat/config.toml";
};
in "${lib.getExe cfg.package} ${worblehatArgs} deadline-daemon";
User = "worblehat";
Group = "worblehat";
};
};
})
];
} }
@@ -48,6 +48,7 @@ nixpkgs.lib.nixosSystem {
services.worblehat = { services.worblehat = {
enable = true; enable = true;
createLocalDatabase = true; createLocalDatabase = true;
deadline-daemon.enable = true;
}; };
}) })
]; ];
+1
View File
@@ -29,6 +29,7 @@ nixpkgs.lib.nixosSystem {
DEBUG = true; DEBUG = true;
}; };
}; };
deadline-daemon.enable = true;
}; };
}) })
]; ];
+3 -3
View File
@@ -7,10 +7,10 @@ license = { file = "LICENSE" }
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"alembic>=1.17", "alembic>=1.16",
"beautifulsoup4>=4.14", "beautifulsoup4>=4.14",
"click>=8.3", "click>=8.2",
"flask-admin>=2.0", "flask-admin>=1.6",
"flask-sqlalchemy>=3.1", "flask-sqlalchemy>=3.1",
"flask>=3.0", "flask>=3.0",
"isbnlib>=3.10", "isbnlib>=3.10",
@@ -1,4 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
# TODO: Add more languages # TODO: Add more languages
LANGUAGES: set[str] = { LANGUAGES: set[str] = {
@@ -19,12 +20,14 @@ LANGUAGES: set[str] = {
@dataclass @dataclass
class BookMetadata: class BookData:
"""A class representing metadata for a book.""" """
A class representing metadata for a book that we might want to fetch from external sources
"""
isbn: str isbn: str
title: str title: str
# The source of the metadata provider # ID of the data fetcher used to fetch this instance
source: str source: str
authors: set[str] authors: set[str]
language: str | None language: str | None
@@ -32,11 +35,11 @@ class BookMetadata:
num_pages: int | None num_pages: int | None
subjects: set[str] subjects: set[str]
def to_dict(self) -> dict[str, any]: def to_dict(self) -> dict[str, Any]:
return { return {
"isbn": self.isbn, "isbn": self.isbn,
"title": self.title, "title": self.title,
"source": self.metadata_source_id(), "source": self.source,
"authors": set() if self.authors is None else self.authors, "authors": set() if self.authors is None else self.authors,
"language": self.language, "language": self.language,
"publish_date": self.publish_date, "publish_date": self.publish_date,
@@ -0,0 +1,22 @@
# base fetcher.
from abc import ABC, abstractmethod
from .BookData import BookData
class BookDataFetcher(ABC):
"""
A base class for adapters that fetch book data from external sources.
"""
@classmethod
@abstractmethod
def fetcher_id(cls) -> str:
"""Returns a unique identifier for this specific fetcher, to identify where the data came from."""
pass
@classmethod
@abstractmethod
def try_fetch_data(cls, isbn: str) -> BookData | None:
"""Tries to fetch data for the given ISBN."""
pass
@@ -0,0 +1,3 @@
from .book_data_fetcher import fetch_book_data_from_multiple_sources
__all__ = ["fetch_book_data_from_multiple_sources"]
@@ -0,0 +1,72 @@
"""
this module contains the fetch_book_data_from_multiple_sources() function which combines all fetchers and returns ranked results (if any)
"""
from concurrent.futures import ThreadPoolExecutor
from worblehat.book_data_fetchers.BookData import BookData
from worblehat.book_data_fetchers.BookDataFetcher import BookDataFetcher
from worblehat.book_data_fetchers.fetchers.GoogleBooksFetcher import GoogleBooksFetcher
from worblehat.book_data_fetchers.fetchers.OpenLibraryFetcher import OpenLibraryFetcher
from worblehat.book_data_fetchers.fetchers.OutlandScraperFetcher import (
OutlandScraperFetcher,
)
# The order of these fetchers determines the priority of the sources.
# The first fetcher in the list has the highest priority.
FETCHERS: list[BookDataFetcher] = [
OpenLibraryFetcher,
GoogleBooksFetcher,
OutlandScraperFetcher,
]
FETCHER_SOURCE_IDS: list[str] = [fetcher.fetcher_id() for fetcher in FETCHERS]
def sort_data_by_priority(data: list[BookData]) -> list[BookData]:
"""
Sorts the given data by the priority of the sources.
The order of the data is the same as the order of the sources in the FETCHERS list.
"""
# Note that this function is O(n^2) but the number of fetchers is small so it's fine.
return sorted(data, key=lambda m: FETCHER_SOURCE_IDS.index(m.source))
def fetch_book_data_from_multiple_sources(isbn: str, strict: bool=False) -> list[BookData]:
"""
Returns a list of data fetched from multiple fetchers.
Fetchers that are not able to retrieve any data for the given ISBN will be ignored.
There is no guarantee that there will be any book data.
The results are always ordered in the same way as the fetchers are listed in the FETCHERS list.
"""
isbn = isbn.replace("-", "").replace("_", "").strip().lower()
if len(isbn) != 10 and len(isbn) != 13 and not isbn.isnumeric():
raise ValueError("Invalid ISBN")
results: list[BookData] = []
with ThreadPoolExecutor() as executor:
futures = [executor.submit(fetcher.try_fetch_data, isbn) for fetcher in FETCHERS]
for future in futures:
result = future.result()
if result is not None:
results.append(result)
for result in results:
try:
result.validate()
except ValueError as e:
if strict:
raise e
print(f"Invalid data: {e}")
results.remove(result)
return sort_data_by_priority(results)
@@ -4,17 +4,17 @@ A BookMetadataFetcher for the Google Books API.
import requests import requests
from worblehat.services.metadata_fetchers.BookMetadata import BookMetadata from worblehat.book_data_fetchers.BookData import BookData
from worblehat.services.metadata_fetchers.BookMetadataFetcher import BookMetadataFetcher from worblehat.book_data_fetchers.BookDataFetcher import BookDataFetcher
class GoogleBooksFetcher(BookMetadataFetcher): class GoogleBooksFetcher(BookDataFetcher):
@classmethod @classmethod
def metadata_source_id(_cls) -> str: def fetcher_id(_cls) -> str:
return "google_books" return "google_books"
@classmethod @classmethod
def fetch_metadata(cls, isbn: str) -> BookMetadata | None: def try_fetch_data(cls, isbn: str) -> BookData | None:
try: try:
jsonInput = requests.get( jsonInput = requests.get(
"https://www.googleapis.com/books/v1/volumes", "https://www.googleapis.com/books/v1/volumes",
@@ -33,19 +33,13 @@ class GoogleBooksFetcher(BookMetadataFetcher):
except Exception: except Exception:
return None return None
return BookMetadata( return BookData(
isbn=isbn, isbn=isbn,
title=title, title=title,
source=cls.metadata_source_id(), source=cls.fetcher_id(),
authors=authors, authors=authors,
language=languages, language=languages,
publish_date=publishDate, publish_date=publishDate,
num_pages=numberOfPages, num_pages=numberOfPages,
subjects=subjects, subjects=subjects,
) )
if __name__ == "__main__":
book_data = GoogleBooksFetcher.fetch_metadata("0132624788")
book_data.validate()
print(book_data)
@@ -4,21 +4,21 @@ A BookMetadataFetcher for the Open Library API.
import requests import requests
from worblehat.services.metadata_fetchers.BookMetadata import BookMetadata from worblehat.book_data_fetchers.BookData import BookData
from worblehat.services.metadata_fetchers.BookMetadataFetcher import BookMetadataFetcher from worblehat.book_data_fetchers.BookDataFetcher import BookDataFetcher
LANGUAGE_MAP = { LANGUAGE_MAP = {
"Norwegian": "no", "Norwegian": "no",
} }
class OpenLibraryFetcher(BookMetadataFetcher): class OpenLibraryFetcher(BookDataFetcher):
@classmethod @classmethod
def metadata_source_id(_cls) -> str: def fetcher_id(_cls) -> str:
return "open_library" return "open_library"
@classmethod @classmethod
def fetch_metadata(cls, isbn: str) -> BookMetadata | None: def try_fetch_data(cls, isbn: str) -> BookData | None:
try: try:
jsonInput = requests.get(f"https://openlibrary.org/isbn/{isbn}.json").json() jsonInput = requests.get(f"https://openlibrary.org/isbn/{isbn}.json").json()
@@ -48,19 +48,13 @@ class OpenLibraryFetcher(BookMetadataFetcher):
except Exception: except Exception:
return None return None
return BookMetadata( return BookData(
isbn=isbn, isbn=isbn,
title=title, title=title,
source=cls.metadata_source_id(), source=cls.fetcher_id(),
authors=author_names, authors=author_names,
language=language, language=language,
publish_date=publishDate, publish_date=publishDate,
num_pages=numberOfPages, num_pages=numberOfPages,
subjects=subjects, subjects=subjects,
) )
if __name__ == "__main__":
book_data = OpenLibraryFetcher.fetch_metadata("9788205530751")
book_data.validate()
print(book_data)
@@ -5,8 +5,8 @@ A BookMetadataFetcher that webscrapes https://outland.no/
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from worblehat.services.metadata_fetchers.BookMetadata import BookMetadata from worblehat.book_data_fetchers.BookData import BookData
from worblehat.services.metadata_fetchers.BookMetadataFetcher import BookMetadataFetcher from worblehat.book_data_fetchers.BookDataFetcher import BookDataFetcher
LANGUAGE_MAP = { LANGUAGE_MAP = {
"Norsk": "no", "Norsk": "no",
@@ -25,13 +25,13 @@ LANGUAGE_MAP = {
} }
class OutlandScraperFetcher(BookMetadataFetcher): class OutlandScraperFetcher(BookDataFetcher):
@classmethod @classmethod
def metadata_source_id(_cls) -> str: def fetcher_id(_cls) -> str:
return "outland_scraper" return "outland_scraper"
@classmethod @classmethod
def fetch_metadata(cls, isbn: str) -> BookMetadata | None: def try_fetch_data(cls, isbn: str) -> BookData | None:
try: try:
# Find the link to the product page # Find the link to the product page
response = requests.get(f"https://outland.no/{isbn}") response = requests.get(f"https://outland.no/{isbn}")
@@ -89,19 +89,13 @@ class OutlandScraperFetcher(BookMetadataFetcher):
except Exception: except Exception:
return None return None
return BookMetadata( return BookData(
isbn=isbn, isbn=isbn,
title=bookData.get("Title"), title=bookData.get("Title"),
source=cls.metadata_source_id(), source=cls.fetcher_id(),
authors=bookData.get("Authors"), authors=bookData.get("Authors"),
language=bookData.get("Language"), language=bookData.get("Language"),
publish_date=bookData.get("PublishDate"), publish_date=bookData.get("PublishDate"),
num_pages=bookData.get("NumberOfPages"), num_pages=bookData.get("NumberOfPages"),
subjects=bookData.get("Subjects"), subjects=bookData.get("Subjects"),
) )
if __name__ == "__main__":
book_data = OutlandScraperFetcher.fetch_metadata("9781947808225")
book_data.validate()
print(book_data)
+12 -17
View File
@@ -16,6 +16,7 @@ from worblehat.models import *
from worblehat.services import ( from worblehat.services import (
create_bookcase_item_from_isbn, create_bookcase_item_from_isbn,
is_valid_isbn, is_valid_isbn,
Config,
) )
from .subclis import ( from .subclis import (
@@ -47,26 +48,13 @@ class WorblehatCli(NumberedCmd):
self.sql_session_dirty = False self.sql_session_dirty = False
self.prompt_header = None self.prompt_header = None
@classmethod def run_with_safe_exit_wrapper(self) -> None:
def run_with_safe_exit_wrapper(cls, sql_session: Session) -> None:
tool = cls(sql_session)
while True: while True:
try: try:
tool.cmdloop() self.cmdloop()
except KeyboardInterrupt: except KeyboardInterrupt:
if not tool.sql_session_dirty: print("\n\n-----------------\n")
exit(0) self.do_exit("Exit")
try:
print()
if prompt_yes_no(
"Are you sure you want to exit without saving?",
default=False,
):
raise KeyboardInterrupt
except KeyboardInterrupt:
if tool.sql_session is not None:
tool.sql_session.rollback()
exit(0)
def do_show_bookcase(self, arg: str) -> None: def do_show_bookcase(self, arg: str) -> None:
bookcase_selector = InteractiveItemSelector( bookcase_selector = InteractiveItemSelector(
@@ -75,6 +63,8 @@ class WorblehatCli(NumberedCmd):
) )
bookcase_selector.cmdloop() bookcase_selector.cmdloop()
bookcase = bookcase_selector.result bookcase = bookcase_selector.result
if bookcase == None:
return
for shelf in bookcase.shelfs: for shelf in bookcase.shelfs:
print(shelf.short_str()) print(shelf.short_str())
@@ -138,6 +128,8 @@ class WorblehatCli(NumberedCmd):
) )
bookcase_selector.cmdloop() bookcase_selector.cmdloop()
bookcase = bookcase_selector.result bookcase = bookcase_selector.result
if bookcase == None:
return
bookcase_item.shelf = select_bookcase_shelf(bookcase, self.sql_session) bookcase_item.shelf = select_bookcase_shelf(bookcase, self.sql_session)
@@ -152,6 +144,8 @@ class WorblehatCli(NumberedCmd):
media_type_selector.cmdloop() media_type_selector.cmdloop()
bookcase_item.media_type = media_type_selector.result bookcase_item.media_type = media_type_selector.result
if bookcase_item.media_type == None:
return
username = input("Who owns this book? [PVV]> ") username = input("Who owns this book? [PVV]> ")
if username != "": if username != "":
@@ -240,6 +234,7 @@ class WorblehatCli(NumberedCmd):
self.sql_session.commit() self.sql_session.commit()
else: else:
self.sql_session.rollback() self.sql_session.rollback()
if Config["general.quit_allowed"]:
exit(0) exit(0)
funcs = { funcs = {
@@ -384,6 +384,8 @@ class EditBookcaseCli(NumberedCmd):
) )
bookcase_selector.cmdloop() bookcase_selector.cmdloop()
bookcase = bookcase_selector.result bookcase = bookcase_selector.result
if bookcase == None:
return
assert isinstance(bookcase, Bookcase) assert isinstance(bookcase, Bookcase)
shelf = select_bookcase_shelf(bookcase, self.sql_session) shelf = select_bookcase_shelf(bookcase, self.sql_session)
-170
View File
@@ -1,170 +0,0 @@
from worblehat.cli.subclis import select_bookcase_shelf
from sqlalchemy import select, event
from sqlalchemy.orm.session import Session
from worblehat.models import Bookcase, BookcaseShelf
from worblehat.services.metadata_fetchers import fetch_metadata_from_multiple_sources
from worblehat.services import is_valid_isbn
from pprint import pprint
from libdib.repl import (
NumberedCmd,
prompt_yes_no,
)
class BatchScanner(NumberedCmd):
def __init__(self, sql_session: Session):
super().__init__()
self.sql_session = sql_session
self.sql_session_dirty = False
self.bookcase_shelf = None
self.prompt_header = "[SHELF| NONE]"
@event.listens_for(self.sql_session, "after_flush")
def mark_session_as_dirty(*_):
self.sql_session_dirty = True
self.prompt_header = "(unsaved changes)"
@event.listens_for(self.sql_session, "after_commit")
@event.listens_for(self.sql_session, "after_rollback")
def mark_session_as_clean(*_):
self.sql_session_dirty = False
self.prompt_header = None
def _set_bookcase_shelf(self, bookcase_shelf: BookcaseShelf):
self.bookcase_shelf = bookcase_shelf
self.prompt_header = f"[SHELF| {bookcase_shelf.bookcase.uid} {bookcase_shelf.bookcase.description} / {bookcase_shelf.column}-{bookcase_shelf.row}: {bookcase_shelf.description}]"
def default(self, isbn: str):
isbn = isbn.strip()
if not is_valid_isbn(isbn):
super()._default(isbn)
return
if self.bookcase_shelf is None:
print("Please set the bookcase shelf first.")
return
print(f"Scanned ISBN: {isbn}")
data = fetch_metadata_from_multiple_sources(isbn)
pprint(data)
def do_set_bookcase_shelf(self, _: str):
bookcase = self._choose_bookcase(self.sql_session)
print()
self._print_bookcase_shelves(
sql_session=self.sql_session,
bookcase=bookcase,
)
print()
bookcase_shelf = select_bookcase_shelf(
sql_session=self.sql_session,
bookcase=bookcase,
)
self._set_bookcase_shelf(bookcase_shelf)
print(f"Bookcase shelf set to {bookcase_shelf}")
def _choose_bookcase(
self,
sql_session: Session,
) -> Bookcase:
bookcases = sql_session.scalars(
select(Bookcase)
).all()
while True:
print("Available bookcases:")
for bookcase in bookcases:
print(f" {bookcase.name} - {bookcase.description}")
bookcase_name = input("Choose a bookcase> ").strip()
bookcase = sql_session.scalars(
select(Bookcase).where(Bookcase.name == bookcase_name)
).one_or_none()
if not bookcase:
print(f"Bookcase {bookcase_name} not found")
continue
return bookcase
def _print_bookcase_shelves(
self,
sql_session: Session,
bookcase: Bookcase,
) -> None:
shelves = sql_session.scalars(
select(BookcaseShelf).where(BookcaseShelf.bookcase == bookcase)
).all()
min_col = min([shelf.column for shelf in shelves])
max_col = max([shelf.column for shelf in shelves])
min_row = min([shelf.row for shelf in shelves])
max_row = max([shelf.row for shelf in shelves])
print('Available shelves:')
for col in range(min_col, max_col + 1):
for row in range(min_row, max_row + 1):
shelf = sql_session.scalars(
select(BookcaseShelf).where(
BookcaseShelf.bookcase == bookcase,
BookcaseShelf.column == col,
BookcaseShelf.row == row,
)
).one_or_none()
if shelf:
print(f"{col}-{row}: {shelf.description}")
else:
print(f"{col}-{row}: None")
def do_save(self, _: str):
if not self.sql_session_dirty:
print("No changes to save.")
return
self.sql_session.commit()
def do_abort(self, _: str):
if not self.sql_session_dirty:
print("No changes to abort.")
return
self.sql_session.rollback()
def do_exit(self, _: str):
if self.sql_session_dirty:
if prompt_yes_no("Would you like to save your changes?"):
self.sql_session.commit()
else:
self.sql_session.rollback()
exit(0)
funcs = {
0: {
"f": default,
"doc": "Add item with its ISBN",
},
1: {
'f' : do_set_bookcase_shelf,
'doc' : 'Select shelf',
},
7: {
"f": do_save,
"doc": "Save changes",
},
8: {
"f": do_abort,
"doc": "Abort changes",
},
9: {
"f": do_exit,
"doc": "Exit",
},
}
+2 -7
View File
@@ -65,7 +65,8 @@ def main() -> None:
if args.command == "cli": if args.command == "cli":
sql_session = _connect_to_database(echo=Config["logging.debug_sql"]) sql_session = _connect_to_database(echo=Config["logging.debug_sql"])
WorblehatCli.run_with_safe_exit_wrapper(sql_session) worblehat = WorblehatCli(sql_session)
worblehat.run_with_safe_exit_wrapper()
exit(0) exit(0)
if args.command == "create-db": if args.command == "create-db":
@@ -85,12 +86,6 @@ def main() -> None:
from .devscripts.seed_test_data import main from .devscripts.seed_test_data import main
main(sql_session) main(sql_session)
elif args.script == "batch-scanner":
from .devscripts.batch_scanner import BatchScanner
result = BatchScanner(sql_session).cmdloop()
print(result)
else: else:
print(devscripts_arg_parser.format_help()) print(devscripts_arg_parser.format_help())
exit(1) exit(1)
@@ -52,11 +52,6 @@ devscripts_subparsers.add_parser(
help="Seed data tailorded for testing the deadline daemon, into the database", help="Seed data tailorded for testing the deadline daemon, into the database",
) )
devscripts_subparsers.add_parser(
"batch-scanner",
help="REPL optimized for scanning in tons of books",
)
arg_parser.add_argument( arg_parser.add_argument(
"-V", "-V",
"--version", "--version",
+3 -2
View File
@@ -2,12 +2,13 @@ import isbnlib
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from worblehat.book_data_fetchers import fetch_book_data_from_multiple_sources
from ..models import ( from ..models import (
Author, Author,
BookcaseItem, BookcaseItem,
Language, Language,
) )
from .metadata_fetchers import fetch_metadata_from_multiple_sources
def is_valid_pvv_isbn(isbn: str) -> bool: def is_valid_pvv_isbn(isbn: str) -> bool:
@@ -41,7 +42,7 @@ def create_bookcase_item_from_isbn(
Please not that the returned BookcaseItem will likely not be fully populated with the required Please not that the returned BookcaseItem will likely not be fully populated with the required
data, such as the book's location in the library, and the owner of the book, etc. data, such as the book's location in the library, and the owner of the book, etc.
""" """
metadata = fetch_metadata_from_multiple_sources(isbn) metadata = fetch_book_data_from_multiple_sources(isbn)
if len(metadata) == 0: if len(metadata) == 0:
return None return None
+1 -1
View File
@@ -40,7 +40,7 @@ class Config:
def read_password(password_field: str) -> str: def read_password(password_field: str) -> str:
if Path(password_field).is_file(): if Path(password_field).is_file():
with Path(password_field).open() as f: with Path(password_field).open() as f:
return f.read() return f.read().strip()
else: else:
return password_field return password_field
@@ -1,22 +0,0 @@
# base fetcher.
from abc import ABC, abstractmethod
from .BookMetadata import BookMetadata
class BookMetadataFetcher(ABC):
"""
A base class for metadata fetchers.
"""
@classmethod
@abstractmethod
def metadata_source_id(cls) -> str:
"""Returns a unique identifier for the metadata source, to identify where the metadata came from."""
pass
@classmethod
@abstractmethod
def fetch_metadata(cls, isbn: str) -> BookMetadata | None:
"""Tries to fetch metadata for the given ISBN."""
pass
@@ -1,3 +0,0 @@
from .book_metadata_fetcher import fetch_metadata_from_multiple_sources
__all__ = ["fetch_metadata_from_multiple_sources"]
@@ -1,80 +0,0 @@
"""
this module contains the fetch_book_metadata() function which fetches book metadata from multiple sources in threads and returns the higest ranked non-None result.
"""
from concurrent.futures import ThreadPoolExecutor
from worblehat.services.metadata_fetchers.BookMetadata import BookMetadata
from worblehat.services.metadata_fetchers.BookMetadataFetcher import BookMetadataFetcher
from worblehat.services.metadata_fetchers.GoogleBooksFetcher import GoogleBooksFetcher
from worblehat.services.metadata_fetchers.OpenLibraryFetcher import OpenLibraryFetcher
from worblehat.services.metadata_fetchers.OutlandScraperFetcher import (
OutlandScraperFetcher,
)
# The order of these fetchers determines the priority of the sources.
# The first fetcher in the list has the highest priority.
FETCHERS: list[BookMetadataFetcher] = [
OpenLibraryFetcher,
GoogleBooksFetcher,
OutlandScraperFetcher,
]
FETCHER_SOURCE_IDS: list[str] = [fetcher.metadata_source_id() for fetcher in FETCHERS]
def sort_metadata_by_priority(metadata: list[BookMetadata]) -> list[BookMetadata]:
"""
Sorts the given metadata by the priority of the sources.
The order of the metadata is the same as the order of the sources in the FETCHERS list.
"""
# Note that this function is O(n^2) but the number of fetchers is small so it's fine.
return sorted(metadata, key=lambda m: FETCHER_SOURCE_IDS.index(m.source))
def fetch_metadata_from_multiple_sources(isbn: str, strict: bool=False) -> list[BookMetadata]:
"""
Returns a list of metadata fetched from multiple sources.
Sources that does not have metadata for the given ISBN will be ignored.
There is no guarantee that there will be any metadata.
The results are always ordered in the same way as the fetchers are listed in the FETCHERS list.
"""
isbn = isbn.replace("-", "").replace("_", "").strip().lower()
if len(isbn) != 10 and len(isbn) != 13 and not isbn.isnumeric():
raise ValueError("Invalid ISBN")
results: list[BookMetadata] = []
with ThreadPoolExecutor() as executor:
futures = [executor.submit(fetcher.fetch_metadata, isbn) for fetcher in FETCHERS]
for future in futures:
result = future.result()
if result is not None:
results.append(result)
for result in results:
try:
result.validate()
except ValueError as e:
if strict:
raise e
print(f"Invalid metadata: {e}")
results.remove(result)
return sort_metadata_by_priority(results)
if __name__ == "__main__":
from pprint import pprint
isbn = "0132624788"
metadata = fetch_metadata_from_multiple_sources(isbn)
pprint(metadata)