Compare commits

...
268 Commits
Author SHA1 Message Date
Ozzie Isaacs e732b3b614 Output message only in production mode 2026-05-24 10:19:49 +02:00
Ozzie Isaacs 69f8767be5 Fixes from testrun 2026-05-17 10:21:19 +02:00
Ozzie Isaacs 6cbba6d170 Merge remote-tracking branch 'fix/commentxss' into Develop 2026-05-09 10:30:59 +02:00
Ozzie Isaacs 888ab4dc8f Update requirements 2026-05-09 10:28:12 +02:00
Ozzie Isaacs 753c19e700 Merge remote-tracking branch 'py314/py3.14-janeczku' into Develop 2026-05-09 10:24:58 +02:00
Ozzie Isaacs 4f9390332a Merge remote-tracking branch 'fix/attr' into Develop 2026-05-09 10:20:45 +02:00
Ozzie Isaacs ffddd85800 Merge remote-tracking branch 'fix/fixlter' into Develop 2026-05-09 10:19:57 +02:00
Ozzie Isaacs 652b0f20e0 Merge remote-tracking branch 'fix/token' into Develop 2026-05-09 10:17:57 +02:00
Ozzie Isaacs 598f08b916 Merge branch 'master' into Develop 2026-05-09 10:17:48 +02:00
Jacob Chapman 55cedba693 bump lxml to v6 for python 3.14 2026-04-29 22:05:40 -05:00
Jacob Chapman 10caf68d8b fix manifest reference 2026-04-29 22:05:40 -05:00
Ozzie Isaacs f4ed816f6e Fixes from testrun 2026-04-26 07:27:06 +02:00
Ozzie Isaacs ba77f41325 Merge remote-tracking branch 'fixes/notrace' 2026-04-25 16:04:51 +02:00
Ozzie Isaacs ea567fcbf0 Updated translation 2026-04-25 13:26:40 +02:00
Ozzie Isaacs edb05abcbe Merge remote-tracking branch 'tmp-orphan-on-download/fix-calibredb-cover-sibling' 2026-04-25 13:25:34 +02:00
Ozzie Isaacs 46811219ca Updated Requirements 2026-04-25 13:20:25 +02:00
Ozzie Isaacs 0aab0cac73 Merge remote-tracking branch 'tmp-orphan-on-download/fix-tmp-orphan-on-download' 2026-04-25 13:13:36 +02:00
Ozzie Isaacs 3257a36892 Merge remote-tracking branch 'ko/ko-translate-update' 2026-04-25 13:11:14 +02:00
limeade23 f10b680b0e Update Korean language 2026-04-23 17:41:46 +09:00
Ozzie Isaacs c2b1950cd5 Merge remote-tracking branch 'opds_date_update/fix-opds-atom-updated-reflects-modifications' 2026-04-20 15:55:24 +02:00
Ozzie Isaacs 00c8ff8ac4 Merge remote-tracking branch 'metadata-provider-none-crash/fix/metadata-provider-none-crash' 2026-04-20 15:49:26 +02:00
walrusec 0ca6e86177 Fix metadata search crash when ComicVine or Douban return None on error
Both comicvine.py and douban.py return None instead of [] when an HTTP
error occurs during a metadata search. The consumer in search_metadata.py
iterates the result directly, which raises TypeError: 'NoneType' object
is not iterable and crashes the server on single-threaded deployments.

Fixes #3606
2026-04-19 08:54:47 -04:00
haraldpdl ab17992ebb fix(opds): atom:updated reflects last modification, not date added
Books.atom_timestamp returned Books.timestamp (date added), which is
set at import and never changes. OPDS clients use atom:updated to
decide whether a book has changed on the server, so cover swaps,
metadata edits, and any other post-import change were invisible to
sync clients; they would keep serving the stale cover and title
until a manual refresh.

Atom RFC 4287 defines updated as "the most recent instant in time
when an entry or feed was modified", and Calibre already tracks that
field as last_modified, bumping it on every metadata and cover edit.
Switching the property to return last_modified (with a fallback to
timestamp when last_modified is NULL) aligns Calibre-Web's behaviour
with the Atom contract.

This change only affects the OPDS feed's atom:updated element. Kobo
sync uses its own last_modified comparison path, so it is unaffected.
2026-04-19 10:51:45 +02:00
haraldpdl 570371cc82 Don't write a cover sibling per download (--dont-save-cover)
calibredb export writes <uuid>.jpg next to <uuid>.<format> by
default, leaving an unused cover image in /tmp/calibre_web for
every download that goes through do_calibre_export. The cover
file is never read by calibre-web (cover serving uses a separate
path), so it is pure waste of disk space and IO.

Pass --dont-save-cover, paired with the existing --dont-write-opf,
to skip the unwanted side-effect at the source. Verified that
none of the three do_calibre_export call sites (download via
do_download_file, email via tasks/mail.py, kepubify via
tasks/convert.py) read the cover sibling.
2026-04-18 07:27:33 +02:00
haraldpdl 674b47bdbd Remove staged download from /tmp/calibre_web after response is sent
When config_embed_metadata is enabled with config_binariesdir or
config_kepubifypath set, do_download_file stages a copy of every
downloaded book under get_temp_dir() but never removes it. A bulk
OPDS or Kobo sync can fill the host filesystem in minutes; the
effect is amplified for comic formats (CBZ/CBR), where each
staged file can run to hundreds of MB or several GB. Once the
disk fills, downloads silently 404 and a container restart does
not recover the space.

Add an after_this_request hook that removes the staged copy after
the response is sent, gated on filename == get_temp_dir() so the
non-embed and gdrive-direct paths are untouched.
2026-04-18 07:12:38 +02:00
jvoisin 8cff413c4d Clean atributes as well in clean_string
`<img src=x onerror=alert(1)>` womp womp
2026-04-15 23:30:49 +02:00
jvoisin fd744af75d Don't give non-admin users a full stacktrace on 500
As `traceback.format_exc()` might contain internal file paths, library versions,
function names, and variable values.
2026-04-15 23:28:27 +02:00
jvoisin 6208d5e264 Correctly filter results kobo API
Multiple Kobo API endpoints use calibre_db.get_book_by_uuid() at db.py:748-749,
which performs a raw unfiltered query, meaning that common_filters() isn't
applied, so tag-based ACLs, language restrictions, and archived-book filtering
are all bypassed.
2026-04-15 23:14:09 +02:00
jvoisin 42dc36cc10 Escape comment columns names
Custom columns of type comments are rendered with `|safe` (disabling Jinja2
auto-escaping) and no `clean_string` sanitization. Compare with regular book
comments which correctly use `{{ entry.comments[0].text|clean_string|safe }}`.

Any user with edit permissions can set a custom comment column to
`<script>alert(document.cookie)</script>` and it will execute for every user who
views the book detail page or the OPDS feed. This is stored XSS with no
authentication barrier beyond edit permission.
2026-04-15 23:03:30 +02:00
jvoisin c23d35db4a Use 128 bits of entropy instead of only 32 in csp/ub.py
Remote login tokens are generated from only 4 bytes of randomness (32 bits = ~4
billion possibilities, 8 hex characters). The /ajax/verify_token endpoint at
remotelogin.py:98 has no rate limiting. The token is valid for 10 minutes.

At even modest request rates (10,000 req/sec), an attacker can test ~6 million
tokens during the 10-minute window , which isn't enough to exhaust the full
space, sure, but combined with multiple concurrent login sessions (each
generating a new token), or if the attacker can trigger the victim to initiate
remote login, the attack becomes more feasible. Compare with the Kobo auth
token which uses urandom(16) (128 bits).
2026-04-15 22:47:35 +02:00
Ozzie Isaacs fca580505c Fix typo 2026-04-15 20:31:40 +02:00
Ozzie Isaacs 19da54a7ae Merge remote-tracking branch 'fixes/escape_ldap' 2026-04-15 19:37:31 +02:00
Ozzie Isaacs 9c87f0fbde Merge remote-tracking branch 'fixes/fix_gdrive_md5' 2026-04-15 19:36:56 +02:00
Ozzie Isaacs e21f943712 Merge remote-tracking branch 'fixes/oauth_relink' 2026-04-15 19:36:48 +02:00
Ozzie Isaacs 4bb553f51d Merge remote-tracking branch 'fixes/noxxe' 2026-04-15 19:35:40 +02:00
Ozzie Isaacs 0615637f58 Merge remote-tracking branch 'fixes/nodebugleak' 2026-04-15 19:33:06 +02:00
Ozzie Isaacs a6c55deac6 Merge remote-tracking branch 'fixes/accessbp' 2026-04-15 19:31:29 +02:00
Ozzie Isaacs 2de8df7e0d Merge remote-tracking branch 'fixes/fix_kobo' 2026-04-15 19:28:55 +02:00
Ozzie Isaacs 4c1d7a9f6b Merge remote-tracking branch 'fixes/permshelf' 2026-04-15 19:28:12 +02:00
Ozzie Isaacs 9489905602 Merge remote-tracking branch 'fixes/chm' 2026-04-15 19:24:12 +02:00
Ozzie Isaacs 5e7c0c9d34 Merge remote-tracking branch 'fixes/sqlidb' 2026-04-15 19:23:01 +02:00
jvoisin b5da0df42a Prevent SQLI via dbpath
This is reachable only by the admin users, but is still a straightforward RCE
vector, if only via SQLite's `ATTACH DATABASE` trick
2026-04-14 23:23:12 +02:00
jvoisin 0959f84fd5 Use a sane permission for the encryption key file
The typical Linux umask of 0022, meaning the encrypted file is world-readable
(-rw-r--r--). Any OS-level user on the same system can read the key and decrypt
the encrypted credentials from app.db.
2026-04-14 23:08:35 +02:00
jvoisin 84777319d7 Fix access bypass on /show/ (serve_book)
The `serve_book` function uses `get_book()` which performs no access filtering:
it simply fetches by ID. Compare with `read_book` at web.py:1562 which
correctly uses `get_filtered_book()`. The `common_filters()` function enforces
per-user tag restrictions, language restrictions, and hidden-book rules.
2026-04-14 23:05:18 +02:00
jvoisin d85bef6c38 Don't leak credentials in debug_info
No need to dump Gmail OAuth client_secret, refresh_token, and
access_token in the debug ZIP in plaintext.
2026-04-14 22:55:08 +02:00
jvoisin 8ad9f4e3b7 Fix a dumb type condition in gdrive.py
hashlib.md5(dbpath) returns a hash object, not a hex string. Comparing a string
(md5Checksum) to a hash object with != always returns True. This means the
DB-replacement code path is always entered, allowing an attacker who sends a
forged notification (with the known static token) to trigger an arbitrary
metadata.db download from GDrive, replacing the live database.
2026-04-14 22:51:36 +02:00
jvoisin cde3888e17 Prevent LDAP injection in bind_user>get_object_details 2026-04-14 22:35:13 +02:00
jvoisin 387678a771 Prevent OAuth relinking.
When an OAuth provider_user_id is already linked to User A, and User B
authenticates with the same OAuth identity, User B is silently logged in as
User A. This is by design for single-user OAuth, but in a multi-user
environment it means: if an attacker gains access to the same OAuth provider
account (e.g., a shared GitHub org account, or by compromising the OAuth
provider), they can log in as the linked Calibre-Web user with no password
needed.
2026-04-14 22:28:06 +02:00
jvoisin c451daad3c Don't allow users to edit shelves they don't have permission to edit. 2026-04-14 22:25:36 +02:00
jvoisin 224915bba1 Prevent XXE in epub/fb2/goodreads API
The lxml.etree.fromstring() function use the default XML parser, which resolves
external entities because XML handling defaults in Python sucks. There is no
need for such dangerous misfeatures in calibre-web, so let's disable it.

A user able to upload epub/fb2 could add something like this to the file:

```xml
<?xml version="1.0"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<container><rootfiles><rootfile full-path="&xxe;"/></rootfiles></container>
```

and obtain the content of the `/etc/passwd` file, which is bad™.
2026-04-14 22:12:57 +02:00
jvoisin d598054195 Fix an IDOR in kobo tokens
Authenticated users shouldn't be able to generate/delete kobo auth tokens for
other users if they're not admin.
2026-04-14 22:10:30 +02:00
Ozzie Isaacs 088778969d Better error handling on series display in basic view 2026-04-06 15:28:10 +02:00
Ozzie Isaacs 0ed4d81da2 Better error handling on order parameter for sorting of user and books table 2026-04-06 15:12:09 +02:00
Ozzie Isaacs 9478325c23 Merge branch 'master' into Develop 2026-04-03 09:09:43 +02:00
Ozzie Isaacs 7c715f34dc Clean strings in comments before displaying 2026-04-03 09:01:01 +02:00
Ozzie Isaacs 00686ec1f2 Merge remote-tracking branch 'page_count/fix-series-list-view-sorts-by-name-instead-of-sort-field' 2026-03-07 10:24:58 +01:00
Ozzie Isaacs 83d331c89b Merge remote-tracking branch 'page_count/fix-kobo-prioritytimestamp-put-response' 2026-03-07 09:55:45 +01:00
Ozzie Isaacs f6338a9fda Merge remote-tracking branch 'filename/master' 2026-03-07 09:46:16 +01:00
Ozzie Isaacs 73efef17bd Merge branch 'master' into Develop 2026-03-07 09:36:19 +01:00
Ozzie Isaacs 74725b68b0 Merge remote-tracking branch 'it/patch-1' 2026-03-07 09:28:14 +01:00
mapi68 efbdd0b6e3 Update italian messages.po 2026-03-05 06:44:48 +01:00
jarynclouatre 1134f54be4 fix: series list view sorts by name instead of sort field
In series_list(), the SQLite query correctly orders results by
Series.sort, but a subsequent Python sorted() call (needed to
re-order after appending the "None" category entry) was using
Series.name as the sort key instead of Series.sort.

This caused series titles with leading articles (A, An, The) to
sort strictly alphabetically by the article rather than by the
meaningful word, e.g. "A Collins-Burke Mystery" appeared under
"A" instead of "C".

Fix by using Series.sort (with a fallback to Series.name if sort
is NULL) as the key in the Python re-sort, consistent with the
intent of the existing DB query.

Fixes #3583
2026-03-01 20:44:05 -06:00
jarynclouatre 2d4ca23d0c Kobo: include PriorityTimestamp in PUT /state response
Kobo: include PriorityTimestamp in PUT /state response
2026-03-01 18:19:40 -06:00
jarynclouatre 6157f5027c Update search_metadata.py
Fix TypeError when metadata provider returns None
2026-03-01 15:44:29 +01:00
Ozzie Isaacs 5e48a64b15 Merge remote-tracking branch 'opds-500-unauthenticated-request/fix/opds-500-unauthenticated-request' 2026-02-22 10:43:11 +01:00
Rafik Farhad 36a7ff19bc Fix AttributeError on unauthenticated OPDS requests
request_username() is used as flask-limiter's key_func for the OPDS
blueprint. The limiter evaluates key_func in a before_request handler,
before the route's auth decorator runs. When no Authorization header is
present, request.authorization is None, causing an AttributeError and
a 500 response instead of the expected 401.

Guard against None so unauthenticated requests fall back to an empty
string key, allowing the auth decorator to handle the 401 correctly.

Fixes #3592

