+
t('Calibre OPDS library')); ?>
+
+
+ t('Saved')); ?>
+
+
diff --git a/tests/files/metadata.sql b/tests/files/metadata.sql
new file mode 100644
index 0000000..356695b
--- /dev/null
+++ b/tests/files/metadata.sql
@@ -0,0 +1,641 @@
+CREATE TABLE authors ( id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL COLLATE NOCASE,
+ sort TEXT COLLATE NOCASE,
+ link TEXT NOT NULL DEFAULT '',
+ UNIQUE(name)
+ );
+CREATE TABLE books ( id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL DEFAULT 'Unknown' COLLATE NOCASE,
+ sort TEXT COLLATE NOCASE,
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ pubdate TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ series_index REAL NOT NULL DEFAULT 1.0,
+ author_sort TEXT COLLATE NOCASE,
+ isbn TEXT DEFAULT '' COLLATE NOCASE,
+ lccn TEXT DEFAULT '' COLLATE NOCASE,
+ path TEXT NOT NULL DEFAULT '',
+ flags INTEGER NOT NULL DEFAULT 1,
+ uuid TEXT,
+ has_cover BOOL DEFAULT 0,
+ last_modified TIMESTAMP NOT NULL DEFAULT '2000-01-01 00:00:00+00:00');
+CREATE TABLE books_authors_link ( id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ author INTEGER NOT NULL,
+ UNIQUE(book, author)
+ );
+CREATE TABLE books_languages_link ( id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ lang_code INTEGER NOT NULL,
+ item_order INTEGER NOT NULL DEFAULT 0,
+ UNIQUE(book, lang_code)
+ );
+CREATE TABLE books_plugin_data(id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ val TEXT NOT NULL,
+ UNIQUE(book,name));
+CREATE TABLE books_publishers_link ( id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ publisher INTEGER NOT NULL,
+ UNIQUE(book)
+ );
+CREATE TABLE books_ratings_link ( id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ rating INTEGER NOT NULL,
+ UNIQUE(book, rating)
+ );
+CREATE TABLE books_series_link ( id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ series INTEGER NOT NULL,
+ UNIQUE(book)
+ );
+CREATE TABLE books_tags_link ( id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ tag INTEGER NOT NULL,
+ UNIQUE(book, tag)
+ );
+CREATE TABLE comments ( id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ text TEXT NOT NULL COLLATE NOCASE,
+ UNIQUE(book)
+ );
+CREATE TABLE conversion_options ( id INTEGER PRIMARY KEY,
+ format TEXT NOT NULL COLLATE NOCASE,
+ book INTEGER,
+ data BLOB NOT NULL,
+ UNIQUE(format,book)
+ );
+CREATE TABLE custom_columns (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ label TEXT NOT NULL,
+ name TEXT NOT NULL,
+ datatype TEXT NOT NULL,
+ mark_for_delete BOOL DEFAULT 0 NOT NULL,
+ editable BOOL DEFAULT 1 NOT NULL,
+ display TEXT DEFAULT '{}' NOT NULL,
+ is_multiple BOOL DEFAULT 0 NOT NULL,
+ normalized BOOL NOT NULL,
+ UNIQUE(label)
+ );
+CREATE TABLE data ( id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ format TEXT NOT NULL COLLATE NOCASE,
+ uncompressed_size INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ UNIQUE(book, format)
+);
+CREATE TABLE feeds ( id INTEGER PRIMARY KEY,
+ title TEXT NOT NULL,
+ script TEXT NOT NULL,
+ UNIQUE(title)
+ );
+CREATE TABLE identifiers ( id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ type TEXT NOT NULL DEFAULT 'isbn' COLLATE NOCASE,
+ val TEXT NOT NULL COLLATE NOCASE,
+ UNIQUE(book, type)
+ );
+CREATE TABLE languages ( id INTEGER PRIMARY KEY,
+ lang_code TEXT NOT NULL COLLATE NOCASE,
+ link TEXT NOT NULL DEFAULT '',
+ UNIQUE(lang_code)
+ );
+CREATE TABLE library_id ( id INTEGER PRIMARY KEY,
+ uuid TEXT NOT NULL,
+ UNIQUE(uuid)
+ );
+CREATE TABLE metadata_dirtied(id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ UNIQUE(book));
+CREATE TABLE annotations_dirtied(id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ UNIQUE(book));
+CREATE TABLE preferences(id INTEGER PRIMARY KEY,
+ key TEXT NOT NULL,
+ val TEXT NOT NULL,
+ UNIQUE(key));
+CREATE TABLE publishers ( id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL COLLATE NOCASE,
+ sort TEXT COLLATE NOCASE,
+ link TEXT NOT NULL DEFAULT '',
+ UNIQUE(name)
+ );
+CREATE TABLE ratings ( id INTEGER PRIMARY KEY,
+ rating INTEGER CHECK(rating > -1 AND rating < 11),
+ link TEXT NOT NULL DEFAULT '',
+ UNIQUE (rating)
+ );
+CREATE TABLE series ( id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL COLLATE NOCASE,
+ sort TEXT COLLATE NOCASE,
+ link TEXT NOT NULL DEFAULT '',
+ UNIQUE (name)
+ );
+CREATE TABLE tags ( id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL COLLATE NOCASE,
+ link TEXT NOT NULL DEFAULT '',
+ UNIQUE (name)
+ );
+CREATE TABLE last_read_positions ( id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ format TEXT NOT NULL COLLATE NOCASE,
+ user TEXT NOT NULL,
+ device TEXT NOT NULL,
+ cfi TEXT NOT NULL,
+ epoch REAL NOT NULL,
+ pos_frac REAL NOT NULL DEFAULT 0,
+ UNIQUE(user, device, book, format)
+);
+
+CREATE TABLE annotations ( id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ format TEXT NOT NULL COLLATE NOCASE,
+ user_type TEXT NOT NULL,
+ user TEXT NOT NULL,
+ timestamp REAL NOT NULL,
+ annot_id TEXT NOT NULL,
+ annot_type TEXT NOT NULL,
+ annot_data TEXT NOT NULL,
+ searchable_text TEXT NOT NULL DEFAULT '',
+ UNIQUE(book, user_type, user, format, annot_type, annot_id)
+);
+
+CREATE VIRTUAL TABLE annotations_fts USING fts5(searchable_text, content = 'annotations', content_rowid = 'id', tokenize = 'unicode61 remove_diacritics 2');
+CREATE VIRTUAL TABLE annotations_fts_stemmed USING fts5(searchable_text, content = 'annotations', content_rowid = 'id', tokenize = 'porter unicode61 remove_diacritics 2');
+
+CREATE TRIGGER annotations_fts_insert_trg AFTER INSERT ON annotations
+BEGIN
+ INSERT INTO annotations_fts(rowid, searchable_text) VALUES (NEW.id, NEW.searchable_text);
+ INSERT INTO annotations_fts_stemmed(rowid, searchable_text) VALUES (NEW.id, NEW.searchable_text);
+END;
+
+CREATE TRIGGER annotations_fts_delete_trg AFTER DELETE ON annotations
+BEGIN
+ INSERT INTO annotations_fts(annotations_fts, rowid, searchable_text) VALUES('delete', OLD.id, OLD.searchable_text);
+ INSERT INTO annotations_fts_stemmed(annotations_fts_stemmed, rowid, searchable_text) VALUES('delete', OLD.id, OLD.searchable_text);
+END;
+
+CREATE TRIGGER annotations_fts_update_trg AFTER UPDATE ON annotations
+BEGIN
+ INSERT INTO annotations_fts(annotations_fts, rowid, searchable_text) VALUES('delete', OLD.id, OLD.searchable_text);
+ INSERT INTO annotations_fts(rowid, searchable_text) VALUES (NEW.id, NEW.searchable_text);
+ INSERT INTO annotations_fts_stemmed(annotations_fts_stemmed, rowid, searchable_text) VALUES('delete', OLD.id, OLD.searchable_text);
+ INSERT INTO annotations_fts_stemmed(rowid, searchable_text) VALUES (NEW.id, NEW.searchable_text);
+END;
+
+
+CREATE VIEW meta AS
+ SELECT id, title,
+ (SELECT sortconcat(bal.id, name) FROM books_authors_link AS bal JOIN authors ON(author = authors.id) WHERE book = books.id) authors,
+ (SELECT name FROM publishers WHERE publishers.id IN (SELECT publisher from books_publishers_link WHERE book=books.id)) publisher,
+ (SELECT rating FROM ratings WHERE ratings.id IN (SELECT rating from books_ratings_link WHERE book=books.id)) rating,
+ timestamp,
+ (SELECT MAX(uncompressed_size) FROM data WHERE book=books.id) size,
+ (SELECT concat(name) FROM tags WHERE tags.id IN (SELECT tag from books_tags_link WHERE book=books.id)) tags,
+ (SELECT text FROM comments WHERE book=books.id) comments,
+ (SELECT name FROM series WHERE series.id IN (SELECT series FROM books_series_link WHERE book=books.id)) series,
+ series_index,
+ sort,
+ author_sort,
+ (SELECT concat(format) FROM data WHERE data.book=books.id) formats,
+ isbn,
+ path,
+ lccn,
+ pubdate,
+ flags,
+ uuid
+ FROM books;
+CREATE VIEW tag_browser_authors AS SELECT
+ id,
+ name,
+ (SELECT COUNT(id) FROM books_authors_link WHERE author=authors.id) count,
+ (SELECT AVG(ratings.rating)
+ FROM books_authors_link AS tl, books_ratings_link AS bl, ratings
+ WHERE tl.author=authors.id AND bl.book=tl.book AND
+ ratings.id = bl.rating AND ratings.rating <> 0) avg_rating,
+ sort AS sort
+ FROM authors;
+CREATE VIEW tag_browser_filtered_authors AS SELECT
+ id,
+ name,
+ (SELECT COUNT(books_authors_link.id) FROM books_authors_link WHERE
+ author=authors.id AND books_list_filter(book)) count,
+ (SELECT AVG(ratings.rating)
+ FROM books_authors_link AS tl, books_ratings_link AS bl, ratings
+ WHERE tl.author=authors.id AND bl.book=tl.book AND
+ ratings.id = bl.rating AND ratings.rating <> 0 AND
+ books_list_filter(bl.book)) avg_rating,
+ sort AS sort
+ FROM authors;
+CREATE VIEW tag_browser_filtered_publishers AS SELECT
+ id,
+ name,
+ (SELECT COUNT(books_publishers_link.id) FROM books_publishers_link WHERE
+ publisher=publishers.id AND books_list_filter(book)) count,
+ (SELECT AVG(ratings.rating)
+ FROM books_publishers_link AS tl, books_ratings_link AS bl, ratings
+ WHERE tl.publisher=publishers.id AND bl.book=tl.book AND
+ ratings.id = bl.rating AND ratings.rating <> 0 AND
+ books_list_filter(bl.book)) avg_rating,
+ name AS sort
+ FROM publishers;
+CREATE VIEW tag_browser_filtered_ratings AS SELECT
+ id,
+ rating,
+ (SELECT COUNT(books_ratings_link.id) FROM books_ratings_link WHERE
+ rating=ratings.id AND books_list_filter(book)) count,
+ (SELECT AVG(ratings.rating)
+ FROM books_ratings_link AS tl, books_ratings_link AS bl, ratings
+ WHERE tl.rating=ratings.id AND bl.book=tl.book AND
+ ratings.id = bl.rating AND ratings.rating <> 0 AND
+ books_list_filter(bl.book)) avg_rating,
+ rating AS sort
+ FROM ratings;
+CREATE VIEW tag_browser_filtered_series AS SELECT
+ id,
+ name,
+ (SELECT COUNT(books_series_link.id) FROM books_series_link WHERE
+ series=series.id AND books_list_filter(book)) count,
+ (SELECT AVG(ratings.rating)
+ FROM books_series_link AS tl, books_ratings_link AS bl, ratings
+ WHERE tl.series=series.id AND bl.book=tl.book AND
+ ratings.id = bl.rating AND ratings.rating <> 0 AND
+ books_list_filter(bl.book)) avg_rating,
+ (title_sort(name)) AS sort
+ FROM series;
+CREATE VIEW tag_browser_filtered_tags AS SELECT
+ id,
+ name,
+ (SELECT COUNT(books_tags_link.id) FROM books_tags_link WHERE
+ tag=tags.id AND books_list_filter(book)) count,
+ (SELECT AVG(ratings.rating)
+ FROM books_tags_link AS tl, books_ratings_link AS bl, ratings
+ WHERE tl.tag=tags.id AND bl.book=tl.book AND
+ ratings.id = bl.rating AND ratings.rating <> 0 AND
+ books_list_filter(bl.book)) avg_rating,
+ name AS sort
+ FROM tags;
+CREATE VIEW tag_browser_publishers AS SELECT
+ id,
+ name,
+ (SELECT COUNT(id) FROM books_publishers_link WHERE publisher=publishers.id) count,
+ (SELECT AVG(ratings.rating)
+ FROM books_publishers_link AS tl, books_ratings_link AS bl, ratings
+ WHERE tl.publisher=publishers.id AND bl.book=tl.book AND
+ ratings.id = bl.rating AND ratings.rating <> 0) avg_rating,
+ name AS sort
+ FROM publishers;
+CREATE VIEW tag_browser_ratings AS SELECT
+ id,
+ rating,
+ (SELECT COUNT(id) FROM books_ratings_link WHERE rating=ratings.id) count,
+ (SELECT AVG(ratings.rating)
+ FROM books_ratings_link AS tl, books_ratings_link AS bl, ratings
+ WHERE tl.rating=ratings.id AND bl.book=tl.book AND
+ ratings.id = bl.rating AND ratings.rating <> 0) avg_rating,
+ rating AS sort
+ FROM ratings;
+CREATE VIEW tag_browser_series AS SELECT
+ id,
+ name,
+ (SELECT COUNT(id) FROM books_series_link WHERE series=series.id) count,
+ (SELECT AVG(ratings.rating)
+ FROM books_series_link AS tl, books_ratings_link AS bl, ratings
+ WHERE tl.series=series.id AND bl.book=tl.book AND
+ ratings.id = bl.rating AND ratings.rating <> 0) avg_rating,
+ (title_sort(name)) AS sort
+ FROM series;
+CREATE VIEW tag_browser_tags AS SELECT
+ id,
+ name,
+ (SELECT COUNT(id) FROM books_tags_link WHERE tag=tags.id) count,
+ (SELECT AVG(ratings.rating)
+ FROM books_tags_link AS tl, books_ratings_link AS bl, ratings
+ WHERE tl.tag=tags.id AND bl.book=tl.book AND
+ ratings.id = bl.rating AND ratings.rating <> 0) avg_rating,
+ name AS sort
+ FROM tags;
+CREATE INDEX authors_idx ON books (author_sort COLLATE NOCASE);
+CREATE INDEX books_authors_link_aidx ON books_authors_link (author);
+CREATE INDEX books_authors_link_bidx ON books_authors_link (book);
+CREATE INDEX books_idx ON books (sort COLLATE NOCASE);
+CREATE INDEX books_languages_link_aidx ON books_languages_link (lang_code);
+CREATE INDEX books_languages_link_bidx ON books_languages_link (book);
+CREATE INDEX books_publishers_link_aidx ON books_publishers_link (publisher);
+CREATE INDEX books_publishers_link_bidx ON books_publishers_link (book);
+CREATE INDEX books_ratings_link_aidx ON books_ratings_link (rating);
+CREATE INDEX books_ratings_link_bidx ON books_ratings_link (book);
+CREATE INDEX books_series_link_aidx ON books_series_link (series);
+CREATE INDEX books_series_link_bidx ON books_series_link (book);
+CREATE INDEX books_tags_link_aidx ON books_tags_link (tag);
+CREATE INDEX books_tags_link_bidx ON books_tags_link (book);
+CREATE INDEX comments_idx ON comments (book);
+CREATE INDEX conversion_options_idx_a ON conversion_options (format COLLATE NOCASE);
+CREATE INDEX conversion_options_idx_b ON conversion_options (book);
+CREATE INDEX custom_columns_idx ON custom_columns (label);
+CREATE INDEX data_idx ON data (book);
+CREATE INDEX lrp_idx ON last_read_positions (book);
+CREATE INDEX annot_idx ON annotations (book);
+CREATE INDEX formats_idx ON data (format);
+CREATE INDEX languages_idx ON languages (lang_code COLLATE NOCASE);
+CREATE INDEX publishers_idx ON publishers (name COLLATE NOCASE);
+CREATE INDEX series_idx ON series (name COLLATE NOCASE);
+CREATE INDEX tags_idx ON tags (name COLLATE NOCASE);
+CREATE TRIGGER books_delete_trg
+ AFTER DELETE ON books
+ BEGIN
+ DELETE FROM books_authors_link WHERE book=OLD.id;
+ DELETE FROM books_publishers_link WHERE book=OLD.id;
+ DELETE FROM books_ratings_link WHERE book=OLD.id;
+ DELETE FROM books_series_link WHERE book=OLD.id;
+ DELETE FROM books_tags_link WHERE book=OLD.id;
+ DELETE FROM books_languages_link WHERE book=OLD.id;
+ DELETE FROM data WHERE book=OLD.id;
+ DELETE FROM last_read_positions WHERE book=OLD.id;
+ DELETE FROM annotations WHERE book=OLD.id;
+ DELETE FROM comments WHERE book=OLD.id;
+ DELETE FROM conversion_options WHERE book=OLD.id;
+ DELETE FROM books_plugin_data WHERE book=OLD.id;
+ DELETE FROM identifiers WHERE book=OLD.id;
+ END;
+CREATE TRIGGER books_insert_trg AFTER INSERT ON books
+ BEGIN
+ UPDATE books SET sort=title_sort(NEW.title),uuid=uuid4() WHERE id=NEW.id;
+ END;
+CREATE TRIGGER books_update_trg
+ AFTER UPDATE ON books
+ BEGIN
+ UPDATE books SET sort=title_sort(NEW.title)
+ WHERE id=NEW.id AND OLD.title <> NEW.title;
+ END;
+CREATE TRIGGER fkc_comments_insert
+ BEFORE INSERT ON comments
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_comments_update
+ BEFORE UPDATE OF book ON comments
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_data_insert
+ BEFORE INSERT ON data
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_data_update
+ BEFORE UPDATE OF book ON data
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_lrp_insert
+ BEFORE INSERT ON last_read_positions
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_lrp_update
+ BEFORE UPDATE OF book ON last_read_positions
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_annot_insert
+ BEFORE INSERT ON annotations
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_annot_update
+ BEFORE UPDATE OF book ON annotations
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_delete_on_authors
+ BEFORE DELETE ON authors
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT COUNT(id) FROM books_authors_link WHERE author=OLD.id) > 0
+ THEN RAISE(ABORT, 'Foreign key violation: authors is still referenced')
+ END;
+ END;
+CREATE TRIGGER fkc_delete_on_languages
+ BEFORE DELETE ON languages
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT COUNT(id) FROM books_languages_link WHERE lang_code=OLD.id) > 0
+ THEN RAISE(ABORT, 'Foreign key violation: language is still referenced')
+ END;
+ END;
+CREATE TRIGGER fkc_delete_on_languages_link
+ BEFORE INSERT ON books_languages_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ WHEN (SELECT id from languages WHERE id=NEW.lang_code) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: lang_code not in languages')
+ END;
+ END;
+CREATE TRIGGER fkc_delete_on_publishers
+ BEFORE DELETE ON publishers
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT COUNT(id) FROM books_publishers_link WHERE publisher=OLD.id) > 0
+ THEN RAISE(ABORT, 'Foreign key violation: publishers is still referenced')
+ END;
+ END;
+CREATE TRIGGER fkc_delete_on_series
+ BEFORE DELETE ON series
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT COUNT(id) FROM books_series_link WHERE series=OLD.id) > 0
+ THEN RAISE(ABORT, 'Foreign key violation: series is still referenced')
+ END;
+ END;
+CREATE TRIGGER fkc_delete_on_tags
+ BEFORE DELETE ON tags
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT COUNT(id) FROM books_tags_link WHERE tag=OLD.id) > 0
+ THEN RAISE(ABORT, 'Foreign key violation: tags is still referenced')
+ END;
+ END;
+CREATE TRIGGER fkc_insert_books_authors_link
+ BEFORE INSERT ON books_authors_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ WHEN (SELECT id from authors WHERE id=NEW.author) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: author not in authors')
+ END;
+ END;
+CREATE TRIGGER fkc_insert_books_publishers_link
+ BEFORE INSERT ON books_publishers_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ WHEN (SELECT id from publishers WHERE id=NEW.publisher) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: publisher not in publishers')
+ END;
+ END;
+CREATE TRIGGER fkc_insert_books_ratings_link
+ BEFORE INSERT ON books_ratings_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ WHEN (SELECT id from ratings WHERE id=NEW.rating) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: rating not in ratings')
+ END;
+ END;
+CREATE TRIGGER fkc_insert_books_series_link
+ BEFORE INSERT ON books_series_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ WHEN (SELECT id from series WHERE id=NEW.series) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: series not in series')
+ END;
+ END;
+CREATE TRIGGER fkc_insert_books_tags_link
+ BEFORE INSERT ON books_tags_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ WHEN (SELECT id from tags WHERE id=NEW.tag) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: tag not in tags')
+ END;
+ END;
+CREATE TRIGGER fkc_update_books_authors_link_a
+ BEFORE UPDATE OF book ON books_authors_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_update_books_authors_link_b
+ BEFORE UPDATE OF author ON books_authors_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from authors WHERE id=NEW.author) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: author not in authors')
+ END;
+ END;
+CREATE TRIGGER fkc_update_books_languages_link_a
+ BEFORE UPDATE OF book ON books_languages_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_update_books_languages_link_b
+ BEFORE UPDATE OF lang_code ON books_languages_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from languages WHERE id=NEW.lang_code) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: lang_code not in languages')
+ END;
+ END;
+CREATE TRIGGER fkc_update_books_publishers_link_a
+ BEFORE UPDATE OF book ON books_publishers_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_update_books_publishers_link_b
+ BEFORE UPDATE OF publisher ON books_publishers_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from publishers WHERE id=NEW.publisher) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: publisher not in publishers')
+ END;
+ END;
+CREATE TRIGGER fkc_update_books_ratings_link_a
+ BEFORE UPDATE OF book ON books_ratings_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_update_books_ratings_link_b
+ BEFORE UPDATE OF rating ON books_ratings_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from ratings WHERE id=NEW.rating) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: rating not in ratings')
+ END;
+ END;
+CREATE TRIGGER fkc_update_books_series_link_a
+ BEFORE UPDATE OF book ON books_series_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_update_books_series_link_b
+ BEFORE UPDATE OF series ON books_series_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from series WHERE id=NEW.series) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: series not in series')
+ END;
+ END;
+CREATE TRIGGER fkc_update_books_tags_link_a
+ BEFORE UPDATE OF book ON books_tags_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from books WHERE id=NEW.book) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: book not in books')
+ END;
+ END;
+CREATE TRIGGER fkc_update_books_tags_link_b
+ BEFORE UPDATE OF tag ON books_tags_link
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT id from tags WHERE id=NEW.tag) IS NULL
+ THEN RAISE(ABORT, 'Foreign key violation: tag not in tags')
+ END;
+ END;
+CREATE TRIGGER series_insert_trg
+ AFTER INSERT ON series
+ BEGIN
+ UPDATE series SET sort=title_sort(NEW.name) WHERE id=NEW.id;
+ END;
+CREATE TRIGGER series_update_trg
+ AFTER UPDATE ON series
+ BEGIN
+ UPDATE series SET sort=title_sort(NEW.name) WHERE id=NEW.id;
+ END;
+pragma user_version=26;
diff --git a/tests/files/test-data.sql b/tests/files/test-data.sql
new file mode 100644
index 0000000..14f2677
--- /dev/null
+++ b/tests/files/test-data.sql
@@ -0,0 +1,59 @@
+BEGIN TRANSACTION;
+
+INSERT INTO books (id, title, pubdate, path, has_cover, series_index, timestamp, last_modified) VALUES
+ (11, 'The Theory and Practice of Oligarchical Collectivism', '1984-11-07', 'oligarchical_collectivism', 0, 1.0, '', '1949-06-08'),
+ (12, 'Cicero for Dummies', '2012-12-12', 'dummies_cicero', 1, 100500.0, '2022-02-24 04:00', '2023-09-30 17:18'),
+ (13, 'Whores of Eroticon 6', '1978-03-08', 'whores_eroticon6', 1, 1.0, '', '2001-05-11 00:00'),
+ (14, 'Plato for Dummies', '2011-11-11', 'dummies_plato', 1, 100499.0, '', '2023-09-30 17:18');
+INSERT INTO comments (id, book, text) VALUES
+ (21, 12, 'Simple explanation of Cicero for imbeciles.');
+INSERT INTO identifiers (id, book, type, val) VALUES
+ (31, 12, 'isbn', '978-0140440997');
+INSERT INTO data (id, book, format, uncompressed_size, name) VALUES
+ (41, 12, 'EPUB', 123456, 'cicero_for_dummies');
+
+INSERT INTO authors (id, name, sort, link) VALUES
+ (51, 'Aaron Zeroth', 'Zeroth, Aaron', ''),
+ (52, 'Beth Wildgoose', 'Wildgoose, Beth', 'http://example.com/'),
+ (53, 'Emmanuel Goldstein', 'Goldstein, Emmanuel', ''),
+ (54, 'Conrad Trachtenberg','Trachtenberg, Conrad', '');
+INSERT INTO books_authors_link (id, book, author) VALUES
+ (61, 11, 53),
+ (62, 12, 52),
+ (63, 12, 54),
+ (64, 13, 51),
+ (65, 14, 52);
+
+INSERT INTO languages (id, lang_code) VALUES
+ (71, 'en'),
+ (72, 'ru'),
+ (73, 'uk'),
+ (74, 'enm'),
+ (75, 'la');
+INSERT INTO books_languages_link (id, book, lang_code) VALUES
+ (81, 11, 71),
+ (82, 12, 71),
+ (83, 12, 75);
+
+INSERT INTO publishers (id, name) VALUES
+ (91, 'Megadodo Publications'),
+ (92, 'Big Brother Books');
+INSERT INTO books_publishers_link (id, book, publisher) VALUES
+ (101, 11, 92),
+ (102, 13, 91);
+
+INSERT INTO series (id, name) VALUES
+ (111, 'Philosophy For Dummies');
+INSERT INTO books_series_link (id, book, series) VALUES
+ (122, 12, 111),
+ (123, 14, 111);
+
+INSERT INTO tags (id, name, link) VALUES
+ (131, 'Political theory', 'http://example.com/politics'),
+ (132, 'Translations', '');
+INSERT INTO books_tags_link (id, book, tag) VALUES
+ (141, 11, 131),
+ (142, 12, 131),
+ (143, 12, 132);
+
+COMMIT;
diff --git a/tests/stubs/CalibreStub.php b/tests/stubs/CalibreStub.php
new file mode 100644
index 0000000..c2bae7f
--- /dev/null
+++ b/tests/stubs/CalibreStub.php
@@ -0,0 +1,31 @@
+isSubclassOf(CalibreItem::class)) {
+ throw new Exception('class '.$cls.' is not a subclass of CalibreItem');
+ }
+ $instConstructor = $instCls->getConstructor();
+ $instConstructor->setAccessible(true);
+ /** @var CalibreItem */
+ $inst = $instCls->newInstanceWithoutConstructor();
+ $instConstructor->invoke($inst, $db, $data);
+ if (!is_null($subData)) {
+ $prop = $instCls->getParentClass()->getProperty('data');
+ $prop->setAccessible(true);
+ $value = $prop->getValue($inst);
+ $prop->setValue($inst, array_merge($value, $subData));
+ }
+ return $inst;
+ }
+}
diff --git a/tests/stubs/L10NStub.php b/tests/stubs/L10NStub.php
new file mode 100644
index 0000000..0841eeb
--- /dev/null
+++ b/tests/stubs/L10NStub.php
@@ -0,0 +1,28 @@
+createStub(IL10N::class);
+ $locale = locale_get_default();
+ $l->method('getLocaleCode')->willReturn($locale);
+ $l->method('getLanguageCode')->willReturn(locale_get_display_language($locale));
+ $l->method('t')->willReturnCallback(function (string $text, $parameters = []): string {
+ return sprintf($text, ...$parameters);
+ });
+ $l->method('n')->willReturnCallback(function (string $text_singular, string $text_plural, int $count, array $parameters = []): string {
+ return $this->t($text_plural, $parameters);
+ });
+ $l->method('l')->willReturnCallback(function (string $type, $data, array $options = []) {
+ return $data;
+ });
+ $this->l = $l;
+ }
+}
diff --git a/tests/stubs/LoggerInterfaceStub.php b/tests/stubs/LoggerInterfaceStub.php
new file mode 100644
index 0000000..8869394
--- /dev/null
+++ b/tests/stubs/LoggerInterfaceStub.php
@@ -0,0 +1,46 @@
+createStub(LoggerInterface::class);
+ $logger->method('log')->willReturnCallback($this->emulateLog(...));
+ $logger->method('emergency')->willReturnCallback(
+ fn ($message, array $context) => $this->emulateLog(LogLevel::EMERGENCY, $message, $context)
+ );
+ $logger->method('alert')->willReturnCallback(
+ fn ($message, array $context) => $this->emulateLog(LogLevel::ALERT, $message, $context)
+ );
+ $logger->method('critical')->willReturnCallback(
+ fn ($message, array $context) => $this->emulateLog(LogLevel::CRITICAL, $message, $context)
+ );
+ $logger->method('error')->willReturnCallback(
+ fn ($message, array $context) => $this->log(LogLevel::ERROR, $message, $context)
+ );
+ $logger->method('warning')->willReturnCallback(
+ fn ($message, array $context) => $this->log(LogLevel::WARNING, $message, $context)
+ );
+ $logger->method('notice')->willReturnCallback(
+ fn ($message, array $context) => $this->log(LogLevel::NOTICE, $message, $context)
+ );
+ $logger->method('info')->willReturnCallback(
+ fn ($message, array $context) => $this->log(LogLevel::INFO, $message, $context)
+ );
+ $logger->method('debug')->willReturnCallback(
+ fn ($message, array $context) => $this->log(LogLevel::DEBUG, $message, $context)
+ );
+ $this->logger = $logger;
+ }
+}
diff --git a/tests/stubs/SettingsServiceStub.php b/tests/stubs/SettingsServiceStub.php
new file mode 100644
index 0000000..00b9bae
--- /dev/null
+++ b/tests/stubs/SettingsServiceStub.php
@@ -0,0 +1,34 @@
+createStub(ISettingsService::class);
+ $settings->method('getAppId')->willReturn(self::SETTINGS_APP_ID);
+ $settings->method('getAppVersion')->willReturn(self::SETTINGS_APP_VERSION);
+ $settings->method('getAppName')->willReturn(self::SETTINGS_APP_NAME);
+ $settings->method('getAppWebsite')->willReturn(self::SETTINGS_APP_WEBSITE);
+ $settings->method('getAppRouteLink')->willReturnCallback(function (string $route, array $parameters) {
+ return 'app-route:'.$route.'?'.implode('&', array_map(fn ($k, $v) => $k.'='.$v, array_keys($parameters), array_values($parameters)));
+ });
+ $settings->method('getAppImageLink')->willReturnCallback(function (string $path) {
+ return 'app-img:'.$path;
+ });
+ $settings->method('getLanguageName')->willReturnCallback(function (string $code) {
+ return '@'.$code;
+ });
+ $this->settings = $settings;
+ }
+}
diff --git a/tests/stubs/StorageStub.php b/tests/stubs/StorageStub.php
new file mode 100644
index 0000000..8691276
--- /dev/null
+++ b/tests/stubs/StorageStub.php
@@ -0,0 +1,74 @@
+createStub(IStorage::class);
+ $storage->method('getLocalFile')->willReturnCallback(
+ fn (string $filePath): string => $fake ? $path : ($path.'/'.$filePath)
+ );
+ $this->storage = $storage;
+ }
+
+ private function createStorageNode(string $cls, string $type, string $name, bool $readable): Node {
+ $node = $this->createStub($cls);
+ $node->parent = null;
+ $node->name = $name;
+ $node->content = null;
+ $node->method('isReadable')->willReturn($readable);
+ $node->method('getType')->willReturn($type);
+ $node->method('getInternalPath')->willReturnCallback(function () use ($node) {
+ return (is_null($node->parent) ? '' : $node->parent->getInternalPath()).'/'.$node->name;
+ });
+ $node->method('getStorage')->willReturn($this->storage);
+ return $node;
+ }
+
+ protected function createFileNode(string $filename, bool $readable = true): File {
+ $file = $this->createStorageNode(File::class, FileInfo::TYPE_FILE, $filename, $readable);
+ return $file;
+ }
+
+ protected function createFolderNode(string $dirname, array $content, bool $readable = true): Folder {
+ $dir = $this->createStorageNode(Folder::class, FileInfo::TYPE_FOLDER, $dirname, $readable);
+ $dir->content = $content;
+ foreach ($dir->content as $sub) {
+ $sub->parent = $dir;
+ }
+ $dir->method('get')->willReturnCallback(function (string $path) use ($dir): Node {
+ if ($path === '') {
+ throw new NotFoundException('cannot find file with empty name');
+ }
+ $parts = explode('/', $path, 2);
+ foreach ($dir->content as $sub) {
+ if ($sub->name === $parts[0]) {
+ if (isset($parts[1])) {
+ if ($sub->getType() !== FileInfo::TYPE_FOLDER) {
+ throw new NotFoundException('found file, expecting directory');
+ }
+ try {
+ return $sub->get($parts[1]);
+ } catch (NotFoundException $e) {
+ throw new NotFoundException('not found: '.$path, 0, $e);
+ }
+ }
+ return $sub;
+ }
+ }
+ throw new NotFoundException('not found: '.$path);
+ });
+ return $dir;
+ }
+}
diff --git a/tests/stubs/URLGeneratorStub.php b/tests/stubs/URLGeneratorStub.php
new file mode 100644
index 0000000..a04fbe7
--- /dev/null
+++ b/tests/stubs/URLGeneratorStub.php
@@ -0,0 +1,22 @@
+createStub(IURLGenerator::class);
+ $generator->method('linkToRoute')->willReturnCallback(function (string $routeName, array $arguments = []): string {
+ return 'route:'.$routeName.':'.implode(':', array_map(fn ($k, $v) => $k.'='.$v, array_keys($arguments), array_values($arguments)));
+ });
+ $generator->method('imagePath')->willReturnCallback(function (string $appName, string $file): string {
+ return 'image-path:'.$appName.':'.$file;
+ });
+ $this->urlGenerator = $generator;
+ }
+}
diff --git a/tests/unit/CalibreTest.php b/tests/unit/CalibreTest.php
new file mode 100644
index 0000000..ab52d25
--- /dev/null
+++ b/tests/unit/CalibreTest.php
@@ -0,0 +1,353 @@
+initStorage(':memory:', true); // Trick to force an in-memory database
+ $this->root = $this->createFolderNode('.', [
+ $this->createFileNode('metadata.db'),
+ $this->createFolderNode('dummies_cicero', [
+ $this->createFileNode('cover.jpg'),
+ $this->createFileNode('cicero_for_dummies.epub')
+ ])
+ ]);
+
+ /** @var CalibreDB */
+ $this->db = CalibreDB::fromFolder($this->root, false);
+ $pdo = $this->db->getDatabase();
+
+ $ddl = file_get_contents(__DIR__.'/../files/metadata.sql');
+ $pdo->exec($ddl);
+ $ddl = file_get_contents(__DIR__.'/../files/test-data.sql');
+ $pdo->exec($ddl);
+ }
+
+ private function checkDataItem(?array $expected, ?CalibreItem $actual, string $message): void {
+ if (is_null($expected)) {
+ $this->assertNull($actual, $message.' -- null check');
+ return;
+ }
+ $this->assertNotNull($actual, $message.' -- null check');
+ foreach ($expected as $key => $expectedValue) {
+ $msg = $message.' -- key '.$key;
+ $actualValue = $actual->$key;
+ if (is_array($expectedValue)) {
+ $this->checkData($expectedValue, $actualValue, $msg);
+ } else {
+ if (is_string($expectedValue) && str_starts_with($expectedValue, '!!time!!')) {
+ $value = substr($expectedValue, 8); // strlen('!!time!!') === 8
+ $expectedValue = new DateTimeImmutable($value);
+ }
+ $this->assertEquals($expectedValue, $actualValue, $msg);
+ }
+ }
+ }
+
+ private function checkData(array $expected, Traversable $actual, string $message): void {
+ reset($expected);
+ foreach ($actual as $actualItem) {
+ $this->assertInstanceOf(CalibreItem::class, $actualItem, $message.' -- wrong type');
+ $key = key($expected);
+ $expectedItem = current($expected);
+ $this->assertFalse($expectedItem === false, $message.' -- result too long');
+ $this->checkDataItem($expectedItem, $actualItem, $message.' -- key '.$key);
+ next($expected);
+ }
+ $this->assertTrue(current($expected) === false, $message.' -- result too short');
+ }
+
+ public function testAuthorsAll(): void {
+ $authors = CalibreAuthor::getByPrefix($this->db);
+ $this->checkData([
+ [ 'id' => 53, 'name' => 'Emmanuel Goldstein', 'uri' => '', 'count' => 1 ],
+ [ 'id' => 54, 'name' => 'Conrad Trachtenberg', 'uri' => '', 'count' => 1 ],
+ [ 'id' => 52, 'name' => 'Beth Wildgoose', 'uri' => 'http://example.com/', 'count' => 2 ],
+ [ 'id' => 51, 'name' => 'Aaron Zeroth', 'uri' => '', 'count' => 1 ],
+ ], $authors, 'Authors (all)');
+ }
+
+ public function testAuthorsByPrefix(): void {
+ $authors = CalibreAuthor::getByPrefix($this->db, 'W');
+ $this->checkData([
+ [ 'id' => 52 ],
+ ], $authors, 'Authors by prefix');
+ }
+
+ public function testAuthorsByBook(): void {
+ $authors = CalibreAuthor::getByBook($this->db, 12);
+ $this->checkData([
+ [ 'id' => 54 ],
+ [ 'id' => 52 ],
+ ], $authors, 'Authors by book');
+ }
+
+ public function testAuthorById(): void {
+ $author = CalibreAuthor::getById($this->db, 53);
+ $this->checkDataItem([ 'id' => 53 ], $author, 'Author by id');
+ }
+
+ public function testAuthorPrefixes(): void {
+ $prefixes = CalibreAuthorPrefix::getAll($this->db);
+ $this->checkData([
+ [ 'prefix' => 'G', 'id' => 'G', 'name' => 'G', 'count' => 1 ],
+ [ 'prefix' => 'T', 'id' => 'T', 'name' => 'T', 'count' => 1 ],
+ [ 'prefix' => 'W', 'id' => 'W', 'name' => 'W', 'count' => 1 ],
+ [ 'prefix' => 'Z', 'id' => 'Z', 'name' => 'Z', 'count' => 1 ],
+ ], $prefixes, 'Author prefixes');
+ }
+
+ public function testLanguagesAll(): void {
+ $languages = CalibreLanguage::getAll($this->db);
+ $this->checkData([
+ [ 'id' => 71, 'code' => 'en', 'count' => 2 ],
+ [ 'id' => 74, 'code' => 'enm', 'count' => 0 ],
+ [ 'id' => 75, 'code' => 'la', 'count' => 1 ],
+ [ 'id' => 72, 'code' => 'ru', 'count' => 0 ],
+ [ 'id' => 73, 'code' => 'uk', 'count' => 0 ],
+ ], $languages, 'Languages (all)');
+ }
+
+ public function testLanguagesByBook(): void {
+ $languages = CalibreLanguage::getByBook($this->db, 12);
+ $this->checkData([
+ [ 'code' => 'en' ],
+ [ 'code' => 'la' ],
+ ], $languages, 'Languages by book');
+ }
+
+ public function testLanguageById(): void {
+ $language = CalibreLanguage::getById($this->db, 75);
+ $this->checkDataItem([ 'code' => 'la' ], $language, 'Language by id');
+ }
+
+ public function testPublishersAll(): void {
+ $publishers = CalibrePublisher::getAll($this->db);
+ $this->checkData([
+ [ 'id' => 92, 'name' => 'Big Brother Books', 'count' => 1 ],
+ [ 'id' => 91, 'name' => 'Megadodo Publications', 'count' => 1 ],
+ ], $publishers, 'Publishers (all)');
+ }
+
+ public function testPublishersByBook(): void {
+ $publishers = CalibrePublisher::getByBook($this->db, 13);
+ $this->checkData([
+ [ 'name' => 'Megadodo Publications' ]
+ ], $publishers, 'Publishers by book');
+ }
+
+ public function testPublisherById(): void {
+ $publisher = CalibrePublisher::getById($this->db, 92);
+ $this->checkDataItem([ 'name' => 'Big Brother Books' ], $publisher, 'Publisher by id');
+ }
+
+ public function testSeriesAll(): void {
+ $series = CalibreSeries::getAll($this->db);
+ $this->checkData([
+ [ 'id' => 111, 'name' => 'Philosophy For Dummies', 'count' => 2 ],
+ ], $series, 'Series (all)');
+ }
+
+ public function testSeriesByBook(): void {
+ $series = CalibreSeries::getByBook($this->db, 12);
+ $this->checkData([
+ [ 'name' => 'Philosophy For Dummies' ],
+ ], $series, 'Series by book');
+ }
+
+ public function testSeriesById(): void {
+ $series = CalibreSeries::getById($this->db, 111);
+ $this->checkDataItem([ 'name' => 'Philosophy For Dummies' ], $series, 'Series by id');
+ }
+
+ public function testTagsAll(): void {
+ $tags = CalibreTag::getAll($this->db);
+ $this->checkData([
+ [ 'id' => 131, 'name' => 'Political theory', 'count' => 2 ],
+ [ 'id' => 132, 'name' => 'Translations', 'count' => 1 ],
+ ], $tags, 'Tags (all)');
+ }
+
+ public function testTagsByBook(): void {
+ $tags = CalibreTag::getByBook($this->db, 12);
+ $this->checkData([
+ [ 'id' => 131 ],
+ [ 'id' => 132 ],
+ ], $tags, 'Tags by book');
+ }
+
+ public function testTagById(): void {
+ $tag = CalibreTag::getById($this->db, 131);
+ $this->checkDataItem([ 'name' => 'Political theory' ], $tag, 'Tag by id');
+ }
+
+ public function testBookById(): void {
+ $book = CalibreBook::getById($this->db, 12);
+ $this->checkDataItem([
+ 'id' => 12,
+ 'title' => 'Cicero for Dummies',
+ 'pubdate' => '!!time!!2012-12-12',
+ 'timestamp' => '!!time!!2022-02-24 04:00',
+ 'last_modified' => '!!time!!2023-09-30 17:18',
+ 'path' => 'dummies_cicero',
+ 'has_cover' => true,
+ 'comment' => 'Simple explanation of Cicero for imbeciles.',
+ 'authors' => [
+ [ 'name' => 'Conrad Trachtenberg' ],
+ [ 'name' => 'Beth Wildgoose' ],
+ ],
+ 'publishers' => [],
+ 'languages' => [
+ [ 'code' => 'en' ],
+ [ 'code' => 'la' ],
+ ],
+ 'series' => [
+ [ 'name' => 'Philosophy For Dummies' ],
+ ],
+ 'series_index' => 100500.0,
+ 'tags' => [
+ [ 'name' => 'Political theory' ],
+ [ 'name' => 'Translations' ],
+ ],
+ 'formats' => [
+ [ 'format' => 'EPUB', 'name' => 'cicero_for_dummies', 'path' => 'dummies_cicero' ],
+ ],
+ 'identifiers' => [
+ [ 'type' => 'isbn', 'value' => '978-0140440997' ],
+ ],
+ ], $book, 'Book by id');
+ $coverFile = $book->getCoverFile($this->root);
+ $this->assertInstanceOf(File::class, $coverFile, 'Book cover file -- class');
+ $this->assertEquals('/./dummies_cicero/cover.jpg', $coverFile->getInternalPath(), 'Book cover file -- path');
+ }
+
+ public function testBookData(): void {
+ $format = CalibreBookFormat::getByBookAndType($this->db, 12, 'epub');
+ $this->checkDataItem([
+ 'format' => 'EPUB', 'name' => 'cicero_for_dummies', 'path' => 'dummies_cicero'
+ ], $format, 'Book data by book and format');
+ $dataFile = $format->getDataFile($this->root);
+ $this->assertInstanceOf(File::class, $dataFile, 'Book data file -- class');
+ $this->assertEquals('/./dummies_cicero/cicero_for_dummies.epub', $dataFile->getInternalPath(), 'Book data file -- path');
+ }
+
+ public function testBooksAll(): void {
+ $books = CalibreBook::getByCriterion($this->db);
+ $this->checkData([
+ [
+ 'id' => 12, 'title' => 'Cicero for Dummies',
+ 'pubdate' => '!!time!!2012-12-12', 'timestamp' => '!!time!!2022-02-24 04:00', 'last_modified' => '!!time!!2023-09-30 17:18',
+ 'path' => 'dummies_cicero', 'has_cover' => 1,
+ 'series_index' => 100500.0,
+ ],
+ [
+ 'id' => 14, 'title' => 'Plato for Dummies',
+ 'pubdate' => '!!time!!2011-11-11', 'timestamp' => null, 'last_modified' => '!!time!!2023-09-30 17:18',
+ 'path' => 'dummies_plato', 'has_cover' => 1,
+ 'series_index' => 100499.0,
+ ],
+ [
+ 'id' => 11, 'title' => 'The Theory and Practice of Oligarchical Collectivism',
+ 'pubdate' => '!!time!!1984-11-07', 'timestamp' => null, 'last_modified' => '!!time!!1949-06-08',
+ 'path' => 'oligarchical_collectivism', 'has_cover' => 0,
+ 'series_index' => 1.0,
+ ],
+ [
+ 'id' => 13, 'title' => 'Whores of Eroticon 6',
+ 'pubdate' => '!!time!!1978-03-08', 'timestamp' => null, 'last_modified' => '!!time!!2001-05-11 00:00',
+ 'path' => 'whores_eroticon6', 'has_cover' => 1,
+ ],
+ ], $books, 'Books (all)');
+ }
+
+ public static function selectDataProvider(): array {
+ return [
+ [ CalibreBookCriteria::AUTHOR, '51', [
+ [ 'id' => 13, 'title' => 'Whores of Eroticon 6' ],
+ ], 'author' ],
+ [ CalibreBookCriteria::PUBLISHER, '92', [
+ [ 'id' => 11, 'title' => 'The Theory and Practice of Oligarchical Collectivism' ],
+ ], 'publisher' ],
+ [ CalibreBookCriteria::LANGUAGE, '75', [
+ [ 'id' => 12, 'title' => 'Cicero for Dummies' ],
+ ], 'language' ],
+ [ CalibreBookCriteria::SERIES, '111', [
+ [ 'id' => 14, 'title' => 'Plato for Dummies' ],
+ [ 'id' => 12, 'title' => 'Cicero for Dummies' ],
+ ], 'series' ],
+ [ CalibreBookCriteria::TAG, '131', [
+ [ 'id' => 12, 'title' => 'Cicero for Dummies' ],
+ [ 'id' => 11, 'title' => 'The Theory and Practice of Oligarchical Collectivism' ],
+ ], 'tags' ],
+ ];
+ }
+
+ #[DataProvider('selectDataProvider')]
+ public function testBooksSelect(CalibreBookCriteria $criterion, string $id, array $expected, string $type): void {
+ $books = CalibreBook::getByCriterion($this->db, $criterion, $id);
+ $this->checkData($expected, $books, 'Books by '.$type);
+ }
+
+ public static function searchDataProvider(): array {
+ return [
+ [ CalibreBookCriteria::SEARCH, 'whore', [
+ [ 'id' => 13 ],
+ ], 'title' ],
+ [ CalibreBookCriteria::SEARCH, 'imbec', [
+ [ 'id' => 12 ],
+ ], 'comment' ],
+ [ CalibreBookCriteria::SEARCH, 'zero', [
+ [ 'id' => 13 ],
+ ], 'author' ],
+ [ CalibreBookCriteria::SEARCH, 'philosophy', [
+ [ 'id' => 12 ],
+ [ 'id' => 14 ],
+ ], 'series' ],
+ [ CalibreBookCriteria::SEARCH, 'polit', [
+ [ 'id' => 12 ],
+ [ 'id' => 11 ],
+ ], 'tags' ],
+ ];
+ }
+
+ #[DataProvider('searchDataProvider')]
+ public function testBooksSearch(CalibreBookCriteria $criterion, string $term, array $expected, string $type): void {
+ $books = CalibreBook::getByCriterion($this->db, $criterion, $term);
+ $this->checkData($expected, $books, 'Books search ('.$type.')');
+ }
+
+ public function testUnknownProperty(): void {
+ $author = CalibreAuthor::getById($this->db, 53);
+ // NOTE: PHPUnit 10 no longer supports expecting errors.
+ set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool {
+ restore_error_handler();
+ throw new ErrorException($errstr, $errno, E_USER_ERROR, $errfile, $errline);
+ }, E_USER_ERROR);
+ $this->expectException(ErrorException::class);
+ $this->expectExceptionMessageMatches('/Getting unknown property nonexistent_field from object of class .*/');
+ $test = $author->nonexistent_field;
+ }
+}
diff --git a/tests/unit/OpdsFeedTest.php b/tests/unit/OpdsFeedTest.php
new file mode 100644
index 0000000..10abf86
--- /dev/null
+++ b/tests/unit/OpdsFeedTest.php
@@ -0,0 +1,301 @@
+ 'selfValue' ];
+ private const UP_ROUTE = 'up-route';
+ private const UP_PARAMS = [];
+ private const FEED_TITLE = 'feed-title';
+ private const EXPECTED_LINKS = [
+ [ 'start', 'app-route:index?', OpdsResponse::MIME_TYPE_ATOM ],
+ [ 'search', 'app-route:search_xml?', OpenSearchResponse::MIME_TYPE_OPENSEARCH ],
+ [ 'self', 'app-route:self-route?selfParam=selfValue', OpdsResponse::MIME_TYPE_ATOM ],
+ [ 'up', 'app-route:up-route?', OpdsResponse::MIME_TYPE_ATOM ],
+ ];
+ private const EXPECTED_ENTRIES = [
+ [
+ 'sect-id1', 'sect-title1', null, null,
+ null,
+ null,
+ [[ 'subsection', 'app-route:sect-route1?' ]]
+ ],
+ [
+ 'sect-id2', 'sect-title2', 'sect-summary2', null,
+ null,
+ null,
+ [[ 'subsection', 'app-route:sect-route2?' ]]
+ ],
+ [
+ 'author-prefix:author-prefix-value', 'author-prefix-value', 'Authors: 123', null,
+ null,
+ null,
+ [[ 'subsection', 'app-route:authors?prefix=author-prefix-value' ]]
+ ],
+ [
+ 'author:author-id', 'author-name', 'Books: 123', null,
+ null,
+ null,
+ [[ 'subsection', 'app-route:books?criterion=author&id=author-id' ]]
+ ],
+ [
+ 'publisher:publisher-id', 'publisher-name', 'Books: 123', null,
+ null,
+ null,
+ [[ 'subsection', 'app-route:books?criterion=publisher&id=publisher-id' ]]
+ ],
+ [
+ 'series:series-id', 'series-name', 'Books: 123', null,
+ null,
+ null,
+ [[ 'subsection', 'app-route:books?criterion=series&id=series-id' ]]
+ ],
+ [
+ 'tag:tag-id', 'tag-name', 'Books: 123', null,
+ null,
+ null,
+ [[ 'subsection', 'app-route:books?criterion=tag&id=tag-id' ]]
+ ],
+ [
+ 'lang:lang-id', '@lang-code', 'Books: 123', null,
+ null,
+ null,
+ [[ 'subsection', 'app-route:books?criterion=language&id=lang-id' ]]
+ ],
+ [
+ 'book:book-id', 'book-title', 'book-comment', null,
+ [
+ [ 'author-name1', 'author-uri1' ],
+ [ 'author-name2', 'author-uri2' ],
+ ],
+ [
+ [ 'tag-name1' ],
+ [ 'tag-name2' ],
+ ],
+ [
+ [ 'http://opds-spec.org/image', 'app-route:book_cover?id=book-id', 'image/jpeg' ],
+ [ 'http://opds-spec.org/acquisition', 'app-route:book_data?id=book-id&type=format-type1', 'application/octet-stream' ],
+ [ 'http://opds-spec.org/acquisition', 'app-route:book_data?id=book-id&type=format-type2', 'application/octet-stream' ],
+ ],
+ [
+ [ 'dc', 'issued', '1980-02-01T00:00:00+00:00' ],
+ [ null, 'published', '2023-10-01T01:02:00+00:00' ],
+ [ 'dc', 'identifier', 'urn:uuid:book-uuid' ],
+ [ 'dc', 'identifier', 'id-uri1' ],
+ [ 'dc', 'identifier', 'id-urn2' ],
+ [ 'dc', 'identifier', 'id-epubbud3' ],
+ [ 'dc', 'identifier', 'urn:id-type4:id-value4' ],
+ [ 'dc', 'publisher', 'publisher-name1' ],
+ [ 'dc', 'publisher', 'publisher-name2' ],
+ [ 'dc', 'language', 'lang-code1' ],
+ [ 'dc', 'language', 'lang-code2' ],
+ [ 'dc', 'isPartOf', 'app-route:books?criterion=series&id=series-id1' ],
+ [ 'dc', 'isPartOf', 'app-route:books?criterion=series&id=series-id2' ],
+ ],
+ ],
+ ];
+
+ use L10NStub;
+ use SettingsServiceStub;
+ use CalibreStub;
+
+ private ICalibreDB $db;
+
+ public function setUp(): void {
+ $this->initL10N();
+ $this->initSettingsService();
+ $this->db = $this->createStub(ICalibreDB::class);
+ }
+
+ public function testOpdsFeed(): void {
+ $builder = new OpdsFeedBuilder($this->settings, $this->l,
+ self::SELF_ROUTE, self::SELF_PARAMS, self::FEED_TITLE,
+ self::UP_ROUTE, self::UP_PARAMS
+ );
+ $builder->addSubsectionItem('sect-id1', 'sect-route1', 'sect-title1', null);
+ $builder->addSubsectionItem('sect-id2', 'sect-route2', 'sect-title2', 'sect-summary2');
+ $builder->addNavigationEntry($this->createCalibreItem(CalibreAuthorPrefix::class, $this->db, [
+ 'prefix' => 'author-prefix-value', 'count' => 123
+ ]));
+ $builder->addNavigationEntry($this->createCalibreItem(CalibreAuthor::class, $this->db, [
+ 'id' => 'author-id', 'name' => 'author-name', 'uri' => 'author-uri', 'count' => 123
+ ]));
+ $builder->addNavigationEntry($this->createCalibreItem(CalibrePublisher::class, $this->db, [
+ 'id' => 'publisher-id', 'name' => 'publisher-name', 'count' => 123
+ ]));
+ $builder->addNavigationEntry($this->createCalibreItem(CalibreSeries::class, $this->db, [
+ 'id' => 'series-id', 'name' => 'series-name', 'count' => 123
+ ]));
+ $builder->addNavigationEntry($this->createCalibreItem(CalibreTag::class, $this->db, [
+ 'id' => 'tag-id', 'name' => 'tag-name', 'count' => 123
+ ]));
+ $builder->addNavigationEntry($this->createCalibreItem(CalibreLanguage::class, $this->db, [
+ 'id' => 'lang-id', 'code' => 'lang-code', 'count' => 123
+ ]));
+ $builder->addBookEntry($this->createCalibreItem(CalibreBook::class, $this->db, [
+ 'id' => 'book-id', 'title' => 'book-title',
+ 'timestamp' => '2023-10-01 01:02', 'pubdate' => '1980-02-01', 'last_modified' => null,
+ 'series_index' => 1.0, 'uuid' => 'book-uuid',
+ 'has_cover' => 1, 'path' => 'book-path',
+ 'comment' => 'book-comment'
+ ], [
+ 'authors' => [
+ $this->createCalibreItem(CalibreAuthor::class, $this->db, [
+ 'id' => 'author-id1', 'name' => 'author-name1', 'uri' => 'author-uri1'
+ ]),
+ $this->createCalibreItem(CalibreAuthor::class, $this->db, [
+ 'id' => 'author-id2', 'name' => 'author-name2', 'uri' => 'author-uri2'
+ ]),
+ ],
+ 'publishers' => [
+ $this->createCalibreItem(CalibrePublisher::class, $this->db, [
+ 'id' => 'publisher-id1', 'name' => 'publisher-name1'
+ ]),
+ $this->createCalibreItem(CalibrePublisher::class, $this->db, [
+ 'id' => 'publisher-id2', 'name' => 'publisher-name2'
+ ]),
+ ],
+ 'languages' => [
+ $this->createCalibreItem(CalibreLanguage::class, $this->db, [
+ 'id' => 'lang-id1', 'code' => 'lang-code1'
+ ]),
+ $this->createCalibreItem(CalibreLanguage::class, $this->db, [
+ 'id' => 'lang-id2', 'code' => 'lang-code2'
+ ]),
+ ],
+ 'series' => [
+ $this->createCalibreItem(CalibreSeries::class, $this->db, [
+ 'id' => 'series-id1', 'name' => 'series-name1'
+ ]),
+ $this->createCalibreItem(CalibreSeries::class, $this->db, [
+ 'id' => 'series-id2', 'name' => 'series-name2'
+ ]),
+ ],
+ 'tags' => [
+ $this->createCalibreItem(CalibreTag::class, $this->db, [
+ 'id' => 'tag-id1', 'name' => 'tag-name1'
+ ]),
+ $this->createCalibreItem(CalibreTag::class, $this->db, [
+ 'id' => 'tag-id2', 'name' => 'tag-name2'
+ ]),
+ ],
+ 'formats' => [
+ $this->createCalibreItem(CalibreBookFormat::class, $this->db, [
+ 'path' => 'book-path', 'name' => 'format-name1', 'format' => 'format-type1'
+ ]),
+ $this->createCalibreItem(CalibreBookFormat::class, $this->db, [
+ 'path' => 'book-path', 'name' => 'format-name2', 'format' => 'format-type2'
+ ]),
+ ],
+ 'identifiers' => [
+ $this->createCalibreItem(CalibreBookId::class, $this->db, [
+ 'type' => 'uri', 'value' => 'id-uri1'
+ ]),
+ $this->createCalibreItem(CalibreBookId::class, $this->db, [
+ 'type' => 'urn', 'value' => 'id-urn2'
+ ]),
+ $this->createCalibreItem(CalibreBookId::class, $this->db, [
+ 'type' => 'epubbud', 'value' => 'id-epubbud3'
+ ]),
+ $this->createCalibreItem(CalibreBookId::class, $this->db, [
+ 'type' => 'id-type4', 'value' => 'id-value4'
+ ]),
+ ],
+ ]));
+ $resp = $builder->getResponse();
+
+ $app = $resp->getOpdsApp();
+ $this->assertEquals(self::SETTINGS_APP_ID, $app->getAppId(), 'Feed -- app ID');
+ $this->assertEquals(self::SETTINGS_APP_NAME, $app->getAppName(), 'Feed -- app name');
+ $this->assertEquals(self::SETTINGS_APP_VERSION, $app->getAppVersion(), 'Feed -- app version');
+ $this->assertEquals(self::SETTINGS_APP_WEBSITE, $app->getAppWebsite(), 'Feed -- app website');
+ $this->assertEquals('self-route:selfParam=selfValue', $resp->getId(), 'Feed -- feed ID');
+ $this->assertEquals(self::FEED_TITLE, $resp->getTitle(), 'Feed -- feed title');
+ $this->assertEquals('app-img:icon.ico', $resp->getIconURL(), 'Feed -- feed icon');
+
+ $expectedLinks = self::EXPECTED_LINKS;
+ foreach ($resp->getLinks() as $key => $link) {
+ $this->assertNotEmpty($expectedLinks, 'Feed -- too many links');
+ $expected = array_shift($expectedLinks);
+ $this->assertEquals($expected[0], $link->getRel(), 'Feed -- link '.$key.' -- rel');
+ $this->assertEquals($expected[1], $link->getURL(), 'Feed -- link '.$key.' -- URL');
+ $this->assertEquals($expected[2], $link->getMimeType(), 'Feed -- link '.$key.' -- MIME type');
+ }
+ $this->assertEmpty($expectedLinks, 'Feed -- not enough links');
+
+ $expectedEntries = self::EXPECTED_ENTRIES;
+ foreach ($resp->getEntries() as $key => $entry) {
+ $this->assertNotEmpty($expectedEntries, 'Feed -- too many entries');
+ $expected = array_shift($expectedEntries);
+ $this->assertEquals($expected[0], $entry->getId(), 'Feed -- entry '.$key.' -- ID');
+ $this->assertEquals($expected[1], $entry->getTitle(), 'Feed -- entry '.$key.' -- title');
+ $this->assertEquals($expected[2], $entry->getSummary(), 'Feed -- entry '.$key.' -- summary');
+
+ $expectedUpdated = $expected[3] ?? null;
+ if (!is_null($expectedUpdated)) {
+ $expectedUpdated = new DateTimeImmutable($expectedUpdated);
+ }
+ $this->assertEquals($expectedUpdated, $entry->getUpdated(), 'Feed -- entry '.$key.' -- updated');
+
+ $expectedAuthors = $expected[4] ?? [];
+ foreach ($entry->getAuthors() as $authorKey => $author) {
+ $this->assertNotEmpty($expectedAuthors, 'Feed -- entry '.$key.' -- too many authors');
+ $expectedAuthor = array_shift($expectedAuthors);
+ $this->assertEquals($expectedAuthor[0], $author->getName(), 'Feed -- entry '.$key.' -- author '.$authorKey.' -- name');
+ $this->assertEquals($expectedAuthor[1] ?? null, $author->getURI(), 'Feed -- entry '.$key.' -- author '.$authorKey.' -- URI');
+ $this->assertEquals($expectedAuthor[2] ?? null, $author->getEMail(), 'Feed -- entry '.$key.' -- author '.$authorKey.' -- e-mail');
+ }
+ $this->assertEmpty($expectedAuthors, 'Feed -- entry '.$key.' -- not enough authors');
+
+ $expectedCategories = $expected[5] ?? [];
+ foreach ($entry->getCategories() as $catKey => $cat) {
+ $this->assertNotEmpty($expectedCategories, 'Feed -- entry '.$key.' -- too many categories');
+ $expectedCat = array_shift($expectedCategories);
+ $this->assertEquals($expectedCat[0], $cat->getTerm(), 'Feed -- entry '.$key.' -- category '.$catKey.' -- term');
+ $this->assertEquals($expectedCat[1] ?? null, $cat->getSchema(), 'Feed -- entry '.$key.' -- category '.$catKey.' -- schema');
+ $this->assertEquals($expectedCat[2] ?? null, $cat->getLabel(), 'Feed -- entry '.$key.' -- category '.$catKey.' -- label');
+ }
+ $this->assertEmpty($expectedCategories, 'Feed -- entry '.$key.' -- not enough categories');
+
+ $expectedLinks = $expected[6] ?? [];
+ foreach ($entry->getLinks() as $linkKey => $link) {
+ $this->assertNotEmpty($expectedLinks, 'Feed -- entry '.$key.' -- too many links');
+ $expectedLink = array_shift($expectedLinks);
+ $this->assertEquals($expectedLink[0], $link->getRel(), 'Feed -- entry '.$key.' -- link '.$linkKey.' -- rel');
+ $this->assertEquals($expectedLink[1], $link->getURL(), 'Feed -- entry '.$key.' -- link '.$linkKey.' -- URL');
+ $this->assertEquals($expectedLink[2] ?? OpdsResponse::MIME_TYPE_ATOM, $link->getMimeType(), 'Feed -- entry '.$key.' -- link '.$linkKey.' -- MIME type');
+ }
+ $this->assertEmpty($expectedLinks, 'Feed -- entry '.$key.' -- not enough links');
+
+ $expectedAttrs = $expected[7] ?? [];
+ foreach ($entry->getAttributes() as $attrKey => $attr) {
+ $this->assertNotEmpty($expectedAttrs, 'Feed -- entry '.$key.' -- too many attributes');
+ $expectedAttr = array_shift($expectedAttrs);
+ $this->assertEquals($expectedAttr[0], $attr->getNs(), 'Feed -- entry '.$key.' -- attr '.$attrKey.' -- NS');
+ $this->assertEquals($expectedAttr[1], $attr->getTag(), 'Feed -- entry '.$key.' -- attr '.$attrKey.' -- tag');
+ $this->assertEquals($expectedAttr[2] ?? null, $attr->getValueText(), 'Feed -- entry '.$key.' -- attr '.$attrKey.' -- value');
+ }
+ $this->assertEmpty($expectedAttrs, 'Feed -- entry '.$key.' -- not enough attributes');
+ }
+ $this->assertEmpty($expectedEntries, 'Feed -- not enough entries');
+ }
+}
diff --git a/tests/unit/OpdsSearchTest.php b/tests/unit/OpdsSearchTest.php
new file mode 100644
index 0000000..10ec786
--- /dev/null
+++ b/tests/unit/OpdsSearchTest.php
@@ -0,0 +1,60 @@
+
+