Disclaimer: AI assisted—humans supervised.
2026-02-21 16:54:14 -06:00
Ozzie Isaacs 1b4f0d0967 Merge remote-tracking branch 'add-to-shelves-dropdown-menu/add-to-shelves-dropdown-menu' 2026-02-21 09:43:50 +01:00
Ozzie Isaacs 181117f916 Merge remote-tracking branch 'Kobo_sync/fix/kobo-library-sync-url-rewrite' 2026-02-21 09:42:09 +01:00
Ozzie Isaacs 32741be6c8 Merge remote-tracking branch 'kobo_sync/pr/fix-kobo-progress' 2026-02-21 09:31:56 +01:00
Ozzie Isaacs f64fe5d835 Merge remote-tracking branch 'Exclamationmark/chore_fix_error_messages' 2026-02-21 09:29:02 +01:00
Ozzie Isaacs 0db93a6036 Merge remote-tracking branch 'sort/master' 2026-02-21 09:27:04 +01:00
leahjessie a4bf028537 Fix Kobo popup when ProgressPercent is 0 by using is-not-None check instead of truthiness 2026-02-20 15:34:25 -08:00
AsukaVuuyn 47625a616d fix: support native UTF-8 downloaded filenames for browsers 2026-02-20 23:07:06 +08:00
Ozzie Isaacs 87635a5ace Merge branch 'Develop'
Update Requirements
Update Teststatus
2026-02-20 11:50:43 +01:00
Noé Sierra-Velasquez a9713bd497 Fix Kobo sync failure behind reverse proxy: rewrite library_sync URL in init response
The HandleInitRequest() function rewrites image URLs to point to the local
Calibre-Web instance, but library_sync was left pointing to storeapi.kobo.com.
This caused Kobo e-readers to call Kobo's servers for sync instead of the local
server, resulting in no books being synced when running behind a reverse proxy.
2026-02-19 20:34:50 +01:00
Ozzie Isaacs 8cef72b847 Updated requirements 2026-02-18 19:24:23 +01:00
Ozzie Isaacs fbed304df8 Better Fix for flask limiter 4.0 2026-02-18 19:11:38 +01:00
Ozzie Isaacs e3bf369ad6 Fix for flask limiter 4.0
Fix reseting flask_limiter keys
2026-02-18 18:59:29 +01:00
leahjessie d229f71151 Fix Kobo "Return to last page read" popup caused by float/int mismatch 2026-02-17 16:34:46 -08:00
Courville Software 3aef161cb8 fix title sort with "l'" in french
regex should not impose a space after "l'" otherwise detection for "L'arbre" fails.
2026-02-14 17:06:44 +01:00
Ozzie Isaacs cabcace3f0 Update translation
Fixes for flask_limiter version >4
2026-02-14 11:30:50 +01:00
Ozzie Isaacs 424e493946 Lower version of flask_limiter again 2026-02-14 11:30:15 +01:00
Ozzie Isaacs 848302f69e Merge branch 'master' into Develop 2026-02-14 10:21:05 +01:00
Ozzie Isaacs 8bdd95fc45 Fixes for flask_limiter > 4 2026-02-14 10:08:32 +01:00
Ozzie Isaacs 9c7a834cec Fix italian translation error 2026-02-13 19:18:00 +01:00
Ozzie Isaacs 51a2f36966 Revert flask_limiter version 2026-02-13 19:16:18 +01:00
Ozzie Isaacs b64645e5f5 Cover path is now selected based on correct setting and not if split_path is present or not (fix for #3527)
Version bump
Updated requirements
2026-02-08 12:12:34 +01:00
Ozzie Fernandez Isaacs bed8957eae Update pyproject.toml 2026-02-07 09:22:39 +01:00
Ozzie Fernandez Isaacs 0ce8c19e88 Update pyproject.toml 2026-02-06 21:59:49 +01:00
Ozzie Isaacs 5a1f3d8eec Version bump 2026-02-06 20:40:07 +01:00
lb803 315584690d remove exclamation marks 2026-02-05 21:40:45 +00:00
Ozzie Isaacs f14faff308 Merge remote-tracking branch 'cn/patch_cn' 2026-01-31 12:43:58 +01:00
Ozzie Isaacs 6f782c5687 Merge remote-tracking branch 'pt-br/translate-ptbr' 2026-01-31 12:40:01 +01:00
Ozzie Isaacs 7f7fc884c2 Fix for calibre 9.0 2026-01-31 12:31:44 +01:00
Webysther Sperandio 92084bab71 🌐 i18n(translations): typo 2026-01-31 05:51:04 +01:00
Webysther Sperandio f00471baf5 🌐 i18n(translations): add missing pt_BR translations in messages.po 2026-01-31 05:44:18 +01:00
Webysther Sperandio 9ed0774d19 doc: pt-br translation 2026-01-31 05:19:48 +01:00
Kamille eb20db55a4 Update Simplified Chinese 2026-01-31 11:25:50 +08:00
Webysther Sperandio a91724edb0 💄 style(css): include remove menu in dropdown scroll styling 2026-01-28 21:09:47 +01:00
Webysther Sperandio 5ad6b7fa64 💄 style(ui): limit add-to-shelves dropdown height
- 【style】 constrain add-to-shelves dropdown height and enable scroll
  - 【style】 prevent overly tall menus from overflowing the page
2026-01-26 19:39:36 +01:00
Webysther Sperandio c1be944a48 💄 style(ui): limit add-to-shelves dropdown height
- 【style】 constrain add-to-shelves dropdown height and enable scroll
  - 【style】 prevent overly tall menus from overflowing the page
2026-01-26 19:36:47 +01:00
Ozzie Isaacs e2c3c08d65 Merge remote-tracking branch 'origin/master' 2026-01-25 10:45:09 +01:00
Ozzie Isaacs 4fece545e4 Merge remote-tracking branch 'de/master' 2026-01-25 10:42:15 +01:00
Ozzie Isaacs 876a11dc58 Merge branch 'Develop'
Remove Get Parameter sort_param, where it is not valid (#3447)
Generate valid session-cookie-path(s) (fix for #3459)
Mass remove books from shelf (Fix for #3520)
2026-01-25 10:35:59 +01:00
Ozzie Isaacs 486914905e Merge branch 'Develop'
# Conflicts:
#	test/Calibre-Web TestSummary_Linux.html
2026-01-24 20:50:59 +01:00
Ozzie Isaacs d47af66499 Bugfix after testrun 2026-01-24 13:01:12 +01:00
Tom Chiverton 832ec80f1a Update README.md
Highlight how to enable uploads, as it's a FAQ
2026-01-21 18:46:47 +01:00
Kugeleis 85d1aab6a8 Add German translations for several UI strings 2026-01-18 18:07:51 +01:00
Ozzie Isaacs 8db0d004b6 Remove Get Parameter sort_param, where it is not valid (#3447) 2026-01-18 16:07:04 +01:00
Ozzie Isaacs c13eac91c8 Generate valid session-cookie-path(s) (fix for #3459) 2026-01-18 15:38:44 +01:00
Ozzie Isaacs cf3b619c73 Fix for #3520 (Mass remove books from shelf) 2026-01-18 12:59:40 +01:00
Ozzie Isaacs 269ab0e04e Fix for #3522 (Public registration does not apply default Allowed/Denied tags to new users ) 2026-01-17 12:27:16 +01:00
Ozzie Isaacs 47168f5ce9 Merge remote-tracking branch 'typo-epub-reader/webysther-deprecated-apple-meta' 2026-01-17 12:18:17 +01:00
Ozzie Isaacs 73d7306627 Merge remote-tracking branch 'typo-epub-reader/webysther-typo-epub-reader' 2026-01-17 12:16:45 +01:00
Ozzie Isaacs dd1cbb1018 Merge remote-tracking branch 'fr/patch-1' 2026-01-17 12:15:55 +01:00
Ozzie Isaacs 91fdf57b46 Update Teststatus 2026-01-17 12:14:13 +01:00
Webysther Sperandio d77d682ee0 Replace apple-mobile-web-app-capable with mobile-web-app-capable
https://web.dev/learn/pwa/web-app-manifest?hl=pt-br#designing_your_pwa_experience
2026-01-16 06:13:03 +01:00
Webysther Sperandio e2eed3d311 Update layout.html
https://web.dev/learn/pwa/web-app-manifest?hl=pt-br#designing_your_pwa_experience
2026-01-16 06:12:45 +01:00
Webysther Sperandio 54a47cfc18 Change meta tag for mobile web app capability
https://web.dev/learn/pwa/web-app-manifest?hl=pt-br#designing_your_pwa_experience
2026-01-16 06:12:21 +01:00
Webysther Sperandio b5dac9e1a7 Change meta tag for mobile web app capability
https://web.dev/learn/pwa/web-app-manifest?hl=pt-br#designing_your_pwa_experience
2026-01-16 06:12:02 +01:00
Webysther Sperandio b00abd283a Update readtxt.html
https://web.dev/learn/pwa/web-app-manifest?hl=pt-br#designing_your_pwa_experience
2026-01-16 06:11:29 +01:00
Webysther Sperandio 4821b2b34c Change meta tag from apple-mobile to mobile-web app
https://web.dev/learn/pwa/web-app-manifest?hl=pt-br#designing_your_pwa_experience
2026-01-16 06:10:54 +01:00
Webysther Sperandio 0e31be5357 Change meta tag for mobile web app capability
https://web.dev/learn/pwa/web-app-manifest?hl=pt-br#designing_your_pwa_experience
2026-01-16 06:10:36 +01:00
Webysther Sperandio dc19d40db1 Change meta tag for mobile web app capability
https://web.dev/learn/pwa/web-app-manifest?hl=pt-br#designing_your_pwa_experience
2026-01-16 06:10:05 +01:00
Webysther Sperandio 02b5409b95 TYPO
Fix theme selection prompt in read.html
2026-01-16 05:57:31 +01:00
Edouard Rouch 63cebcd849 Completed french translation
Added missing translations.
Corrected grammatical or spelling errors.
Harmonized recurring translations (Success !, Oops !, Show...)
2026-01-15 12:34:47 +01:00
Ozzie Isaacs 99e91e622e Change quoting of downloaded filename 2026-01-10 12:53:16 +01:00
Ozzie Isaacs 36ef81dd91 Make kobo resources editable (#3472) 2026-01-10 12:40:19 +01:00
Ozzie Isaacs 2700b4f0d1 Support kobo/auth/refresh endpoint 2026-01-10 12:20:52 +01:00
Ozzie Isaacs d73f9fdd6f Fix for #3515 (Exclude shelves with Advanced search not working correct) 2026-01-08 20:01:39 +01:00
Ozzie Isaacs be89c25a90 Merge branch 'master' into Develop 2026-01-08 19:58:48 +01:00
Ozzie Isaacs c5e9526fc8 Bugfix Exclude shelfs 2026-01-07 20:35:26 +01:00
Ozzie Isaacs a99960e6c5 Merge remote-tracking branch 'it/patch-1' 2026-01-05 15:06:41 +01:00
mapi68 8105f54ebb Update Italian language 2026-01-04 02:24:15 +01:00
Ozzie Isaacs d01010812b Merge remote-tracking branch 'fix_missing_progress_bar_for_cbz_file/fix_missing_progress_bar_for_cbz_file' 2025-12-29 14:37:11 +01:00
Julian_Chu 02fbef3dba Move progress css style to dedicated file
In kthoom implementation, there is also a DOM

    `<div id="progress"....>`

If this part of css style stays in main.css, it also effects the kthoom
implementation, therefore the progress bar will disappear when reading
comic files (such as cbz).
2025-12-29 18:49:44 +09:00
Ozzie Isaacs eb8b0096a1 Update Teststatus 2025-12-23 14:25:21 +01:00
Ozzie Isaacs 38b0af9ea2 Remove debug code 2025-12-20 13:22:20 +01:00
Ozzie Isaacs 83b5d0f22b Merge remote-tracking branch 'replace_archive_icon/chore_replace_archive_icon' 2025-12-14 14:45:46 +01:00
Ozzie Isaacs 38c58bd7c9 Merge remote-tracking branch 'pl/patch-1' 2025-12-14 14:44:46 +01:00
Ozzie Isaacs 6276806c0e Merge remote-tracking branch '401/patch-1' 2025-12-14 14:40:49 +01:00
Evan Goldenberg 97a8fc661f Send WWW-Authenticate header with 401 responses 2025-12-10 00:06:32 -08:00
lb803 0569c644a3 change archive button icon from glyphicon-trash to glyphicon-folder-open 2025-12-09 21:44:36 +00:00
szepix 3565e5f068 Updated polish translations
Did all missing translations
2025-12-08 12:53:57 +01:00
Ozzie Isaacs 8b8bc1b688 Merge remote-tracking branch 'show-shelf-size/show-shelf-size'
Added some missing texts to caliblur theme
Caliblur theme fix language icon
2025-12-07 19:32:40 +01:00
Ozzie Isaacs ff9dabadf1 Merge remote-tracking branch 'cache/master' 2025-12-07 18:27:58 +01:00
Ozzie Isaacs a75d0caa02 Merge remote-tracking branch 'it/patch-1' 2025-12-07 14:32:58 +01:00
Ozzie Isaacs eb872865a5 Merge Reader improvements 2025-12-07 14:31:16 +01:00
Ozzie Isaacs 8031fff5af Merge branch 'Develop' 2025-12-07 10:55:04 +01:00
Ozzie Isaacs ac1ae66246 Merge branch 'master' into Develop 2025-12-07 10:54:40 +01:00
mapi68 310443afc5 Update italian 2025-12-07 04:08:44 +01:00
Ozzie Isaacs 25c9aba469 Fix vertical scrollbar in list.html 2025-12-06 19:26:30 +01:00
Ozzie Isaacs 3a3c8ad2a4 Merge remote-tracking branch 'lock-header-sidebar' 2025-12-06 13:21:23 +01:00
Ozzie Isaacs 10816c7f6d Merge remote-tracking branch 'performance_optimize-search-queries/performance/optimize-search-queries' into Develop 2025-12-06 11:54:28 +01:00
Ozzie Isaacs adcfd8afe8 Merge remote-tracking branch 'cn/patch-1'
Updated requirements
2025-12-06 11:52:18 +01:00
Ozzie Isaacs 74a386a284 Merge branch 'master' into Develop 2025-12-06 11:50:00 +01:00
Ozzie Isaacs fceca5cc08 Merge remote-tracking branch 'performance_optimize-search-queries/performance/optimize-search-queries' into Develop
Updated Requirements
2025-12-06 11:36:02 +01:00
Arlen Cole 13b515d3ab Update Chinese translations in messages.po 2025-12-06 13:54:25 +08:00
Ozzie Isaacs d2015dd998 Optimized search query 2025-12-05 16:33:30 +01:00
Ozzie Isaacs 3959a8eddd Caliblur theme display fix
Rename Gooble Books Metadata provider
2025-12-04 18:52:30 +01:00
Ozzie Isaacs ca63d343bd Merge remote-tracking branch 'resolution/Develop' into Develop 2025-12-04 18:51:37 +01:00
Ozzie Isaacs ecc6e00bfc Sort names before trying to extract coverfile of comics 2025-12-04 14:19:58 +01:00
root c1114cd786 Fix display issue on small width resolution with fetch metadata form on caliblur theme
Renamed Google metadata provider to Google Books
2025-11-27 19:07:16 +00:00
Ozzie Isaacs cf01bd7ad4 Merge remote-tracking branch 'kobo_tree/fix/kobo-store-proxy-routing' 2025-11-22 15:32:30 +01:00
Ozzie Isaacs cf1b3bb37f Merge remote-tracking branch 'de/patch-1' 2025-11-22 15:29:39 +01:00
Ozzie Isaacs 70fefa2f11 Merge remote-tracking branch 'de/patch-1' 2025-11-22 15:17:49 +01:00
Ozzie Isaacs 80df8b1fa6 Merge remote-tracking branch 'ko/patch-1' 2025-11-22 15:16:13 +01:00
Ozzie Isaacs cac33165dc Merge remote-tracking branch 'fr/patch-1' 2025-11-22 15:14:59 +01:00
Ozzie Isaacs bf2ab66362 Merge remote-tracking branch 'ga/patch-1' 2025-11-22 15:13:49 +01:00
Ozzie Isaacs 5e14b4bf41 Merge remote-tracking branch 'ga/patch-2' 2025-11-22 15:13:13 +01:00
Ozzie Isaacs a6fdd8ab36 Updated testresults
Fix goodreads
Remove unused import
Limit number of routes for book pages
2025-11-22 15:06:40 +01:00
Félix Pardal González d091b9d8b9 Update Galician translations in messages.po
Edited msgstr entried adding translation to Galician.
2025-11-22 13:43:28 +01:00
Félix Pardal González 2eb7c0d3f0 Update Spanish translations for various messages
Reviewed msgid entries and added spanish translation in msgstr entries.
2025-11-22 13:30:53 +01:00
Nico31240 54eb30e2b9 Update messages.po French 2025-11-12 10:30:42 +01:00
shsm0520 47f44d71cf Update Korean translations in messages.po
updated translate
2025-11-05 21:54:59 -05:00
Aljosa Asanovic 654095054f fix(kobo): add catch-all route for proxying unknown product endpoints
The Kobo store proxy feature was failing with 404 errors for certain
store endpoints (e.g., /v1/products/featuredforkoboplus/) because
Flask's routing couldn't match these paths. This prevented the Discover
tab from working when "Proxy unknown requests to Kobo Store" was enabled.

Added catch-all routes using Flask's path converter to match any
/v1/products/* paths not handled by more specific routes, allowing
the proxy logic to properly redirect these requests to the official
Kobo store API.
2025-11-02 13:10:25 -05:00
alcibiadesc 8544702bb2 Add security fixes and improvements to search optimization
- Add FTS5 table existence check to avoid log spam on non-FTS databases
- Escape FTS5 special characters (quotes) to prevent query errors
- Wrap FTS5 search terms in quotes for phrase matching accuracy
- Improve logging: change author ordering debug to warning for visibility
- Add comment explaining author_sort data issues

These changes improve robustness and security without affecting performance.
2025-11-01 20:05:53 +01:00
alcibiadesc a3b5d2eac7 Optimize search performance for large libraries
This commit introduces several performance optimizations to the search
system, significantly reducing query times for large Calibre libraries.

Key improvements:

1. FTS5 Integration
   - Added FTS5 full-text search support with automatic fallback
   - Uses indexed search when available, providing sub-second results
   - Gracefully degrades to traditional search if FTS5 is unavailable

2. Query Optimization
   - Replaced expensive .any() subqueries with efficient JOIN-based
     subqueries for tags, series, authors, and publishers
   - Reduced SQL complexity and improved query planning
   - Added selectinload() for authors to prevent N+1 query problems

3. LIMIT+1 Pattern
   - Implemented LIMIT+1 estimation pattern in get_search_results()
   - Avoids expensive COUNT(*) operations on large result sets
   - Provides fast pagination without sacrificing accuracy

4. Author Ordering Optimization
   - Replaced nested database queries with O(1) dictionary lookups
   - Eliminated N+1 query anti-pattern in order_authors()
   - Reduced author sorting from O(n²) to O(n) complexity

Performance Impact:
In testing with a library of 129,000+ books, these optimizations reduced
search times from 3-9 seconds to 85-330ms, achieving 89-97% improvement
across different search types.

The changes maintain backward compatibility and include fallbacks for
environments without FTS5 support.
2025-11-01 19:48:16 +01:00
IgorKurkov bba7812a07 feat(epub.js): add autosaving of current book reading position in localStorage to open this book on the last read point 2025-10-29 22:20:08 +01:00
IgorKurkov 458d0d1e3d feat(read.html, epub): add custom theme selection with color picker, used pickr.js 2025-10-29 22:10:08 +01:00
IgorKurkov 6d6d5ac097 add books button, add page count on bottom and in settings, add swipe for closing sidebar, fix settings modal, fix few styles 2025-10-29 22:06:09 +01:00
IgorKurkov 560e8e132d add swipes by gestures in settings as an option, hammer.js for mobile 2025-10-29 20:37:15 +01:00
schnabelewobski 3433b62864 Update messages.po
Added some missing German translations.
2025-10-29 14:03:04 +01:00
IgorKurkov d8fbf967b3 add console process interruption by ctrl C 2025-10-26 21:28:58 +01:00
IgorKurkov 902368efa7 feat(epub.js): add saving for settings of the font family to localstorage 2025-10-26 18:47:56 +01:00
IgorKurkov c5571fc359 feat(epub.js): add font size selector with precised + and - controls 2025-10-26 18:40:52 +01:00
IgorKurkov 132c17151a feat(epub.js): add font size saving in localstorage like theme saving 2025-10-26 18:24:44 +01:00
IgorKurkov 1ce1aee35e fix(js reader): adjust height to dynamic viewport height, add amber theme 2025-10-26 18:23:07 +01:00
Sebastian Holzer 4dc8948199 Some more fixes for some German translations in messages.po
Added some more translations that I stumbled over. Also added my name to it.
Please note however, the translation in line 3120 (book will be deleted from database) and the next translation (from hard disk) don't work well that way in german. Please consider defining two separate translations (one with and one without the hard disk). The german translation grammatically works the way I committed it here, but it's really not how one would write it if they had a choice. The "gelöscht" should always be at the end in german.
2025-10-22 20:02:04 +02:00
Sebastian Holzer 7db6481704 Added missing and fixed some existing German translations in messages.po
Updated German translations in messages.po, correcting various phrases and ensuring consistency. But most importantly added missing translations. Didn't check all translations though, just those that catched my attention
2025-10-22 19:47:25 +02:00
Ozzie Isaacs 6b11d0b4f3 Removed fuzzy translated strings which cause error during language compiling 2025-09-12 08:50:03 +02:00
Ozzie Isaacs c30d550ee1 Merge remote-tracking branch 'zh_Hant_TW/master' 2025-09-12 08:32:05 +02:00
Ozzie Isaacs c902fe1c5a Merge remote-tracking branch 'fr/master' 2025-09-12 08:28:27 +02:00
Ozzie Isaacs a6fe639e2f Merge remote-tracking branch 'it/patch-1' 2025-09-12 08:27:55 +02:00
mapi68 e1bce5d5fe Update italian messages.po 2025-09-11 01:36:07 +02:00
Ganfoud 5057e68b84 Add files via upload
FR translation update
2025-09-10 21:26:01 +02:00
Finrod Chen f88e776b1f Update zh_Hant_TW translations. 2025-09-10 11:56:43 +08:00
Ganfoud 957c906e53 Add files via upload
added some fr translations
2025-09-09 22:21:02 +02:00
Ozzie Isaacs cfda7f109a Merge remote-tracking branch 'books_api/cjwin2000-google_books_apikey' 2025-09-09 09:59:49 +02:00
Ozzie Isaacs 9fcd636533 Omit exporting of api strings 2025-09-09 09:40:37 +02:00
Ozzie Isaacs 0e50794d9b Testrun 2025-09-09 09:08:37 +02:00
Ozzie Isaacs 714c19f558 Some code cosmetics 2025-09-08 17:31:43 +02:00
Ozzie Isaacs c2d2db913e Merge remote-tracking branch 'opds/opds_improvements' 2025-09-08 15:59:33 +02:00
Ozzie Isaacs e3537235ca Merge remote-tracking branch 'fix_booklist_read_status/fix_booklist_read_status' 2025-09-08 12:52:25 +02:00
Ozzie Isaacs dc5663f21f Merge remote-tracking branch 'nl/patch-1' 2025-09-08 12:22:27 +02:00
Ozzie Isaacs dcc091e354 Merge remote-tracking branch 'instapaper/patch-1' 2025-09-08 12:07:32 +02:00
Ozzie Isaacs 4ad747decf Merge branch 'Develop' 2025-09-08 12:06:11 +02:00
Ozzie Isaacs 0d07e445de Bugfix mass edit books 2025-09-08 12:05:59 +02:00
Hot Garbage 9e7efb187b kobo: add Instapaper config
Latest firmware version writes empty values for Instapaper parameters if missing from the /initialization response.
Add default values to `NATIVE_KOBO_RESOURCES`
2025-09-01 12:15:06 -07:00
Ozzie Isaacs d480c55bcb Uodate optional-requirements.txt 2025-08-25 15:52:41 +02:00
Ozzie Isaacs 0ff1feaef0 Update optional-requirements.txt (#3446) 2025-08-25 14:43:39 +02:00
Ozzie Isaacs 0bfe6de62a Add certifi to requirements for Mac computer (fix for #3385) 2025-08-24 11:02:50 +02:00
rjaakke b632f1b9a2 Update get_filtered_book call to include archived books. 2025-08-18 20:31:31 +02:00
Syer10 9aab6d90f7 Keep tags since its now in html 2025-08-15 11:51:14 -04:00
Syer10 5cd914f007 Fix newlines 2025-08-15 11:41:38 -04:00
Syer10 3a899afcce Opds Improvements 2025-08-15 11:10:14 -04:00
Peter Kraus 7418c2c0b4 Update messages.po
Dutch translations almost completely finished
2025-08-15 15:39:07 +02:00
Ozzie Isaacs e86833a2a9 Updated requirements 2025-08-05 20:10:44 +02:00
Ozzie Isaacs f3a4df1759 Merge branch 'master' into Develop 2025-07-04 20:22:11 +02:00
Ozzie Isaacs 8938598aab Merge remote-tracking branch 'caliblur_fixes/book-table-layout' into Develop 2025-06-29 09:01:11 +02:00
Ozzie Isaacs 7472cdf43f Merge remote-tracking branch 'caliblur_fixes/css-speedup' into Develop 2025-06-29 08:54:31 +02:00
Asher Max Schweigart 4e245def8f Moving scrollbar to main panel instead of the body to lock header and sidebar in place 2025-04-29 14:20:17 -04:00
David Champion d32ad1d5ca Show shelf sizes in layout 2025-04-03 13:14:15 -07:00
quarz12 7029fa8b60 fix 2025-04-02 21:56:11 +02:00
quarz12 8c98b91a25 hide desktop navbar when in mobile resolution 2025-04-02 20:53:22 +02:00
Kevin Rauwolf feac864e73 Rename CACHE_DIR env var to CACHE_DIRECTORY (#3343) 2025-04-01 01:13:45 -07:00
Ozzie Isaacs d75adfe85e Improved testability 2025-03-31 19:06:53 +02:00
Ozzie Isaacs dbdaa0884e Merge branch 'master' into Develop 2025-03-30 13:48:08 +02:00
Ozzie Isaacs 1b1b7455d2 Merge branch 'master' into Develop 2025-03-30 09:42:58 +02:00
Ozzie Isaacs daaf7337ab Update Testresults 2025-03-30 09:42:31 +02:00
Ozzie Isaacs d08acdc3cc Merge branch 'master' into Develop 2025-03-24 18:59:12 +01:00
Ozzie Isaacs fd88dd2383 Merge remote-tracking branch 'Kobo_pen/Develop' into Develop 2025-03-23 08:44:11 +01:00
Ozzie Isaacs bcb72d780f Testupdate 2025-03-23 07:24:46 +01:00
Jørgen V 2f6322b0ee Fix #2849 2025-02-23 22:17:01 +01:00
cjwin2000 dbf104787c Update google.py 2025-01-10 20:31:17 -05:00
cjwin2000 bf31e675db Update config_edit.html 2025-01-10 20:28:09 -05:00
cjwin2000 7e24890991 Update admin.py 2025-01-10 20:20:08 -05:00
cjwin2000 4cf4c4dfe6 Update config_sql.py 2025-01-10 20:14:17 -05:00
Ozzie Isaacs e531bc0f0c Fixes from Test 2025-01-04 06:54:34 +01:00
Ozzie Isaacs f78a2367a2 Fix title sort, author sort setting for multi book table editing 2024-12-31 11:54:32 +01:00
Ozzie Isaacs 928ed10c2e Improved error handling on mass edit list 2024-12-29 20:12:27 +01:00
Ozzie Isaacs 32d120bd39 Fix for (#3261 handle permission error on generating new folders during rename of authors) 2024-12-21 11:36:18 +01:00
Ozzie Isaacs be5247a2ef Fix for (#3261 handle permission error on generating new folders during rename of authors) 2024-12-21 11:30:02 +01:00
Ozzie Isaacs 14bb01010d Bugfix edit books list (delete value) 2024-12-15 16:09:11 +01:00
Ozzie Isaacs 0afba50953 Merge branch 'master' into Develop 2024-12-14 14:54:00 +01:00
Ozzie Isaacs 24fdadbeb6 Update error handling book table edit 2024-12-14 14:39:17 +01:00
Ozzie Isaacs 08527ae3ce Update mass edit
Refactored delete User function
Updated testresults
2024-12-13 18:03:14 +01:00
Ozzie Isaacs 89e9958222 Merge remote-tracking branch 'massedit/bulk-delete' into Develop 2024-12-07 10:32:17 +01:00
James Armstrong 54d9d33e53 Revert "Add shift-click to select multiple books at once"
This reverts commit fe78222142.
2024-09-23 09:42:04 -07:00
James Armstrong 338441f116 fix author sort not updating when bulk editing 2024-09-17 12:26:35 -07:00
James Armstrong 31380f2af9 fix typo 2024-09-17 03:19:03 -07:00
James Armstrong 7e5d8978ae Merge branch 'master' into bulk-delete 2024-09-11 11:08:01 -07:00
James Armstrong fe78222142 Add shift-click to select multiple books at once 2024-08-05 10:43:51 -07:00
James Armstong de3f883992 Add change_archived_books() description 2024-08-03 11:52:28 -07:00
James Armstong 2afce66486 Fix change_archived so state=none is a toggle. Fixes /togglearchived endpoint 2024-08-03 11:50:28 -07:00
James Armstong 2ae80d3007 Fix book_read_status marking as read even though read_status is passed as false 2024-08-03 11:40:11 -07:00
James Armstong e31763d388 Fix kobo sync status marking as archived even though state = false 2024-08-03 11:27:58 -07:00
James Armstong bee6a3593d remove spacing 2024-08-02 23:31:04 -07:00
James Armstong a335dd75a1 Make edit metadata pass all data in a single REST call. Modularize editbooks 2024-08-02 23:29:34 -07:00
James Armstong 96fb2c1439 Auto disable author/title sort input in metadata edit form 2024-08-02 21:39:01 -07:00
James Armstong 9def910924 switch title_sort to sort in api edit request 2024-08-02 21:16:34 -07:00
James Armstong ab3d4e4f21 Fix emptying metadata form after submit 2024-08-02 20:10:43 -07:00
James Armstong ecda717bed Add bulk metadata edit button 2024-08-02 16:05:11 -07:00
James Armstrong 4ed0633edf Add bulk read/unread buttons; Fix buttons not working when moved into table 2024-08-02 14:57:20 -07:00
James Armstrong 34fec0ed97 Move buttons into table 2024-08-02 10:19:25 -07:00
James Armstrong ca9fc74541 typo fix 2024-08-02 00:40:47 -07:00
James Armstong d9a2a7a1e8 Add bulk archive/unarchive buttons 2024-08-01 21:34:55 -07:00
James Armstong d0d9985370 Add bulk delete button 2024-08-01 19:19:14 -07:00
134 changed files with 23268 additions and 19054 deletions
+1
View File
@@ -143,6 +143,7 @@ in your Docker run/compose file. Omit this variable for a lightweight image.
- **Common Issues**: - **Common Issues**:
- If you experience issues starting the application, check the log files located in the `logs` directory for error messages. - If you experience issues starting the application, check the log files located in the `logs` directory for error messages.
- If eBooks fail to load, verify that the `Location of Calibre database` is correctly set and that the database file is accessible. - If eBooks fail to load, verify that the `Location of Calibre database` is correctly set and that the database file is accessible.
- You need to enable uploads under `Basic settings` for this option to appear
- **Configuration Errors**: Ensure that your Calibre database is compatible and properly formatted. Refer to the Calibre documentation for guidance on maintaining the database. - **Configuration Errors**: Ensure that your Calibre database is compatible and properly formatted. Refer to the Calibre documentation for guidance on maintaining the database.
+19 -2
View File
@@ -42,9 +42,26 @@ def hide_console_windows():
if __name__ == '__main__': if __name__ == '__main__':
if os.name == "nt": # Only hide the console window on Windows when there is no interactive
# console attached. This preserves the ability to use Ctrl+C from a
# terminal (stdin is a tty) to stop the server.
if os.name == "nt" and not sys.stdin.isatty():
hide_console_windows() hide_console_windows()
main()
try:
main()
except KeyboardInterrupt:
# Allow stopping the server with Ctrl+C from an interactive console, after reboot of calibre-web
# (initally the SIGINT signal CTRL+C is catched in server.py via the signal callback).
try:
# Try to perform a graceful stop if web_server is available.
# Import here to avoid import-time side-effects when not needed.
from cps import web_server
web_server.stop()
except Exception:
pass
print('\nCalibre-Web: received interrupt, shutting down')
sys.exit(0)
+9 -1
View File
@@ -26,6 +26,7 @@ import os
import mimetypes import mimetypes
from flask import Flask from flask import Flask
from flask.sessions import SecureCookieSessionInterface
from .MyLoginManager import MyLoginManager from .MyLoginManager import MyLoginManager
from flask_principal import Principal from flask_principal import Principal
@@ -110,12 +111,19 @@ web_server = WebServer()
updater_thread = Updater() updater_thread = Updater()
if limiter_present: if limiter_present:
limiter = Limiter(key_func=True, headers_enabled=True, auto_check=False, swallow_errors=False) limiter = Limiter(key_func=True, headers_enabled=True, in_memory_fallback_enabled=True, default_limits=[],
swallow_errors=True)
else: else:
limiter = None limiter = None
class ScriptNameSessionInterface(SecureCookieSessionInterface):
def get_cookie_path(self, app):
# Called once per response, after request context exists
return app.wsgi_app.script_name.rstrip("/") or "/"
def create_app(): def create_app():
app.session_interface = ScriptNameSessionInterface()
if csrf: if csrf:
csrf.init_app(app) csrf.init_app(app)
+19 -10
View File
@@ -40,7 +40,7 @@ from flask_babel import gettext as _
from flask_babel import get_locale, format_time, format_datetime, format_timedelta from flask_babel import get_locale, format_time, format_datetime, format_timedelta
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError, ArgumentError
from sqlalchemy.sql.expression import func, or_, text from sqlalchemy.sql.expression import func, or_, text
from . import constants, logger, helper, services, cli_param from . import constants, logger, helper, services, cli_param
@@ -349,8 +349,9 @@ def list_users():
if sort not in ub.User.__table__.columns.keys(): if sort not in ub.User.__table__.columns.keys():
sort = "id" sort = "id"
order = request.args.get("order", "").lower() order = request.args.get("order", "").lower()
if sort != "state" and order: if sort != "state" and order:
if not order in ["asc", "desc"]:
order = "asc"
order = text(sort + " " + order) order = text(sort + " " + order)
elif not state: elif not state:
order = ub.User.id.asc() order = ub.User.id.asc()
@@ -386,13 +387,12 @@ def list_users():
@user_login_required @user_login_required
@admin_required @admin_required
def delete_user(): def delete_user():
user_ids = request.form.to_dict(flat=False) user_ids = request.get_json().get("userid")
users = None
message = "" message = ""
if "userid[]" in user_ids: try:
users = ub.session.query(ub.User).filter(ub.User.id.in_(user_ids['userid[]'])).all() users = ub.session.query(ub.User).filter(ub.User.id.in_(user_ids)).all()
elif "userid" in user_ids: except (ArgumentError):
users = ub.session.query(ub.User).filter(ub.User.id == user_ids['userid'][0]).all() users = None
count = 0 count = 0
errors = list() errors = list()
success = list() success = list()
@@ -408,10 +408,10 @@ def delete_user():
errors.append({'type': "danger", 'message': str(ex)}) errors.append({'type': "danger", 'message': str(ex)})
if count == 1: if count == 1:
log.info("User {} deleted".format(user_ids)) log.info("User {} deleted".format(user_ids[0]))
success = [{'type': "success", 'message': message}] success = [{'type': "success", 'message': message}]
elif count > 1: elif count > 1:
log.info("Users {} deleted".format(user_ids)) log.info("Users {} deleted".format(", ".join([str(user_id) for user_id in user_ids])))
success = [{'type': "success", 'message': _("{} users deleted successfully").format(count)}] success = [{'type': "success", 'message': _("{} users deleted successfully").format(count)}]
success.extend(errors) success.extend(errors)
return make_response(jsonify(success)) return make_response(jsonify(success))
@@ -618,6 +618,8 @@ def load_dialogtexts(element_id):
texts["main"] = _('Do you really want to delete this domain?') texts["main"] = _('Do you really want to delete this domain?')
elif element_id == "btndeluser": elif element_id == "btndeluser":
texts["main"] = _('Do you really want to delete this user?') texts["main"] = _('Do you really want to delete this user?')
elif element_id == "btndelbook":
texts["main"] = _('Do you really want to delete this book?')
elif element_id == "delete_shelf": elif element_id == "delete_shelf":
texts["main"] = _('Are you sure you want to delete this shelf?') texts["main"] = _('Are you sure you want to delete this shelf?')
elif element_id == "select_locale": elif element_id == "select_locale":
@@ -626,6 +628,10 @@ def load_dialogtexts(element_id):
texts["main"] = _('Are you sure you want to change visible book languages for selected user(s)?') texts["main"] = _('Are you sure you want to change visible book languages for selected user(s)?')
elif element_id == "role": elif element_id == "role":
texts["main"] = _('Are you sure you want to change the selected role for the selected user(s)?') texts["main"] = _('Are you sure you want to change the selected role for the selected user(s)?')
elif element_id == "archive_books":
texts["main"] = _('Are you sure you want to change the archive status for the selected book(s)?')
elif element_id == "read_books":
texts["main"] = _('Are you sure you want to change the read status for the selected book(s)?')
elif element_id == "restrictions": elif element_id == "restrictions":
texts["main"] = _('Are you sure you want to change the selected restrictions for the selected user(s)?') texts["main"] = _('Are you sure you want to change the selected restrictions for the selected user(s)?')
elif element_id == "sidebar_view": elif element_id == "sidebar_view":
@@ -1833,6 +1839,9 @@ def _configuration_update_helper():
services.goodreads_support.connect(config.config_goodreads_api_key, services.goodreads_support.connect(config.config_goodreads_api_key,
config.config_use_goodreads) config.config_use_goodreads)
# Google Books API configuration
reboot_required |=_config_string(to_save, "config_googlebooks_api_key")
_config_int(to_save, "config_updatechannel") _config_int(to_save, "config_updatechannel")
# Reverse proxy login configuration # Reverse proxy login configuration
-1
View File
@@ -19,7 +19,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from cps.pagination import Pagination
from flask import Blueprint from flask import Blueprint
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_babel import get_locale from flask_babel import get_locale
+7 -2
View File
@@ -35,8 +35,13 @@ def clean_string(unsafe_text, book_id=0):
try: try:
if bleach: if bleach:
allowed_tags = list(ALLOWED_TAGS) allowed_tags = list(ALLOWED_TAGS)
allowed_tags.extend(["p", "span", "div", "pre", "br", "h1", "h2", "h3", "h4", "h5", "h6"]) allowed_tags.extend(["p", "span", "div", "pre", "br", "h1", "h2", "h3", "h4", "h5", "h6", "img"])
safe_text = clean_html(unsafe_text, tags=set(allowed_tags)) allowed_attributes = {
"*": ["class", "style"],
"a": ["href", "title", "rel"],
"img": ["src", "alt", "title", "width", "height"],
}
safe_text = clean_html(unsafe_text, tags=set(allowed_tags), attributes=allowed_attributes)
else: else:
safe_text = clean_html(unsafe_text) safe_text = clean_html(unsafe_text)
except ParserError as e: except ParserError as e:
+4 -4
View File
@@ -65,7 +65,7 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
cover_data = extension = None cover_data = extension = None
if original_file_extension.upper() == '.CBZ': if original_file_extension.upper() == '.CBZ':
cf = zipfile.ZipFile(tmp_file_name) cf = zipfile.ZipFile(tmp_file_name)
for name in cf.namelist(): for name in sorted(cf.namelist()):
ext = os.path.splitext(name) ext = os.path.splitext(name)
if len(ext) > 1: if len(ext) > 1:
extension = ext[1].lower() extension = ext[1].lower()
@@ -74,7 +74,7 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
break break
elif original_file_extension.upper() == '.CBT': elif original_file_extension.upper() == '.CBT':
cf = tarfile.TarFile(tmp_file_name) cf = tarfile.TarFile(tmp_file_name)
for name in cf.getnames(): for name in sorted(cf.getnames()):
ext = os.path.splitext(name) ext = os.path.splitext(name)
if len(ext) > 1: if len(ext) > 1:
extension = ext[1].lower() extension = ext[1].lower()
@@ -85,7 +85,7 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
try: try:
rarfile.UNRAR_TOOL = rar_executable rarfile.UNRAR_TOOL = rar_executable
cf = rarfile.RarFile(tmp_file_name) cf = rarfile.RarFile(tmp_file_name)
for name in cf.namelist(): for name in sorted(cf.namelist()):
ext = os.path.splitext(name) ext = os.path.splitext(name)
if len(ext) > 1: if len(ext) > 1:
extension = ext[1].lower() extension = ext[1].lower()
@@ -96,7 +96,7 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
log.error('Rarfile failed with error: {}'.format(ex)) log.error('Rarfile failed with error: {}'.format(ex))
elif original_file_extension.upper() == '.CB7' and use_7zip: elif original_file_extension.upper() == '.CB7' and use_7zip:
cf = py7zr.SevenZipFile(tmp_file_name) cf = py7zr.SevenZipFile(tmp_file_name)
for name in cf.getnames(): for name in sorted(cf.getnames()):
ext = os.path.splitext(name) ext = os.path.splitext(name)
if len(ext) > 1: if len(ext) > 1:
extension = ext[1].lower() extension = ext[1].lower()
+8 -4
View File
@@ -84,8 +84,8 @@ class _Settings(_Base):
config_authors_max = Column(Integer, default=0) config_authors_max = Column(Integer, default=0)
config_read_column = Column(Integer, default=0) config_read_column = Column(Integer, default=0)
config_title_regex = Column(String, config_title_regex = Column(String,
default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine' default=r"^(A|The|An|Der|Die|Das|Den|Ein|Eine"
r'|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+') r"|Einen|Dem|Des|Einem|Eines|Le|La|Les|L'|Un|Une)(\s+|(?<='))")
config_theme = Column(Integer, default=0) config_theme = Column(Integer, default=0)
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL) config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
@@ -117,6 +117,7 @@ class _Settings(_Base):
config_use_goodreads = Column(Boolean, default=False) config_use_goodreads = Column(Boolean, default=False)
config_goodreads_api_key = Column(String) config_goodreads_api_key = Column(String)
config_googlebooks_api_key = Column(String, default='')
config_register_email = Column(Boolean, default=False) config_register_email = Column(Boolean, default=False)
config_login_type = Column(Integer, default=0) config_login_type = Column(Integer, default=0)
@@ -325,7 +326,9 @@ class ConfigSQL(object):
def to_dict(self): def to_dict(self):
storage = {} storage = {}
for k, v in self.__dict__.items(): for k, v in self.__dict__.items():
if k[0] != '_' and not k.endswith("_e") and not k == "cli": if k[0] != '_' and not k.endswith("_e") and not k == "cli" \
and 'api' not in k.lower() and 'token' not in k.lower() \
and 'secret' not in k.lower():
storage[k] = v storage[k] = v
return storage return storage
@@ -402,7 +405,7 @@ class ConfigSQL(object):
self.save() self.save()
def get_book_path(self): def get_book_path(self):
return self.config_calibre_split_dir if self.config_calibre_split_dir else self.config_calibre_dir return self.config_calibre_split_dir if self.config_calibre_split else self.config_calibre_dir
def store_calibre_uuid(self, calibre_db, Library_table): def store_calibre_uuid(self, calibre_db, Library_table):
from . import app from . import app
@@ -582,6 +585,7 @@ def get_encryption_key(key_path):
try: try:
with open(key_file, "wb") as f: with open(key_file, "wb") as f:
f.write(key) f.write(key)
os.chmod(key_file, 0o600)
except PermissionError as e: except PermissionError as e:
error = e error = e
return key, error return key, error
+6 -3
View File
@@ -37,9 +37,12 @@ STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static')
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates') TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations') TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
# Cache dir - use CACHE_DIR environment variable, otherwise use the default directory: cps/cache # Cache dir - use CACHE_DIRECTORY environment variable, otherwise CACHE_DIR (for backwards compatibility),
# otherwise use the default directory: cps/cache
DEFAULT_CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache') DEFAULT_CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache')
CACHE_DIR = os.environ.get('CACHE_DIR', DEFAULT_CACHE_DIR) CACHE_DIRECTORY = os.environ.get(
'CACHE_DIRECTORY', os.environ.get('CACHE_DIR', DEFAULT_CACHE_DIR)
)
if HOME_CONFIG: if HOME_CONFIG:
home_dir = os.path.join(os.path.expanduser("~"), ".calibre-web") home_dir = os.path.join(os.path.expanduser("~"), ".calibre-web")
@@ -172,7 +175,7 @@ BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, d
'series_id, languages, publisher, pubdate, identifiers') 'series_id, languages, publisher, pubdate, identifiers')
# python build process likes to have x.y.zbw -> b for beta and w a counting number # python build process likes to have x.y.zbw -> b for beta and w a counting number
STABLE_VERSION = '0.6.25' STABLE_VERSION = '0.6.27b'
NIGHTLY_VERSION = dict() NIGHTLY_VERSION = dict()
NIGHTLY_VERSION[0] = '$Format:%H$' NIGHTLY_VERSION[0] = '$Format:%H$'
+2 -34
View File
@@ -7,7 +7,6 @@ from flask import abort
from flask import current_app from flask import current_app
from flask import flash from flask import flash
from flask import g from flask import g
from flask import has_app_context
from flask import redirect from flask import redirect
from flask import request from flask import request
from flask import session from flask import session
@@ -469,7 +468,7 @@ class LoginManager:
config = current_app.config config = current_app.config
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME) cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
domain = config.get("REMEMBER_COOKIE_DOMAIN") domain = config.get("REMEMBER_COOKIE_DOMAIN")
path = config.get("REMEMBER_COOKIE_PATH", "/") path = config.get("REMEMBER_COOKIE_PATH", current_app.wsgi_app.script_name)
secure = config.get("REMEMBER_COOKIE_SECURE", COOKIE_SECURE) secure = config.get("REMEMBER_COOKIE_SECURE", COOKIE_SECURE)
httponly = config.get("REMEMBER_COOKIE_HTTPONLY", COOKIE_HTTPONLY) httponly = config.get("REMEMBER_COOKIE_HTTPONLY", COOKIE_HTTPONLY)
@@ -520,36 +519,5 @@ class LoginManager:
config = current_app.config config = current_app.config
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME) cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
domain = config.get("REMEMBER_COOKIE_DOMAIN") domain = config.get("REMEMBER_COOKIE_DOMAIN")
path = config.get("REMEMBER_COOKIE_PATH", "/") path = config.get("REMEMBER_COOKIE_PATH", current_app.wsgi_app.script_name)
response.delete_cookie(cookie_name, domain=domain, path=path) response.delete_cookie(cookie_name, domain=domain, path=path)
@property
def _login_disabled(self):
"""Legacy property, use app.config['LOGIN_DISABLED'] instead."""
import warnings
warnings.warn(
"'_login_disabled' is deprecated and will be removed in"
" Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
" instead.",
DeprecationWarning,
stacklevel=2,
)
if has_app_context():
return current_app.config.get("LOGIN_DISABLED", False)
return False
@_login_disabled.setter
def _login_disabled(self, newvalue):
"""Legacy property setter, use app.config['LOGIN_DISABLED'] instead."""
import warnings
warnings.warn(
"'_login_disabled' is deprecated and will be removed in"
" Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
" instead.",
DeprecationWarning,
stacklevel=2,
)
current_app.config["LOGIN_DISABLED"] = newvalue
+154 -60
View File
@@ -30,7 +30,7 @@ from sqlite3 import OperationalError as sqliteOperationalError
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy import Table, Column, ForeignKey, CheckConstraint from sqlalchemy import Table, Column, ForeignKey, CheckConstraint
from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
from sqlalchemy.orm import relationship, sessionmaker, scoped_session from sqlalchemy.orm import relationship, sessionmaker, scoped_session, selectinload
from sqlalchemy.orm.collections import InstrumentedList from sqlalchemy.orm.collections import InstrumentedList
from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.ext.declarative import DeclarativeMeta
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
@@ -416,8 +416,8 @@ class Books(Base):
path = Column(String, default="", nullable=False) path = Column(String, default="", nullable=False)
has_cover = Column(Integer, default=0) has_cover = Column(Integer, default=0)
uuid = Column(String) uuid = Column(String)
isbn = Column(String(collation='NOCASE'), default="") # isbn = Column(String(collation='NOCASE'), default="")
flags = Column(Integer, nullable=False, default=1) # flags = Column(Integer, nullable=False, default=1)
authors = relationship(Authors, secondary=books_authors_link, backref='books') authors = relationship(Authors, secondary=books_authors_link, backref='books')
tags = relationship(Tags, secondary=books_tags_link, backref='books', order_by="Tags.name") tags = relationship(Tags, secondary=books_tags_link, backref='books', order_by="Tags.name")
@@ -449,7 +449,14 @@ class Books(Base):
@property @property
def atom_timestamp(self): def atom_timestamp(self):
return self.timestamp.strftime('%Y-%m-%dT%H:%M:%S+00:00') or '' # OPDS atom:updated is defined as "the most recent instant in time
# when the entry was modified". Books.timestamp is the date added and
# never changes after import, so metadata and cover edits were
# invisible to OPDS sync clients. Use last_modified, which Calibre
# updates on every metadata or cover change; fall back to timestamp
# only if last_modified happens to be missing.
t = self.last_modified or self.timestamp
return t.strftime('%Y-%m-%dT%H:%M:%S+00:00') if t else ''
class CustomColumns(Base): class CustomColumns(Base):
@@ -640,8 +647,8 @@ class CalibreDB:
connect_args={'check_same_thread': False}, connect_args={'check_same_thread': False},
poolclass=StaticPool) poolclass=StaticPool)
with check_engine.begin() as connection: with check_engine.begin() as connection:
connection.execute(text("attach database '{}' as calibre;".format(dbpath))) connection.execute(text("attach database '{}' as calibre;".format(dbpath.replace("'", "''"))))
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path))) connection.execute(text("attach database '{}' as app_settings;".format(app_db_path.replace("'", "''"))))
local_session = scoped_session(sessionmaker()) local_session = scoped_session(sessionmaker())
local_session.configure(bind=connection) local_session.configure(bind=connection)
database_uuid = local_session().query(Library_Id).one_or_none() database_uuid = local_session().query(Library_Id).one_or_none()
@@ -694,8 +701,8 @@ class CalibreDB:
poolclass=StaticPool) poolclass=StaticPool)
with engine.begin() as connection: with engine.begin() as connection:
connection.execute(text('PRAGMA cache_size = 10000;')) connection.execute(text('PRAGMA cache_size = 10000;'))
connection.execute(text("attach database '{}' as calibre;".format(dbpath))) connection.execute(text("attach database '{}' as calibre;".format(dbpath.replace("'", "''"))))
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path))) connection.execute(text("attach database '{}' as app_settings;".format(app_db_path.replace("'", "''"))))
conn = engine.connect() conn = engine.connect()
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302 # conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
@@ -746,7 +753,8 @@ class CalibreDB:
.filter(self.common_filters(allow_show_archived)).first()) .filter(self.common_filters(allow_show_archived)).first())
def get_book_by_uuid(self, book_uuid): def get_book_by_uuid(self, book_uuid):
return self.session.query(Books).filter(Books.uuid == book_uuid).first() return self.session.query(Books).filter(Books.uuid == book_uuid). \
filter(self.common_filters()).first()
def get_book_format(self, book_id, file_format): def get_book_format(self, book_id, file_format):
return self.session.query(Data).filter(Data.book == book_id).filter(Data.format == file_format).first() return self.session.query(Data).filter(Data.book == book_id).filter(Data.format == file_format).first()
@@ -901,29 +909,36 @@ class CalibreDB:
for entry in entries: for entry in entries:
if combined: if combined:
sort_authors = entry.Books.author_sort.split('&') sort_authors = entry.Books.author_sort.split('&')
ids = [a.id for a in entry.Books.authors] authors_list = entry.Books.authors
else: else:
sort_authors = entry.author_sort.split('&') sort_authors = entry.author_sort.split('&')
ids = [a.id for a in entry.authors] authors_list = entry.authors
authors_ordered = list()
# error = False # Create dictionary for O(1) lookup instead of nested loops
authors_by_sort = {}
authors_by_id = {}
for author in authors_list:
authors_by_sort[author.sort] = author
authors_by_id[author.id] = author
authors_ordered = []
ids_remaining = set(authors_by_id.keys())
# Order authors based on sort field using dictionary lookup
for auth in sort_authors: for auth in sort_authors:
auth = strip_whitespaces(auth) auth = strip_whitespaces(auth)
results = self.session.query(Authors).filter(Authors.sort == auth).all() if auth in authors_by_sort:
# ToDo: How to handle not found author name author = authors_by_sort[auth]
if not len(results): authors_ordered.append(author)
book_id = entry.id if isinstance(entry, Books) else entry[0].id ids_remaining.discard(author.id)
log.error("Author '{}' of book {} not found to display name in right order".format(auth, book_id)) else:
# error = True # This can happen if author_sort has stale data or formatting issues
break book_id = entry.id if isinstance(entry, Books) else (entry.Books.id if combined else entry.id)
for r in results: log.warning("Author '{}' of book {} not found in author list, skipping in sort order".format(auth, book_id))
if r.id in ids:
authors_ordered.append(r) # Add any remaining authors not in sort order
ids.remove(r.id) for author_id in ids_remaining:
for author_id in ids: authors_ordered.append(authors_by_id[author_id])
result = self.session.query(Authors).filter(Authors.id == author_id).first()
authors_ordered.append(result)
if list_return: if list_return:
if combined: if combined:
@@ -937,16 +952,13 @@ class CalibreDB:
def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()): def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()):
query = query or '' query = query or ''
self.create_functions() self.create_functions()
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
entries = self.session.query(database).filter(tag_filter). \ entries = self.session.query(database).filter(tag_filter). \
filter(func.lower(database.name).ilike("%" + query + "%")).all() filter(func.lower(database.name).ilike("%" + query + "%")).all()
# json_dumps = json.dumps([dict(name=escape(r.name.replace(*replace))) for r in entries])
json_dumps = json.dumps([dict(name=r.name.replace(*replace)) for r in entries]) json_dumps = json.dumps([dict(name=r.name.replace(*replace)) for r in entries])
return json_dumps return json_dumps
def check_exists_book(self, authr, title): def check_exists_book(self, authr, title):
self.create_functions() self.create_functions()
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
q = list() q = list()
author_terms = re.split(r'\s*&\s*', authr) author_terms = re.split(r'\s*&\s*', authr)
for author_term in author_terms: for author_term in author_terms:
@@ -956,36 +968,93 @@ class CalibreDB:
.filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first() .filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first()
def search_query(self, term, config, *join): def search_query(self, term, config, *join):
strip_whitespaces(term).lower() term = strip_whitespaces(term).lower()
self.create_functions() self.create_functions()
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
q = list()
author_terms = re.split("[, ]+", term)
for author_term in author_terms:
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + author_term + "%")))
query = self.generate_linked_query(config.config_read_column, Books)
if len(join) == 6:
query = query.outerjoin(join[0], join[1]).outerjoin(join[2]).outerjoin(join[3], join[4]).outerjoin(join[5])
if len(join) == 3:
query = query.outerjoin(join[0], join[1]).outerjoin(join[2])
elif len(join) == 2:
query = query.outerjoin(join[0], join[1])
elif len(join) == 1:
query = query.outerjoin(join[0])
# Try FTS5 search first for better performance
fts_ids = None
# Check if FTS5 table exists before attempting search
if not hasattr(self, '_fts_available'):
try:
result = self.session.execute(
text("SELECT name FROM sqlite_master WHERE type='table' AND name='books_fts'")
).fetchone()
self._fts_available = result is not None
except Exception:
self._fts_available = False
if self._fts_available:
try:
# Escape FTS5 special characters to prevent query errors
term_fts = term.replace('"', '""')
# Wrap in quotes for phrase matching and better accuracy
fts_results = self.session.execute(
text("SELECT DISTINCT rowid FROM books_fts WHERE books_fts MATCH :term"),
{"term": f'"{term_fts}"'}
).fetchall()
if fts_results:
fts_ids = [r[0] for r in fts_results]
except Exception as ex:
# FTS5 query failed, fall back to traditional search
log.debug("FTS5 search failed for term '{}', using fallback: {}".format(term, ex))
# Build base query with optimized joins
base_query = self.generate_linked_query(config.config_read_column, Books)
base_query = base_query.filter(self.common_filters(True))
# Apply eager loading for authors to avoid N+1 queries
base_query = base_query.options(selectinload(Books.authors))
if len(join) == 6:
base_query = base_query.outerjoin(join[0], join[1]).outerjoin(join[2]).outerjoin(join[3], join[4]).outerjoin(join[5])
if len(join) == 3:
base_query = base_query.outerjoin(join[0], join[1]).outerjoin(join[2])
elif len(join) == 2:
base_query = base_query.outerjoin(join[0], join[1])
elif len(join) == 1:
base_query = base_query.outerjoin(join[0])
# If FTS5 found results, use those IDs
if fts_ids:
return base_query.filter(Books.id.in_(fts_ids))
# Fallback to traditional search with optimized subqueries
author_terms = re.split("[, ]+", term)
# Use subquery for authors to avoid expensive .any() with OR
author_subquery = self.session.query(books_authors_link.c.book).join(
Authors, books_authors_link.c.author == Authors.id
)
author_filters = []
for author_term in author_terms:
author_filters.append(func.lower(Authors.name).ilike("%" + author_term + "%"))
if author_filters:
author_subquery = author_subquery.filter(and_(*author_filters))
# Build optimized filter expressions
cc = self.get_cc_columns(config, filter_config_custom_read=True) cc = self.get_cc_columns(config, filter_config_custom_read=True)
filter_expression = [Books.tags.any(func.lower(Tags.name).ilike("%" + term + "%")), filter_expression = [
Books.series.any(func.lower(Series.name).ilike("%" + term + "%")), Books.id.in_(self.session.query(books_tags_link.c.book).join(
Books.authors.any(and_(*q)), Tags, books_tags_link.c.tag == Tags.id
Books.publishers.any(func.lower(Publishers.name).ilike("%" + term + "%")), ).filter(func.lower(Tags.name).ilike("%" + term + "%"))),
func.lower(Books.title).ilike("%" + term + "%")] Books.id.in_(self.session.query(books_series_link.c.book).join(
Series, books_series_link.c.series == Series.id
).filter(func.lower(Series.name).ilike("%" + term + "%"))),
Books.id.in_(author_subquery),
Books.id.in_(self.session.query(books_publishers_link.c.book).join(
Publishers, books_publishers_link.c.publisher == Publishers.id
).filter(func.lower(Publishers.name).ilike("%" + term + "%"))),
func.lower(Books.title).ilike("%" + term + "%")
]
for c in cc: for c in cc:
if c.datatype not in ["datetime", "rating", "bool", "int", "float"]: if c.datatype not in ["datetime", "rating", "bool", "int", "float"]:
filter_expression.append( filter_expression.append(
getattr(Books, getattr(Books,
'custom_column_' + str(c.id)).any( 'custom_column_' + str(c.id)).any(
func.lower(cc_classes[c.id].value).ilike("%" + term + "%"))) func.lower(cc_classes[c.id].value).ilike("%" + term + "%")))
return query.filter(self.common_filters(True)).filter(or_(*filter_expression))
return base_query.filter(or_(*filter_expression))
def get_cc_columns(self, config, filter_config_custom_read=False): def get_cc_columns(self, config, filter_config_custom_read=False):
tmp_cc = self.session.query(CustomColumns).filter(CustomColumns.datatype.notin_(cc_exceptions)).all() tmp_cc = self.session.query(CustomColumns).filter(CustomColumns.datatype.notin_(cc_exceptions)).all()
@@ -1007,18 +1076,32 @@ class CalibreDB:
def get_search_results(self, term, config, offset=None, order=None, limit=None, *join): def get_search_results(self, term, config, offset=None, order=None, limit=None, *join):
order = order[0] if order else [Books.sort] order = order[0] if order else [Books.sort]
pagination = None pagination = None
result = self.search_query(term, config, *join).order_by(*order).all()
result_count = len(result)
if offset is not None and limit is not None: if offset is not None and limit is not None:
offset = int(offset) offset = int(offset)
limit_all = offset + int(limit) limit_int = int(limit)
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
# Use LIMIT+1 pattern to estimate total count without expensive count()
query = self.search_query(term, config, *join).order_by(*order)
result = query.limit(offset + limit_int + 1).all()
# Check if there are more results
has_more = len(result) > (offset + limit_int)
if has_more:
result_count = offset + limit_int + 1 # Estimate: at least this many
else:
result_count = len(result)
# Extract the page of results
result = result[offset:offset + limit_int]
pagination = Pagination((offset / limit_int + 1), limit_int, result_count)
else: else:
offset = 0 # No pagination, fetch all results
limit_all = result_count result = self.search_query(term, config, *join).order_by(*order).all()
result_count = len(result)
ub.store_combo_ids(result) ub.store_combo_ids(result)
entries = self.order_authors(result[offset:limit_all], list_return=True, combined=True) entries = self.order_authors(result, list_return=True, combined=True)
return entries, result_count, pagination return entries, result_count, pagination
@@ -1043,7 +1126,7 @@ class CalibreDB:
.filter(self.common_filters()) .filter(self.common_filters())
.count()) .count())
if no_lang_count: if no_lang_count:
tags.append([Category(_("None"), "none"), no_lang_count]) tags.append([Category(_("None"), "None", "none"), no_lang_count])
return sorted(tags, key=lambda x: x[0].name.lower(), reverse=reverse_order) return sorted(tags, key=lambda x: x[0].name.lower(), reverse=reverse_order)
else: else:
if not languages: if not languages:
@@ -1097,14 +1180,25 @@ def lcase(s):
return s.lower() return s.lower()
def title_sort(title, config):
# calibre sort stuff
title_pat = re.compile(config.config_title_regex, re.IGNORECASE)
match = title_pat.search(title)
if match:
prep = match.group(1)
title = title[len(prep):] + ', ' + prep
return strip_whitespaces(title)
class Category: class Category:
name = None name = None
sort = None
id = None id = None
count = None count = None
rating = None rating = None
def __init__(self, name, cat_id, rating=None): def __init__(self, name, cat_id, rating=None):
self.name = name self.name = name
self.sort = name
self.id = cat_id self.id = cat_id
self.rating = rating self.rating = rating
self.count = 1 self.count = 1
+384 -166
View File
@@ -73,17 +73,18 @@ def edit_required(f):
return inner return inner
@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"]) @editbook.route("/ajax/deletebook", methods=["POST"])
@user_login_required @user_login_required
def delete_book_from_details(book_id): def delete_books_ajax():
return delete_book_from_table(book_id, "", True) book_ids = request.get_json().get("bookid")
return check_delete_book(book_ids, "", True)
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"]) @editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"]) @editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
@user_login_required @user_login_required
def delete_book_ajax(book_id, book_format): def delete_book(book_id, book_format):
return delete_book_from_table(book_id, book_format, False, request.form.to_dict().get('location', "")) return check_delete_book(book_id, book_format, False, request.form.to_dict().get('location', ""))
@editbook.route("/admin/book/<int:book_id>", methods=['GET']) @editbook.route("/admin/book/<int:book_id>", methods=['GET'])
@@ -161,7 +162,7 @@ def upload():
return make_response(jsonify(resp)) return make_response(jsonify(resp))
else: else:
resp = {"location": url_for('web.show_book', book_id=book_id)} resp = {"location": url_for('web.show_book', book_id=book_id)}
return make_response(jsonify(resp)) return Response(json.dumps(resp), mimetype='application/json')
except (OperationalError, IntegrityError, StaleDataError) as e: except (OperationalError, IntegrityError, StaleDataError) as e:
calibre_db.session.rollback() calibre_db.session.rollback()
log.error_or_exception("Database error: {}".format(e)) log.error_or_exception("Database error: {}".format(e))
@@ -213,96 +214,240 @@ def table_get_custom_enum(c_id):
@login_required_if_no_ano @login_required_if_no_ano
@edit_required @edit_required
def edit_list_book(param): def edit_list_book(param):
vals = request.form.to_dict() vals = request.get_json()
book = calibre_db.get_book(vals['pk']) multi = vals.get('multi', False) == "True"
calibre_db.create_functions(config) ret_value = edit_book_param(param, vals, multi)
sort_param = "" if isinstance(ret_value, dict):
ret = "" return jsonify(ret_value)
try: else:
if param == 'series_index': return ret_value
edit_book_series_index(vals['value'], book)
ret = make_response(jsonify(success=True, newValue=book.series_index)) @editbook.route("/ajax/editselectedbooks", methods=['POST'])
elif param == 'tags': @login_required_if_no_ano
edit_book_tags(vals['value'], book) @edit_required
ret = make_response(jsonify(success=True, newValue=', '.join([tag.name for tag in book.tags]))) def edit_selected_books():
elif param == 'series': d = request.get_json()
edit_book_series(vals['value'], book) selections = d.get('selections')
ret = make_response(jsonify(success=True, newValue=', '.join([serie.name for serie in book.series]))) title = d.get('title')
elif param == 'publishers': title_sort = d.get('title_sort')
edit_book_publisher(vals['value'], book) author_sort = d.get('author_sort')
ret = make_response(jsonify(success=True, authors = d.get('authors')
newValue=', '.join([publisher.name for publisher in book.publishers]))) categories = d.get('categories')
elif param == 'languages': series = d.get('series')
invalid = list() languages = d.get('languages')
edit_book_languages(vals['value'], book, invalid=invalid) publishers = d.get('publishers')
if invalid: comments = d.get('comments')
ret = make_response(jsonify(success=False,
msg='Invalid languages in request: {}'.format(','.join(invalid)))) if not (
else: title or title_sort or authors or categories or series or languages or publishers or comments) or not selections:
lang_names = list() return _("Parameter not found"), 400
for lang in book.languages: vals = {
lang_names.append(isoLanguages.get_language_name(get_locale(), lang.lang_code)) "pk": selections,
ret = make_response(jsonify(success=True, newValue=', '.join(lang_names))) "value": None,
elif param == 'author_sort': "checkA": d.get('checkA'),
book.author_sort = vals['value'] "checkT": d.get('checkT'),
ret = make_response(jsonify(success=True, newValue=book.author_sort)) }
elif param == 'title': res = list()
sort_param = book.sort if title:
if handle_title_on_edit(book, vals.get('value', "")): vals['value'] = title
rename_error = helper.update_dir_structure(book.id, config.get_book_path()) out = edit_book_param('title', vals, True)
if not rename_error: if out[0].get('success') != True:
ret = make_response(jsonify(success=True, newValue=book.title)) res.extend(out)
else: if title_sort:
ret = make_response(jsonify(success=False, msg=rename_error)) vals['value'] = title_sort
elif param == 'sort': out = edit_book_param('sort', vals, True)
book.sort = vals['value'] if out[0].get('success') != True:
ret = make_response(jsonify(success=True,newValue=book.sort)) res.extend(out)
elif param == 'comments': if author_sort:
edit_book_comments(vals['value'], book) vals['value'] = author_sort
ret = make_response(jsonify(success=True, newValue=book.comments[0].text)) out = edit_book_param('author_sort', vals, True)
elif param == 'authors': if out[0].get('success') != True:
input_authors, __ = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true") res.extend(out)
rename_error = helper.update_dir_structure(book.id, config.get_book_path(), input_authors[0]) if authors:
if not rename_error: vals['value'] = authors
ret = make_response(jsonify( out = edit_book_param('authors', vals, True)
success=True, if out[0].get('success') != True:
newValue=' & '.join([author.replace('|', ',') for author in input_authors]))) res.extend(out)
else: if categories:
ret = make_response(jsonify(success=False, msg=rename_error)) vals['value'] = categories
elif param == 'is_archived': out = edit_book_param('tags', vals, True)
is_archived = change_archived_books(book.id, vals['value'] == "True", if out[0].get('success') != True:
message="Book {} archive bit set to: {}".format(book.id, vals['value'])) res.extend(out)
if is_archived: if series:
kobo_sync_status.remove_synced_book(book.id) vals['value'] = series
return "" out = edit_book_param('series', vals, True)
elif param == 'read_status': if out[0].get('success') != True:
ret = helper.edit_book_read_status(book.id, vals['value'] == "True") res.extend(out)
if ret: if languages:
return ret, 400 vals['value'] = languages
elif param.startswith("custom_column_"): out = edit_book_param('languages', vals, True)
new_val = dict() if out[0].get('success') != True:
new_val[param] = vals['value'] res.extend(out)
edit_single_cc_data(book.id, book, param[14:], new_val) if publishers:
# ToDo: Very hacky find better solution vals['value'] = publishers
if vals['value'] in ["True", "False"]: out = edit_book_param('publishers', vals, True)
ret = "" if out[0].get('success') != True:
else: res.extend(out)
ret = make_response(jsonify(success=True, newValue=vals['value'])) if comments:
else: vals['value'] = comments
return _("Parameter not found"), 400 out = edit_book_param('comments', vals, True)
book.last_modified = datetime.now(timezone.utc) if out[0].get('success') != True:
res.extend(out)
if len(res) == 0:
return jsonify([{'success': True, "msg": _("Changes successfully applied")}])
else:
return jsonify(res)
# Separated from /editbooks so that /editselectedbooks can also use this
#
# param: the property of the book to be changed
# vals - JSON Object:
# {
# 'pk': "the book id",
# 'value': "changes value of param to what's passed here"
# 'checkA': "Optional. Used to check if autosort author is enabled. Assumed as true if not passed"
# 'checkT': "Optional. Used to check if autotitle author is enabled. Assumed as true if not passed"
# }
#
@login_required_if_no_ano
@edit_required
def edit_book_param(param, vals, multi=False):
elements = vals.get('pk',[])
if vals.get('value', None) is None:
return {'success':False, 'msg':_("Value is missing on request")}
if not elements or len(elements) > 1 and multi == False:
return {"success":False, "msg":_("Oops! Selected book is unavailable. File does not exist or is not accessible")}
ret = {}
out = list()
for elem in elements:
book = calibre_db.get_book(elem)
if not book:
ret = {"success": False,
"msg": _("Oops! Selected book is unavailable. File does not exist or is not accessible")}
if multi:
out.append(ret)
continue
else:
return ret
calibre_db.create_functions(config)
sort_param = ""
try:
if param == 'series_index':
edit_book_series_index(vals['value'], book)
ret = {"success":True,
"newValue":book.series_index}
elif param == 'tags':
edit_book_tags(vals['value'], book)
ret = {"success":True,
"newValue":', '.join([tag.name for tag in book.tags])}
elif param == 'series':
edit_book_series(vals['value'], book)
ret = {"success":True,
"newValue":', '.join([serie.name for serie in book.series])}
elif param == 'publishers':
edit_book_publisher(vals['value'], book)
ret = {"success":True,
"newValue":', '.join([publisher.name for publisher in book.publishers])}
elif param == 'languages':
invalid = list()
edit_book_languages(vals['value'], book, invalid=invalid)
if invalid:
ret = {"success": False, "msg": 'Invalid languages in request: {}'.format(','.join(invalid))}
if multi:
out.append(ret)
else:
lang_names = list()
for lang in book.languages:
lang_names.append(isoLanguages.get_language_name(get_locale(), lang.lang_code))
ret = {"success":True,
"newValue":', '.join(lang_names)}
elif param == 'author_sort':
book.author_sort = vals['value']
ret = {"success":True,
"newValue":book.author_sort}
elif param == 'title':
sort_param = book.sort
if handle_title_on_edit(book, vals.get('value', "")):
rename_error = helper.update_dir_structure(book.id, config.get_book_path())
if not rename_error:
calibre_db.session.commit()
ret = {"success":True,
"newValue":book.title}
else:
calibre_db.session.rollback()
ret = {"success":False, "msg":rename_error}
if multi:
out.append(ret)
elif param == 'sort':
book.sort = vals['value']
ret = {"success":True,
"newValue":book.sort}
elif param == 'comments':
edit_book_comments(vals['value'], book)
ret = {"success":True,
"newValue":book.comments[0].text}
elif param == 'authors':
input_authors, __ = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == True)
rename_error = helper.update_dir_structure(book.id, config.get_book_path(), input_authors[0])
if not rename_error:
calibre_db.session.commit()
ret = {"success":True,
"newValue":' & '.join([author.replace('|', ',') for author in input_authors])}
else:
calibre_db.session.rollback()
ret = {"success":False, "msg":rename_error}
if multi:
out.append(ret)
elif param == 'is_archived':
is_archived = change_archived_books(book.id, vals['value'] == "True",
message="Book {} archive bit set to: {}".format(book.id,
vals['value']))
if is_archived:
kobo_sync_status.remove_synced_book(book.id)
continue
elif param == 'read_status':
error = helper.edit_book_read_status(book.id, vals['value'] == "True")
if error:
if multi:
out.append({"success":False, "msg":error})
continue
else:
return error, 400
continue
elif param.startswith("custom_column_"):
new_val = dict()
new_val[param] = vals['value']
edit_single_cc_data(book.id, book, param[14:], new_val)
# ToDo: Very hacky find better solution
if vals['value'] in ["True", "False"]:
ret = {}
else:
ret = {"success":True, "newValue":vals['value']}
else:
if multi:
out.append({"success":False, "msg":_("Parameter not found")})
continue
return _("Parameter not found"), 400
book.last_modified = datetime.now(timezone.utc)
calibre_db.session.commit()
# revert change for sort if automatic fields link is deactivated
if param == 'title' and vals.get('checkT') == "false":
book.sort = sort_param
calibre_db.session.commit() calibre_db.session.commit()
except (OperationalError, IntegrityError, StaleDataError) as e: # revert change for sort if automatic fields link is deactivated
calibre_db.session.rollback() if param == 'title' and vals.get('checkT') == False:
log.error_or_exception("Database error: {}".format(e)) book.sort = sort_param
ret = make_response(jsonify(success=False, calibre_db.session.commit()
msg='Database error: {}'.format(e.orig if hasattr(e, "orig") else e))) except (OperationalError, IntegrityError, StaleDataError, AttributeError) as e:
return ret calibre_db.session.rollback()
log.error_or_exception("Database error: {}".format(e))
ret = {"success":False, "msg":'Database error: {}'.format(e.orig if hasattr(e, "orig") else e)}
if multi:
out.append(ret)
if multi:
if len(out) > 0:
return out
else:
return [ret]
else:
return ret
@editbook.route("/ajax/sort_value/<field>/<int:bookid>") @editbook.route("/ajax/sort_value/<field>/<int:bookid>")
@@ -337,6 +482,55 @@ def simulate_merge_list_book():
return make_response(jsonify({'to': to_book, 'from': from_book})) return make_response(jsonify({'to': to_book, 'from': from_book}))
return "" return ""
@editbook.route("/ajax/displayselectedbooks", methods=['POST'])
@user_login_required
@edit_required
def display_selected_books():
vals = request.get_json().get('selections')
books = []
if vals:
for book_id in vals:
books.append(calibre_db.get_book(book_id).title)
return json.dumps({'books': books})
return ""
@editbook.route("/ajax/archiveselectedbooks", methods=['POST'])
@login_required_if_no_ano
@edit_required
def archive_selected_books():
vals = request.get_json().get('selections')
state = request.get_json().get('archive')
if vals:
for book_id in vals:
is_archived = change_archived_books(book_id, state,
message="Book {} archive bit set to: {}".format(book_id, state))
if is_archived:
kobo_sync_status.remove_synced_book(book_id)
return json.dumps({'success': True})
return ""
@editbook.route("/ajax/readselectedbooks", methods=['POST'])
@user_login_required
@edit_required
def read_selected_books():
vals = request.get_json().get('selections')
markAsRead = request.get_json().get('markAsRead')
if vals:
try:
for book_id in vals:
ret = helper.edit_book_read_status(book_id, markAsRead)
except (OperationalError, IntegrityError, StaleDataError) as e:
calibre_db.session.rollback()
log.error_or_exception("Database error: {}".format(e))
ret = Response(json.dumps({'success': False,
'msg': 'Database error: {}'.format(e.orig if hasattr(e, "orig") else e)}),
mimetype='application/json')
return json.dumps({'success': True})
return ""
@editbook.route("/ajax/mergebooks", methods=['POST']) @editbook.route("/ajax/mergebooks", methods=['POST'])
@user_login_required @user_login_required
@@ -371,7 +565,7 @@ def merge_list_book():
element.format, element.format,
element.uncompressed_size, element.uncompressed_size,
to_name)) to_name))
delete_book_from_table(from_book.id, "", True) check_delete_book([from_book.id], "", True)
return make_response(jsonify(success=True)) return make_response(jsonify(success=True))
return "" return ""
@@ -648,8 +842,9 @@ def prepare_authors(authr, calibre_path, gdrive=False):
all_new_name = helper.get_valid_filename(one_book.title, chars=42) + ' - ' \ all_new_name = helper.get_valid_filename(one_book.title, chars=42) + ' - ' \
+ helper.get_valid_filename(renamed_author.name, chars=42) + helper.get_valid_filename(renamed_author.name, chars=42)
# change location in database to new author/title path # change location in database to new author/title path
helper.rename_all_files_on_change(one_book, new_path, new_path, all_new_name, gdrive) error = helper.rename_all_files_on_change(one_book, new_path, new_path, all_new_name, gdrive)
if error:
flash(error)
return input_authors return input_authors
@@ -841,86 +1036,109 @@ def delete_whole_book(book_id, book):
calibre_db.session.query(db.Books).filter(db.Books.id == book_id).delete() calibre_db.session.query(db.Books).filter(db.Books.id == book_id).delete()
def render_delete_book_result(book_format, json_response, warning, book_id, location=""): def render_delete_book_result(book_format, book_id, location=""):
if book_format: if book_format:
if json_response: flash(_('Book Format Successfully Deleted'), category="success")
return jsonify([warning, {"location": url_for("edit-book.show_edit_book", book_id=book_id), return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
"type": "success",
"format": book_format,
"message": _('Book Format Successfully Deleted')}])
else:
flash(_('Book Format Successfully Deleted'), category="success")
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
else: else:
if json_response: flash(_('Book Successfully Deleted'), category="success")
return jsonify([warning, {"location": get_redirect_location(location, "web.index"), return redirect(get_redirect_location(location, "web.index"))
"type": "success",
"format": book_format,
"message": _('Book Successfully Deleted')}])
else:
flash(_('Book Successfully Deleted'), category="success")
return redirect(get_redirect_location(location, "web.index"))
def delete_book_from_table(book_id, book_format, json_response, location=""): def check_delete_book(book_id, book_format, json_response, location=""):
warning = {}
if current_user.role_delete_books(): if current_user.role_delete_books():
book = calibre_db.get_book(book_id) if json_response:
if book: # if json response is set, it's possible to delete more than one book, but never a format is deleted
try: res = list()
result, error = helper.delete_book(book, config.get_book_path(), book_format=book_format.upper()) for b in book_id:
if not result: ret = delete_book_from_table(b)
if json_response: if ret:
return jsonify([{"location": url_for("edit-book.show_edit_book", book_id=book_id), res.extend([ret])
"type": "danger", if len(res) == 0:
"format": "", return [{"location": get_redirect_location(location, "web.index"),
"message": error}]) "type": "success",
else: "format": "",
flash(error, category="error") "message": _('Book Successfully Deleted')}]
return redirect(url_for('edit-book.show_edit_book', book_id=book_id)) return jsonify(res)
if error:
if json_response:
warning = {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "warning",
"format": "",
"message": error}
else:
flash(error, category="warning")
if not book_format:
delete_whole_book(book_id, book)
else:
calibre_db.session.query(db.Data).filter(db.Data.book == book.id).\
filter(db.Data.format == book_format).delete()
if book_format.upper() in ['KEPUB', 'EPUB', 'EPUB3']:
kobo_sync_status.remove_synced_book(book.id, True)
calibre_db.session.commit()
except Exception as ex:
log.error_or_exception(ex)
calibre_db.session.rollback()
if json_response:
return jsonify([{"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": ex}])
else:
flash(str(ex), category="error")
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
else: else:
# book not found return delete_book_from_UI(book_id, book_format, location)
log.error('Book with id "%s" could not be deleted: not found', book_id)
return render_delete_book_result(book_format, json_response, warning, book_id, location)
message = _("You are missing permissions to delete books") message = _("You are missing permissions to delete books")
if json_response: if json_response:
return jsonify({"location": url_for("edit-book.show_edit_book", book_id=book_id), try:
"type": "danger", return jsonify({"location": url_for("edit-book.show_edit_book", book_id=int(book_id)),
"format": "", "type": "danger",
"message": message}) "format": "",
"message": message})
except TypeError as e:
return jsonify({"location": url_for("web.index"), "type": "danger", "format": "",
"message": str(e)})
else: else:
flash(message, category="error") flash(message, category="error")
return redirect(url_for('edit-book.show_edit_book', book_id=book_id)) return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
def delete_book_from_UI(book_id, book_format, location=""):
book = calibre_db.get_book(book_id)
if book:
try:
result, error = helper.delete_book(book, config.get_book_path(), book_format=book_format.upper())
if not result:
flash(error, category="error")
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
if error:
flash(error, category="warning")
if not book_format:
delete_whole_book(book_id, book)
else:
calibre_db.session.query(db.Data).filter(db.Data.book == book.id). \
filter(db.Data.format == book_format).delete()
if book_format.upper() in ['KEPUB', 'EPUB', 'EPUB3']:
kobo_sync_status.remove_synced_book(book.id, True)
calibre_db.session.commit()
except Exception as ex:
log.error_or_exception(ex)
calibre_db.session.rollback()
flash(str(ex), category="error")
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
else:
# book not found
log.error('Book with id "%s" could not be deleted: not found', book_id)
return render_delete_book_result(book_format, book_id, location)
def delete_book_from_table(book_id):
book = calibre_db.get_book(book_id)
if book:
try:
result, error = helper.delete_book(book, config.get_book_path(), book_format="")
if not result:
return {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": error}
delete_whole_book(book_id, book)
calibre_db.session.commit()
if error:
return {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "warning",
"format": "",
"message": error}
except Exception as ex:
log.error_or_exception(ex)
calibre_db.session.rollback()
return {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": ex}
else:
# book not found
log.error('Book with id "%s" could not be deleted: not found', book_id)
return {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": _('Book with id "{}" could not be deleted: not found'.format(book_id))}
def render_edit_book(book_id): def render_edit_book(book_id):
cc = calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.datatype.notin_(db.cc_exceptions)).all() cc = calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.datatype.notin_(db.cc_exceptions)).all()
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
@@ -1393,7 +1611,7 @@ def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
if db_type == 'author': if db_type == 'author':
new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ','))) new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')))
elif db_type == 'series': elif db_type == 'series':
new_element = db_object(add_element, add_element) new_element = db_object(add_element, db.title_sort(add_element, config))
elif db_type == 'custom': elif db_type == 'custom':
new_element = db_object(value=add_element) new_element = db_object(value=add_element)
elif db_type == 'publisher': elif db_type == 'publisher':
@@ -1424,7 +1642,7 @@ def create_objects_for_addition(db_element, add_element, db_type):
elif db_type == 'series': elif db_type == 'series':
if db_element.name != add_element: if db_element.name != add_element:
db_element.name = add_element db_element.name = add_element
db_element.sort = add_element db_element.sort = db.title_sort(add_element, config)
elif db_type == 'author': elif db_type == 'author':
if db_element.name != add_element: if db_element.name != add_element:
db_element.name = add_element db_element.name = add_element
+2 -1
View File
@@ -36,7 +36,8 @@ def do_calibre_export(book_id, book_format):
if config.config_calibre_split: if config.config_calibre_split:
my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db") my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db")
library_path = config.get_book_path() library_path = config.get_book_path()
opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--with-library', library_path, opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--dont-save-cover',
'--with-library', library_path,
'--to-dir', tmp_dir, '--formats', book_format, "--template", "{}".format(temp_file_name), '--to-dir', tmp_dir, '--formats', book_format, "--template", "{}".format(temp_file_name),
str(book_id)] str(book_id)]
p = process_open(opf_command, quotes, my_env) p = process_open(opf_command, quotes, my_env)
+1 -1
View File
@@ -175,7 +175,7 @@ def parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path):
for cs in cover_section: for cs in cover_section:
if cs.endswith('.xhtml') or cs.endswith('.html'): if cs.endswith('.xhtml') or cs.endswith('.html'):
markup = epub_zip.read(os.path.join(cover_path, cs)) markup = epub_zip.read(os.path.join(cover_path, cs))
markup_tree = etree.fromstring(markup) markup_tree = etree.fromstring(markup, parser=etree.XMLParser(resolve_entities=False, no_network=True))
# no matter xhtml or html with no namespace # no matter xhtml or html with no namespace
img_src = markup_tree.xpath("//*[local-name() = 'img']/@src") img_src = markup_tree.xpath("//*[local-name() = 'img']/@src")
# Alternative image source # Alternative image source
+6 -2
View File
@@ -53,16 +53,20 @@ def updateEpub(src, dest, filename, data, ):
zf.writestr(filename, data) zf.writestr(filename, data)
# Safe parser: disable entity resolution and network access to prevent XXE attacks
_safe_parser = etree.XMLParser(resolve_entities=False, no_network=True)
def get_content_opf(file_path, ns=None): def get_content_opf(file_path, ns=None):
if ns is None: if ns is None:
ns = default_ns ns = default_ns
epubZip = zipfile.ZipFile(file_path) epubZip = zipfile.ZipFile(file_path)
txt = epubZip.read('META-INF/container.xml') txt = epubZip.read('META-INF/container.xml')
tree = etree.fromstring(txt) tree = etree.fromstring(txt, parser=_safe_parser)
cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0] cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
cf = epubZip.read(cf_name) cf = epubZip.read(cf_name)
return etree.fromstring(cf), cf_name return etree.fromstring(cf, parser=_safe_parser), cf_name
def create_new_metadata_backup(book, custom_columns, export_language, translated_cover_name, lang_type=3): def create_new_metadata_backup(book, custom_columns, export_language, translated_cover_name, lang_type=3):
+37 -4
View File
@@ -18,21 +18,29 @@
import traceback import traceback
from flask import render_template from flask import render_template, request, flash, make_response
from flask_limiter import RateLimitExceeded
from flask_babel import gettext as _
from werkzeug.exceptions import default_exceptions from werkzeug.exceptions import default_exceptions
from .cw_login import current_user
try: try:
from werkzeug.exceptions import FailedDependency from werkzeug.exceptions import FailedDependency
except ImportError: except ImportError:
from werkzeug.exceptions import UnprocessableEntity as FailedDependency from werkzeug.exceptions import UnprocessableEntity as FailedDependency
from . import config, app, logger, services from . import config, app, logger, services
from .render_template import render_title_template
from .web import render_login
from .usermanagement import auth
from cps.string_helper import strip_whitespaces
log = logger.create() log = logger.create()
# custom error page # custom error page
def error_http(error): def error_http(error):
headers = {'WWW-Authenticate': 'Basic realm="calibre-web"'} if error.code == 401 else {}
return render_template('http_error.html', return render_template('http_error.html',
error_code="Error {0}".format(error.code), error_code="Error {0}".format(error.code),
error_name=error.name, error_name=error.name,
@@ -40,7 +48,7 @@ def error_http(error):
goto_admin=False, goto_admin=False,
unconfigured=not config.db_configured, unconfigured=not config.db_configured,
instance=config.config_calibre_web_title instance=config.config_calibre_web_title
), error.code ), error.code, headers
def internal_error(error): def internal_error(error):
@@ -56,6 +64,13 @@ def internal_error(error):
error_stack="", error_stack="",
instance=config.config_calibre_web_title instance=config.config_calibre_web_title
), 500 ), 500
log.error("500 Internal Server Error: %s", traceback.format_exc())
error_stack = ""
try:
if current_user.is_authenticated and current_user.role_admin():
error_stack = traceback.format_exc().split("\n")
except Exception:
pass
return render_template('http_error.html', return render_template('http_error.html',
error_code="500 Internal Server Error", error_code="500 Internal Server Error",
error_name='The server encountered an internal error and was unable to complete your ' error_name='The server encountered an internal error and was unable to complete your '
@@ -63,7 +78,7 @@ def internal_error(error):
issue=True, issue=True,
goto_admin=False, goto_admin=False,
unconfigured=False, unconfigured=False,
error_stack=traceback.format_exc().split("\n"), error_stack=error_stack,
instance=config.config_calibre_web_title instance=config.config_calibre_web_title
), 500 ), 500
@@ -84,3 +99,21 @@ def init_errorhandler():
log.debug('LDAP server not accessible while trying to login to opds feed') log.debug('LDAP server not accessible while trying to login to opds feed')
return error_http(FailedDependency()) return error_http(FailedDependency())
@app.errorhandler(RateLimitExceeded)
def handle_rate_limit(__):
log.error("Rate limit exceeded {}".format(request.endpoint))
if "register" in request.endpoint:
flash(_(u"Please wait one minute to register next user"), category="error")
return render_title_template('register.html', config=config, title=_("Register"), page="register")
elif "login" in request.endpoint:
form = request.form.to_dict()
username = strip_whitespaces(form.get('username', "")).lower().replace("\n", "").replace("\r", "")
flash(_("Please wait one minute before next login"), category="error")
return render_login(username, form.get("password", ""))
elif "opds" in request.endpoint:
return auth.auth_error_callback(429)
else:
return make_response('', 429)
+4 -1
View File
@@ -20,6 +20,9 @@ from lxml import etree
from .constants import BookMeta from .constants import BookMeta
# Safe parser: disable entity resolution and network access to prevent XXE attacks
_safe_parser = etree.XMLParser(resolve_entities=False, no_network=True)
def get_fb2_info(tmp_file_path, original_file_extension): def get_fb2_info(tmp_file_path, original_file_extension):
@@ -29,7 +32,7 @@ def get_fb2_info(tmp_file_path, original_file_extension):
} }
fb2_file = open(tmp_file_path, encoding="utf-8") fb2_file = open(tmp_file_path, encoding="utf-8")
tree = etree.fromstring(fb2_file.read().encode()) tree = etree.fromstring(fb2_file.read().encode(), parser=_safe_parser)
authors = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:author', namespaces=ns) authors = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:author', namespaces=ns)
+2 -2
View File
@@ -17,7 +17,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from . import logger from . import logger
from .constants import CACHE_DIR from .constants import CACHE_DIRECTORY
from os import makedirs, remove from os import makedirs, remove
from os.path import isdir, isfile, join from os.path import isdir, isfile, join
from shutil import rmtree from shutil import rmtree
@@ -25,7 +25,7 @@ from shutil import rmtree
class FileSystem: class FileSystem:
_instance = None _instance = None
_cache_dir = CACHE_DIR _cache_dir = CACHE_DIRECTORY
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
+1 -1
View File
@@ -140,7 +140,7 @@ try:
if response: if response:
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode() dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
if not response['deleted'] and response['file']['title'] == 'metadata.db' \ if not response['deleted'] and response['file']['title'] == 'metadata.db' \
and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec and response['file']['md5Checksum'] != hashlib.md5(dbpath).hexdigest(): # nosec
tmp_dir = get_temp_dir() tmp_dir = get_temp_dir()
log.info('Database file updated') log.info('Database file updated')
+48 -48
View File
@@ -30,7 +30,7 @@ import requests
import unidecode import unidecode
from uuid import uuid4 from uuid import uuid4
from flask import send_from_directory, make_response, abort, url_for, Response, request from flask import send_from_directory, make_response, abort, url_for, Response, request, after_this_request
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_babel import lazy_gettext as N_ from flask_babel import lazy_gettext as N_
from flask_babel import get_locale from flask_babel import get_locale
@@ -307,18 +307,16 @@ def edit_book_read_status(book_id, read_status=None):
if not config.config_read_column: if not config.config_read_column:
book = ub.session.query(ub.ReadBook).filter(and_(ub.ReadBook.user_id == int(current_user.id), book = ub.session.query(ub.ReadBook).filter(and_(ub.ReadBook.user_id == int(current_user.id),
ub.ReadBook.book_id == book_id)).first() ub.ReadBook.book_id == book_id)).first()
if book: if not book:
if read_status is None:
if book.read_status == ub.ReadBook.STATUS_FINISHED:
book.read_status = ub.ReadBook.STATUS_UNREAD
else:
book.read_status = ub.ReadBook.STATUS_FINISHED
else:
book.read_status = ub.ReadBook.STATUS_FINISHED if read_status else ub.ReadBook.STATUS_UNREAD
else:
read_book = ub.ReadBook(user_id=current_user.id, book_id=book_id) read_book = ub.ReadBook(user_id=current_user.id, book_id=book_id)
read_book.read_status = ub.ReadBook.STATUS_FINISHED
book = read_book book = read_book
if read_status is None:
if book.read_status == ub.ReadBook.STATUS_FINISHED:
book.read_status = ub.ReadBook.STATUS_UNREAD
else:
book.read_status = ub.ReadBook.STATUS_FINISHED
else:
book.read_status = ub.ReadBook.STATUS_FINISHED if read_status == True else ub.ReadBook.STATUS_UNREAD
if not book.kobo_reading_state: if not book.kobo_reading_state:
kobo_reading_state = ub.KoboReadingState(user_id=current_user.id, book_id=book_id) kobo_reading_state = ub.KoboReadingState(user_id=current_user.id, book_id=book_id)
kobo_reading_state.current_bookmark = ub.KoboBookmark() kobo_reading_state.current_bookmark = ub.KoboBookmark()
@@ -329,7 +327,7 @@ def edit_book_read_status(book_id, read_status=None):
else: else:
try: try:
calibre_db.create_functions(config) calibre_db.create_functions(config)
book = calibre_db.get_filtered_book(book_id) book = calibre_db.get_filtered_book(book_id, True)
book_read_status = getattr(book, 'custom_column_' + str(config.config_read_column)) book_read_status = getattr(book, 'custom_column_' + str(config.config_read_column))
if len(book_read_status): if len(book_read_status):
if read_status is None: if read_status is None:
@@ -396,10 +394,16 @@ def delete_book_file(book, calibrepath, book_format=None):
def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdrive=False): def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdrive=False):
for file_format in one_book.data: for file_format in one_book.data:
if not gdrive: if not gdrive:
if not os.path.exists(new_path): try:
os.makedirs(new_path) if not os.path.exists(new_path):
shutil.move(os.path.join(old_path, file_format.name + '.' + file_format.format.lower()), os.makedirs(new_path)
os.path.join(new_path, all_new_name + '.' + file_format.format.lower())) shutil.move(os.path.join(old_path, file_format.name + '.' + file_format.format.lower()),
os.path.join(new_path, all_new_name + '.' + file_format.format.lower()))
except (PermissionError, FileNotFoundError) as ex:
log.error("Moving book-id %s folder %s failed: %s", one_book.id, new_path, ex)
return _("Moving book path of Book %(book_id)s to: '%(src)s' failed with error: %(error)s",
book_id=one_book.id, src=new_path, error=str(ex))
else: else:
g_file = gd.getFileFromEbooksFolder(old_path, g_file = gd.getFileFromEbooksFolder(old_path,
file_format.name + '.' + file_format.format.lower()) file_format.name + '.' + file_format.format.lower())
@@ -412,6 +416,7 @@ def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdriv
# change name in Database # change name in Database
file_format.name = all_new_name file_format.name = all_new_name
return False
def rename_author_path(first_author, old_author_dir, renamed_author, calibre_path="", gdrive=False): def rename_author_path(first_author, old_author_dir, renamed_author, calibre_path="", gdrive=False):
@@ -466,14 +471,15 @@ def update_dir_structure_file(book_id, calibre_path, original_filepath, new_auth
db_filename, db_filename,
original_filepath, original_filepath,
path) path)
new_path = os.path.join(calibre_path, new_author_dir, new_title_dir).replace('\\', '/') if not error:
all_new_name = get_valid_filename(local_book.title, chars=42) + ' - ' \ new_path = os.path.join(calibre_path, new_author_dir, new_title_dir).replace('\\', '/')
+ get_valid_filename(new_author, chars=42) all_new_name = get_valid_filename(local_book.title, chars=42) + ' - ' \
# Book folder already moved, only files need to be renamed + get_valid_filename(new_author, chars=42)
rename_all_files_on_change(local_book, new_path, new_path, all_new_name) # Book folder already moved, only files need to be renamed
renameerror = rename_all_files_on_change(local_book, new_path, new_path, all_new_name)
if error: if error or renameerror:
return error return error or renameerror
return False return False
@@ -517,7 +523,7 @@ def update_dir_structure_gdrive(book_id, first_author):
if titledir != new_titledir or authordir != new_authordir : if titledir != new_titledir or authordir != new_authordir :
all_new_name = get_valid_filename(book.title, chars=42) + ' - ' \ all_new_name = get_valid_filename(book.title, chars=42) + ' - ' \
+ get_valid_filename(new_authordir, chars=42) + get_valid_filename(new_authordir, chars=42)
rename_all_files_on_change(book, book.path, book.path, all_new_name, gdrive=True) # todo: Move filenames on gdrive return rename_all_files_on_change(book, book.path, book.path, all_new_name, gdrive=True) # todo: Move filenames on gdrive
return False return False
@@ -553,33 +559,13 @@ def move_files_on_change(calibre_path, new_author_dir, new_titledir, localbook,
log.error("Deleting authorpath for book %s failed: %s", localbook.id, ex) log.error("Deleting authorpath for book %s failed: %s", localbook.id, ex)
# change location in database to new author/title path # change location in database to new author/title path
localbook.path = os.path.join(new_author_dir, new_titledir).replace('\\', '/') localbook.path = os.path.join(new_author_dir, new_titledir).replace('\\', '/')
except OSError as ex: except (OSError, FileNotFoundError) as ex:
log.error_or_exception("Rename title from {} to {} failed with error: {}".format(path, new_path, ex)) log.error_or_exception("Rename title from {} to {} failed with error: {}".format(path, new_path, ex))
return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s", return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
src=path, dest=new_path, error=str(ex)) src=path, dest=new_path, error=str(ex))
return False return False
def rename_files_on_change(first_author,
renamed_author,
local_book,
original_filepath="",
path="",
calibre_path="",
gdrive=False):
# Rename all files from old names to new names
#try:
#clean_author_database(renamed_author, calibre_path, gdrive=gdrive)
#if first_author and first_author not in renamed_author:
# clean_author_database([first_author], calibre_path, local_book, gdrive)
#if not gdrive and not renamed_author and not original_filepath and len(os.listdir(os.path.dirname(path))) == 0:
# shutil.rmtree(os.path.dirname(path))
#except (OSError, FileNotFoundError) as ex:
# log.error_or_exception("Error in rename file in path {}".format(ex))
# return _("Error in rename file in path: {}".format(str(ex)))
return False
def delete_book_gdrive(book, book_format): def delete_book_gdrive(book, book_format):
error = None error = None
if book_format: if book_format:
@@ -970,6 +956,17 @@ def do_download_file(book, book_format, client, data, headers):
else: else:
download_name = book_name download_name = book_name
# Clean up staged copies in /tmp/calibre_web after the response is sent
# (kepubify / calibre-export branches) so the temp dir does not grow unbounded.
if filename == get_temp_dir():
_tmp_path = os.path.join(filename, download_name + "." + book_format)
@after_this_request
def _cleanup_staged_download(resp):
try:
os.remove(_tmp_path)
except OSError as ex:
log.warning('Failed to remove staged download %s: %s', _tmp_path, ex)
return resp
response = make_response(send_from_directory(filename, download_name + "." + book_format)) response = make_response(send_from_directory(filename, download_name + "." + book_format))
# ToDo Check headers parameter # ToDo Check headers parameter
for element in headers: for element in headers:
@@ -1105,14 +1102,17 @@ def get_download_link(book_id, book_format, client):
file_name = book.title file_name = book.title
if len(book.authors) > 0: if len(book.authors) > 0:
file_name = file_name + ' - ' + book.authors[0].name file_name = file_name + ' - ' + book.authors[0].name
original_name = file_name
file_name = get_valid_filename(file_name, replace_whitespace=False, force_unidecode=True)
if client == "kindle": if client == "kindle":
file_name = get_valid_filename(file_name, replace_whitespace=False, force_unidecode=True) quoted_file_name = file_name
else: else:
file_name = quote(get_valid_filename(file_name, replace_whitespace=False)) native_name = get_valid_filename(original_name, replace_whitespace=False, force_unidecode=False)
quoted_file_name = quote(native_name)
headers = Headers() headers = Headers()
headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream") headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream")
headers["Content-Disposition"] = ('attachment; filename="{}.{}"; filename*=UTF-8\'\'{}.{}').format( headers["Content-Disposition"] = ('attachment; filename="{}.{}"; filename*=UTF-8\'\'{}.{}').format(
file_name, book_format, file_name, book_format) file_name, book_format, quoted_file_name, book_format)
return do_download_file(book, book_format, client, data1, headers) return do_download_file(book, book_format, client, data1, headers)
else: else:
log.error("Book id {} not found for downloading".format(book_id)) log.error("Book id {} not found for downloading".format(book_id))
+7
View File
@@ -30,6 +30,7 @@ from uuid import uuid4
from flask import Blueprint, request, url_for, g from flask import Blueprint, request, url_for, g
from flask_babel import format_date from flask_babel import format_date
from .cw_login import current_user from .cw_login import current_user
from .clean_html import clean_string as html_clean_string
from . import constants, logger from . import constants, logger
@@ -181,3 +182,9 @@ def contains_music(book_formats):
if format.format.lower() in g.constants.EXTENSIONS_AUDIO: if format.format.lower() in g.constants.EXTENSIONS_AUDIO:
result = True result = True
return result return result
@jinjia.app_template_filter('clean_string')
def clean_string(unsafe_text):
return html_clean_string(unsafe_text)
+50 -14
View File
@@ -25,6 +25,7 @@ import zipfile
from time import gmtime, strftime from time import gmtime, strftime
import json import json
from urllib.parse import unquote from urllib.parse import unquote
import requests
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -41,13 +42,11 @@ from werkzeug.datastructures import Headers
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.sql.expression import and_, or_ from sqlalchemy.sql.expression import and_, or_
from sqlalchemy.exc import StatementError from sqlalchemy.exc import StatementError
from sqlalchemy.sql import select
import requests
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
from . import isoLanguages from . import isoLanguages, limiter
from .epub import get_epub_layout from .epub import get_epub_layout
from .constants import COVER_THUMBNAIL_SMALL, COVER_THUMBNAIL_MEDIUM, COVER_THUMBNAIL_LARGE from .constants import COVER_THUMBNAIL_SMALL, COVER_THUMBNAIL_MEDIUM, COVER_THUMBNAIL_LARGE, BASE_DIR
from .helper import get_download_link from .helper import get_download_link
from .services import SyncToken as SyncToken from .services import SyncToken as SyncToken
from .web import download_required from .web import download_required
@@ -141,7 +140,6 @@ def convert_to_kobo_timestamp_string(timestamp):
@kobo.route("/v1/library/sync") @kobo.route("/v1/library/sync")
@requires_kobo_auth @requires_kobo_auth
# @download_required
def HandleSyncRequest(): def HandleSyncRequest():
if not current_user.role_download(): if not current_user.role_download():
log.info("Users need download permissions for syncing library to Kobo reader") log.info("Users need download permissions for syncing library to Kobo reader")
@@ -369,7 +367,7 @@ def get_download_url_for_book(book_id, book_format):
) )
return url_for( return url_for(
"kobo.download_book", "kobo.download_book",
auth_token=kobo_auth.get_auth_token(), auth_token=get_auth_token(),
book_id=book_id, book_id=book_id,
book_format=book_format.lower(), book_format=book_format.lower(),
_external=True, _external=True,
@@ -504,6 +502,7 @@ def get_metadata(book):
return metadata return metadata
@csrf.exempt @csrf.exempt
@kobo.route("/v1/library/tags", methods=["POST", "DELETE"]) @kobo.route("/v1/library/tags", methods=["POST", "DELETE"])
@requires_kobo_auth @requires_kobo_auth
@@ -812,6 +811,8 @@ def HandleStateRequest(book_uuid):
ub.session.merge(kobo_reading_state) ub.session.merge(kobo_reading_state)
ub.session_commit() ub.session_commit()
update_results_response["LastModified"] = convert_to_kobo_timestamp_string(kobo_reading_state.last_modified)
update_results_response["PriorityTimestamp"] = convert_to_kobo_timestamp_string(kobo_reading_state.priority_timestamp)
return jsonify({ return jsonify({
"RequestResult": "Success", "RequestResult": "Success",
"UpdateResults": [update_results_response], "UpdateResults": [update_results_response],
@@ -888,14 +889,21 @@ def get_statistics_response(statistics):
return resp return resp
def _clean_progress(value):
"""Return progress as int if it's a whole number, preserving Kobo device expectations."""
if value is not None and value == int(value):
return int(value)
return value
def get_current_bookmark_response(current_bookmark): def get_current_bookmark_response(current_bookmark):
resp = { resp = {
"LastModified": convert_to_kobo_timestamp_string(current_bookmark.last_modified), "LastModified": convert_to_kobo_timestamp_string(current_bookmark.last_modified),
} }
if current_bookmark.progress_percent: if current_bookmark.progress_percent is not None:
resp["ProgressPercent"] = current_bookmark.progress_percent resp["ProgressPercent"] = _clean_progress(current_bookmark.progress_percent)
if current_bookmark.content_source_progress_percent: if current_bookmark.content_source_progress_percent is not None:
resp["ContentSourceProgressPercent"] = current_bookmark.content_source_progress_percent resp["ContentSourceProgressPercent"] = _clean_progress(current_bookmark.content_source_progress_percent)
if current_bookmark.location_value: if current_bookmark.location_value:
resp["Location"] = { resp["Location"] = {
"Value": current_bookmark.location_value, "Value": current_bookmark.location_value,
@@ -963,6 +971,7 @@ def HandleBookDeletionRequest(book_uuid):
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET", "POST"]) @kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET", "POST"])
@kobo.route("/v1/library/<dummy>/preview", methods=["POST"]) @kobo.route("/v1/library/<dummy>/preview", methods=["POST"])
def HandleUnimplementedRequest(dummy=None): def HandleUnimplementedRequest(dummy=None):
[limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits]
log.debug("Unimplemented Library Request received: %s (request is forwarded to kobo if configured)", log.debug("Unimplemented Library Request received: %s (request is forwarded to kobo if configured)",
request.base_url) request.base_url)
return redirect_or_proxy_request() return redirect_or_proxy_request()
@@ -977,6 +986,9 @@ def HandleUnimplementedRequest(dummy=None):
@kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"]) @kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"])
@kobo.route("/v1/assets", methods=["GET"]) @kobo.route("/v1/assets", methods=["GET"])
def HandleUserRequest(dummy=None): def HandleUserRequest(dummy=None):
[limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits]
log.error("Key: {}".format(limiter.current_limit.key))
log.error("Remaining: {}".format(limiter.current_limit.remaining))
log.debug("Unimplemented User Request received: %s (request is forwarded to kobo if configured)", request.base_url) log.debug("Unimplemented User Request received: %s (request is forwarded to kobo if configured)", request.base_url)
return redirect_or_proxy_request() return redirect_or_proxy_request()
@@ -984,6 +996,7 @@ def HandleUserRequest(dummy=None):
@csrf.exempt @csrf.exempt
@kobo.route("/v1/user/loyalty/benefits", methods=["GET"]) @kobo.route("/v1/user/loyalty/benefits", methods=["GET"])
def handle_benefits(): def handle_benefits():
[limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits]
if config.config_kobo_proxy: if config.config_kobo_proxy:
return redirect_or_proxy_request() return redirect_or_proxy_request()
else: else:
@@ -993,6 +1006,7 @@ def handle_benefits():
@csrf.exempt @csrf.exempt
@kobo.route("/v1/analytics/gettests", methods=["GET", "POST"]) @kobo.route("/v1/analytics/gettests", methods=["GET", "POST"])
def handle_getests(): def handle_getests():
[limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits]
if config.config_kobo_proxy: if config.config_kobo_proxy:
return redirect_or_proxy_request() return redirect_or_proxy_request()
else: else:
@@ -1014,9 +1028,12 @@ def handle_getests():
@kobo.route("/v1/products/dailydeal", methods=["GET", "POST"]) @kobo.route("/v1/products/dailydeal", methods=["GET", "POST"])
@kobo.route("/v1/products/deals", methods=["GET", "POST"]) @kobo.route("/v1/products/deals", methods=["GET", "POST"])
@kobo.route("/v1/products", methods=["GET", "POST"]) @kobo.route("/v1/products", methods=["GET", "POST"])
@kobo.route("/v1/products/<path:dummy>", methods=["GET", "POST"])
@kobo.route("/v1/products/<path:dummy>/", methods=["GET", "POST"])
@kobo.route("/v1/affiliate", methods=["GET", "POST"]) @kobo.route("/v1/affiliate", methods=["GET", "POST"])
@kobo.route("/v1/deals", methods=["GET", "POST"]) @kobo.route("/v1/deals", methods=["GET", "POST"])
def HandleProductsRequest(dummy=None): def HandleProductsRequest(dummy=None):
[limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits]
log.debug("Unimplemented Products Request received: %s (request is forwarded to kobo if configured)", log.debug("Unimplemented Products Request received: %s (request is forwarded to kobo if configured)",
request.base_url) request.base_url)
return redirect_or_proxy_request() return redirect_or_proxy_request()
@@ -1042,9 +1059,12 @@ def make_calibre_web_auth_response():
@csrf.exempt @csrf.exempt
@kobo.route("/v1/auth/refresh", methods=["POST"])
@kobo.route("/v1/auth/device", methods=["POST"]) @kobo.route("/v1/auth/device", methods=["POST"])
@requires_kobo_auth @requires_kobo_auth
def HandleAuthRequest(): def HandleAuthRequest():
log.error(limiter.current_limit)
log.error(limiter.current_limit)
log.debug('Kobo Auth request') log.debug('Kobo Auth request')
if config.config_kobo_proxy: if config.config_kobo_proxy:
try: try:
@@ -1086,7 +1106,7 @@ def HandleInitRequest():
kobo_resources["image_host"] = calibre_web_url kobo_resources["image_host"] = calibre_web_url
kobo_resources["image_url_quality_template"] = unquote(calibre_web_url + kobo_resources["image_url_quality_template"] = unquote(calibre_web_url +
url_for("kobo.HandleCoverImageRequest", url_for("kobo.HandleCoverImageRequest",
auth_token=kobo_auth.get_auth_token(), auth_token=get_auth_token(),
book_uuid="{ImageId}", book_uuid="{ImageId}",
width="{width}", width="{width}",
height="{height}", height="{height}",
@@ -1094,15 +1114,17 @@ def HandleInitRequest():
isGreyscale='isGreyscale')) isGreyscale='isGreyscale'))
kobo_resources["image_url_template"] = unquote(calibre_web_url + kobo_resources["image_url_template"] = unquote(calibre_web_url +
url_for("kobo.HandleCoverImageRequest", url_for("kobo.HandleCoverImageRequest",
auth_token=kobo_auth.get_auth_token(), auth_token=get_auth_token(),
book_uuid="{ImageId}", book_uuid="{ImageId}",
width="{width}", width="{width}",
height="{height}", height="{height}",
isGreyscale='false')) isGreyscale='false'))
kobo_resources["library_sync"] = calibre_web_url + url_for("kobo.HandleSyncRequest",
auth_token=kobo_auth.get_auth_token())
else: else:
kobo_resources["image_host"] = url_for("web.index", _external=True).strip("/") kobo_resources["image_host"] = url_for("web.index", _external=True).strip("/")
kobo_resources["image_url_quality_template"] = unquote(url_for("kobo.HandleCoverImageRequest", kobo_resources["image_url_quality_template"] = unquote(url_for("kobo.HandleCoverImageRequest",
auth_token=kobo_auth.get_auth_token(), auth_token=get_auth_token(),
book_uuid="{ImageId}", book_uuid="{ImageId}",
width="{width}", width="{width}",
height="{height}", height="{height}",
@@ -1110,12 +1132,15 @@ def HandleInitRequest():
isGreyscale='isGreyscale', isGreyscale='isGreyscale',
_external=True)) _external=True))
kobo_resources["image_url_template"] = unquote(url_for("kobo.HandleCoverImageRequest", kobo_resources["image_url_template"] = unquote(url_for("kobo.HandleCoverImageRequest",
auth_token=kobo_auth.get_auth_token(), auth_token=get_auth_token(),
book_uuid="{ImageId}", book_uuid="{ImageId}",
width="{width}", width="{width}",
height="{height}", height="{height}",
isGreyscale='false', isGreyscale='false',
_external=True)) _external=True))
kobo_resources["library_sync"] = url_for("kobo.HandleSyncRequest",
auth_token=kobo_auth.get_auth_token(),
_external=True)
response = make_response(jsonify({"Resources": kobo_resources})) response = make_response(jsonify({"Resources": kobo_resources}))
response.headers["x-kobo-apitoken"] = "e30=" response.headers["x-kobo-apitoken"] = "e30="
@@ -1131,6 +1156,14 @@ def download_book(book_id, book_format):
def NATIVE_KOBO_RESOURCES(): def NATIVE_KOBO_RESOURCES():
kobo_file = os.path.join(BASE_DIR, "kobo_resources.txt")
try:
if os.path.isfile(kobo_file):
with open(kobo_file, "r") as f:
lines = f.read()
return json.loads(lines)
except Exception as e:
log.error(e)
return { return {
"account_page": "https://www.kobo.com/account/settings", "account_page": "https://www.kobo.com/account/settings",
"account_page_rakuten": "https://my.rakuten.co.jp/", "account_page_rakuten": "https://my.rakuten.co.jp/",
@@ -1201,6 +1234,9 @@ def NATIVE_KOBO_RESOURCES():
"image_host": "//cdn.kobo.com/book-images/", "image_host": "//cdn.kobo.com/book-images/",
"image_url_quality_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg", "image_url_quality_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg",
"image_url_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg", "image_url_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg",
"instapaper_enabled": "True",
"instapaper_env_url": "https://www.instapaper.com/api/kobo",
"instapaper_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkinstapaper",
"kobo_audiobooks_credit_redemption": "False", "kobo_audiobooks_credit_redemption": "False",
"kobo_audiobooks_enabled": "True", "kobo_audiobooks_enabled": "True",
"kobo_audiobooks_orange_deal_enabled": "False", "kobo_audiobooks_orange_deal_enabled": "False",
+5 -9
View File
@@ -67,7 +67,6 @@ from functools import wraps
from flask import g, Blueprint, abort, request from flask import g, Blueprint, abort, request
from .cw_login import login_user, current_user from .cw_login import login_user, current_user
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_limiter import RateLimitExceeded
from . import logger, config, calibre_db, db, helper, ub, lm, limiter from . import logger, config, calibre_db, db, helper, ub, lm, limiter
from .render_template import render_title_template from .render_template import render_title_template
@@ -82,6 +81,8 @@ kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
@kobo_auth.route("/generate_auth_token/<int:user_id>") @kobo_auth.route("/generate_auth_token/<int:user_id>")
@user_login_required @user_login_required
def generate_auth_token(user_id): def generate_auth_token(user_id):
if current_user.id != user_id and not current_user.role_admin():
abort(403)
warning = False warning = False
host_list = request.host.rsplit(':') host_list = request.host.rsplit(':')
if len(host_list) == 1: if len(host_list) == 1:
@@ -124,6 +125,8 @@ def generate_auth_token(user_id):
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"]) @kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
@user_login_required @user_login_required
def delete_auth_token(user_id): def delete_auth_token(user_id):
if current_user.id != user_id and not current_user.role_admin():
abort(403)
# Invalidate any previously generated Kobo Auth token for this user # Invalidate any previously generated Kobo Auth token for this user
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\ ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
.filter(ub.RemoteAuthToken.token_type==1).delete() .filter(ub.RemoteAuthToken.token_type==1).delete()
@@ -154,13 +157,6 @@ def requires_kobo_auth(f):
def inner(*args, **kwargs): def inner(*args, **kwargs):
auth_token = get_auth_token() auth_token = get_auth_token()
if auth_token is not None: if auth_token is not None:
try:
limiter.check()
except RateLimitExceeded:
return abort(429)
except (ConnectionError, Exception) as e:
log.error("Connection error to limiter backend: %s", e)
return abort(429)
user = ( user = (
ub.session.query(ub.User) ub.session.query(ub.User)
.join(ub.RemoteAuthToken) .join(ub.RemoteAuthToken)
@@ -169,7 +165,7 @@ def requires_kobo_auth(f):
) )
if user is not None: if user is not None:
login_user(user) login_user(user)
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits] [limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits]
return f(*args, **kwargs) return f(*args, **kwargs)
log.debug("Received Kobo request without a recognizable auth token.") log.debug("Received Kobo request without a recognizable auth token.")
return abort(401) return abort(401)
+4 -2
View File
@@ -51,13 +51,15 @@ def remove_synced_book(book_id, all=False, session=None):
ub.session_commit(_session=session) ub.session_commit(_session=session)
# If state == none, it will toggle the archive state of the passed book_id.
# state = true archives it, state = false unarchives it
def change_archived_books(book_id, state=None, message=None): def change_archived_books(book_id, state=None, message=None):
archived_book = ub.session.query(ub.ArchivedBook).filter(and_(ub.ArchivedBook.user_id == int(current_user.id), archived_book = ub.session.query(ub.ArchivedBook).filter(and_(ub.ArchivedBook.user_id == int(current_user.id),
ub.ArchivedBook.book_id == book_id)).first() ub.ArchivedBook.book_id == book_id)).first()
if not archived_book: if not archived_book: # and (state == True or state == None):
archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id) archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id)
archived_book.is_archived = state if state else not archived_book.is_archived archived_book.is_archived = state if state != None else not archived_book.is_archived
archived_book.last_modified = datetime.now(timezone.utc) # toDo. Check utc timestamp archived_book.last_modified = datetime.now(timezone.utc) # toDo. Check utc timestamp
ub.session.merge(archived_book) ub.session.merge(archived_book)
+3 -3
View File
@@ -24,7 +24,7 @@ from flask import request
def request_username(): def request_username():
return request.authorization.username return request.authorization.username if request.authorization else ""
def main(): def main():
@@ -66,8 +66,8 @@ def main():
app.register_blueprint(tasks) app.register_blueprint(tasks)
app.register_blueprint(web) app.register_blueprint(web)
app.register_blueprint(basic) app.register_blueprint(basic)
app.register_blueprint(opds)
limiter.limit("3/minute", key_func=request_username)(opds) limiter.limit("3/minute", key_func=request_username)(opds)
app.register_blueprint(opds)
app.register_blueprint(jinjia) app.register_blueprint(jinjia)
app.register_blueprint(about) app.register_blueprint(about)
app.register_blueprint(shelf) app.register_blueprint(shelf)
@@ -77,9 +77,9 @@ def main():
app.register_blueprint(gdrive) app.register_blueprint(gdrive)
app.register_blueprint(editbook) app.register_blueprint(editbook)
if kobo_available: if kobo_available:
limiter.limit("3/minute", key_func=get_remote_address)(kobo)
app.register_blueprint(kobo) app.register_blueprint(kobo)
app.register_blueprint(kobo_auth) app.register_blueprint(kobo_auth)
limiter.limit("3/minute", key_func=get_remote_address)(kobo)
if oauth_available: if oauth_available:
app.register_blueprint(oauth) app.register_blueprint(oauth)
success = web_server.start() success = web_server.start()
+1 -1
View File
@@ -57,7 +57,7 @@ class ComicVine(Metadata):
result.raise_for_status() result.raise_for_status()
except Exception as e: except Exception as e:
log.warning(e) log.warning(e)
return None return []
for result in result.json()["results"]: for result in result.json()["results"]:
match = self._parse_search_result( match = self._parse_search_result(
result=result, generic_cover=generic_cover, locale=locale result=result, generic_cover=generic_cover, locale=locale
+1 -1
View File
@@ -155,7 +155,7 @@ class Douban(Metadata):
r.raise_for_status() r.raise_for_status()
except Exception as e: except Exception as e:
log.warning(e) log.warning(e)
return None return []
match = MetaRecord( match = MetaRecord(
id=id, id=id,
+4 -3
View File
@@ -23,7 +23,7 @@ from datetime import datetime
import requests import requests
from cps import logger from cps import logger, config
from cps.isoLanguages import get_lang3, get_language_name from cps.isoLanguages import get_lang3, get_language_name
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
@@ -31,13 +31,14 @@ log = logger.create()
class Google(Metadata): class Google(Metadata):
__name__ = "Google" __name__ = "Google Books"
__id__ = "google" __id__ = "google"
DESCRIPTION = "Google Books" DESCRIPTION = "Google Books"
META_URL = "https://books.google.com/" META_URL = "https://books.google.com/"
BOOK_URL = "https://books.google.com/books?id=" BOOK_URL = "https://books.google.com/books?id="
SEARCH_URL = "https://www.googleapis.com/books/v1/volumes?q=" SEARCH_URL = "https://www.googleapis.com/books/v1/volumes?q="
ISBN_TYPE = "ISBN_13" ISBN_TYPE = "ISBN_13"
API_KEY = "&key=" + config.config_googlebooks_api_key
def search( def search(
self, query: str, generic_cover: str = "", locale: str = "en" self, query: str, generic_cover: str = "", locale: str = "en"
@@ -50,7 +51,7 @@ class Google(Metadata):
tokens = [quote(t.encode("utf-8")) for t in title_tokens] tokens = [quote(t.encode("utf-8")) for t in title_tokens]
query = "+".join(tokens) query = "+".join(tokens)
try: try:
results = requests.get(Google.SEARCH_URL + query) results = requests.get(Google.SEARCH_URL + query + Google.API_KEY)
results.raise_for_status() results.raise_for_status()
except Exception as e: except Exception as e:
log.warning(e) log.warning(e)
+8
View File
@@ -134,6 +134,14 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider
oauth_entry = query.first() oauth_entry = query.first()
# already bind with user, just login # already bind with user, just login
if oauth_entry.user: if oauth_entry.user:
# If a user is already logged in and it's a different account, reject the link
# to prevent account takeover via shared OAuth identities
if current_user and current_user.is_authenticated and oauth_entry.user_id != current_user.id:
flash(_("This %(oauth)s account is already linked to a different user",
oauth=provider_name), category="error")
log.warning("User %s tried to link OAuth account already bound to user %s",
current_user.id, oauth_entry.user_id)
return redirect(url_for('web.profile'))
login_user(oauth_entry.user) login_user(oauth_entry.user)
log.debug("You are now logged in as: '%s'", oauth_entry.user.name) log.debug("You are now logged in as: '%s'", oauth_entry.user.name)
flash(_("Success! You are now logged in as: %(nickname)s", nickname=oauth_entry.user.name), flash(_("Success! You are now logged in as: %(nickname)s", nickname=oauth_entry.user.name),
+45 -24
View File
@@ -21,7 +21,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime import datetime
# import json
from urllib.parse import unquote_plus from urllib.parse import unquote_plus
from flask import Blueprint, request, render_template, make_response, abort, g, jsonify from flask import Blueprint, request, render_template, make_response, abort, g, jsonify
@@ -88,8 +87,8 @@ def feed_letter_books(book_id):
letter, letter,
[db.Books.sort], [db.Books.sort],
True, config.config_read_column) True, config.config_read_column)
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', entries=entries, pagination=pagination) return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
@opds.route("/opds/new") @opds.route("/opds/new")
@@ -101,7 +100,8 @@ def feed_new():
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0, entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
db.Books, True, [db.Books.timestamp.desc()], db.Books, True, [db.Books.timestamp.desc()],
True, config.config_read_column) True, config.config_read_column)
return render_xml_template('feed.xml', entries=entries, pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
@opds.route("/opds/discover") @opds.route("/opds/discover")
@@ -112,7 +112,8 @@ def feed_discover():
query = calibre_db.generate_linked_query(config.config_read_column, db.Books) query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page) entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page)
pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page)) pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page))
return render_xml_template('feed.xml', entries=entries, pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
@opds.route("/opds/rated") @opds.route("/opds/rated")
@@ -125,7 +126,8 @@ def feed_best_rated():
db.Books, db.Books.ratings.any(db.Ratings.rating > 9), db.Books, db.Books.ratings.any(db.Ratings.rating > 9),
[db.Books.timestamp.desc()], [db.Books.timestamp.desc()],
True, config.config_read_column) True, config.config_read_column)
return render_xml_template('feed.xml', entries=entries, pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
@opds.route("/opds/hot") @opds.route("/opds/hot")
@@ -149,7 +151,8 @@ def feed_hot():
num_books = entries.__len__() num_books = entries.__len__()
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1),
config.config_books_per_page, num_books) config.config_books_per_page, num_books)
return render_xml_template('feed.xml', entries=entries, pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
@opds.route("/opds/author") @opds.route("/opds/author")
@@ -174,7 +177,8 @@ def feed_letter_author(book_id):
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
entries.count()) entries.count())
entries = entries.limit(config.config_books_per_page).offset(off).all() entries = entries.limit(config.config_books_per_page).offset(off).all()
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination, cc=cc)
@opds.route("/opds/author/<int:book_id>") @opds.route("/opds/author/<int:book_id>")
@@ -197,7 +201,8 @@ def feed_publisherindex():
.limit(config.config_books_per_page).offset(off) .limit(config.config_books_per_page).offset(off)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(calibre_db.session.query(db.Publishers).all())) len(calibre_db.session.query(db.Publishers).all()))
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_publisher', pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_publisher', pagination=pagination, cc=cc)
@opds.route("/opds/publisher/<int:book_id>") @opds.route("/opds/publisher/<int:book_id>")
@@ -230,7 +235,8 @@ def feed_letter_category(book_id):
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
entries.count()) entries.count())
entries = entries.offset(off).limit(config.config_books_per_page).all() entries = entries.offset(off).limit(config.config_books_per_page).all()
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination, cc=cc)
@opds.route("/opds/category/<int:book_id>") @opds.route("/opds/category/<int:book_id>")
@@ -263,7 +269,8 @@ def feed_letter_series(book_id):
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
entries.count()) entries.count())
entries = entries.offset(off).limit(config.config_books_per_page).all() entries = entries.offset(off).limit(config.config_books_per_page).all()
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination, cc=cc)
@opds.route("/opds/series/<int:book_id>") @opds.route("/opds/series/<int:book_id>")
@@ -275,7 +282,8 @@ def feed_series(book_id):
db.Books.series.any(db.Series.id == book_id), db.Books.series.any(db.Series.id == book_id),
[db.Books.series_index], [db.Books.series_index],
True, config.config_read_column) True, config.config_read_column)
return render_xml_template('feed.xml', entries=entries, pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
@opds.route("/opds/ratings") @opds.route("/opds/ratings")
@@ -297,7 +305,8 @@ def feed_ratingindex():
element = list() element = list()
for entry in entries: for entry in entries:
element.append(FeedObject(entry[0].id, _("{} Stars").format(entry.name))) element.append(FeedObject(entry[0].id, _("{} Stars").format(entry.name)))
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination, cc=cc)
@opds.route("/opds/ratings/<book_id>") @opds.route("/opds/ratings/<book_id>")
@@ -321,7 +330,8 @@ def feed_formatindex():
element = list() element = list()
for entry in entries: for entry in entries:
element.append(FeedObject(entry.format, entry.format)) element.append(FeedObject(entry.format, entry.format))
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_format', pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_format', pagination=pagination, cc=cc)
@opds.route("/opds/formats/<book_id>") @opds.route("/opds/formats/<book_id>")
@@ -333,7 +343,8 @@ def feed_format(book_id):
db.Books.data.any(db.Data.format == book_id.upper()), db.Books.data.any(db.Data.format == book_id.upper()),
[db.Books.timestamp.desc()], [db.Books.timestamp.desc()],
True, config.config_read_column) True, config.config_read_column)
return render_xml_template('feed.xml', entries=entries, pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
@opds.route("/opds/language") @opds.route("/opds/language")
@@ -351,7 +362,8 @@ def feed_languagesindex():
languages[0].name = isoLanguages.get_language_name(get_locale(), languages[0].lang_code) languages[0].name = isoLanguages.get_language_name(get_locale(), languages[0].lang_code)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(languages)) len(languages))
return render_xml_template('feed.xml', listelements=languages, folder='opds.feed_languages', pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', listelements=languages, folder='opds.feed_languages', pagination=pagination, cc=cc)
@opds.route("/opds/language/<int:book_id>") @opds.route("/opds/language/<int:book_id>")
@@ -363,7 +375,8 @@ def feed_languages(book_id):
db.Books.languages.any(db.Languages.id == book_id), db.Books.languages.any(db.Languages.id == book_id),
[db.Books.timestamp.desc()], [db.Books.timestamp.desc()],
True, config.config_read_column) True, config.config_read_column)
return render_xml_template('feed.xml', entries=entries, pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
@opds.route("/opds/shelfindex") @opds.route("/opds/shelfindex")
@@ -377,7 +390,8 @@ def feed_shelfindex():
number = len(shelf) number = len(shelf)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
number) number)
return render_xml_template('feed.xml', listelements=shelf, folder='opds.feed_shelf', pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', listelements=shelf, folder='opds.feed_shelf', pagination=pagination, cc=cc)
@opds.route("/opds/shelf/<int:book_id>") @opds.route("/opds/shelf/<int:book_id>")
@@ -417,7 +431,8 @@ def feed_shelf(book_id):
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
ub.session.rollback() ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e)) log.error_or_exception("Settings Database error: {}".format(e))
return render_xml_template('feed.xml', entries=result, pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', entries=result, pagination=pagination, cc=cc)
@opds.route("/opds/download/<book_id>/<book_format>/") @opds.route("/opds/download/<book_id>/<book_format>/")
@@ -470,7 +485,8 @@ def feed_read_books():
return abort(403) return abort(403)
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True) result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
return render_xml_template('feed.xml', entries=result, pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', entries=result, pagination=pagination, cc=cc)
@opds.route("/opds/unreadbooks") @opds.route("/opds/unreadbooks")
@@ -480,7 +496,8 @@ def feed_unread_books():
return abort(403) return abort(403)
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True) result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)
return render_xml_template('feed.xml', entries=result, pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', entries=result, pagination=pagination, cc=cc)
class FeedObject: class FeedObject:
@@ -502,7 +519,8 @@ def feed_search(term):
entries, __, ___ = calibre_db.get_search_results(term, config=config) entries, __, ___ = calibre_db.get_search_results(term, config=config)
entries_count = len(entries) if len(entries) > 0 else 1 entries_count = len(entries) if len(entries) > 0 else 1
pagination = Pagination(1, entries_count, entries_count) pagination = Pagination(1, entries_count, entries_count)
return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination, cc=cc)
else: else:
return render_xml_template('feed.xml', searchterm="") return render_xml_template('feed.xml', searchterm="")
@@ -524,7 +542,8 @@ def render_xml_dataset(data_table, book_id):
getattr(db.Books, data_table.__tablename__).any(data_table.id == book_id), getattr(db.Books, data_table.__tablename__).any(data_table.id == book_id),
[db.Books.timestamp.desc()], [db.Books.timestamp.desc()],
True, config.config_read_column) True, config.config_read_column)
return render_xml_template('feed.xml', entries=entries, pagination=pagination) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
def render_element_index(database_column, linked_table, folder): def render_element_index(database_column, linked_table, folder):
@@ -545,7 +564,9 @@ def render_element_index(database_column, linked_table, folder):
elements.append({'id': entry.id, 'name': entry.id}) elements.append({'id': entry.id, 'name': entry.id})
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(entries) + 1) len(entries) + 1)
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
return render_xml_template('feed.xml', return render_xml_template('feed.xml',
letterelements=elements, letterelements=elements,
folder=folder, folder=folder,
pagination=pagination) pagination=pagination,
cc=cc)
+1 -1
View File
@@ -29,7 +29,7 @@
from urllib.parse import urlparse, urljoin from urllib.parse import urlparse, urljoin
from flask import request, url_for, redirect, current_app from flask import request, url_for, current_app
def is_safe_url(target): def is_safe_url(target):
+11 -11
View File
@@ -47,7 +47,7 @@ def get_sidebar_config(kwargs=None):
if current_user.role_admin(): if current_user.role_admin():
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list', sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list',
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not current_user.is_anonymous), "id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not current_user.is_anonymous),
"page": "download", "show_text": _('Show Downloaded Books'), "page": "download", "show_text": _('Show Downloaded Books'), "no_param":True,
"config_show": content}) "config_show": content})
else: else:
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list', sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list',
@@ -69,37 +69,37 @@ def get_sidebar_config(kwargs=None):
"visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover", "visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover",
"show_text": _('Show Random Books'), "config_show": True}) "show_text": _('Show Random Books'), "config_show": True})
sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat", sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat",
"visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category", "visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category", "no_param":True,
"show_text": _('Show Category Section'), "config_show": True}) "show_text": _('Show Category Section'), "config_show": True})
sidebar.append({"glyph": "glyphicon-bookmark", "text": _('Series'), "link": 'web.series_list', "id": "serie", sidebar.append({"glyph": "glyphicon-bookmark", "text": _('Series'), "link": 'web.series_list', "id": "serie",
"visibility": constants.SIDEBAR_SERIES, 'public': True, "page": "series", "visibility": constants.SIDEBAR_SERIES, 'public': True, "page": "series", "no_param":True,
"show_text": _('Show Series Section'), "config_show": True}) "show_text": _('Show Series Section'), "config_show": True})
sidebar.append({"glyph": "glyphicon-user", "text": _('Authors'), "link": 'web.author_list', "id": "author", sidebar.append({"glyph": "glyphicon-user", "text": _('Authors'), "link": 'web.author_list', "id": "author",
"visibility": constants.SIDEBAR_AUTHOR, 'public': True, "page": "author", "visibility": constants.SIDEBAR_AUTHOR, 'public': True, "page": "author", "no_param":True,
"show_text": _('Show Author Section'), "config_show": True}) "show_text": _('Show Author Section'), "config_show": True})
sidebar.append( sidebar.append(
{"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "publisher", {"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "publisher",
"visibility": constants.SIDEBAR_PUBLISHER, 'public': True, "page": "publisher", "visibility": constants.SIDEBAR_PUBLISHER, 'public': True, "page": "publisher", "no_param":True,
"show_text": _('Show Publisher Section'), "config_show":True}) "show_text": _('Show Publisher Section'), "config_show":True})
sidebar.append({"glyph": "glyphicon-flag", "text": _('Languages'), "link": 'web.language_overview', "id": "lang", sidebar.append({"glyph": "glyphicon-flag", "text": _('Languages'), "link": 'web.language_overview', "id": "lang",
"visibility": constants.SIDEBAR_LANGUAGE, 'public': (current_user.filter_language() == 'all'), "visibility": constants.SIDEBAR_LANGUAGE, 'public': (current_user.filter_language() == 'all'),
"page": "language", "page": "language", "no_param":True,
"show_text": _('Show Language Section'), "config_show": True}) "show_text": _('Show Language Section'), "config_show": True})
sidebar.append({"glyph": "glyphicon-star-empty", "text": _('Ratings'), "link": 'web.ratings_list', "id": "rate", sidebar.append({"glyph": "glyphicon-star-empty", "text": _('Ratings'), "link": 'web.ratings_list', "id": "rate",
"visibility": constants.SIDEBAR_RATING, 'public': True, "visibility": constants.SIDEBAR_RATING, 'public': True, "no_param":True,
"page": "rating", "show_text": _('Show Ratings Section'), "config_show": True}) "page": "rating", "show_text": _('Show Ratings Section'), "config_show": True})
sidebar.append({"glyph": "glyphicon-file", "text": _('File formats'), "link": 'web.formats_list', "id": "format", sidebar.append({"glyph": "glyphicon-file", "text": _('File formats'), "link": 'web.formats_list', "id": "format",
"visibility": constants.SIDEBAR_FORMAT, 'public': True, "visibility": constants.SIDEBAR_FORMAT, 'public': True, "no_param":True,
"page": "format", "show_text": _('Show File Formats Section'), "config_show": True}) "page": "format", "show_text": _('Show File Formats Section'), "config_show": True})
sidebar.append( sidebar.append(
{"glyph": "glyphicon-trash", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived", {"glyph": "glyphicon-folder-open", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived",
"visibility": constants.SIDEBAR_ARCHIVED, 'public': (not current_user.is_anonymous), "page": "archived", "visibility": constants.SIDEBAR_ARCHIVED, 'public': (not current_user.is_anonymous), "page": "archived",
"show_text": _('Show Archived Books'), "config_show": content}) "show_text": _('Show Archived Books'), "config_show": content})
if not simple: if not simple:
sidebar.append( sidebar.append(
{"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list", {"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list",
"visibility": constants.SIDEBAR_LIST, 'public': (not current_user.is_anonymous), "page": "list", "visibility": constants.SIDEBAR_LIST, 'public': (not current_user.is_anonymous),
"show_text": _('Show Books List'), "config_show": content}) "show_text": _('Show Books List'), "config_show": content, "no_param":True})
g.shelves_access = ub.session.query(ub.Shelf).filter( g.shelves_access = ub.session.query(ub.Shelf).filter(
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == current_user.id)).order_by(ub.Shelf.name).all() or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == current_user.id)).order_by(ub.Shelf.name).all()
+2
View File
@@ -61,11 +61,13 @@ class ReverseProxied(object):
def __call__(self, environ, start_response): def __call__(self, environ, start_response):
self.proxied = False self.proxied = False
self.script_name = "/"
script_name = environ.get('HTTP_X_SCRIPT_NAME', '') script_name = environ.get('HTTP_X_SCRIPT_NAME', '')
if script_name: if script_name:
self.proxied = True self.proxied = True
environ['SCRIPT_NAME'] = script_name environ['SCRIPT_NAME'] = script_name
path_info = environ.get('PATH_INFO', '') path_info = environ.get('PATH_INFO', '')
self.script_name = script_name
if path_info and path_info.startswith(script_name): if path_info and path_info.startswith(script_name):
environ['PATH_INFO'] = path_info[len(script_name):] environ['PATH_INFO'] = path_info[len(script_name):]
+19 -5
View File
@@ -177,11 +177,24 @@ def adv_search_serie(q, include_series_inputs, exclude_series_inputs):
q = q.filter(not_(db.Books.series.any(db.Series.id == serie))) q = q.filter(not_(db.Books.series.any(db.Series.id == serie)))
return q return q
def adv_search_shelf(q, include_shelf_inputs, exclude_shelf_inputs): def adv_search_shelf(q, include_shelf_inputs, exclude_shelf_inputs):
q = q.outerjoin(ub.BookShelf, db.Books.id == ub.BookShelf.book_id)\ if len(include_shelf_inputs):
.filter(or_(ub.BookShelf.shelf == None, ub.BookShelf.shelf.notin_(exclude_shelf_inputs))) in_shelf = (
if len(include_shelf_inputs) > 0: calibre_db.session.query(ub.BookShelf.id)
q = q.filter(ub.BookShelf.shelf.in_(include_shelf_inputs)) .filter(ub.BookShelf.book_id == db.Books.id)
.filter(ub.BookShelf.shelf.in_(include_shelf_inputs))
.exists()
)
q = q.filter(in_shelf)
if len(exclude_shelf_inputs):
ex_shelf = (
calibre_db.session.query(ub.BookShelf.id)
.filter(ub.BookShelf.book_id == db.Books.id)
.filter(ub.BookShelf.shelf.in_(exclude_shelf_inputs))
.exists()
)
q = q.filter(~ex_shelf)
return q return q
def extend_search_term(searchterm, def extend_search_term(searchterm,
@@ -245,7 +258,6 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
calibre_db.create_functions() calibre_db.create_functions()
# calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase)
query = calibre_db.generate_linked_query(config.config_read_column, db.Books) query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\ q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\
.outerjoin(db.Series)\ .outerjoin(db.Series)\
@@ -341,10 +353,12 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
q = adv_search_ratings(q, rating_high, rating_low) q = adv_search_ratings(q, rating_high, rating_low)
if description: if description:
pass
q = q.filter(db.Books.comments.any(func.lower(db.Comments.text).ilike("%" + description + "%"))) q = q.filter(db.Books.comments.any(func.lower(db.Comments.text).ilike("%" + description + "%")))
# search custom columns # search custom columns
try: try:
pass
q = adv_search_custom_columns(cc, term, q) q = adv_search_custom_columns(cc, term, q)
except AttributeError as ex: except AttributeError as ex:
log.debug_or_exception(ex) log.debug_or_exception(ex)
+1 -1
View File
@@ -135,5 +135,5 @@ def metadata_search():
if active.get(c.__id__, True) if active.get(c.__id__, True)
} }
for future in concurrent.futures.as_completed(meta): for future in concurrent.futures.as_completed(meta):
data.extend([asdict(x) for x in future.result() if x]) data.extend([asdict(x) for x in (future.result() or []) if x])
return make_response(jsonify(data)) return make_response(jsonify(data))
+15 -1
View File
@@ -41,7 +41,7 @@ except ImportError:
VERSION = 'Tornado ' + _version VERSION = 'Tornado ' + _version
_GEVENT = False _GEVENT = False
from . import logger from . import logger, constants
log = logger.create() log = logger.create()
@@ -216,6 +216,13 @@ class WebServer(object):
try: try:
sock, output = self._make_gevent_listener() sock, output = self._make_gevent_listener()
log.info('Starting Gevent server on %s', output) log.info('Starting Gevent server on %s', output)
try:
# Also print to stdout so interactive terminals show a clear success message
if constants.APP_MODE not in ['development', 'test']:
print(f"Calibre-Web: server started on {output}")
except Exception:
print(f"Calibre-Web: error {output}")
pass
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, handler_class=MyWSGIHandler, self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, handler_class=MyWSGIHandler,
error_log=log, error_log=log,
spawn=Pool(), **ssl_args) spawn=Pool(), **ssl_args)
@@ -266,6 +273,13 @@ class WebServer(object):
output = _readable_listen_address(self.listen_address, self.listen_port) output = _readable_listen_address(self.listen_address, self.listen_port)
http_server.listen(self.listen_port, self.listen_address) http_server.listen(self.listen_port, self.listen_address)
log.info('Starting Tornado server on %s', output) log.info('Starting Tornado server on %s', output)
# Also print to stdout so interactive terminals show a clear success message
try:
if constants.APP_MODE not in ['development', 'test']:
print(f"Calibre-Web: server started on {output}")
except Exception:
print(f"Calibre-Web: error {output}")
pass
self.wsgiserver = IOLoop.current() self.wsgiserver = IOLoop.current()
self.wsgiserver.start() self.wsgiserver.start()
+37 -2
View File
@@ -22,7 +22,7 @@ import requests
from goodreads.client import GoodreadsClient from goodreads.client import GoodreadsClient
from goodreads.request import GoodreadsRequest from goodreads.request import GoodreadsRequest
import xmltodict from lxml import etree
try: try:
import Levenshtein import Levenshtein
@@ -33,6 +33,39 @@ from .. import logger
from ..clean_html import clean_string from ..clean_html import clean_string
def etree_to_dict(t):
"""
Convert lxml ElementTree to a nested dict (similar to xmltodict).
"""
d = {t.tag: {} if t.attrib else None}
children = list(t)
if children:
dd = {}
for dc in map(etree_to_dict, children):
for k, v in dc.items():
if k in dd:
if not isinstance(dd[k], list):
dd[k] = [dd[k]]
dd[k].append(v)
else:
dd[k] = v
d = {t.tag: dd}
if t.attrib:
d[t.tag].update(('@' + k, v) for k, v in t.attrib.items())
text = (t.text or '').strip()
if text:
if children or t.attrib:
d[t.tag]['#text'] = text
else:
d[t.tag] = text
return d
class my_GoodreadsClient(GoodreadsClient): class my_GoodreadsClient(GoodreadsClient):
def request(self, *args, **kwargs): def request(self, *args, **kwargs):
@@ -59,7 +92,9 @@ class my_GoodreadsRequest(GoodreadsRequest):
if resp.status_code != 200: if resp.status_code != 200:
raise GoodreadsRequestException(resp.reason, self.path) raise GoodreadsRequestException(resp.reason, self.path)
if self.req_format == 'xml': if self.req_format == 'xml':
data_dict = xmltodict.parse(resp.content) root = etree.fromstring(resp.content, parser=etree.XMLParser(resolve_entities=False, no_network=True))
data_dict = etree_to_dict(root)
return data_dict['GoodreadsResponse'] return data_dict['GoodreadsResponse']
else: else:
raise Exception("Invalid format") raise Exception("Invalid format")
+14 -2
View File
@@ -31,6 +31,16 @@ except ImportError:
log = logger.create() log = logger.create()
def _escape_ldap_filter(s):
"""Escape special characters for safe use in LDAP filter strings (RFC 4515)."""
s = s.replace('\\', '\\5c')
s = s.replace('*', '\\2a')
s = s.replace('(', '\\28')
s = s.replace(')', '\\29')
s = s.replace('\x00', '\\00')
return s
class LDAPLogger(object): class LDAPLogger(object):
@staticmethod @staticmethod
@@ -148,9 +158,11 @@ def bind_user(username, password):
:returns: True if login succeeded, False if login failed, None if server unavailable. :returns: True if login succeeded, False if login failed, None if server unavailable.
''' '''
# Escape LDAP special characters to prevent LDAP injection in search filters
safe_username = _escape_ldap_filter(username)
try: try:
if _ldap.get_object_details(username): if _ldap.get_object_details(safe_username):
result = _ldap.bind_user(username, password) result = _ldap.bind_user(safe_username, password)
log.debug("LDAP login '%s': %r", username, result) log.debug("LDAP login '%s': %r", username, result)
return result is not None, None return result is not None, None
return None, None # User not found return None, None # User not found
+1 -1
View File
@@ -235,7 +235,7 @@ class CalibreTask:
@property @property
def dead(self): def dead(self):
"""Determines whether this task can be garbage collected """Determines whether or not this task can be garbage collected
We have a separate dictating this because there may be certain tasks that want to override this We have a separate dictating this because there may be certain tasks that want to override this
""" """
+50 -3
View File
@@ -102,6 +102,52 @@ def add_to_shelf(shelf_id, book_id):
return "", 204 return "", 204
@shelf.route("/shelf/massremove/<int:shelf_id>", methods=["POST"])
@user_login_required
def search_from_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf is None:
log.error("Invalid shelf specified: {}".format(shelf_id))
flash(_("Invalid shelf specified"), category="error")
return redirect(url_for('web.index'))
if not check_shelf_edit_permissions(shelf):
log.warning("You are not allowed to remove a book from the shelf".format(shelf.name))
flash(_("You are not allowed to remove a book from the shelf"), category="error")
return redirect(url_for('web.index'))
if current_user.id in ub.searched_ids and ub.searched_ids[current_user.id]:
books_from_shelf = list()
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all()
if books_in_shelf:
book_ids = [book_id.book_id for book_id in books_in_shelf]
for searchid in ub.searched_ids[current_user.id]:
if searchid in book_ids:
books_from_shelf.append(searchid)
else:
log.error("No Books are part of {}".format(shelf.name))
flash(_("No Books are part of the shelf: %(name)s", name=shelf.name), category="error")
return redirect(url_for('web.index'))
# maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()[0] or 0
for book in books_from_shelf:
ub.session.delete(ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).filter(
ub.BookShelf.book_id == book).first())
shelf.last_modified = datetime.now(timezone.utc)
try:
ub.session.commit()
flash(_("Books have been removed from shelf: %(sname)s", sname=shelf.name), category="success")
except (OperationalError, InvalidRequestError) as e:
ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e))
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
else:
log.error("Could not remove books from shelf: {}".format(shelf.name))
flash(_("Could not remove books from shelf: %(sname)s", sname=shelf.name), category="error")
return redirect(url_for('web.index'))
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"]) @shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
@user_login_required @user_login_required
def search_to_shelf(shelf_id): def search_to_shelf(shelf_id):
@@ -120,9 +166,7 @@ def search_to_shelf(shelf_id):
books_for_shelf = list() books_for_shelf = list()
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all() books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all()
if books_in_shelf: if books_in_shelf:
book_ids = list() book_ids = [book_id.book_id for book_id in books_in_shelf]
for book_id in books_in_shelf:
book_ids.append(book_id.book_id)
for searchid in ub.searched_ids[current_user.id]: for searchid in ub.searched_ids[current_user.id]:
if searchid not in book_ids: if searchid not in book_ids:
books_for_shelf.append(searchid) books_for_shelf.append(searchid)
@@ -264,6 +308,9 @@ def order_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf and check_shelf_view_permissions(shelf): if shelf and check_shelf_view_permissions(shelf):
if request.method == "POST": if request.method == "POST":
if not check_shelf_edit_permissions(shelf):
flash(_("Sorry you are not allowed to edit this shelf"), category="error")
return redirect(url_for('web.index'))
to_save = request.form.to_dict() to_save = request.form.to_dict()
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by( books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by(
ub.BookShelf.order.asc()).all() ub.BookShelf.order.asc()).all()
+88 -44
View File
@@ -2018,7 +2018,7 @@ body > div.container-fluid > div > div.col-sm-10 > div.pagination > .page-previo
display: none display: none
} }
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container > div.col-xs-12, body.catlist > div.container-fluid > div > div.col-sm-10 > div.container > div.col-xs-12, body.langlist > div.container-fluid > div > div.col-sm-10 > div.container > div.col-sm-6, body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container > div.col-sm-6 { body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container1 > div.col-xs-12, body.downloadlist > div.container-fluid > div > div.col-sm-10 > div.container1 > div.col-xs-12, body.catlist > div.container-fluid > div > div.col-sm-10 > div.container1 > div.col-xs-12, body.langlist > div.container-fluid > div > div.col-sm-10 > div.container1 > div.col-sm-6, body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container1 > div.col-sm-6 {
width: 100% width: 100%
} }
@@ -2042,7 +2042,7 @@ body.serieslist > div.container-fluid > div > div.col-sm-10:before {
} }
} }
body > div.container-fluid > div > div.col-sm-10 > div.container { body > div.container-fluid > div > div.col-sm-10 > div.container1 {
width: calc(80% - 30px); width: calc(80% - 30px);
margin: 120px 0 30px 20%; margin: 120px 0 30px 20%;
border-top: 2px solid rgba(0, 0, 0, .3); border-top: 2px solid rgba(0, 0, 0, .3);
@@ -2051,19 +2051,19 @@ body > div.container-fluid > div > div.col-sm-10 > div.container {
max-width: calc(80% - 30px) max-width: calc(80% - 30px)
} }
body > div.container-fluid > div > div.col-sm-10 > div.container > div { body > div.container-fluid > div > div.col-sm-10 > div.container1 > div {
padding: 0 padding: 0
} }
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row { body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row {
padding: 4px 0 padding: 4px 0
} }
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row:nth-of-type(even) { body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row:nth-of-type(even) {
background: hsla(0, 0%, 100%, .02) background: hsla(0, 0%, 100%, .02)
} }
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row > .col-xs-6 { body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row > .col-xs-6 {
width: calc(91.666% - 30px); width: calc(91.666% - 30px);
overflow: hidden; overflow: hidden;
-o-text-overflow: ellipsis; -o-text-overflow: ellipsis;
@@ -2072,7 +2072,7 @@ body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row >
padding-left: 60px padding-left: 60px
} }
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row > .col-xs-6 > a { body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row > .col-xs-6 > a {
-o-text-overflow: ellipsis; -o-text-overflow: ellipsis;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -2082,7 +2082,7 @@ body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row >
} }
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container { body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1 {
width: calc(80% - 30px); width: calc(80% - 30px);
margin: 120px 0 30px 20%; margin: 120px 0 30px 20%;
border-top: 2px solid rgba(0, 0, 0, .3); border-top: 2px solid rgba(0, 0, 0, .3);
@@ -2091,19 +2091,19 @@ body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container
max-width: calc(80% - 30px) max-width: calc(80% - 30px)
} }
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container > div { body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1 > div {
padding: 0 padding: 0
} }
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container > div > .row { body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1 > div > .row {
padding: 4px 0 padding: 4px 0
} }
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container > div > .row:nth-of-type(even) { body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1 > div > .row:nth-of-type(even) {
background: hsla(0, 0%, 100%, .02) background: hsla(0, 0%, 100%, .02)
} }
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container > div > .row > .col-xs-6 { body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1 > div > .row > .col-xs-6 {
width: calc(91.666% - 30px); width: calc(91.666% - 30px);
overflow: hidden; overflow: hidden;
-o-text-overflow: ellipsis; -o-text-overflow: ellipsis;
@@ -2112,7 +2112,7 @@ body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container
padding-left: 60px padding-left: 60px
} }
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container > div > .row > .col-xs-6 > a { body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1 > div > .row > .col-xs-6 > a {
-o-text-overflow: ellipsis; -o-text-overflow: ellipsis;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -2146,7 +2146,6 @@ body.catlist > div.container-fluid > div.row-fluid > div.col-sm-10:before {
} }
} }
body.authorlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.langlist > div.container-fluid > div > div.col-sm-10:before { body.authorlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.langlist > div.container-fluid > div > div.col-sm-10:before {
font-family: 'Glyphicons Halflings', serif; font-family: 'Glyphicons Halflings', serif;
font-style: normal; font-style: normal;
@@ -2172,10 +2171,12 @@ body.authorlist > div.container-fluid > div.row-fluid > div.col-sm-10:before {
} }
body.langlist > div.container-fluid > div > div.col-sm-10:before { body.langlist > div.container-fluid > div > div.col-sm-10:before {
content: "\e034" content: "\e034";
top: 240px;
} }
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container:before, body.catlist > div.container-fluid > div > div.col-sm-10 > div.container:before, body.langlist > div.container-fluid > div > div.col-sm-10 > div.container:before, body.me > div.container-fluid > div > div.col-sm-10 > div.discover:before, body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.downloadlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.catlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.langlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.me > div.container-fluid > div > div.col-sm-10 > div.discover:before, body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
top: 60px; top: 60px;
font-size: 24px; font-size: 24px;
color: #eee; color: #eee;
@@ -2186,63 +2187,104 @@ body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container:befo
font-weight: 400 font-weight: 400
} }
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
content: "Authors"; content: "Authors";
top: 115px; top: 115px;
display: table;
} }
@media screen and (max-width: 992px) { @media screen and (max-width: 992px) {
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
top: 60px; top: 60px;
} }
} }
body.catlist > div.container-fluid > div > div.col-sm-10 > div.container:before { @media screen and (max-width: 992px) {
body.downloadlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
top: 60px;
}
}
body.downloadlist > div.container-fluid > div.row-fluid > div.col-sm-10:before {
font-family: Glyphicons Halflings, serif;
content: "\E026";
font-style: normal;
font-weight: 400;
line-height: 1;
font-size: 6vw;
position: fixed;
left: 240px;
top: 240px;
width: calc(20% - 55px);
text-align: center
}
@media screen and (max-width: 992px) {
body.downloadlist > div.container-fluid > div.row-fluid > div.col-sm-10:before {
top: 180px;
}
}
body.downloadlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
content: "Downloads";
top: 115px;
display: table;
}
body.catlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
content: "Categories"; content: "Categories";
top: 115px; top: 115px;
display: table;
} }
@media screen and (max-width: 992px) { @media screen and (max-width: 992px) {
body.catlist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.catlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
top: 60px; top: 60px;
} }
} }
body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
content: "Series"; content: "Series";
top: 115px; top: 115px;
display: table;
} }
@media screen and (max-width: 992px) { @media screen and (max-width: 992px) {
body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
top: 60px; top: 60px;
} }
} }
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
content: "File formats"; content: "File formats";
top: 115px; top: 115px;
display: table;
} }
@media screen and (max-width: 992px) { @media screen and (max-width: 992px) {
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
top: 60px; top: 60px;
} }
} }
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
content: "File formats"; content: "File formats";
top: 115px; top: 115px;
display: table;
} }
@media screen and (max-width: 992px) { @media screen and (max-width: 992px) {
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
top: 60px; top: 60px;
} }
} }
body.langlist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.langlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
content: "Languages" content: "Languages";
top: 115px;
display: table;
} }
body.advsearch > div.container-fluid > div > div.col-sm-10 > div.col-md-10.col-lg-6 { body.advsearch > div.container-fluid > div > div.col-sm-10 > div.col-md-10.col-lg-6 {
@@ -6421,7 +6463,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
display: none !important display: none !important
} }
.shelforder > .container-fluid > .row-fluid > .col-sm-10 > div.col-sm-6.col-lg-6.col-xs-6 > h2:before, body.advsearch > div.container-fluid > div > div.col-sm-10:before, body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container:before, body.catlist > div.container-fluid > div > div.col-sm-10 > div.container:before, body.langlist > div.container-fluid > div > div.col-sm-10 > div.container:before, body.login .navbar > .container-fluid > .navbar-header > button.navbar-toggle, body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container:before { .shelforder > .container-fluid > .row-fluid > .col-sm-10 > div.col-sm-6.col-lg-6.col-xs-6 > h2:before, body.advsearch > div.container-fluid > div > div.col-sm-10:before, body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.catlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.langlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.downloadlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.login .navbar > .container-fluid > .navbar-header > button.navbar-toggle, body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
display: none display: none
} }
@@ -6527,7 +6569,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
background-size: 30px background-size: 30px
} }
body.advanced_search > div.container-fluid > div > div.col-sm-10 > div.col-sm-8:before, body.author:not(.authorlist) .undefined-img:before, body.authorlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.catlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.langlist > div.container-fluid > div > div.col-sm-10:before, body.ratingslist > div.container-fluid > div > div.col-sm-10:before, body.serieslist > div.container-fluid > div > div.col-sm-10:before, body.shelforder > div.container-fluid > div.row-fluid > div.col-sm-10:before { body.advanced_search > div.container-fluid > div > div.col-sm-10 > div.col-sm-8:before, body.author:not(.authorlist) .undefined-img:before, body.authorlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.catlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.downloadlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.langlist > div.container-fluid > div > div.col-sm-10:before, body.ratingslist > div.container-fluid > div > div.col-sm-10:before, body.serieslist > div.container-fluid > div > div.col-sm-10:before, body.shelforder > div.container-fluid > div.row-fluid > div.col-sm-10:before {
position: fixed; position: fixed;
top: 62px; top: 62px;
right: 23px; right: 23px;
@@ -6547,7 +6589,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
border-top: 1px solid hsla(0, 0%, 78%, .15) border-top: 1px solid hsla(0, 0%, 78%, .15)
} }
body > div.container-fluid > div > div.col-sm-10 > div.container { body > div.container-fluid > div > div.col-sm-10 > div.container1 {
left: 0; left: 0;
width: auto; width: auto;
margin: 0 !important; margin: 0 !important;
@@ -6674,7 +6716,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
margin-bottom: 5px margin-bottom: 5px
} }
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row { body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row {
padding: 0 padding: 0
} }
@@ -7441,11 +7483,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
text-decoration: none text-decoration: none
} }
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row:nth-of-type(even) { body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row:nth-of-type(even) {
background: 0 0 background: 0 0
} }
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row { body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row {
border-bottom: 1px solid hsla(0, 0%, 78%, .15) border-bottom: 1px solid hsla(0, 0%, 78%, .15)
} }
@@ -7600,7 +7642,7 @@ body.formatslist > div.container-fluid > div > div.col-sm-10:before {
} }
} }
body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
content: "Publishers"; content: "Publishers";
top: 115px; top: 115px;
font-size: 24px; font-size: 24px;
@@ -7608,17 +7650,18 @@ body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container:b
line-height: 1; line-height: 1;
padding-left: 15px; padding-left: 15px;
position: absolute; position: absolute;
display: table;
font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif; font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif;
font-weight: 400 font-weight: 400
} }
@media screen and (max-width: 992px) { @media screen and (max-width: 992px) {
body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
top: 60px; top: 60px;
} }
} }
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
content: "Ratings"; content: "Ratings";
top: 115px; top: 115px;
font-size: 24px; font-size: 24px;
@@ -7626,23 +7669,24 @@ body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container:bef
line-height: 1; line-height: 1;
padding-left: 15px; padding-left: 15px;
position: absolute; position: absolute;
display: table;
font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif; font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif;
font-weight: 400 font-weight: 400
} }
@media screen and (max-width: 992px) { @media screen and (max-width: 992px) {
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
top: 60px; top: 60px;
} }
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
display: none; display: none;
} }
} }
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
content: "File formats"; content: "File formats";
top: 115px; top: 115px;
font-size: 24px; font-size: 24px;
@@ -7655,18 +7699,18 @@ body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container:bef
} }
@media screen and (max-width: 992px) { @media screen and (max-width: 992px) {
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
top: 60px; top: 60px;
} }
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
display: none; display: none;
} }
} }
body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container > div.col-sm-6 { body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container1 > div.col-sm-6 {
width: 100% width: 100%
} }
@@ -7705,7 +7749,7 @@ body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container >
color: hsla(0, 0%, 100%, .7) color: hsla(0, 0%, 100%, .7)
} }
body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container:before { body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
display: none !important display: none !important
} }
} }
+11
View File
@@ -26,3 +26,14 @@ body.serieslist.grid-view div.container-fluid > div > div.col-sm-10::before {
input.datepicker {color: transparent} input.datepicker {color: transparent}
input.datepicker:focus {color: transparent} input.datepicker:focus {color: transparent}
input.datepicker:focus + input {color: #555} input.datepicker:focus + input {color: #555}
.col-sm-3.col-lg-2.col-xs-6.book.session {
margin-left: 0;
margin-right: 0;
}
@media only screen and (max-width: 767px) {
.row-fluid > .col-sm-2 {
visibility: hidden;
}
}
+5
View File
@@ -13,6 +13,11 @@
color: #000; color: #000;
} }
.amberTheme {
background: #e8bf5e;
color: #000;
}
.blackTheme { .blackTheme {
background: #000; background: #000;
color: #fff; color: #fff;
+167 -81
View File
@@ -1,11 +1,10 @@
@font-face { @font-face {
font-family: 'fontello'; font-family: "fontello";
src: url('fonts/fontello.eot?60518104'); src: url("fonts/fontello.eot?60518104");
src: src: url("fonts/fontello.eot?60518104#iefix") format("embedded-opentype"),
url('fonts/fontello.eot?60518104#iefix') format('embedded-opentype'), url("fonts/fontello.woff?60518104") format("woff"),
url('fonts/fontello.woff?60518104') format('woff'), url("fonts/fontello.ttf?60518104") format("truetype"),
url('fonts/fontello.ttf?60518104') format('truetype'), url("fonts/fontello.svg?60518104#fontello") format("svg");
url('fonts/fontello.svg?60518104#fontello') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@@ -16,7 +15,7 @@ body {
} }
.myselect { .myselect {
overflow: visible !important; overflow: visible !important;
} }
#main { #main {
@@ -48,7 +47,7 @@ body {
-webkit-transition: opacity 0.5s; -webkit-transition: opacity 0.5s;
-moz-transition: opacity 0.5s; -moz-transition: opacity 0.5s;
transition: opacity 0.5s; transition: opacity 0.5s;
z-index: 10; z-index: 100;
} }
#titlebar:hover { #titlebar:hover {
@@ -93,14 +92,18 @@ body {
box-shadow: inset 0 0 6px rgba(155, 155, 155, 0.8); box-shadow: inset 0 0 6px rgba(155, 155, 155, 0.8);
} }
#book-title { font-weight: 600; } #book-title {
#title-seperator { display: none; } font-weight: 600;
}
#title-seperator {
display: none;
}
#viewer { #viewer {
width: 80%; width: 80%;
height: 80%; height: 80%;
margin: 0 auto; margin: 0 auto;
z-index: 2; z-index: 0;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
@@ -136,6 +139,7 @@ body {
user-select: none; user-select: none;
padding-top: 160px; padding-top: 160px;
padding-bottom: 160px; padding-bottom: 160px;
z-index: 10;
} }
.arrow:hover { .arrow:hover {
@@ -200,10 +204,16 @@ body {
max-width: 80%; max-width: 80%;
} }
#title-controls { float: right; } #title-controls {
float: right;
}
#panels a::before { visibility: visible; } #panels a::before {
#panels a:hover { color: #aaa; } visibility: visible;
}
#panels a:hover {
color: #aaa;
}
.list_item.currentChapter > a, .list_item.currentChapter > a,
.list_item a:hover { .list_item a:hover {
@@ -237,8 +247,12 @@ body {
margin-top: -1px; margin-top: -1px;
} }
input::-webkit-input-placeholder { color: #454545; } input::-webkit-input-placeholder {
input:-moz-placeholder { color: #454545; } color: #454545;
}
input:-moz-placeholder {
color: #454545;
}
#divider { #divider {
position: absolute; position: absolute;
@@ -378,14 +392,14 @@ input:-moz-placeholder { color: #454545; }
} }
.toc_toggle::before { .toc_toggle::before {
content: '▸'; content: "▸";
color: #fff; color: #fff;
margin-right: -4px; margin-right: -4px;
} }
.currentChapter > .toc_toggle::before, .currentChapter > .toc_toggle::before,
.openChapter > .toc_toggle::before { .openChapter > .toc_toggle::before {
content: '▾'; content: "▾";
} }
.view { .view {
@@ -499,21 +513,33 @@ input:-moz-placeholder { color: #454545; }
list-style-type: none; list-style-type: none;
} }
#settingsPanel .xsmall { font-size: x-small; } #settingsPanel .xsmall {
#settingsPanel .small { font-size: small; } font-size: x-small;
#settingsPanel .medium { font-size: medium; } }
#settingsPanel .large { font-size: large; } #settingsPanel .small {
#settingsPanel .xlarge { font-size: x-large; } font-size: small;
}
#settingsPanel .medium {
font-size: medium;
}
#settingsPanel .large {
font-size: large;
}
#settingsPanel .xlarge {
font-size: x-large;
}
.highlight { background-color: yellow; } .highlight {
background-color: yellow;
}
.modal { .modal {
position: fixed; position: fixed;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 100vw; width: 97vw;
height: auto; height: 90dvh;
max-width: 630px; max-width: 630px;
z-index: 2000; z-index: 2000;
visibility: hidden; visibility: hidden;
@@ -539,7 +565,7 @@ input:-moz-placeholder { color: #454545; }
} }
.md-show ~ .overlay { .md-show ~ .overlay {
opacity: 1; /* opacity: 1; */
visibility: visible; visibility: visible;
} }
@@ -554,7 +580,7 @@ input:-moz-placeholder { color: #454545; }
} }
.md-content > div { .md-content > div {
padding: 15px 40px 30px; padding: 10px 20px;
margin: 0; margin: 0;
font-weight: 300; font-weight: 300;
font-size: 14px; font-size: 14px;
@@ -581,6 +607,7 @@ input:-moz-placeholder { color: #454545; }
color: white; color: white;
} }
.md-content .themes button.lightTheme,
.md-content .themes button.whiteTheme { .md-content .themes button.whiteTheme {
background-color: white; background-color: white;
color: black; color: black;
@@ -591,6 +618,11 @@ input:-moz-placeholder { color: #454545; }
color: black; color: black;
} }
.md-content .themes button.amberTheme {
background-color: #e8bf5e;
color: black;
}
.md-content .themes button.blackTheme { .md-content .themes button.blackTheme {
background-color: black; background-color: black;
color: white; color: white;
@@ -638,31 +670,47 @@ input:-moz-placeholder { color: #454545; }
@media only screen and (max-width: 900px) { @media only screen and (max-width: 900px) {
#viewer { #viewer {
width: 60%; width: 100%;
margin-left: 20%; height: calc(100dvh - 99px);
margin-left: 0;
}
#viewer iframe {
width: 100%;
height: 100%;
} }
#prev { #prev {
left: 20px; left: 20px;
z-index: 10;
} }
#next { #next {
right: 20px; right: 20px;
z-index: 10;
} }
} }
@media only screen and (max-width: 550px) { @media only screen and (max-width: 550px) {
#viewer { #viewer {
width: 80%; width: 100%;
margin-left: 10%; height: calc(100dvh - 99px);
margin-left: 0;
}
#viewer iframe {
width: 100%;
height: 100%;
} }
#prev { #prev {
left: 0; left: 0;
z-index: 10;
} }
#next { #next {
right: 0; right: 0;
z-index: 10;
} }
.arrow { .arrow {
@@ -712,11 +760,7 @@ input:-moz-placeholder { color: #454545; }
} }
} }
@media only screen @media only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: landscape) /* and (-webkit-min-device-pixel-ratio: 2) */ {
and (min-device-width: 768px)
and (max-device-width: 1024px)
and (orientation: landscape)
/* and (-webkit-min-device-pixel-ratio: 2)*/ {
#viewer { #viewer {
width: 80%; width: 80%;
margin-left: 10%; margin-left: 10%;
@@ -739,47 +783,52 @@ input:-moz-placeholder { color: #454545; }
/* For iPhone 6\6s portrait layouts only */ /* For iPhone 6\6s portrait layouts only */
@media only screen and (min-device-width: 375px) and (max-device-width: 667px) and (orientation: portrait) { @media only screen and (min-device-width: 375px) and (max-device-width: 667px) and (orientation: portrait) {
#viewer { #viewer {
width: 300px; width: 100%;
height: 480px; height: calc(100dvh - 99px);
} }
#viewer iframe { #viewer iframe {
width: 300px; width: 100%;
height: 480px; height: 100%;
} }
} }
/* For iPhone 6\6s landscape layouts only */ /* For iPhone 6\6s landscape layouts only */
@media only screen and (min-device-width: 375px) and (max-device-width: 667px) and (orientation: landscape) { @media only screen and (min-device-width: 375px) and (max-device-width: 667px) and (orientation: landscape) {
#viewer { #viewer {
width: 450px; width: 100%;
height: 300px; height: calc(100dvh - 99px);
} }
#viewer iframe { #viewer iframe {
width: 450px; width: 100%;
height: 300px; height: 100%;
} }
} }
/* For iPhone portrait layouts only */ /* For iPhone portrait layouts only */
@media only screen and (max-device-width: 374px) and (orientation: portrait) { @media only screen and (max-device-width: 374px) and (orientation: portrait) {
#viewer { #viewer {
width: 256px; width: 100%;
height: 432px; height: calc(100dvh - 99px);
} }
#viewer iframe { #viewer iframe {
width: 256px; width: 100%;
height: 432px; height: 100%;
} }
} }
/* For iPhone landscape layouts only */ /* For iPhone landscape layouts only */
@media only screen and (max-device-width: 374px) and (orientation: landscape) { @media only screen and (max-device-width: 374px) and (orientation: landscape) {
#viewer {
width: 100%;
height: calc(100dvh - 99px);
}
#viewer iframe { #viewer iframe {
width: 256px; width: 100%;
height: 124px; height: 100%;
} }
} }
@@ -803,32 +852,69 @@ input:-moz-placeholder { color: #454545; }
font-size: 112%; font-size: 112%;
} }
.icon-search::before { content: '\e807'; } /* '' */ .icon-search::before {
.icon-resize-full-1::before { content: '\e804'; } /* '' */ content: "\e807";
.icon-cancel-circled2::before { content: '\e80f'; } /* '' */ } /* '' */
.icon-link::before { content: '\e80d'; } /* '' */ .icon-resize-full-1::before {
.icon-bookmark::before { content: '\e805'; } /* '' */ content: "\e804";
.icon-bookmark-empty::before { content: '\e806'; } /* '' */ } /* '' */
.icon-download-cloud::before { content: '\e811'; } /* '' */ .icon-cancel-circled2::before {
.icon-edit::before { content: '\e814'; } /* '' */ content: "\e80f";
.icon-menu::before { content: '\e802'; } /* '' */ } /* '' */
.icon-cog::before { content: '\e813'; } /* '' */ .icon-link::before {
.icon-resize-full::before { content: '\e812'; } /* '' */ content: "\e80d";
.icon-cancel-circled::before { content: '\e80e'; } /* '' */ } /* '' */
.icon-up-dir::before { content: '\e80c'; } /* '' */ .icon-bookmark::before {
.icon-right-dir::before { content: '\e80b'; } /* '' */ content: "\e805";
.icon-angle-right::before { content: '\e809'; } /* '' */ } /* '' */
.icon-angle-down::before { content: '\e80a'; } /* '' */ .icon-bookmark-empty::before {
.icon-right::before { content: '\e815'; } /* '' */ content: "\e806";
.icon-list-1::before { content: '\e803'; } /* '' */ } /* '' */
.icon-list-numbered::before { content: '\e801'; } /* '' */ .icon-download-cloud::before {
.icon-columns::before { content: '\e810'; } /* '' */ content: "\e811";
.icon-list::before { content: '\e800'; } /* '' */ } /* '' */
.icon-resize-small::before { content: '\e808'; } /* '' */ .icon-edit::before {
content: "\e814";
#progress{ } /* '' */
right: 4rem; .icon-menu::before {
bottom: 4rem; content: "\e802";
width: fit-content; } /* '' */
position: absolute; .icon-cog::before {
} content: "\e813";
} /* '' */
.icon-resize-full::before {
content: "\e812";
} /* '' */
.icon-cancel-circled::before {
content: "\e80e";
} /* '' */
.icon-up-dir::before {
content: "\e80c";
} /* '' */
.icon-right-dir::before {
content: "\e80b";
} /* '' */
.icon-angle-right::before {
content: "\e809";
} /* '' */
.icon-angle-down::before {
content: "\e80a";
} /* '' */
.icon-right::before {
content: "\e815";
} /* '' */
.icon-list-1::before {
content: "\e803";
} /* '' */
.icon-list-numbered::before {
content: "\e801";
} /* '' */
.icon-columns::before {
content: "\e810";
} /* '' */
.icon-list::before {
content: "\e800";
} /* '' */
.icon-resize-small::before {
content: "\e808";
} /* '' */
+69 -35
View File
@@ -1,47 +1,81 @@
.fontSizeWrapper { .fontSizeWrapper {
position: relative; position: relative;
} }
.slider { .slider {
position: absolute; position: absolute;
top: 50%; top: 50%;
transform: translate(0,-50%); transform: translate(0, -50%);
width: 90%; width: 90%;
height: 60px; height: 60px;
background: transparent; background: transparent;
border-radius: 20px; border-radius: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
box-shadow: 0px 15px 40px #7E6D5766; box-shadow: 0px 15px 40px #7e6d5766;
} }
.slider label { .slider label {
font-size: 20px; font-size: 20px;
font-weight: 400; font-weight: 400;
font-family: Open Sans; font-family: Open Sans;
padding-right: 10px; padding-right: 10px;
color: white; color: white;
} }
.slider input[type="range"] { .slider input[type="range"] {
width: 80%; width: 80%;
height: 5px; height: 5px;
background: black; background: black;
border: none; border: none;
outline: none; outline: none;
} }
.item { .item {
font-size: 20px; font-size: 14px;
font-weight: 400; font-weight: 600;
font-family: Open Sans; font-family: Open Sans;
padding-right: 10px; padding-right: 10px;
color: white; color: white;
} }
.item~button { .item ~ button {
display: inline-block; display: inline-block;
border: none; border: none;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
margin-top: 5%; margin-top: 5%;
margin-right: 1%; margin-right: 1%;
font-size: 16px; font-size: 16px;
} }
#main,
#viewer,
#sidebar,
#tocView {
touch-action: pan-y;
}
.read-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px;
}
.read-footer #pages-count {
font-size: 12px;
position: absolute;
left: 12px;
bottom: 2px;
}
#back-button {
width: 50px !important;
right: 50px;
// position: absolute;
}
#progress {
right: 4rem;
bottom: 2px;
width: fit-content;
position: absolute;
}
+47
View File
@@ -1,3 +1,7 @@
#add-to-shelves.dropdown-menu, #remove-from-shelves.dropdown-menu {
max-height: 300px;
overflow-y: auto;
}
.tooltip.bottom .tooltip-inner { .tooltip.bottom .tooltip-inner {
font-size: 13px; font-size: 13px;
@@ -429,6 +433,9 @@ div.log {
padding: 0.5em; padding: 0.5em;
} }
.badge-sm{
font-size: 70%;
}
#detailcover { cursor:zoom-in; } #detailcover { cursor:zoom-in; }
#detailcover:-webkit-full-screen { cursor:zoom-out; border: 0; } #detailcover:-webkit-full-screen { cursor:zoom-out; border: 0; }
#detailcover:-moz-full-screen { cursor:zoom-out; border: 0; } #detailcover:-moz-full-screen { cursor:zoom-out; border: 0; }
@@ -438,3 +445,43 @@ div.log {
.error-list { .error-list {
margin-top: 5px; margin-top: 5px;
} }
body > div.container-fluid > div.row-fluid > div.col-sm-10 {
max-height: calc(100vh - 111px);
overflow-y: auto;
}
body > div.container-fluid > div.row-fluid > div.col-sm-2 {
max-height: calc(100vh - 111px);
overflow-y: auto;
/* hide scrollbar in IE, Edge */
-ms-overflow-style: none;
/* hide scrollbar in Firefox */
scrollbar-width: none;
}
/* hide scrollbar in WebKit browsers (Chrome, Safari, Opera) */
body > div.container-fluid > div.row-fluid > div.col-sm-2::-webkit-scrollbar {
display: none;
}
.scrollable-col {
/* keep vertical scrolling */
overflow-y: auto;
/* hide scrollbar in IE, Edge */
-ms-overflow-style: none;
/* hide scrollbar in Firefox */
scrollbar-width: none;
}
/* hide scrollbar in WebKit browsers (Chrome, Safari, Opera) */
.scrollable-col::-webkit-scrollbar {
display: none;
}
body > div.container-fluid {
padding-right: 0px;
}
+2 -2
View File
@@ -103,11 +103,11 @@ $(function () {
}); });
} }
else { else {
$("#meta-info").html("<p class=\"text-danger\">" + msg.no_result + "!</p>" + $("#meta-info")[0].innerHTML) $("#meta-info").html("<p class=\"text-danger\">" + msg.no_result + "</p>" + $("#meta-info")[0].innerHTML)
} }
}, },
error: function error() { error: function error() {
$("#meta-info").html("<p class=\"text-danger\">" + msg.search_error + "!</p>" + $("#meta-info")[0].innerHTML); $("#meta-info").html("<p class=\"text-danger\">" + msg.search_error + "</p>" + $("#meta-info")[0].innerHTML);
}, },
}); });
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16 -14
View File
@@ -229,10 +229,12 @@ $("#delete_confirm").click(function(event) {
postButton(event, getPath() + "/delete/" + deleteId + "/" + bookFormat); postButton(event, getPath() + "/delete/" + deleteId + "/" + bookFormat);
} else { } else {
if (ajaxResponse) { if (ajaxResponse) {
path = getPath() + "/ajax/delete/" + deleteId;
$.ajax({ $.ajax({
method:"post", url: getPath() + "/ajax/deletebook",
url: path, method: "post",
contentType: "application/json; charset=utf-8",
dataType: "json",
data: JSON.stringify({"bookid": [deleteId]}),
timeout: 900, timeout: 900,
success:function(data) { success:function(data) {
data.forEach(function(item) { data.forEach(function(item) {
@@ -408,17 +410,17 @@ $(function() {
}); });
// fix for infinite scroll on CaliBlur Theme (#981) // fix for infinite scroll on CaliBlur Theme (#981)
if ($("body").hasClass("blur")) {
$(".col-sm-10").bind("scroll", function () { $(".col-sm-10").bind("scroll", function () {
if ( if (
$(this).scrollTop() + $(this).innerHeight() >= $(this).scrollTop() + $(this).innerHeight() >=
$(this)[0].scrollHeight $(this)[0].scrollHeight
) { ) {
$loadMore.infiniteScroll("loadNextPage"); $loadMore.infiniteScroll("loadNextPage");
window.history.replaceState({}, null, $loadMore.infiniteScroll("getAbsolutePath")); window.history.replaceState({}, null, $loadMore.infiniteScroll("getAbsolutePath"));
} }
}); });
}
} }
$("#restart").click(function() { $("#restart").click(function() {
+105 -25
View File
@@ -2,7 +2,7 @@
var reader; var reader;
(function() { (function () {
"use strict"; "use strict";
EPUBJS.filePath = calibre.filePath; EPUBJS.filePath = calibre.filePath;
@@ -10,7 +10,7 @@ var reader;
reader = ePubReader(calibre.bookUrl, { reader = ePubReader(calibre.bookUrl, {
restore: true, restore: true,
bookmarks: calibre.bookmark ? [calibre.bookmark] : [] bookmarks: calibre.bookmark ? [calibre.bookmark] : [],
}); });
Object.keys(themes).forEach(function (theme) { Object.keys(themes).forEach(function (theme) {
@@ -54,29 +54,92 @@ var reader;
// Update progress percentage // Update progress percentage
let progressDiv = document.getElementById("progress"); let progressDiv = document.getElementById("progress");
reader.book.ready.then((()=>{ // Pages counter (virtual pages via EPUB locations)
let locations_key = reader.book.key()+'-locations'; let pagesDiv = document.getElementById("pages-count");
// Honor saved visibility preference for pages counter
(function () {
try {
var pref = localStorage.getItem("calibre.reader.showPages");
var show = pref === null ? true : pref === "true";
if (pagesDiv)
pagesDiv.style.visibility = show ? "visible" : "hidden";
} catch (e) {}
})();
reader.book.ready.then(() => {
let locations_key = reader.book.key() + "-locations";
// Key to persist last-read position for this book in localStorage
let position_key = "calibre.reader.position." + reader.book.key();
let stored_locations = localStorage.getItem(locations_key); let stored_locations = localStorage.getItem(locations_key);
let make_locations, save_locations; let make_locations, save_locations;
if (stored_locations) { if (stored_locations) {
make_locations = Promise.resolve(reader.book.locations.load(stored_locations)); make_locations = Promise.resolve(
reader.book.locations.load(stored_locations)
);
// No-op because locations are already saved // No-op because locations are already saved
save_locations = ()=>{}; save_locations = () => {};
} else { } else {
make_locations = reader.book.locations.generate(); make_locations = reader.book.locations.generate();
save_locations = ()=>{ save_locations = () => {
localStorage.setItem(locations_key, reader.book.locations.save()); localStorage.setItem(
locations_key,
reader.book.locations.save()
);
}; };
} }
make_locations.then(()=>{ make_locations
reader.rendition.on('relocated', (location)=>{ .then(() => {
let percentage = Math.round(location.end.percentage*100); // Try to restore last position (CFI) from localStorage if present
progressDiv.textContent=percentage+"%"; try {
}); var _savedPos = localStorage.getItem(position_key);
reader.rendition.reportLocation(); if (_savedPos) {
progressDiv.style.visibility = "visible"; try {
}).then(save_locations); var _posObj = JSON.parse(_savedPos);
})); if (_posObj && _posObj.cfi) {
// Display the saved CFI location
try {
reader.rendition.display(_posObj.cfi);
} catch (e) {}
}
} catch (e) {}
}
} catch (e) {}
reader.rendition.on("relocated", (location) => {
let percentage = Math.round(location.end.percentage * 100);
progressDiv.textContent = percentage + "%";
// Pages based on generated EPUB locations (CFI positions)
const cfi = location.start.cfi;
const current =
reader.book.locations.locationFromCfi(cfi) || 0; // 1-based index typically
const total = reader.book.locations.length() || 0;
if (total > 0) {
pagesDiv.textContent = current + "/" + total;
pagesDiv.style.visibility = "visible";
} else {
pagesDiv.textContent = "";
pagesDiv.style.visibility = "hidden";
}
// Persist last position (CFI + percentage) to localStorage so reader can restore on next open
try {
var posObj = {
cfi: location.start.cfi,
percentage: location.start.percentage,
};
localStorage.setItem(
position_key,
JSON.stringify(posObj)
);
} catch (e) {}
});
reader.rendition.reportLocation();
progressDiv.style.visibility = "visible";
})
.then(save_locations);
});
/** /**
* @param {string} action - Add or remove bookmark * @param {string} action - Add or remove bookmark
@@ -85,26 +148,43 @@ var reader;
function updateBookmark(action, location) { function updateBookmark(action, location) {
// Remove other bookmarks (there can only be one) // Remove other bookmarks (there can only be one)
if (action === "add") { if (action === "add") {
this.settings.bookmarks.filter(function (bookmark) { this.settings.bookmarks
return bookmark && bookmark !== location; .filter(function (bookmark) {
}).map(function (bookmark) { return bookmark && bookmark !== location;
this.removeBookmark(bookmark); })
}.bind(this)); .map(
function (bookmark) {
this.removeBookmark(bookmark);
}.bind(this)
);
} }
var csrftoken = $("input[name='csrf_token']").val(); var csrftoken = $("input[name='csrf_token']").val();
// Save to database // Save to database
$.ajax(calibre.bookmarkUrl, { $.ajax(calibre.bookmarkUrl, {
method: "post", method: "post",
data: { bookmark: location || "" }, data: { bookmark: location || "" },
headers: { "X-CSRFToken": csrftoken } headers: { "X-CSRFToken": csrftoken },
}).fail(function (xhr, status, error) { }).fail(function (xhr, status, error) {
alert(error); alert(error);
}); });
} }
// Default settings load // Default settings load
const theme = localStorage.getItem("calibre.reader.theme") ?? "lightTheme"; const theme = localStorage.getItem("calibre.reader.theme") ?? "lightTheme";
selectTheme(theme); selectTheme(theme);
// Restore saved font and font size after reader is ready
reader.book.ready.then(() => {
const savedFontSize = localStorage.getItem("calibre.reader.fontSize");
if (savedFontSize) {
reader.rendition.themes.fontSize(`${savedFontSize}%`);
}
const savedFont = localStorage.getItem("calibre.reader.font");
if (savedFont && window.selectFont) {
window.selectFont(savedFont);
}
});
})(); })();
@@ -1,21 +0,0 @@
// register new event emitter locationchange that fires on urlchange
// source: https://stackoverflow.com/a/52809105/21941129
(() => {
let oldPushState = history.pushState;
history.pushState = function pushState() {
let ret = oldPushState.apply(this, arguments);
window.dispatchEvent(new Event('locationchange'));
return ret;
};
let oldReplaceState = history.replaceState;
history.replaceState = function replaceState() {
let ret = oldReplaceState.apply(this, arguments);
window.dispatchEvent(new Event('locationchange'));
return ret;
};
window.addEventListener('popstate', () => {
window.dispatchEvent(new Event('locationchange'));
});
})();
+441 -39
View File
@@ -81,20 +81,78 @@ $(function() {
$("#merge_books").addClass("disabled"); $("#merge_books").addClass("disabled");
$("#merge_books").attr("aria-disabled", true); $("#merge_books").attr("aria-disabled", true);
} }
if (selections.length >= 1) {
$("#delete_selected_books").removeClass("disabled");
$("#delete_selected_books").attr("aria-disabled", false);
$("#archive_selected_books").removeClass("disabled");
$("#archive_selected_books").attr("aria-disabled", false);
$("#unarchive_selected_books").removeClass("disabled");
$("#unarchive_selected_books").attr("aria-disabled", false);
$("#read_selected_books").removeClass("disabled");
$("#read_selected_books").attr("aria-disabled", false);
$("#unread_selected_books").removeClass("disabled");
$("#unread_selected_books").attr("aria-disabled", false);
$("#edit_selected_books").removeClass("disabled");
$("#edit_selected_books").attr("aria-disabled", false);
} else {
$("#delete_selected_books").addClass("disabled");
$("#delete_selected_books").attr("aria-disabled", true);
$("#archive_selected_books").addClass("disabled");
$("#archive_selected_books").attr("aria-disabled", true);
$("#unarchive_selected_books").addClass("disabled");
$("#unarchive_selected_books").attr("aria-disabled", true);
$("#read_selected_books").addClass("disabled");
$("#read_selected_books").attr("aria-disabled", true);
$("#unread_selected_books").addClass("disabled");
$("#unread_selected_books").attr("aria-disabled", true);
$("#edit_selected_books").addClass("disabled");
$("#edit_selected_books").attr("aria-disabled", true);
}
if (selections.length < 1) { if (selections.length < 1) {
$("#delete_selection").addClass("disabled"); // $("#book_delete_selection").addClass("disabled");
$("#delete_selection").attr("aria-disabled", true); // $("#book_delete_selection").attr("aria-disabled", true);
$("#table_xchange").addClass("disabled"); $("#table_xchange").addClass("disabled");
$("#table_xchange").attr("aria-disabled", true); $("#table_xchange").attr("aria-disabled", true);
} else { } else {
$("#delete_selection").removeClass("disabled"); // $("#book_delete_selection").removeClass("disabled");
$("#delete_selection").attr("aria-disabled", false); // $("#book_delete_selection").attr("aria-disabled", false);
$("#table_xchange").removeClass("disabled"); $("#table_xchange").removeClass("disabled");
$("#table_xchange").attr("aria-disabled", false); $("#table_xchange").attr("aria-disabled", false);
} }
handle_header_buttons();
}); });
$("#delete_selection").click(function() {
// Small block to initialize the state of the author/title sort inputs in metadata form
{
let checkA = $('#autoupdate_authorsort').prop('checked');
$('#author_sort_input').prop('disabled', checkA);
let checkT = $('#autoupdate_titlesort').prop('checked');
$('#title_sort_input').prop('disabled', checkT);
}
// Disable/enable author and title sort input in respect to auto-update title/author sort being checked on or not
$("#autoupdate_authorsort").on('change', function(event) {
let checkA = $('#autoupdate_authorsort').prop('checked');
$('#author_sort_input').prop('disabled', checkA);
})
$("#autoupdate_titlesort").on('change', function(event) {
let checkT = $('#autoupdate_titlesort').prop('checked');
$('#title_sort_input').prop('disabled', checkT);
})
/////
$("#book_delete_selection").click(function () {
$("#books-table").bootstrapTable("uncheckAll"); $("#books-table").bootstrapTable("uncheckAll");
}); });
@@ -135,6 +193,250 @@ $(function() {
}); });
}); });
$("#edit_selected_books").click(function(event) {
if ($(this).hasClass("disabled")) {
event.stopPropagation()
} else {
$('#edit_selected_modal').modal("show");
}
});
$("#edit_selected_confirm").click(function(event) {
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/editselectedbooks",
data: JSON.stringify({
"selections": selections,
"title": $("#title_input").val(),
"title_sort": $("#title_sort_input").val(),
"author_sort": $("#author_sort_input").val(),
"authors": $("#authors_input").val(),
"categories": $("#categories_input").val(),
"series": $("#series_input").val(),
"languages": $("#languages_input").val(),
"publishers": $("#publishers_input").val(),
"comments": $("#comments_input").val().toString(),
"checkA": $('#autoupdate_authorsort').prop('checked'),
"checkT": $('#autoupdate_titlesort').prop('checked')
}),
success: function success(data) {
let result = "";
$("#books-table").bootstrapTable("refresh");
$("#books-table").bootstrapTable("uncheckAll");
$("#title_input").val("");
$("#title_sort_input").val("");
$("#author_sort_input").val("");
$("#authors_input").val("");
$("#categories_input").val("");
$("#series_input").val("");
$("#languages_input").val("");
$("#publishers_input").val("");
$("#comments_input").val("");
$("#flash_success").remove();
$("#flash_danger").remove();
if (!jQuery.isEmptyObject(data)) {
data.forEach(function(item) {
if (item.success === true) {
result = "success";
} else {
result = "danger";
}
$(".navbar").after('<div class="row-fluid text-center">' +
'<div id="flash_' + result + '" class="alert alert-' + result + '">' + item.msg + '</div>' +
'</div>');
});
}
$(".table.table-striped").bootstrapTable("refresh");
// handleListServerResponse(data);
}
});
});
$(document).on('click', '#archive_selected_books', function(event) {
if ($(this).hasClass("disabled")) {
event.stopPropagation()
} else {
$('#archive_selected_modal').modal("show");
}
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/displayselectedbooks",
data: JSON.stringify({"selections":selections}),
success: function success(booTitles) {
$('#display-archive-selected-books').empty();
$.each(booTitles.books, function(i, item) {
$("<span>- " + item + "</span><p></p>").appendTo("#display-archive-selected-books");
});
}
});
});
/*$(document).on('click', '#archive_selected_confirm', function(event) {
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/archiveselectedbooks",
data: JSON.stringify({"selections":selections, "archive": true}),
success: function success(booTitles) {
$("#books-table").bootstrapTable("refresh");
$("#books-table").bootstrapTable("uncheckAll");
}
});
});
$(document).on('click', '#unarchive_selected_books', function(event) {
if ($(this).hasClass("disabled")) {
event.stopPropagation()
} else {
$('#unarchive_selected_modal').modal("show");
}
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/displayselectedbooks",
data: JSON.stringify({"selections":selections}),
success: function success(booTitles) {
$('#display-unarchive-selected-books').empty();
$.each(booTitles.books, function(i, item) {
$("<span>- " + item + "</span><p></p>").appendTo("#display-unarchive-selected-books");
});
}
});
});
$(document).on('click', '#unarchive_selected_confirm', function(event) {
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/archiveselectedbooks",
data: JSON.stringify({"selections":selections, "archive": false}),
success: function success(booTitles) {
$("#books-table").bootstrapTable("refresh");
$("#books-table").bootstrapTable("uncheckAll");
}
});
});
$(document).on('click', '#delete_selected_books', function(event) {
if ($(this).hasClass("disabled")) {
event.stopPropagation()
} else {
$('#delete_selected_modal').modal("show");
}
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/displayselectedbooks",
data: JSON.stringify({"selections":selections}),
success: function success(booTitles) {
$('#display-delete-selected-books').empty();
$.each(booTitles.books, function(i, item) {
$("<span>- " + item + "</span><p></p>").appendTo("#display-delete-selected-books");
});
}
});
});
$(document).on('click', '#delete_selected_confirm', function(event) {
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/deleteselectedbooks",
data: JSON.stringify({"selections":selections}),
success: function success(booTitles) {
$("#books-table").bootstrapTable("refresh");
$("#books-table").bootstrapTable("uncheckAll");
}
});
});
$(document).on('click', '#read_selected_books', function(event) {
if ($(this).hasClass("disabled")) {
event.stopPropagation()
} else {
$('#read_selected_modal').modal("show");
}
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/displayselectedbooks",
data: JSON.stringify({"selections":selections}),
success: function success(booTitles) {
$('#display-read-selected-books').empty();
$.each(booTitles.books, function(i, item) {
$("<span>- " + item + "</span><p></p>").appendTo("#display-read-selected-books");
});
}
});
});
$(document).on('click', '#read_selected_confirm', function(event) {
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/readselectedbooks",
data: JSON.stringify({"selections":selections, "markAsRead": true}),
success: function success(booTitles) {
$("#books-table").bootstrapTable("refresh");
$("#books-table").bootstrapTable("uncheckAll");
}
});
});
$(document).on('click', '#unread_selected_books', function(event) {
if ($(this).hasClass("disabled")) {
event.stopPropagation()
} else {
$('#unread_selected_modal').modal("show");
}
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/displayselectedbooks",
data: JSON.stringify({"selections":selections}),
success: function success(booTitles) {
$('#display-unread-selected-books').empty();
$.each(booTitles.books, function(i, item) {
$("<span>- " + item + "</span><p></p>").appendTo("#display-unread-selected-books");
});
}
});
});
$(document).on('click', '#unread_selected_confirm', function(event) {
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/readselectedbooks",
data: JSON.stringify({"selections":selections, "markAsRead": false}),
success: function success(booTitles) {
$("#books-table").bootstrapTable("refresh");
$("#books-table").bootstrapTable("uncheckAll");
}
});
});*/
$("#table_xchange").click(function() { $("#table_xchange").click(function() {
$.ajax({ $.ajax({
method:"post", method:"post",
@@ -157,6 +459,10 @@ $(function() {
editable: { editable: {
mode: "inline", mode: "inline",
emptytext: "<span class='glyphicon glyphicon-plus'></span>", emptytext: "<span class='glyphicon glyphicon-plus'></span>",
ajaxOptions: {
contentType: "application/json; charset=utf-8",
dataType: "json",
},
success: function (response, __) { success: function (response, __) {
if (!response.success) return response.msg; if (!response.success) return response.msg;
return {newValue: response.newValue}; return {newValue: response.newValue};
@@ -164,7 +470,8 @@ $(function() {
params: function (params) { params: function (params) {
params.checkA = $('#autoupdate_authorsort').prop('checked'); params.checkA = $('#autoupdate_authorsort').prop('checked');
params.checkT = $('#autoupdate_titlesort').prop('checked'); params.checkT = $('#autoupdate_titlesort').prop('checked');
return params params.pk = [params.pk];
return JSON.stringify(params);
} }
} }
}; };
@@ -198,7 +505,7 @@ $(function() {
searchAlign: "left", searchAlign: "left",
showSearchButton : true, showSearchButton : true,
searchOnEnterKey: true, searchOnEnterKey: true,
checkboxHeader: false, checkboxHeader: true,
maintainMetaData: true, maintainMetaData: true,
responseHandler: responseHandler, responseHandler: responseHandler,
columns: column, columns: column,
@@ -212,7 +519,7 @@ $(function() {
$.ajax({ $.ajax({
method:"get", method:"get",
dataType: "json", dataType: "json",
url: window.location.pathname + "/../ajax/sort_value/" + field + "/" + row.id, url: getPath() + "/ajax/sort_value/" + field + "/" + row.id,
success: function success(data) { success: function success(data) {
var key = Object.keys(data)[0]; var key = Object.keys(data)[0];
$("#books-table").bootstrapTable("updateCellByUniqueId", { $("#books-table").bootstrapTable("updateCellByUniqueId", {
@@ -224,6 +531,66 @@ $(function() {
}); });
} }
}, },
onPostBody () {
// Remove all checkboxes from Headers for showing the texts in the column selector
$('.columns [data-field]').each(function(){
var elText = $(this).next().text();
$(this).next().empty();
var index = elText.lastIndexOf('\n', elText.length - 2);
if ( index > -1) {
elText = elText.substr(index);
}
$(this).next().text(elText);
});
},
onPostHeader() {
$(".form-check").each(function () {
var item = $(this).parent();
var parent = item.parent().parent();
if (parent.prop('nodeName') === "TH") {
item.prependTo(parent);
}
});
if ($(".button_head").length) {
if (!$._data($(".button_head").get(0), "events")) {
$(".button_head").on("click", function () {
var result = $('#books-table').bootstrapTable('getSelections').map(a => a.id);
confirmDialog(
"btndelbook",
"GeneralDeleteModal",
0,
function () {
$.ajax({
method: "post",
url: getPath() + "/ajax/deletebook",
contentType: "application/json; charset=utf-8",
dataType: "json",
data: JSON.stringify({"bookid": result}),
success: function (data) {
selections = selections.filter((el) => !result.includes(el));
handleListServerResponse(data);
},
error: function (data) {
handleListServerResponse([{type: "danger", message: data.responseText}])
},
});
}
);
});
}
}
if ($(".check_head").length) {
if (!$._data($(".check_head").get(0), "events")) {
$(".check_head").on("change", function () {
var val = $(this).data("set");
var name = $(this).data("name");
var data = $(this).data("val");
bookCheckboxHeader(val, name, data);
});
}
}
},
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
onColumnSwitch: function (field, checked) { onColumnSwitch: function (field, checked) {
var visible = $("#books-table").bootstrapTable("getVisibleColumns"); var visible = $("#books-table").bootstrapTable("getVisibleColumns");
@@ -240,10 +607,16 @@ $(function() {
method:"post", method:"post",
contentType: "application/json; charset=utf-8", contentType: "application/json; charset=utf-8",
dataType: "json", dataType: "json",
url: window.location.pathname + "/../ajax/table_settings", url: getPath() + "/ajax/table_settings",
data: "{" + st + "}", data: "{" + st + "}",
}); });
handle_header_buttons();
}, },
onLoadSuccess: function() {
$("input:radio.check_head:checked").each(function () {
$(this).prop('checked', false);
});
}
}); });
$("#domain_allow_submit").click(function(event) { $("#domain_allow_submit").click(function(event) {
@@ -252,7 +625,7 @@ $(function() {
$(this).closest("form").submit(); $(this).closest("form").submit();
$.ajax ({ $.ajax ({
method:"get", method:"get",
url: window.location.pathname + "/../../ajax/domainlist/1", url: getPath() + "/ajax/domainlist/1",
async: true, async: true,
timeout: 900, timeout: 900,
success:function(data) { success:function(data) {
@@ -273,7 +646,7 @@ $(function() {
$(this).closest("form").submit(); $(this).closest("form").submit();
$.ajax ({ $.ajax ({
method:"get", method:"get",
url: window.location.pathname + "/../../ajax/domainlist/0", url: getPath() + "/ajax/domainlist/0",
async: true, async: true,
timeout: 900, timeout: 900,
success:function(data) { success:function(data) {
@@ -291,12 +664,12 @@ $(function() {
function domainHandle(domainId) { function domainHandle(domainId) {
$.ajax({ $.ajax({
method:"post", method:"post",
url: window.location.pathname + "/../../ajax/deletedomain", url: getPath() + "/ajax/deletedomain",
data: {"domainid":domainId} data: {"domainid":domainId}
}); });
$.ajax({ $.ajax({
method:"get", method:"get",
url: window.location.pathname + "/../../ajax/domainlist/1", url: getPath() + "/ajax/domainlist/1",
async: true, async: true,
timeout: 900, timeout: 900,
success:function(data) { success:function(data) {
@@ -305,7 +678,7 @@ $(function() {
}); });
$.ajax({ $.ajax({
method:"get", method:"get",
url: window.location.pathname + "/../../ajax/domainlist/0", url: getPath() + "/ajax/domainlist/0",
async: true, async: true,
timeout: 900, timeout: 900,
success:function(data) { success:function(data) {
@@ -541,7 +914,7 @@ $(function() {
method:"post", method:"post",
contentType: "application/json; charset=utf-8", contentType: "application/json; charset=utf-8",
dataType: "json", dataType: "json",
url: window.location.pathname + "/../../ajax/user_table_settings", url: getPath() + "/ajax/user_table_settings",
data: "{" + st + "}", data: "{" + st + "}",
}); });
handle_header_buttons(); handle_header_buttons();
@@ -567,8 +940,8 @@ $(function() {
function handle_header_buttons () { function handle_header_buttons () {
if (selections.length < 1) { if (selections.length < 1) {
$("#user_delete_selection").addClass("disabled"); $(".mass_selection").addClass("disabled");
$("#user_delete_selection").attr("aria-disabled", true); $(".mass_selection").attr("aria-disabled", true);
$(".check_head").attr("aria-disabled", true); $(".check_head").attr("aria-disabled", true);
$(".check_head").attr("disabled", true); $(".check_head").attr("disabled", true);
$(".check_head").prop('checked', false); $(".check_head").prop('checked', false);
@@ -580,8 +953,8 @@ function handle_header_buttons () {
$(".multi_selector").attr("disabled", true); $(".multi_selector").attr("disabled", true);
$(".header_select").attr("disabled", true); $(".header_select").attr("disabled", true);
} else { } else {
$("#user_delete_selection").removeClass("disabled"); $(".mass_selection").removeClass("disabled");
$("#user_delete_selection").attr("aria-disabled", false); $(".mass_selection").attr("aria-disabled", false);
$(".check_head").attr("aria-disabled", false); $(".check_head").attr("aria-disabled", false);
$(".check_head").removeAttr("disabled"); $(".check_head").removeAttr("disabled");
$(".button_head").attr("aria-disabled", false); $(".button_head").attr("aria-disabled", false);
@@ -590,8 +963,10 @@ function handle_header_buttons () {
$(".multi_head").removeClass("hidden"); $(".multi_head").removeClass("hidden");
$(".multi_selector").attr("aria-disabled", false); $(".multi_selector").attr("aria-disabled", false);
$(".multi_selector").removeAttr("disabled"); $(".multi_selector").removeAttr("disabled");
$('.multi_selector').selectpicker('refresh');
$(".header_select").removeAttr("disabled"); $(".header_select").removeAttr("disabled");
if (typeof $.fn.selectpicker === "function") {
$('.multi_selector').selectpicker('refresh');
}
} }
} }
@@ -718,7 +1093,7 @@ function loadSuccess() {
$("input[data-name='passwd_role'][data-pk='"+guest.data("pk")+"']").prop("disabled", true); $("input[data-name='passwd_role'][data-pk='"+guest.data("pk")+"']").prop("disabled", true);
$("input[data-name='edit_shelf_role'][data-pk='"+guest.data("pk")+"']").prop("disabled", true); $("input[data-name='edit_shelf_role'][data-pk='"+guest.data("pk")+"']").prop("disabled", true);
$("input[data-name='sidebar_read_and_unread'][data-pk='"+guest.data("pk")+"']").prop("disabled", true); $("input[data-name='sidebar_read_and_unread'][data-pk='"+guest.data("pk")+"']").prop("disabled", true);
$(".user-remove[data-pk='"+guest.data("pk")+"']").hide(); $(".user-remove[data-pk='" + guest.data("pk") + "']").hide();
} }
function move_header_elements() { function move_header_elements() {
@@ -760,7 +1135,7 @@ function move_header_elements() {
function () { function () {
$.ajax({ $.ajax({
method: "post", method: "post",
url: window.location.pathname + "/../../ajax/editlistusers/" + field, url: getPath() + "/ajax/editlistusers/" + field,
data: {"pk": result, "value": values, "action": val}, data: {"pk": result, "value": values, "action": val},
success: function (data) { success: function (data) {
handleListServerResponse(data); handleListServerResponse(data);
@@ -774,7 +1149,6 @@ function move_header_elements() {
}); });
} }
} }
$("#user_delete_selection").click(function () { $("#user_delete_selection").click(function () {
$("#user-table").bootstrapTable("uncheckAll"); $("#user-table").bootstrapTable("uncheckAll");
}); });
@@ -805,8 +1179,10 @@ function move_header_elements() {
function () { function () {
$.ajax({ $.ajax({
method: "post", method: "post",
url: window.location.pathname + "/../../ajax/deleteuser", url: getPath() + "/ajax/deleteuser",
data: {"userid": result}, contentType: "application/json; charset=utf-8",
dataType: "json",
data: JSON.stringify({"userid": result}),
success: function (data) { success: function (data) {
selections = selections.filter((el) => !result.includes(el)); selections = selections.filter((el) => !result.includes(el));
handleListServerResponse(data); handleListServerResponse(data);
@@ -832,7 +1208,7 @@ function handleListServerResponse (data) {
'</div>'); '</div>');
}); });
} }
$("#user-table").bootstrapTable("refresh"); $(".table.table-striped").bootstrapTable("refresh");
} }
function checkboxChange(checkbox, userId, field, field_index) { function checkboxChange(checkbox, userId, field, field_index) {
@@ -847,30 +1223,30 @@ function checkboxChange(checkbox, userId, field, field_index) {
}); });
} }
function BookCheckboxChange(checkbox, userId, field) { function BookCheckboxChange(checkbox, bookId, field) {
var value = checkbox.checked ? "True" : "False"; var value = checkbox.checked ? "True" : "False";
var element = checkbox; var element = checkbox;
$.ajax({ $.ajax({
method: "post", method: "post",
url: getPath() + "/ajax/editbooks/" + field, url: getPath() + "/ajax/editbooks/" + field,
data: {"pk": userId, "value": value}, data: JSON.stringify({"pk": [bookId], "value": value}),
contentType: "application/json; charset=utf-8",
dataType: "json",
error: function(data) { error: function(data) {
element.checked = !element.checked; element.checked = !element.checked;
handleListServerResponse([{type:"danger", message:data.responseText}]) handleListServerResponse([{type:"danger", message:data.responseText}])
}, },
success: handleListServerResponse success: handleListServerResponse
}); });
console.log("test");
} }
function selectHeader(element, field) { function selectHeader(element, field) {
if (element.value !== "None") { if (element.value !== "None") {
confirmDialog(element.id, "GeneralChangeModal", 0, function () { confirmDialog(element.id, "GeneralChangeModal", 0, function () {
var result = $('#user-table').bootstrapTable('getSelections').map(a => a.id); var result = $('#user-table').bootstrapTable('getSelections').map(a => a.id);
$.ajax({ $.ajax({
method: "post", method: "post",
url: window.location.pathname + "/../../ajax/editlistusers/" + field, url: getPath() + "/ajax/editlistusers/" + field,
data: {"pk": result, "value": element.value}, data: {"pk": result, "value": element.value},
error: function (data) { error: function (data) {
handleListServerResponse([{type:"danger", message:data.responseText}]) handleListServerResponse([{type:"danger", message:data.responseText}])
@@ -883,12 +1259,35 @@ function selectHeader(element, field) {
} }
} }
function bookCheckboxHeader(CheckboxState, text, field_index) {
confirmDialog(text, "GeneralChangeModal", 0, function() {
var result = $('#books-table').bootstrapTable('getSelections').map(a => a.id);
$.ajax({
method: "post",
url: getPath() + "/ajax/editbooks/" + field_index,
data: JSON.stringify({"pk": result, "field_index": field_index, "value": CheckboxState, multi: "True"}),
contentType: "application/json; charset=utf-8",
dataType: "json",
error: function (data) {
handleListServerResponse([{type:"danger", message:data.responseText}])
},
success: function (data) {
handleListServerResponse (data, true)
},
});
},function() {
$("input:radio.check_head:checked").each(function() {
$(this).prop('checked', false);
});
});
}
function checkboxHeader(CheckboxState, field, field_index) { function checkboxHeader(CheckboxState, field, field_index) {
confirmDialog(field, "GeneralChangeModal", 0, function() { confirmDialog(field, "GeneralChangeModal", 0, function() {
var result = $('#user-table').bootstrapTable('getSelections').map(a => a.id); var result = $('#user-table').bootstrapTable('getSelections').map(a => a.id);
$.ajax({ $.ajax({
method: "post", method: "post",
url: window.location.pathname + "/../../ajax/editlistusers/" + field, url: getPath() + "/ajax/editlistusers/" + field,
data: {"pk": result, "field_index": field_index, "value": CheckboxState}, data: {"pk": result, "field_index": field_index, "value": CheckboxState},
error: function (data) { error: function (data) {
handleListServerResponse([{type:"danger", message:data.responseText}]) handleListServerResponse([{type:"danger", message:data.responseText}])
@@ -904,7 +1303,7 @@ function checkboxHeader(CheckboxState, field, field_index) {
}); });
} }
function deleteUser(a,id){ function deleteUser(a, id){
confirmDialog( confirmDialog(
"btndeluser", "btndeluser",
"GeneralDeleteModal", "GeneralDeleteModal",
@@ -912,8 +1311,10 @@ function deleteUser(a,id){
function() { function() {
$.ajax({ $.ajax({
method:"post", method:"post",
url: window.location.pathname + "/../../ajax/deleteuser", url: getPath() + "/ajax/deleteuser",
data: {"userid":id}, contentType: "application/json; charset=utf-8",
dataType: "json",
data: JSON.stringify({"userid": [id]}),
success: function (data) { success: function (data) {
userId = parseInt(id, 10); userId = parseInt(id, 10);
selections = selections.filter(item => item !== userId); selections = selections.filter(item => item !== userId);
@@ -940,8 +1341,10 @@ function storeLocation() {
function user_handle (userId) { function user_handle (userId) {
$.ajax({ $.ajax({
method:"post", method:"post",
url: window.location.pathname + "/../../ajax/deleteuser", contentType: "application/json; charset=utf-8",
data: {"userid":userId} dataType: "json",
data: JSON.stringify({"userid": [userId]}),
url: getPath() + "/ajax/deleteuser",
}); });
$("#user-table").bootstrapTable("refresh"); $("#user-table").bootstrapTable("refresh");
} }
@@ -949,6 +1352,5 @@ function user_handle (userId) {
function shorten_html(value, response) { function shorten_html(value, response) {
if(value) { if(value) {
$(this).html("[...]"); $(this).html("[...]");
// value.split('\n').slice(0, 2).join("") +
} }
} }
+2 -2
View File
@@ -31,7 +31,7 @@
<h2>Details</h2> <h2>Details</h2>
{% if entry.series|length > 0 %} {% if entry.series|length > 0 %}
<p>{{ _("Book %(index)s of %(range)s", index=entry.series_index | formatfloat(2), range=(entry.series[0].name)|safe) }}</p> <p>{{ _("Book %(index)s of %(range)s", index=entry.series_index|formatfloat(2), range=entry.series[0].name|e) }}</p>
{% endif %} {% endif %}
{% if entry.languages|length > 0 %} {% if entry.languages|length > 0 %}
@@ -74,7 +74,7 @@
{% if entry.comments|length > 0 and entry.comments[0].text|length > 0 %} {% if entry.comments|length > 0 and entry.comments[0].text|length > 0 %}
<div> <div>
<h2 id="decription">{{ _('Description:') }}</h2> <h2 id="decription">{{ _('Description:') }}</h2>
{{ entry.comments[0].text|safe }} {{ entry.comments[0].text|clean_string|safe }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
+196 -6
View File
@@ -19,6 +19,25 @@
{% if sort %}data-sortable="true" {% endif %} {% if sort %}data-sortable="true" {% endif %}
data-visible="{{visiblility.get(parameter)}}" data-visible="{{visiblility.get(parameter)}}"
data-formatter="bookCheckboxFormatter"> data-formatter="bookCheckboxFormatter">
{% if parameter == "is_archived" %}
<div class="form-check">
<div>
<input type="radio" class="check_head" data-set="True" data-val="{{ parameter }}" name="options_archive_selected_books" id="false_archive_selected_books" data-name="archive_books" disabled>{{_('Archive selected books')}}
</div>
<div>
<input type="radio" class="check_head" data-set="False" data-val="{{ parameter }}" name="options_unarchive_selected_books" id="true_archive_selected_books" data-name="archive_books" disabled>{{_('Unarchive selected books')}}
</div>
</div>
{% elif parameter == "read_status" %}
<div class="form-check">
<div>
<input type="radio" class="check_head" data-set="True" data-val="{{ parameter }}" name="options_read_selected_books" id="false_read_selected_books" data-name="read_books" disabled>{{_('Mark selected books as read')}}
</div>
<div>
<input type="radio" class="check_head" data-set="False" data-val="{{ parameter }}" name="options_unread_selected_books" id="true_read_selected_books" data-name="read_books" disabled>{{_('Mark selected books as unread')}}</div>
</div>
</div>
{% endif %}
{{show_text}} {{show_text}}
</th> </th>
{%- endmacro %} {%- endmacro %}
@@ -34,8 +53,15 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="col-xs-12 col-sm-6"> <div class="col-xs-12 col-sm-6">
<div class="row form-group"> <div class="row form-group">
<div class="btn btn-default disabled" id="merge_books" aria-disabled="true">{{_('Merge selected books')}}</div> <div class="btn btn-default disabled" id="merge_books" aria-disabled="true">
<div class="btn btn-default disabled" id="delete_selection" aria-disabled="true">{{_('Remove Selections')}}</div> {{_('Merge selected books')}}
</div>
<div class="btn btn-default disabled mass_selection" id="book_delete_selection" aria-disabled="true">
{{_('Clear selections')}}
</div>
<div class="btn btn-default disabled" id="edit_selected_books" aria-disabled="true">
{{_('Edit selected books')}}
</div>
</div> </div>
<div class="row form-group"> <div class="row form-group">
<div class="btn btn-default disabled" id="table_xchange" ><span class="glyphicon glyphicon-arrow-up"></span><span class="glyphicon glyphicon-arrow-down"></span>{{_('Exchange author and title')}}</div> <div class="btn btn-default disabled" id="table_xchange" ><span class="glyphicon glyphicon-arrow-up"></span><span class="glyphicon glyphicon-arrow-down"></span>{{_('Exchange author and title')}}</div>
@@ -57,7 +83,7 @@
<thead> <thead>
<tr> <tr>
{% if current_user.role_edit() %} {% if current_user.role_edit() %}
<th data-field="state" data-checkbox="true" data-sortable="true"></th> <th data-field="state" data-checkbox="true" data-visible="true" data-sortable="true"></th>
{% endif %} {% endif %}
<th data-field="id" id="id" data-visible="false" data-switchable="false"></th> <th data-field="id" id="id" data-visible="false" data-switchable="false"></th>
{{ text_table_row('title', _('Enter Title'),_('Title'), true, true) }} {{ text_table_row('title', _('Enter Title'),_('Title'), true, true) }}
@@ -97,14 +123,23 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if current_user.role_delete_books() and current_user.role_edit()%} {% if current_user.role_delete_books() and current_user.role_edit()%}
<th data-align="right" data-formatter="EbookActions" data-switchable="false">{{_('Delete')}}</th> <th data-align="right" data-formatter="EbookActions" data-switchable="false">
<div><div class="btn btn-default button_head disabled" aria-disabled="true">
{{_('Delete selected books')}}
</div></div>
<br>
{{_('Delete')}}
</th>
{% endif %} {% endif %}
</tr> </tr>
</thead> </thead>
</table> </table>
{% endblock %} {% endblock %}
{% block modal %} {% block modal %}
{{ delete_book(current_user.role_delete_books()) }} {{ delete_book(current_user.role_delete_books()) }}
{{ delete_confirm_modal() }}
{{ change_confirm_modal() }}
{% if current_user.role_edit() %} {% if current_user.role_edit() %}
<div class="modal fade" id="mergeModal" role="dialog" aria-labelledby="metaMergeLabel"> <div class="modal fade" id="mergeModal" role="dialog" aria-labelledby="metaMergeLabel">
<div class="modal-dialog"> <div class="modal-dialog">
@@ -123,15 +158,170 @@
<div class="text-left" id="merge_to"></div> <div class="text-left" id="merge_to"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<input id="merge_confirm" type="button" class="btn btn-danger" value="{{_('Merge')}}" name="merge_confirm" id="merge_confirm" data-dismiss="modal"> <input id="merge_confirm" type="button" class="btn btn-danger" value="{{_('Merge')}}" name="merge_confirm" data-dismiss="modal">
<button id="merge_abort" type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button> <button id="merge_abort" type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<!--div class="modal fade" id="delete_selected_modal" role="dialog" aria-labelledby="metaDeleteSelectedLabel">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-center">
<span>{{_('Are you really sure?')}}</span>
</div>
<div class="modal-body">
<p></p>
<div class="text-left">{{_('The following books will be deleted:')}}</div>
<p></p>
<div class="text-left" id="display-delete-selected-books"></div>
<div class="modal-footer">
<input id="delete_selected_confirm" type="button" class="btn btn-danger" value="{{_('Delete')}}" name="delete_selected_confirm" data-dismiss="modal">
<button id="delete_selected_abort" type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
</div>
</div>
</div>
</div>
</div-->
<div class="modal fade" id="archive_selected_modal" role="dialog" aria-labelledby="metaArchiveSelectedLabel">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-center">
<span>{{_('Are you really sure?')}}</span>
</div>
<div class="modal-body">
<p></p>
<div class="text-left">{{_('The following books will be archived:')}}</div>
<p></p>
<div class="text-left" id="display-archive-selected-books"></div>
<div class="modal-footer">
<input id="archive_selected_confirm" type="button" class="btn btn-danger" value="{{_('Archive')}}" name="archive_selected_confirm" data-dismiss="modal">
<button id="archive_selected_abort" type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="unarchive_selected_modal" role="dialog" aria-labelledby="metaUnArchiveSelectedLabel">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-center">
<span>{{_('Are you really sure?')}}</span>
</div>
<div class="modal-body">
<p></p>
<div class="text-left">{{_('The following books will be unarchived:')}}</div>
<p></p>
<div class="text-left" id="display-unarchive-selected-books"></div>
<div class="modal-footer">
<input id="unarchive_selected_confirm" type="button" class="btn btn-danger" value="{{_('Unarchive')}}" name="unarchive_selected_confirm" data-dismiss="modal">
<button id="unarchive_selected_abort" type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="read_selected_modal" role="dialog" aria-labelledby="metaReadSelectedLabel">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-center">
<span>{{_('Are you really sure?')}}</span>
</div>
<div class="modal-body">
<p></p>
<div class="text-left">{{_('The following books will be marked read:')}}</div>
<p></p>
<div class="text-left" id="display-read-selected-books"></div>
<div class="modal-footer">
<input id="read_selected_confirm" type="button" class="btn btn-danger" value="{{_('Mark as read')}}" name="read_selected_confirm" data-dismiss="modal">
<button id="read_selected_abort" type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="unread_selected_modal" role="dialog" aria-labelledby="metaReadSelectedLabel">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-center">
<span>{{_('Are you really sure?')}}</span>
</div>
<div class="modal-body">
<p></p>
<div class="text-left">{{_('The following books will be marked unread:')}}</div>
<p></p>
<div class="text-left" id="display-unread-selected-books"></div>
<div class="modal-footer">
<input id="unread_selected_confirm" type="button" class="btn btn-danger" value="{{_('Mark as unread')}}" name="unread_selected_confirm" data-dismiss="modal">
<button id="read_selected_abort" type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="edit_selected_modal" role="dialog" aria-labelledby="metaEditSelectedLabel">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-info text-center">
<span>{{_('Edit Metadata')}}</span>
</div>
<div class="modal-body">
<div class="text-left">{{_('Edit the fields you want changed. Blank fields will be ignored:')}}</div>
<br>
Title:
<input class="form-control" id="title_input">
<p></p>
Title Sort:
<input class="form-control" id="title_sort_input">
<p></p>
Author Sort:
<input class="form-control" id="author_sort_input">
<p></p>
Authors:
<input class="form-control" id="authors_input">
<p></p>
Categories:
<input class="form-control" id="categories_input">
<p></p>
Series:
<input class="form-control" id="series_input">
<p></p>
Languages:
<input class="form-control" id="languages_input">
<p></p>
Publishers:
<input class="form-control" id="publishers_input">
<p></p>
Comments:
<input class="form-control" id="comments_input">
<p></p>
</div>
<div class="modal-footer">
<input id="edit_selected_confirm" type="button" class="btn btn-danger" value="{{_('Edit')}}" name="edit_selected_confirm" id="edit_selected_confirm" data-dismiss="modal">
<button id="edit_selected_abort" type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}
{% block js %} {% block js %}
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script> <script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-locale-all.min.js') }}"></script> <script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-locale-all.min.js') }}"></script>
+4
View File
@@ -163,6 +163,10 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="form-group">
<label for="config_googlebooks_api_key">{{_('Google Books API Key')}}</label>
<input type="text" class="form-control" id="config_googlebooks_api_key" name="config_googlebooks_api_key" value="{% if config.config_googlebooks_api_key != '' %}{{ config.config_googlebooks_api_key }}{% endif %}" autocomplete="off">
</div>
<div class="form-group"> <div class="form-group">
<input type="checkbox" id="config_allow_reverse_proxy_header_login" name="config_allow_reverse_proxy_header_login" data-control="reverse-proxy-login-settings" {% if config.config_allow_reverse_proxy_header_login %}checked{% endif %}> <input type="checkbox" id="config_allow_reverse_proxy_header_login" name="config_allow_reverse_proxy_header_login" data-control="reverse-proxy-login-settings" {% if config.config_allow_reverse_proxy_header_login %}checked{% endif %}>
<label for="config_allow_reverse_proxy_header_login">{{_('Allow Reverse Proxy Authentication')}}</label> <label for="config_allow_reverse_proxy_header_login">{{_('Allow Reverse Proxy Authentication')}}</label>
+3 -3
View File
@@ -232,7 +232,7 @@
{% elif c.datatype == 'datetime' %} {% elif c.datatype == 'datetime' %}
{{ column.value|formatdate }} {{ column.value|formatdate }}
{% elif c.datatype == 'comments' %} {% elif c.datatype == 'comments' %}
{{ column.value|safe }} {{ column.value|clean_string|safe }}
{% elif c.datatype == 'series' %} {% elif c.datatype == 'series' %}
{{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }} {{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }}
{% elif c.datatype == 'text' %} {% elif c.datatype == 'text' %}
@@ -284,7 +284,7 @@
{% if entry.comments|length > 0 and entry.comments[0].text|length > 0 %} {% if entry.comments|length > 0 and entry.comments[0].text|length > 0 %}
<div class="comments"> <div class="comments">
<h3 id="decription">{{ _('Description:') }}</h3> <h3 id="decription">{{ _('Description:') }}</h3>
{{ entry.comments[0].text|safe }} {{ entry.comments[0].text|clean_string|safe }}
</div> </div>
{% endif %} {% endif %}
@@ -294,7 +294,7 @@
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{% if current_user.shelf.all() or g.shelves_access %} {% if current_user.shelf.all() or g.shelves_access %}
<div id="shelf-actions" class="btn-toolbar" role="toolbar"> <div id="shelf-actions" class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group" aria-label="Add to shelves"> <div class="btn-group dropup" role="group" aria-label="Add to shelves">
<button id="add-to-shelf" type="button" <button id="add-to-shelf" type="button"
class="btn btn-primary btn-sm dropdown-toggle" data-toggle="dropdown" class="btn btn-primary btn-sm dropdown-toggle" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"> aria-haspopup="true" aria-expanded="false">
+51 -1
View File
@@ -63,7 +63,57 @@
term="{{tag.name}}" term="{{tag.name}}"
label="{{tag.name}}"/> label="{{tag.name}}"/>
{% endfor %} {% endfor %}
{% if entry.Books.comments[0] %}<summary>{{entry.Books.comments[0].text|striptags}}</summary>{% endif %} <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">
{% if entry.Books.ratings.__len__() > 0 %}
RATING: {% for number in range((entry.Books.ratings[0].rating/2)|int(2)) %}★{% endfor %}<br/>
{% endif %}
{% if entry.Books.tags|length > 0 %}
TAGS: {% for tag in entry.Books.tags %}{{tag.name}}{{ ", " if not loop.last else "" }}{% endfor %}<br/>
{% endif %}
{% if entry.Books.series.__len__() > 0 %}
SERIES: {{entry.Books.series[0].name}} [{{entry.Books.series_index|formatfloat(2)}}]<br/>
{% endif %}
{% if cc|length > 0 %}
{% for c in cc %}
{% if entry.Books['custom_column_' ~ c.id]|length > 0 %}
{{ c.name }}:
{% for column in entry.Books['custom_column_' ~ c.id] %}
{% if c.datatype == 'rating' %}
{{ (column.value / 2)|formatfloat }}
{% else %}
{% if c.datatype == 'bool' %}
{% if column.value == true %}
✓
{% else %}
✕
{% endif %}
{% else %}
{% if c.datatype == 'float' %}
{{ column.value|formatfloat(2) }}
{% elif c.datatype == 'datetime' %}
{{ column.value|formatdate }}
{% elif c.datatype == 'comments' %}
{{ column.value|clean_string|safe }}
{% elif c.datatype == 'series' %}
{{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }}
{% elif c.datatype == 'text' %}
{{ column.value.strip() }}{% if not loop.last %}, {% endif %}
{% else %}
{{ column.value }}
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
<br/>
{% endif %}
{% endfor %}
{% endif %}
{% if entry.Books.comments[0] %}
<p>{{entry.Books.comments[0].text}}</p>
{% endif %}
</div></content>
{% if entry.Books.has_cover %} {% if entry.Books.has_cover %}
<link type="image/jpeg" href="{{url_for('opds.feed_get_cover', book_id=entry.Books.id)}}" rel="http://opds-spec.org/image"/> <link type="image/jpeg" href="{{url_for('opds.feed_get_cover', book_id=entry.Books.id)}}" rel="http://opds-spec.org/image"/>
<link type="image/jpeg" href="{{url_for('opds.feed_get_cover', book_id=entry.Books.id)}}" rel="http://opds-spec.org/image/thumbnail"/> <link type="image/jpeg" href="{{url_for('opds.feed_get_cover', book_id=entry.Books.id)}}" rel="http://opds-spec.org/image/thumbnail"/>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
{% if g.google_site_verification|length > 0 %} {% if g.google_site_verification|length > 0 %}
<meta name="google-site-verification" content="{{g.google_site_verification}}"> <meta name="google-site-verification" content="{{g.google_site_verification}}">
{% endif %} {% endif %}
+6 -9
View File
@@ -7,7 +7,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
{% if g.google_site_verification|length > 0 %} {% if g.google_site_verification|length > 0 %}
<meta name="google-site-verification" content="{{g.google_site_verification}}"> <meta name="google-site-verification" content="{{g.google_site_verification}}">
{% endif %} {% endif %}
@@ -41,8 +41,7 @@
<div class="plexBack"><a href="{{url_for('web.index')}}"></a></div> <div class="plexBack"><a href="{{url_for('web.index')}}"></a></div>
{% endif %} {% endif %}
{% if current_user.is_authenticated or g.allow_anonymous %} {% if current_user.is_authenticated or g.allow_anonymous %}
<!--# margin 0, padding 15, background color--> <form class="navbar-form navbar-left" role="search" action="{{url_for('search.simple_search')}}" method="GET">
<form class="navbar-form navbar-left" role="search" action="{{url_for('search.simple_search')}}" method="GET">
<div class="form-group input-group input-group-sm"> <div class="form-group input-group input-group-sm">
<label for="query" class="sr-only">{{_('Search')}}</label> <label for="query" class="sr-only">{{_('Search')}}</label>
<input type="text" class="form-control" id="query" name="query" placeholder="{{_('Search Library')}}" value="{{searchterm}}"> <input type="text" class="form-control" id="query" name="query" placeholder="{{_('Search Library')}}" value="{{searchterm}}">
@@ -148,13 +147,13 @@
<li class="nav-head hidden-xs">{{_('Browse')}}</li> <li class="nav-head hidden-xs">{{_('Browse')}}</li>
{% for element in sidebar %} {% for element in sidebar %}
{% if current_user.check_visibility(element['visibility']) and element['public'] %} {% if current_user.check_visibility(element['visibility']) and element['public'] %}
<li id="nav_{{element['id']}}" {% if page == element['page'] %}class="active"{% endif %}><a href="{{url_for(element['link'], data=element['page'], sort_param='stored')}}"><span class="glyphicon {{element['glyph']}}"></span> {{_(element['text'])}}</a></li> <li id="nav_{{element['id']}}" {% if page == element['page'] %}class="active"{% endif %}><a href="{% if element['no_param'] %}{{url_for(element['link'], data=element['page'])}}{%else%}{{url_for(element['link'], data=element['page'], sort_param='stored')}}{% endif %}"><span class="glyphicon {{element['glyph']}}"></span> {{_(element['text'])}}</a></li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if current_user.is_authenticated or g.allow_anonymous %} {% if current_user.is_authenticated or g.allow_anonymous %}
<li class="nav-head hidden-xs public-shelves">{{_('Shelves')}}</li> <li class="nav-head hidden-xs public-shelves">{{_('Shelves')}}</li>
{% for shelf in g.shelves_access %} {% for shelf in g.shelves_access %}
<li><a href="{{url_for('shelf.show_shelf', shelf_id=shelf.id)}}"><span class="glyphicon glyphicon-list shelf"></span> {{shelf.name|shortentitle(40)}}{% if shelf.is_public == 1 %} {{_('(Public)')}}{% endif %}</a></li> <li><a href="{{url_for('shelf.show_shelf', shelf_id=shelf.id)}}"><span class="glyphicon glyphicon-list shelf"></span> {{shelf.name|shortentitle(40)}}{% if shelf.is_public == 1 %} {{_('(Public)')}}{% endif %} <span class="badge badge-sm">{{shelf.books.all()|length}}</span></a></li>
{% endfor %} {% endfor %}
{% if not current_user.is_anonymous %} {% if not current_user.is_anonymous %}
<li id="nav_createshelf" class="create-shelf"><a href="{{url_for('shelf.create_shelf')}}">{{_('Create a Shelf')}}</a></li> <li id="nav_createshelf" class="create-shelf"><a href="{{url_for('shelf.create_shelf')}}">{{_('Create a Shelf')}}</a></li>
@@ -171,8 +170,7 @@
{% if pagination and (pagination.has_next or pagination.has_prev) %} {% if pagination and (pagination.has_next or pagination.has_prev) %}
<div class="pagination"> <div class="pagination">
{% if pagination.has_prev %} {% if pagination.has_prev %}
<li class="page-item page-previous"><a class="page-link" aria-label="next page" href="{{ (pagination.page - 1)|url_for_other_page <li class="page-item page-previous"><a class="page-link" aria-label="next page" href="{{ (pagination.page - 1)|url_for_other_page }}">&laquo; {{_('Previous')}}</a></li>
}}">&laquo; {{_('Previous')}}</a></li>
{% endif %} {% endif %}
{% for page in pagination.iter_pages() %} {% for page in pagination.iter_pages() %}
{% if page %} {% if page %}
@@ -186,8 +184,7 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if pagination.has_next %} {% if pagination.has_next %}
<li class="page-item page-next"><a class="page-link next" aria-label="next page" href="{{ (pagination.page + 1)|url_for_other_page <li class="page-item page-next"><a class="page-link next" aria-label="next page" href="{{ (pagination.page + 1)|url_for_other_page }}">{{_('Next')}} &raquo;</a></li>
}}">{{_('Next')}} &raquo;</a></li>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
+1 -1
View File
@@ -22,7 +22,7 @@
<button class="update-view btn btn-primary" data-target="series_view" id="grid-button" data-view="grid">{{_('Grid')}}</button> <button class="update-view btn btn-primary" data-target="series_view" id="grid-button" data-view="grid">{{_('Grid')}}</button>
{% endif %} {% endif %}
</div> </div>
<div class="container"> <div class="container1">
<div id="list" class="col-xs-12 col-sm-6"> <div id="list" class="col-xs-12 col-sm-6">
{% for entry in entries %} {% for entry in entries %}
{% if loop.index0 == (loop.length/2+loop.length%2)|int and loop.length > 20 %} {% if loop.index0 == (loop.length/2+loop.length%2)|int and loop.length > 20 %}
+3 -3
View File
@@ -7,7 +7,7 @@
<title>{{ entry.title }}</title> <title>{{ entry.title }}</title>
<meta name="description" content=""> <meta name="description" content="">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
{% if g.google_site_verification|length > 0 %} {% if g.google_site_verification|length > 0 %}
<meta name="google-site-verification" content="{{g.google_site_verification}}"> <meta name="google-site-verification" content="{{g.google_site_verification}}">
{% endif %} {% endif %}
@@ -134,7 +134,7 @@
{% elif c.datatype == 'datetime' %} {% elif c.datatype == 'datetime' %}
{{ column.value|formatdate }} {{ column.value|formatdate }}
{% elif c.datatype == 'comments' %} {% elif c.datatype == 'comments' %}
{{column.value|safe}} {{column.value|clean_string|safe}}
{% elif c.datatype == 'series' %} {% elif c.datatype == 'series' %}
{{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }} {{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }}
{% elif c.datatype == 'text' %} {% elif c.datatype == 'text' %}
@@ -175,7 +175,7 @@
{% if entry.comments|length > 0 and entry.comments[0].text|length > 0%} {% if entry.comments|length > 0 and entry.comments[0].text|length > 0%}
<div class="comments"> <div class="comments">
<h3 id="decription">{{_('Description:')}}</h3> <h3 id="decription">{{_('Description:')}}</h3>
{{entry.comments[0].text|safe}} {{entry.comments[0].text|clean_string|safe}}
</div> </div>
{% endif %} {% endif %}
+1 -2
View File
@@ -48,8 +48,7 @@
<div class="modal-body text-center"> <div class="modal-body text-center">
<p> <p>
<span class="hidden" id="book_format">{{_('This book format will be permanently erased from database')}}</span> <span class="hidden" id="book_format">{{_('This book format will be permanently erased from database')}}</span>
<span class="hidden" id="book_complete">{{_('This book will be permanently erased from database')}}</span> <span class="hidden" id="book_complete">{{_('This book will be permanently erased from database and hard disk')}}</span>
<span>{{_('and hard disk')}}</span>
</p> </p>
{% if config.config_kobo_sync %} {% if config.config_kobo_sync %}
<p> <p>
+311 -37
View File
@@ -7,7 +7,7 @@
<title>{{_('epub Reader')}} | {{title}}</title> <title>{{_('epub Reader')}} | {{title}}</title>
<meta name="description" content=""> <meta name="description" content="">
<meta name="viewport" content="width=device-width, user-scalable=no"> <meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
{% if g.google_site_verification|length > 0 %} {% if g.google_site_verification|length > 0 %}
<meta name="google-site-verification" content="{{g.google_site_verification}}"> <meta name="google-site-verification" content="{{g.google_site_verification}}">
{% endif %} {% endif %}
@@ -29,6 +29,7 @@
<!--a id="show-Search" class="show_view icon-search" data-view="Search">Search</a--> <!--a id="show-Search" class="show_view icon-search" data-view="Search">Search</a-->
<a id="show-Toc" class="show_view icon-list-1 active" data-view="Toc">TOC</a> <a id="show-Toc" class="show_view icon-list-1 active" data-view="Toc">TOC</a>
<a id="show-Bookmarks" class="show_view icon-bookmark" data-view="Bookmarks">Bookmarks</a> <a id="show-Bookmarks" class="show_view icon-bookmark" data-view="Bookmarks">Bookmarks</a>
<a id="back-button" data-view="Back to Books" href="{{ url_for('web.index') }}">Books</a>
<!--a id="show-Notes" class="show_view icon-edit" data-view="Notes">Notes</a--> <!--a id="show-Notes" class="show_view icon-edit" data-view="Notes">Notes</a-->
</div> </div>
@@ -49,7 +50,6 @@
</div--> </div-->
</div> </div>
<div id="main"> <div id="main">
<div id="titlebar"> <div id="titlebar">
<div id="opener"> <div id="opener">
<a id="slider" class="icon-menu">Menu</a> <a id="slider" class="icon-menu">Menu</a>
@@ -70,37 +70,58 @@
<div id="prev" class="arrow">‹</div> <div id="prev" class="arrow">‹</div>
<div id="viewer"></div> <div id="viewer"></div>
<div id="next" class="arrow">›</div> <div id="next" class="arrow">›</div>
<div id="progress">0%</div> <div class="read-footer">
<div id="loader"><img src="{{ url_for('static', filename='img/loader.gif') }}"></div> <div id="pages-count"></div>
</div> <div id="progress">0%</div>
</div>
<div id="loader">
<img src="{{ url_for('static', filename='img/loader.gif') }}">
</div>
</div>
<div class="modal md-effect-1" id="settings-modal"> <div class="modal md-effect-1" id="settings-modal">
<div class="md-content"> <div class="md-content">
<h3>{{_('Settings')}}</h3> <h3>{{_('Settings')}}</h3>
<div class="form-group themes" id="themes"> <div class="form-group themes" id="themes">
{{_('Choose a theme below:')}}}<br /> {{_('Choose a theme below:')}}<br />
<!-- Hardcoded a tick in the light theme button because it is the "default" theme. Need to find a way to do this dynamically on startup--> <!-- Hardcoded a tick in the light theme button because it is the "default" theme. Need to find a way to do this dynamically on startup-->
<button type="button" id="lightTheme" class="lightTheme" onclick="selectTheme(this.id)"><span <button type="button" id="lightTheme" class="lightTheme" onclick="selectTheme(this.id)"><span
id="lightSelected">✓</span>{{_('Light')}}</button> id="lightSelected">✓</span>{{_('Light')}}</button>
<button type="button" id="darkTheme" class="darkTheme" onclick="selectTheme(this.id)"><span <button type="button" id="darkTheme" class="darkTheme" onclick="selectTheme(this.id)"><span
id="darkSelected"> </span>{{_('Dark')}}</button> id="darkSelected"> </span>{{_('Dark')}}</button>
<button type="button" id="sepiaTheme" class="sepiaTheme" onclick="selectTheme(this.id)"><span <button type="button" id="sepiaTheme" class="sepiaTheme" onclick="selectTheme(this.id)"><span
id="sepiaSelected"> </span>{{_('Sepia')}}</button> id="sepiaSelected"> </span>{{_('Sepia')}}</button>
<button type="button" id="amberTheme" class="amberTheme" onclick="selectTheme(this.id)"><span
id="amberSelected"> </span>{{_('Amber')}}</button>
<button type="button" id="blackTheme" class="blackTheme" onclick="selectTheme(this.id)"><span <button type="button" id="blackTheme" class="blackTheme" onclick="selectTheme(this.id)"><span
id="blackSelected"> </span>{{_('Black')}}</button> id="blackSelected"> </span>{{_('Black')}}</button>
</div>
<!-- Custom theme: color input + pickr -->
<div id="customThemeWrapper" style="display: inline-flex; align-items: center;gap: 8px;margin: 5px 8px;">Custom:
<!-- Picker-only UI: swatch acts as the picker button, and a small span shows selection tick -->
<div id="customThemeSwatch" title="Pick color" style="width: 30px; height: 30px; border: 1px solid #ccc; border-radius: 3px;cursor: pointer;"></div>
<span id="customSelected"> </span>
</div>
</div>
<div class="form-group"> <div class="form-group">
<p> <p>
<input type="checkbox" id="sidebarReflow" <input type="checkbox" id="sidebarReflow"
name="sidebarReflow">{{_('Reflow text when sidebars are open.')}} name="sidebarReflow">{{_('Reflow text when sidebars are open.')}}
</p> </p>
</div> </div>
<div class="form-group fontSizeWrapper"> <div class="form-group">
<div class="slider"> <p>
<label for="fader">{{ _('Font Sizes') }}</label> <input type="checkbox" id="showPagesCount" name="showPagesCount"/>{{_('Show pages count')}}
<input type="range" min="75" max="200" value="100" id="fontSizeFader" step="25"> </p>
</div> </div>
</div> <div class="form-group fontSizeWrapper">
<label>{{ _('Font Size') }}</label>
<div class="font-size-controls" style="display: flex;align-items: center;gap: 10px;margin-top: 2px;">
<button type="button" id="fontSizeDecrease" style="padding: 8px 15px; font-size: 18px; cursor: pointer">−</button>
<span id="fontSizeDisplay" style="min-width: 60px;text-align: center;font-size: 14px;font-weight: bold;">100%</span>
<button type="button" id="fontSizeIncrease" style="padding: 8px 15px; font-size: 18px; cursor: pointer">+</button>
</div>
</div>
<div class="font" id="font"> <div class="font" id="font">
<label class="item">{{_('Font')}}:</label> <label class="item">{{_('Font')}}:</label>
<button type="button" id="default" onclick="selectFont(this.id)"><span>✓</span>{{_('Default')}}</button> <button type="button" id="default" onclick="selectFont(this.id)"><span>✓</span>{{_('Default')}}</button>
@@ -119,8 +140,7 @@
</div> </div>
<div class="overlay"></div> <div class="overlay"></div>
<script src="{{ url_for('static', filename='js/libs/jquery.min.js') }}"></script> <script src="{{ url_for('static', filename='js/libs/jquery.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/compress/jszip_epub.min.js') }}"> <script src="{{ url_for('static', filename='js/compress/jszip_epub.min.js') }}"></script>
</script>
<script src="{{ url_for('static', filename='js/libs/epub.min.js') }}"></script> <script src="{{ url_for('static', filename='js/libs/epub.min.js') }}"></script>
<script type="text/javascript"> <script type="text/javascript">
window.calibre = { window.calibre = {
@@ -132,6 +152,14 @@
useBookmarks: "{{ current_user.is_authenticated | tojson }}" useBookmarks: "{{ current_user.is_authenticated | tojson }}"
}; };
// load custom theme color from localStorage (if any)
var _savedCustomThemeColor = null;
try {
_savedCustomThemeColor = localStorage.getItem(
"calibre.reader.customTheme"
);
} catch (e) {}
window.themes = { window.themes = {
"darkTheme": { "darkTheme": {
"bgColor": "#202124", "bgColor": "#202124",
@@ -148,6 +176,11 @@
"css_path": "{{ url_for('static', filename='css/epub_themes.css') }}", "css_path": "{{ url_for('static', filename='css/epub_themes.css') }}",
"title-color": "#4f4f4f" "title-color": "#4f4f4f"
}, },
"amberTheme": {
"bgColor": "#e8bf5e",
"css_path": "{{ url_for('static', filename='css/epub_themes.css') }}",
"title-color": "#2f2f2f"
},
"blackTheme": { "blackTheme": {
"bgColor": "black", "bgColor": "black",
"css_path": "{{ url_for('static', filename='css/epub_themes.css') }}", "css_path": "{{ url_for('static', filename='css/epub_themes.css') }}",
@@ -157,52 +190,112 @@
function selectTheme (id) { function selectTheme (id) {
let tickSpans = document.getElementById("themes").querySelectorAll("span"); let tickSpans = document.getElementById("themes").querySelectorAll("span");
// Remove tick mark from all theme buttons
tickSpans.forEach(function (tickSpan) { tickSpans.forEach(function (tickSpan) {
document.getElementById(tickSpan.id).textContent = ""; try {
tickSpan.textContent = "";
} catch (e) {}
}); });
// Add tick mark to the button corresponding to the currently selected theme // If the theme button exists, set its inner span to a tick. Otherwise set any span with the matching id.
document.getElementById(id).querySelector("span").textContent = "✓"; let el = document.getElementById(id);
if (el) {
let sp = el.querySelector("span");
if (sp) sp.textContent = "✓";
} else {
let spById = document.getElementById(id + "Selected") || document.getElementById("customSelected");
if (spById) spById.textContent = "✓";
}
// Saving theme to local storage // Saving theme to local storage
localStorage.setItem("calibre.reader.theme", id); localStorage.setItem("calibre.reader.theme", id);
// Apply theme to epubjs iframe
reader.rendition.themes.select(id);
// Apply theme to rest of the page. // If selecting custom theme, ensure epubjs theme is registered with chosen bg color
if (id === "customTheme") {
let customColor = window.themes.customTheme.bgColor || "#ffffff";
try {
if (reader && reader.rendition && reader.rendition.themes) {
reader.rendition.themes.register("customTheme", {
body: {
background: customColor,
},
});
reader.rendition.themes.select("customTheme");
}
} catch (e) {
console.error("Failed to register/select customTheme", e);
}
} else {
// Apply theme to epubjs iframe
try {
reader.rendition.themes.select(id);
} catch (e) {}
}
// Apply theme to rest of the page.
document.getElementById("main").style.backgroundColor = themes[id]["bgColor"]; document.getElementById("main").style.backgroundColor = themes[id]["bgColor"];
document.getElementById("titlebar").style.color = themes[id]["title-color"] || "#fff"; document.getElementById("titlebar").style.color = themes[id]["title-color"] || "#fff";
document.getElementById("progress").style.color = themes[id]["title-color"] || "#fff"; document.getElementById("progress").style.color = themes[id]["title-color"] || "#fff";
} }
// font size settings logic // font size settings logic
let fontSizeFader = document.getElementById('fontSizeFader'); let currentFontSize = 100; // default 100%
fontSizeFader.addEventListener("change", function () { const minFontSize = 50;
reader.rendition.themes.fontSize(`${this.value}%`); const maxFontSize = 300;
const stepSize = 5;
const fontSizeDisplay = document.getElementById("fontSizeDisplay");
const fontSizeDecrease = document.getElementById("fontSizeDecrease");
const fontSizeIncrease = document.getElementById("fontSizeIncrease");
function updateFontSize(newSize) {
if (newSize < minFontSize) newSize = minFontSize;
if (newSize > maxFontSize) newSize = maxFontSize;
currentFontSize = newSize;
fontSizeDisplay.textContent = newSize + "%";
localStorage.setItem("calibre.reader.fontSize", newSize);
if (reader && reader.rendition) {
reader.rendition.themes.fontSize(`${newSize}%`);
}
}
// Restore saved font size on load
const savedFontSize = localStorage.getItem("calibre.reader.fontSize");
if (savedFontSize) {
currentFontSize = parseInt(savedFontSize);
fontSizeDisplay.textContent = savedFontSize + "%";
}
fontSizeDecrease.addEventListener("click", function () {
updateFontSize(currentFontSize - stepSize);
});
fontSizeIncrease.addEventListener("click", function () {
updateFontSize(currentFontSize + stepSize);
}); });
let defaultFont; let defaultFont;
function selectFont(id) { window.selectFont = function (id) {
if (!defaultFont) { if (!defaultFont) {
defaultFont = reader.rendition.getContents()[0]?.css('font-family'); defaultFont = reader.rendition.getContents()[0]?.css('font-family');
} }
spans = document.getElementById("font").querySelectorAll("span"); spans = document.getElementById("font").querySelectorAll("span");
for(var i = 0; i < spans.length; i++) { for(var i = 0; i < spans.length; i++) {
spans[i].textContent = ""; spans[i].textContent = "";
} }
document.getElementById(id).querySelector("span").textContent = "✓"; document.getElementById(id).querySelector("span").textContent = "✓";
if (id == 'default') { // Save font selection to localStorage
reader.rendition.themes.font(defaultFont); localStorage.setItem("calibre.reader.font", id);
return;
if (id == "default") {
reader.rendition.themes.font(defaultFont);
return;
} }
reader.rendition.themes.font(id); reader.rendition.themes.font(id);
} };
function spread(id) { function spread(id) {
spans = document.getElementById("layout").querySelectorAll("span"); spans = document.getElementById("layout").querySelectorAll("span");
@@ -210,12 +303,193 @@
spans[i].textContent = ""; spans[i].textContent = "";
} }
document.getElementById(id).querySelector("span").textContent = "✓"; document.getElementById(id).querySelector("span").textContent = "✓";
reader.rendition.spread(id==='spread'?true:'none'); reader.rendition.spread(id==="spread" ? true : "none");
} }
// Pages counter visibility setting
(function () {
var checkbox = document.getElementById("showPagesCount");
var pagesEl = document.getElementById("pages-count");
var key = "calibre.reader.showPages";
var saved = localStorage.getItem(key);
var show = saved === null ? true : saved === "true";
if (checkbox) checkbox.checked = show;
if (pagesEl) pagesEl.style.display = show ? "" : "none";
if (checkbox) {
checkbox.addEventListener("change", function () {
var val = checkbox.checked;
localStorage.setItem(key, String(val));
var target = document.getElementById("pages-count");
if (target) target.style.visibility = val ? "visible" : "hidden";
});
}
})();
</script>
<script type="text/javascript">
// Initialize custom theme UI and Pickr once Pickr is available
(function () {
var swatch = document.getElementById("customThemeSwatch");
var saved =
window.themes &&
window.themes.customTheme &&
window.themes.customTheme.bgColor
? window.themes.customTheme.bgColor
: "#ffffff";
if (swatch) swatch.style.background = saved;
function _hexToRgb(hex) {
hex = hex.replace("#", "");
if (hex.length === 3) {
hex = hex
.split("")
.map(function (h) {
return h + h;
})
.join("");
}
var bigint = parseInt(hex, 16);
return {
r: (bigint >> 16) & 255,
g: (bigint >> 8) & 255,
b: bigint & 255,
};
}
// Better contrast decision using WCAG relative luminance and contrast ratio
// Returns true if black text is the better choice (i.e. background is light)
function _isLight(hex) {
try {
var rgb = _hexToRgb(hex);
// convert 0-255 to 0-1
var srgb = { r: rgb.r / 255, g: rgb.g / 255, b: rgb.b / 255 };
function lin(c) {
return c <= 0.03928
? c / 12.92
: Math.pow((c + 0.055) / 1.055, 2.4);
}
var R = lin(srgb.r),
G = lin(srgb.g),
B = lin(srgb.b);
var L = 0.2126 * R + 0.7152 * G + 0.0722 * B; // relative luminance
// contrast ratios against black (L=0) and white (L=1)
var contrastWithBlack = (L + 0.05) / (0.0 + 0.05);
var contrastWithWhite = (1.0 + 0.05) / (L + 0.05);
// pick the text color that gives higher contrast. If black gives >= contrast, treat bg as light
return contrastWithBlack >= contrastWithWhite;
} catch (e) {
return true;
}
}
function applyCustomColor(hex) {
if (!hex) return;
if (hex[0] !== "#") hex = "#" + hex;
window.themes.customTheme.bgColor = hex;
try {
localStorage.setItem("calibre.reader.customTheme", hex);
} catch (e) {}
if (swatch) swatch.style.background = hex;
// Mark theme as selected
try {
localStorage.setItem("calibre.reader.theme", "customTheme");
} catch (e) {}
// Clear ticks and set custom tick
var tickSpans = document
.getElementById("themes")
.querySelectorAll("span");
tickSpans.forEach(function (ts) {
ts.textContent = "";
});
var customTick = document.getElementById("customSelected");
if (customTick) customTick.textContent = "✓";
// Compute title/text color based on contrast (black or white)
var titleColor = _isLight(hex) ? "#000000" : "#ffffff";
try {
document.getElementById("main").style.backgroundColor = hex;
document.getElementById("titlebar").style.color = titleColor;
document.getElementById("progress").style.color = titleColor;
} catch (e) {}
// Persist title-color in themes map so selectTheme reads a consistent value
try {
window.themes.customTheme["title-color"] = titleColor;
} catch (e) {}
// Register and select theme in epub rendition (include text color)
try {
if (reader && reader.rendition && reader.rendition.themes) {
reader.rendition.themes.register("customTheme", {
body: { background: hex, color: titleColor },
});
reader.rendition.themes.select("customTheme");
}
} catch (e) {
console.error("Failed to apply custom theme to reader", e);
}
}
// Delay init until Pickr is available
function ensurePickrAndInit() {
if (window.Pickr) {
try {
var pickr = Pickr.create({
el: "#customThemeSwatch",
theme: "classic",
default: saved,
components: {
preview: true,
opacity: false,
hue: true,
interaction: {
hex: true,
input: true,
save: true,
},
},
});
// Immediate apply when color changes in the picker
pickr.on("change", function (color, instance) {
try {
var hex = color.toHEXA().toString();
if (swatch) swatch.style.background = hex;
applyCustomColor(hex);
} catch (e) {}
});
// Also respond to save (some pickr configs use save)
pickr.on("save", function (color, instance) {
try {
var hex = color.toHEXA().toString();
if (swatch) swatch.style.background = hex;
applyCustomColor(hex);
pickr.hide();
} catch (e) {}
});
// Clicking swatch opens pickr
if (swatch)
swatch.addEventListener("click", function () {
pickr.show();
});
} catch (e) {
console.error("Pickr init failed", e);
}
return;
}
// wait a bit
setTimeout(ensurePickrAndInit, 150);
}
ensurePickrAndInit();
})();
</script> </script>
<script src="{{ url_for('static', filename='js/libs/screenfull.min.js') }}"></script> <script src="{{ url_for('static', filename='js/libs/screenfull.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/reader.min.js') }}"></script> <script src="{{ url_for('static', filename='js/libs/reader.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/reading/epub.js') }}"></script> <script src="{{ url_for('static', filename='js/reading/epub.js') }}"></script>
<!--script src="{{ url_for('static', filename='js/reading/locationchange-polyfill.js') }}"></script-->
</body> </body>
</html> </html>
+1 -1
View File
@@ -7,7 +7,7 @@
<meta name="description" content=""> <meta name="description" content="">
<title>{{_('Comic Reader')}} | {{title}}</title> <title>{{_('Comic Reader')}} | {{title}}</title>
<meta name="viewport" content="width=device-width, user-scalable=no"> <meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
{% if g.google_site_verification|length > 0 %} {% if g.google_site_verification|length > 0 %}
<meta name="google-site-verification" content="{{g.google_site_verification}}"> <meta name="google-site-verification" content="{{g.google_site_verification}}">
{% endif %} {% endif %}
+1 -1
View File
@@ -5,7 +5,7 @@
<title>{{_('DJVU Reader')}} | {{title}}</title> <title>{{_('DJVU Reader')}} | {{title}}</title>
<meta name="description" content=""> <meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
{% if g.google_site_verification|length > 0 %} {% if g.google_site_verification|length > 0 %}
<meta name="google-site-verification" content="{{g.google_site_verification}}"> <meta name="google-site-verification" content="{{g.google_site_verification}}">
{% endif %} {% endif %}
+1 -1
View File
@@ -6,7 +6,7 @@
<title>{{_('txt Reader')}} | {{title}}</title> <title>{{_('txt Reader')}} | {{title}}</title>
<meta name="description" content=""> <meta name="description" content="">
<meta name="viewport" content="width=device-width, user-scalable=no"> <meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
{% if g.google_site_verification|length > 0 %} {% if g.google_site_verification|length > 0 %}
<meta name="google-site-verification" content="{{g.google_site_verification}}"> <meta name="google-site-verification" content="{{g.google_site_verification}}">
{% endif %} {% endif %}
+14
View File
@@ -24,6 +24,20 @@
{%endfor%} {%endfor%}
</ul> </ul>
</div> </div>
<div class="btn-group" role="group" aria-label="Remove from shelves">
<button id="remove-from-shelf" type="button" class="btn btn-primary btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="glyphicon glyphicon-list"></span> {{_('Remove from shelf')}}
<span class="caret"></span>
</button>
<ul id="remove-from-shelves" class="dropdown-menu" aria-labelledby="add-to-shelf">
{% for shelf in g.shelves_access %}
{% if not shelf.is_public or current_user.role_edit_shelfs() %}
<li><a class="postAction" role="button" data-action="{{ url_for('shelf.search_from_shelf', shelf_id=shelf.id) }}"> {{shelf.name}}{% if shelf.is_public == 1 %} {{_('(Public)')}}{% endif %}</a></li>
{% endif %}
{%endfor%}
</ul>
</div>
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
{% if g.google_site_verification|length > 0 %} {% if g.google_site_verification|length > 0 %}
<meta name="google-site-verification" content="{{g.google_site_verification}}"> <meta name="google-site-verification" content="{{g.google_site_verification}}">
{% endif %} {% endif %}
+2 -2
View File
@@ -53,7 +53,7 @@
data-visible="{{element.get(array_field)}}" data-visible="{{element.get(array_field)}}"
data-column="{{value.get(array_field)}}" data-column="{{value.get(array_field)}}"
data-formatter="checkboxFormatter"> data-formatter="checkboxFormatter">
<div class="form-check"> <div class="form-check">
<div> <div>
<input type="radio" class="check_head" data-set="false" data-val="{{value.get(array_field)}}" name="options_{{array_field}}" id="false_{{array_field}}" data-name="{{parameter}}" disabled>{{_('Deny')}} <input type="radio" class="check_head" data-set="false" data-val="{{value.get(array_field)}}" name="options_{{array_field}}" id="false_{{array_field}}" data-name="{{parameter}}" disabled>{{_('Deny')}}
</div> </div>
@@ -121,7 +121,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="col-xs-12 col-sm-12"> <div class="col-xs-12 col-sm-12">
<div class="row"> <div class="row">
<div class="btn btn-default disabled" id="user_delete_selection" aria-disabled="true">{{_('Remove Selections')}}</div> <div class="btn btn-default disabled mass_selection" id="user_delete_selection" aria-disabled="true">{{_('Clear selections')}}</div>
</div> </div>
</div> </div>
<table id="user-table" class="table table-no-bordered table-striped" <table id="user-table" class="table table-no-bordered table-striped"
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More