Compare commits

..
267 Commits
Author SHA1 Message Date
Ozzie Isaacs 54d06e580d Release version 0.6.12 2021-05-22 14:08:46 +02:00
Ozzie Isaacs 7b1d23eba4 Restored Discord Widget 2021-05-22 10:03:48 +02:00
Ozzie Isaacs 2502c279fc Update discord 2021-05-22 09:57:47 +02:00
Ozzie Isaacs 878fc8c2fd Added download stat badge 2021-05-21 20:58:10 +02:00
Ozzie Isaacs 64902eebcb Merge remote-tracking branch 'batches/patch-2' into master 2021-05-21 20:54:06 +02:00
flying-sausages e520f6afc4 Add Shields to README.md 2021-05-20 22:48:51 +02:00
Ozzie Isaacs 0cc1ae9324 Updated Testresult for Linux 2021-05-20 17:54:25 +02:00
Ozzie Isaacs bd4fde9e63 Fix #1992 handle invalid numbers of books seriesindex 2021-05-19 19:54:44 +02:00
Ozzie Isaacs c85cfa90a4 Update testresults on windows
Added encoding on fb2 upload
2021-05-19 19:21:09 +02:00
Ozzie Isaacs a0449b50c8 Downgrade flask -> to slow during reboot 2021-05-17 17:54:43 +02:00
Ozzie Isaacs 394b063b8c Fixes for caliblur theme 2021-05-16 13:25:29 +02:00
Ozzie Isaacs b62d84424e Merge remote-tracking branch 'comic/comic_viewer_changes' into master 2021-05-16 13:19:28 +02:00
Ozzie Isaacs 20b84a9459 Unified translations
Update translations for release
2021-05-16 09:37:45 +02:00
Ozzie Isaacs dafc68f049 Updated requirements 2021-05-16 09:03:47 +02:00
Ozzie Isaacs 4745fc0db1 Bugfix search for publish dates and custom column dates
Bugfix save umplauts in comments unescaped (as calibre does)
Improved testability
2021-05-15 07:51:32 +02:00
Ozzie Isaacs b009dfe4ee Always use bootstrap datepicker 2021-05-14 19:58:06 +02:00
Ozzie Isaacs eba94f430c Updated js libs 2021-05-14 09:29:24 +02:00
Ozzie Isaacs 06347b7e3c Bugfixes from testrun 2021-05-14 08:26:56 +02:00
Ozzie Isaacs 6bc426b21d Fix edit_books.js 2021-05-14 07:29:48 +02:00
Ozzie Isaacs 4a849aab7f Merge remote-tracking branch 'requirements/patch-1' into master 2021-05-13 16:28:54 +02:00
Ozzie Isaacs ad2d0c84e4 Update swedish translation 2021-05-13 16:28:02 +02:00
Ozzie Isaacs 6bf360fbfb Added "comments" type to supported custom columns 2021-05-13 14:00:01 +02:00
Ozzie Isaacs 380292a8aa Custom column datetime now searchable (#1984) 2021-05-13 13:31:36 +02:00
jonyxx-alt f9949da745 Update Swedish translation
Upload files that has been updated
2021-05-13 11:04:57 +00:00
Ozzie Isaacs b0cc52e0aa Enable custom column datetime (adv. search not working yet) #1984 2021-05-13 10:39:36 +02:00
Ozzie Isaacs 9ef705650b Access pages with stored order per default (#1987)
Fix for toggle-text of enable-sort button on shelf page
2021-05-13 09:41:27 +02:00
Ozzie Isaacs c6dadbe75e restrictions are now applied to kobo sync requests (Fix #1938) 2021-05-13 09:17:01 +02:00
Robert Schütz 21026340ad remove singledispatch from requirements
It's not used anywhere.
2021-05-05 14:30:00 +02:00
Ozzieisaacs 541c8c4b93 Improved error handling for disapearing custom column linked to visiblility restrictions 2021-05-01 20:52:48 +02:00
Ozzieisaacs b97373bf37 Improved error handling for disapearing custom column linked to read status 2021-05-01 18:42:57 +02:00
Ozzieisaacs c0b561cb5a Better input check for custom_columns 2021-05-01 17:10:29 +02:00
Ozzieisaacs bd3ccfd0a9 Merge remote-tracking branch 'cn/master' into master 2021-05-01 16:33:47 +02:00
Ozzieisaacs 5470acd3af Fix #1964 (RefreshError with unconfigured gdrive) 2021-05-01 12:45:28 +02:00
Ozzie Isaacs 64696fe973 Bugfixes edit user list (Fix #1938) 2021-05-01 08:36:39 +02:00
Ozzie Isaacs ed2fa4cdd8 Update error handling for user list (#1938) 2021-04-28 20:49:16 +02:00
dalin 03cca1d1c0 update Simplified Chinese 2021-04-28 16:15:21 +08:00
dalin 354b075885 Merge remote-tracking branch 'upstream/master' 2021-04-28 14:15:42 +08:00
Ozzie Isaacs de7f039b99 Update list user editor (#1938) 2021-04-27 20:01:17 +02:00
Ozzie Isaacs 19797d5d23 Merge remote-tracking branch 'it/patch-24' into master 2021-04-26 19:05:25 +02:00
Ozzie Isaacs 144c2b5fc7 Renaming ipadress - ip_address
Bugfix user table
Result testrun
Updated cn translation
2021-04-26 19:03:01 +02:00
GarcaMan 7da40d1c2e Added option to remove Y-Scrollbar and reset page scroll 2021-04-25 19:59:53 +00:00
Ozzie Isaacs 4e3a5ca33b Added additional debug messages, catch additional errors in updater 2021-04-25 11:30:53 +02:00
Ozzie Isaacs 97e4707f72 user list column value and tags editor (#1938)
Bugfixes for massedit in user list (#1938)
Changed no. of debug messages
2021-04-25 10:43:01 +02:00
Ozzie Isaacs 450ee43677 Additional logging for emails 2021-04-22 19:14:56 +02:00
Ozzie Isaacs c0b2e886d2 bugfix books list with selected books and stored on state 2021-04-21 19:28:22 +02:00
Ozzie Isaacs bb4749c65b Result for testrun
Added mass delete of users
refactores user table refresh
Bugfix for sorting with selected users
Bugfix delete books #1938
2021-04-21 19:23:56 +02:00
ElQuimm 5aa37c68a2 Updated version of italian.po
Thank you.
2021-04-19 10:11:26 +02:00
Ozzie Isaacs 6e5a1a1f4d Merge remote-tracking branch 'add_Babelio/patch-1' into master 2021-04-18 12:05:00 +02:00
Ozzie Isaacs b624ce16c3 Merge remote-tracking branch 'it/patch-23' into master 2021-04-18 12:04:08 +02:00
Ozzie Isaacs 3be5b5f919 Merge remote-tracking branch 'comic_reader/master' into master 2021-04-18 12:02:21 +02:00
Ozzie Isaacs 04ac5b69ac Further improvements on user list #1938 2021-04-18 12:01:11 +02:00
Ozzie Isaacs 9cc14ac5c7 Fix for #1905 prevent invalid json data in gmail_token 2021-04-18 11:33:14 +02:00
BuildTools 755eb1405b Fix progress bar direction 2021-04-18 04:25:45 +09:00
subdiox 0138294b36 Fix an issue that space key doesn't work intentionally when inverting reading direction 2021-04-18 03:20:41 +09:00
Ozzie Isaacs c0a4addf30 Cancel button in User edit now leads to the right page back #1938 2021-04-17 18:30:55 +02:00
Ozzie Isaacs 39bbee0eeb Books sort with non Books table column working #1938 2021-04-17 10:27:30 +02:00
Ozzie Isaacs 39dda3f534 Fix opds login with colon in password #1952 2021-04-15 18:02:52 +02:00
Ozzie Isaacs 1cb8dbe795 Update migration routine 2021-04-15 17:42:39 +02:00
Ozzie Isaacs e13820bbf0 Update exception upon migration of database #1935 2021-04-15 12:05:03 +02:00
malletfils 3973362457 Update db.py
Just adding support for Babelio (french website about books) in the link section
2021-04-14 19:57:02 +02:00
Ozzie Isaacs b38877e193 Document change 2021-04-13 19:47:55 +02:00
Ozzie Isaacs 0e1dbb5377 Copy author names for displaying (#1935) 2021-04-13 19:41:44 +02:00
Ozzie Isaacs f07cc8b103 Update optional-requirements: flask-dance
Catch error for invalid oauth tokens
Fixes for displaying error messages on deleting books from list
Fixes for displaying error messages on deleting bookformats
Removed non working sorting in books list
2021-04-13 19:26:10 +02:00
Ozzie Isaacs 67775bc797 Update requirements
Catch error for invalid oauth tokens
Fixes for displaying error messages on deleting books from list
Fixes for displaying error messages on deleting bookformats
2021-04-13 19:08:02 +02:00
ElQuimm 05933a5f0c Updated italian message.po
I have not removed the #, fuzzy lines, but modified the corresponding values.
2021-04-13 18:51:50 +02:00
Ozzie Isaacs d32b2ca524 Prevent traceback after delete user
flash message in case last admin role is removed #1938
2021-04-12 19:04:27 +02:00
Ozzie Isaacs d0a895628e Prevent delete of Guest user #1938 2021-04-12 18:45:44 +02:00
Ozzie Isaacs 90f2b3fb21 Refactored list checkbox sort 2021-04-12 18:39:09 +02:00
Ozzie Isaacs 0f95800dde Update sqlalchemy-utils dependency for oauth
Sort for state checkbox in user list and books list working
2021-04-12 18:23:25 +02:00
Ozzieisaacs 04971f8672 Bugfix wrong js file in user list 2021-04-11 20:01:40 +02:00
Ozzieisaacs b6177b27f4 Sorting of users in table according to selection possible 2021-04-11 19:59:20 +02:00
Ozzieisaacs ae97e87506 Delete user working from user table (#1938)
Comment in helper
2021-04-10 11:32:25 +02:00
Ozzie Isaacs 2d73f541c0 Bugfix sort books list and user list
Prevent transferring password hash to client
2021-04-08 19:37:08 +02:00
Ozzie Isaacs 7561eabe52 Implement Backend to deny editing Guest rights restriction #1938 2021-04-07 18:56:17 +02:00
Ozzie Isaacs 067fb1b0b7 Prevent delete Guest user and redirect to admin page after user delete 2021-04-07 18:47:48 +02:00
Ozzie Isaacs 78071841cc After Deleting a book the list page is still displayed #1938 2021-04-07 18:19:48 +02:00
Ozzie Isaacs fac232229e Added missing ead/unread category in user list #1938 2021-04-07 17:49:19 +02:00
Ozzie Isaacs a43021e87c Fix for #1938 2021-04-07 17:46:17 +02:00
Ozzie Isaacs 1a0de3b3cf Merge remote-tracking branch 'de_translation/patch-1' into master 2021-04-06 18:08:32 +02:00
Ozzie Isaacs 947154dc9c Merge branch 'development' into master 2021-04-06 18:06:25 +02:00
Ozzie Isaacs 8acd1f1fe4 Code refactoring and improved error handling for edit user list
Update teststatus
2021-04-06 17:29:42 +02:00
Ozzie Isaacs 665210e506 Fixes for book edit with title sort and author sort 2021-04-04 14:01:06 +02:00
Ozzie Isaacs 91b9370a21 Merge branch 'master' into development
# Conflicts:
#	cps/static/css/libs/bootstrap-table.min.css
#	cps/static/js/libs/bootstrap-table/bootstrap-table-editable.min.js
#	cps/static/js/libs/bootstrap-table/bootstrap-table.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-af-ZA.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ar-SA.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-bg-BG.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ca-ES.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-cs-CZ.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-da-DK.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-de-DE.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-el-GR.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-en-US.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-es-AR.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-es-CL.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-es-CR.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-es-ES.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-es-MX.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-es-NI.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-es-SP.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-et-EE.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-eu-EU.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fa-IR.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fi-FI.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fr-BE.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fr-CH.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fr-FR.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fr-LU.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-he-IL.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-hr-HR.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-hu-HU.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-id-ID.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-it-IT.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ja-JP.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ka-GE.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ko-KR.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ms-MY.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-nb-NO.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-nl-BE.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-nl-NL.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-pl-PL.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-pt-BR.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-pt-PT.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ro-RO.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ru-RU.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-sk-SK.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-sr-Cyrl-RS.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-sr-Latn-RS.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-sv-SE.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-th-TH.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-tr-TR.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-uk-UA.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ur-PK.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-uz-Latn-UZ.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-vi-VN.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-zh-CN.min.js
#	cps/static/js/libs/bootstrap-table/locale/bootstrap-table-zh-TW.min.js
2021-04-02 17:13:22 +02:00
Ozzie Isaacs ca0aa600b4 Bugfix german translation 2021-04-02 17:12:26 +02:00
Ozzie Isaacs 1c42c4c969 Update bootstrap-table
Fixes for book edit
2021-04-02 17:11:16 +02:00
Ozzie Isaacs 6e406311c3 Fix #1928 (ldap_import_user_filter missing in to_save) 2021-04-02 08:40:29 +02:00
robochud 87884c1af2 Update messages.po
very little typo
2021-03-31 00:56:12 +02:00
Ozzie Isaacs c0623a949c Merge remote-tracking branch 'caliblur/master' into master 2021-03-29 20:54:04 +02:00
cbartondock 42a23ea23c removed some dumb graphical inconsistencies 2021-03-29 14:35:48 -04:00
cbartondock 710c31d1ca Fixed book details not loading after infinite scroll 2021-03-29 14:13:22 -04:00
cbartondock f637ba4dad Fixed border display in book details 2021-03-29 13:52:09 -04:00
Ozzie Isaacs 80d2c60cef Update testresults 2021-03-29 18:03:09 +02:00
Ozzie Isaacs b17d71e4c3 Update optional-requirements.txt
Bugfix Mail task after convert task
2021-03-28 20:36:08 +02:00
Ozzie Isaacs 3c5bd3a605 Added gmail.json file to excluded file, added file also to excluded file during update 2021-03-28 15:01:56 +02:00
Ozzie Isaacs 7cc8d1e693 remove file 2021-03-28 14:58:07 +02:00
Ozzie Isaacs 99520d54a5 Added ability to send emails via gmail (#1905)
Gmail email sending
2021-03-28 14:51:01 +02:00
Ozzie Isaacs e10a8c078b Merge branch 'master' into development
# Conflicts:
#	cps/templates/email_edit.html
2021-03-27 18:36:11 +01:00
Ozzie Isaacs 7a196fed7c Prevent empty email port and server address(#1906) 2021-03-27 18:34:57 +01:00
Ozzie Isaacs c418c7e725 Prevent empty email from field (#1906) 2021-03-27 18:31:49 +01:00
Ozzie Isaacs e35748deff Fix wording and tabs 2021-03-27 17:30:54 +01:00
Ozzie Isaacs b57efbe31c Fix wording and tabs 2021-03-27 17:30:10 +01:00
Ozzie Isaacs 8f91437701 Implementation for gmail server with OAuth2 started 2021-03-27 16:36:24 +01:00
Ozzie Isaacs a7eb547ca4 Renamed brazilian datepicker file 2021-03-27 12:40:23 +01:00
Ozzie Isaacs 0ceb12f74f Renamed brazilian datepicker file 2021-03-27 12:40:01 +01:00
Ozzie Isaacs 4960624de7 Merge branch 'master' into development 2021-03-27 12:20:28 +01:00
Ozzie Isaacs 2c92f24d89 Added Brazilian translation 2021-03-27 12:20:07 +01:00
Ozzie Isaacs 0b7c679dba Changed test email message (fix #657) 2021-03-27 09:48:43 +01:00
Ozzie Isaacs aab3cdc58a Changed test email message 2021-03-27 09:47:43 +01:00
Ozzie Isaacs 3c63f5b204 Merge branch 'master' into development 2021-03-27 09:46:12 +01:00
Ozzie Isaacs 0b97bbf827 Merge remote-tracking branch 'div_a/use-kobo-for-download-link' into master 2021-03-27 09:39:44 +01:00
Ozzie Isaacs 970dbb0c59 Merge branch 'master' into development
# Conflicts:
#	cps/opds.py
#	cps/templates/admin.html
2021-03-27 09:38:12 +01:00
Ozzie Isaacs d21d3c2ceb Merge remote-tracking branch 'div_a/use-btn-anchors' into master 2021-03-27 09:33:28 +01:00
Ozzie Isaacs 0f6493b8ce Update Teststatus 2021-03-27 09:30:59 +01:00
Ozzie Isaacs f26beec1d3 Updated pdf reader 2021-03-25 18:57:40 +01:00
Ozzie Isaacs c4225e29ed Updated pdf reader 2021-03-25 18:57:25 +01:00
Ozzie Isaacs 9243326cd3 Updated testresult 2021-03-25 18:30:31 +01:00
Ozzie Isaacs 86beb8023a Downgrade sqlachemy, as it breaks oauth 2021-03-24 18:47:59 +01:00
Ozzie Isaacs f4e134742b Fix updater
Bugfixes from testruns
Update Testresults
2021-03-24 18:46:03 +01:00
Ozzie Isaacs 2b17bf4114 Update tests
Fixes upload book
2021-03-23 18:57:04 +01:00
Ozzie Isaacs c4f0fc8f7b Update tests
Fixes upload book
2021-03-23 17:57:49 +01:00
Ozzie Isaacs 3d6c836e7d Update tests 2021-03-23 17:39:51 +01:00
Gavin Mogan c279055af4 Use the kobo auth'd version for download link when proxied -- Fixes #1908 2021-03-22 18:08:41 -07:00
Gavin Mogan 657cba042a Use btn classes on anchors not div so the entire button is clickable 2021-03-22 17:25:38 -07:00
Ozzie Isaacs 7a58e48cae Bugfixes opds feed 2021-03-22 19:01:18 +01:00
Ozzie Isaacs 670eab62bf Update opds feed with letters for all titles, authors, categories and series 2021-03-22 17:46:15 +01:00
Ozzie Isaacs fc85586809 Bugfixes for sqlalchemy 1.4.0 2021-03-22 16:33:21 +01:00
Ozzie Isaacs 837fc4988d Letterize authors 2021-03-21 20:21:10 +01:00
Ozzie Isaacs 4664b47851 Fixed alphabetical order in list and grid view
Completed download section
2021-03-21 19:31:32 +01:00
Ozzie Isaacs 9864d932e0 Changed user.nickname to user.name
Added function to view downloads of all users for admins
2021-03-21 18:55:02 +01:00
Ozzie Isaacs 436f60caa9 refactored some functions 2021-03-21 14:17:07 +01:00
Ozzie Isaacs e9530eda9d Bugfix from refactoring 2021-03-21 13:46:13 +01:00
Ozzie Isaacs f4ddac16f9 Improved error logging for #1905 2021-03-21 11:58:51 +01:00
Ozzie Isaacs 33bdc07f55 Fix for #1845 (ods not working in Moonreader an Librera)
Fix opds search with wrong parameter no longer causes error 500
2021-03-21 11:57:42 +01:00
Ozzie Isaacs 130a4ed2d3 Fix opds and error logging in mail 2021-03-21 11:54:39 +01:00
Ozzie Isaacs 59ebc1af8a Code refactoring 2021-03-21 08:20:04 +01:00
Ozzie Isaacs 82e15d2e98 Merge branch 'master' into development
# Conflicts:
#	test/Calibre-Web TestSummary_Linux.html
2021-03-21 07:28:52 +01:00
Ozzie Isaacs 9c842f1895 Bugfixes pdf xmp parsing 2021-03-21 07:28:35 +01:00
Ozzie Isaacs 8cc849488b Code cosmetics 2021-03-21 07:15:38 +01:00
Ozzie Isaacs fcaa232967 Merge remote-tracking branch 'origin/master' into master 2021-03-21 07:11:37 +01:00
Ozzie Isaacs d3f8153b90 Merge branch 'master' into development 2021-03-20 19:34:16 +01:00
Ozzie Isaacs 99ecf22790 Merge remote-tracking branch 'origin/master' into master 2021-03-20 18:10:42 +01:00
Ozzie Isaacs b1b7ee65b4 Refactored advanced search 2021-03-20 18:10:27 +01:00
Ozzie Isaacs 6889456662 Merge remote-tracking branch 'adv_search/master' into master 2021-03-20 17:48:21 +01:00
Ozzie Isaacs 081553dc9f Disable blank issue templates 2021-03-20 16:27:47 +01:00
Ozzie Isaacs 5d8d796807 Compatibility for sqlalchemy 2.0 2021-03-20 10:09:08 +01:00
Ozzie Isaacs f3d88fc746 Update sqlalchemy 1.4 working 2021-03-20 09:31:29 +01:00
Ozzie Isaacs d87ccae6c9 Merge branch 'master' into development
# Conflicts:
#	cps/server.py
#	test/Calibre-Web TestSummary_Linux.html
2021-03-19 20:33:38 +01:00
Ozzie Isaacs 2760a7816d Fix metadata recognition fb2 files 2021-03-19 20:32:15 +01:00
Ozzie Isaacs 8f5c649d0f Updated dependency comicapi (removed pypdf2)
Updated dependency pyPDF2 ->pyPDF3
Fixed broken updater in case of http error
Bugfixes from testrun
Bugfix load cover for BasicMetadata
2021-03-19 13:35:52 +01:00
Ozzie Isaacs fcf9e7a1ef Upload pdf fixes:
Handle no title
Handle no author
Fix import of more than one language
Add missing pdf upload publisher handling
2021-03-17 19:06:51 +01:00
Ozzie Isaacs 2be7b6480a Merge remote-tracking branch 'pdf/XMP_Metadata3' into master 2021-03-17 19:05:26 +01:00
cbartondock f5ded86c02 removed extraneous logs 2021-03-16 19:18:57 -04:00
rra 8abfaf0ffd Parse XMP metadata in separate function, add exception, try multiple metadata formats 2021-03-16 17:53:33 +01:00
Ozzie Isaacs b070ba142f Selects are working in user management with generic confirm dialog 2021-03-16 15:52:31 +01:00
Ozzie Isaacs bd7c6828bf Selects are working in user management 2021-03-16 15:03:38 +01:00
cbartondock d0671ec58c Merge branch 'master' of https://github.com/janeczku/calibre-web 2021-03-15 21:09:51 -04:00
cbartondock 1e40ffd1cc merge changes 2021-03-15 21:08:41 -04:00
Ozzie Isaacs da2c3e9ed7 Edit user mass change checkbox working 2021-03-15 16:46:27 +01:00
Ozzie Isaacs f62d6abb69 Merge branch 'master' into development
# Conflicts:
#	cps/server.py
#	test/Calibre-Web TestSummary_Linux.html
2021-03-15 13:53:23 +01:00
Ozzie Isaacs 12ad7a6322 Suppress some errors 2021-03-15 13:52:44 +01:00
Ozzie Isaacs b75247ea3a Suppress some errors 2021-03-15 13:48:05 +01:00
Ozzie Isaacs 9a963bbe79 Refactored code
Testrun
2021-03-15 13:10:17 +01:00
Ozzie Isaacs 994bc8b0e4 Removed dropdown button on single file listen/read (Fix #1840) 2021-03-15 09:06:40 +01:00
Ozzie Isaacs 2451605033 Merge remote-tracking branch 'djvu/read-djvu' into master 2021-03-15 08:43:25 +01:00
Ozzie Isaacs 10942527f3 Merge branch 'master' into development
# Conflicts:
#	test/Calibre-Web TestSummary_Linux.html
2021-03-15 08:22:12 +01:00
Ozzie Isaacs 4909ed5ccd Merge remote-tracking branch 'cliblur/master' into development
# Conflicts:
#	cps/static/css/style.css
2021-03-15 08:21:44 +01:00
Ozzie Isaacs 5cf5df68dc Merge remote-tracking branch 'cliblur/master' into master 2021-03-15 08:19:56 +01:00
Ozzie Isaacs dd32cc99ea Merge remote-tracking branch 'es/patch-1' into master 2021-03-15 08:17:14 +01:00
Ozzie Isaacs 79092dc8eb Merge remote-tracking branch 'it/patch-21' into master 2021-03-15 08:11:41 +01:00
Ozzie Isaacs 6229e4610a Updated teststatus and bugfix convert ebooks 2021-03-15 08:10:42 +01:00
Northguy bfe36d3f4a change in color definition
Making color the same as in:
.container-fluid .book .meta .author, .container-fluid .book .meta .author > a {
    color: hsla(0, 0%, 100%, .45)
}
2021-03-15 00:12:02 +01:00
Northguy d42bf44fad Added same color for series name and series number 2021-03-14 23:46:33 +01:00
rra 33e352819c Merge remote-tracking branch 'upstream/master' into XMP_Metadata3 2021-03-14 19:58:26 +01:00
Ozzie Isaacs 53ee0aaee1 Some functions refactored 2021-03-14 17:34:47 +01:00
Ozzie Isaacs 42707a19bd Code cosmetics 2021-03-14 16:57:33 +01:00
Ozzie Isaacs 0888706790 Code cosmetics 2021-03-14 16:53:01 +01:00
Ozzie Isaacs 16453a05f8 Code cosmetics 2021-03-14 16:02:37 +01:00
Ozzie Isaacs 2fbb7466d3 Code cosmetics 2021-03-14 15:44:40 +01:00
Ozzie Isaacs f29f94f45f Code cosmetics 2021-03-14 15:41:36 +01:00
Ozzie Isaacs cd973868fc Code cosmetics 2021-03-14 15:29:34 +01:00
Ozzie Isaacs 3c35f02cac Code cosmetics 2021-03-14 15:18:45 +01:00
Ozzie Isaacs 22c93e2389 Merge branch 'master' into development 2021-03-14 15:06:20 +01:00
Ozzie Isaacs 8c751eb532 Some code cosmetics 2021-03-14 15:06:09 +01:00
Ozzie Isaacs 4df443e007 Some code cosmetics 2021-03-14 14:40:04 +01:00
Ozzie Isaacs f52fa41439 Fix restart server
Some code cosmetics
2021-03-14 14:29:40 +01:00
Ozzie Isaacs f77d72fd86 Merge branch 'master' into development
# Conflicts:
#	cps/static/css/style.css
2021-03-14 14:06:33 +01:00
Ozzie Isaacs 9b80c84794 Some code cosmetics 2021-03-14 14:05:36 +01:00
Ozzie Isaacs 725fc658f8 Some code cosmetics 2021-03-14 13:28:52 +01:00
Ozzie Isaacs e4e27662f5 Merge remote-tracking branch 'url/patch-1' into master 2021-03-14 08:35:37 +01:00
cbartondock 83474da7b5 fixed being able to click through dropdown in advanced search results 2021-03-07 22:54:34 -05:00
cbartondock 9146e5f287 fixed advanced search mass add to shelves in caliblur 2021-03-07 19:53:54 -05:00
cbartondock ff4502c63a Made shelf search user specific 2021-03-07 15:23:05 -05:00
cbartondock 9711bd8fe1 added actions boxes to drop downs 2021-03-06 22:34:39 -05:00
cbartondock 05139e53be advanced shelf search is working 2021-03-06 22:15:18 -05:00
cbartondock 870b2642a9 advanced shelf search is almost working 2021-03-06 21:54:39 -05:00
ElQuimm d31b26ae7d Updated italian message.po
Have a nice day.
2021-03-04 14:26:36 +01:00
Zaroz 5511925ba2 Removed elif "url" identifier format type
Removed elif "url" identifier format type, since else output is identical
2021-03-03 21:18:08 -06:00
Angel Docampo f96b20717d Updated spanish messages.po
Corrected a grammatical error
2021-03-04 00:01:03 +01:00
Zaroz 940c9c45d7 Updated db.py
changed __repr__ else statement to allow for custom id entries
2021-03-02 23:46:02 -06:00
Ozzie Isaacs 87d6008dfc Fix for #1870 (png was missing in supported picture formats for comic viewer) 2021-02-27 09:23:34 +01:00
Ozzie Isaacs b9c0c8d2dc Update bootstrap table to 1.18.2 2021-02-20 20:03:39 +01:00
Ozzie Isaacs 81c30d5fd5 Add missing files from bootstrap editable
Added user-table single-select header for locale and default-language
2021-02-20 19:55:51 +01:00
Ozzie Isaacs 0aa33d88a5 Merge branch 'master' into development 2021-02-20 14:19:39 +01:00
Ozzie Isaacs e64a504bb1 Working Locale and default language selection in user table edit 2021-02-20 14:18:27 +01:00
jvoisin bc876a159e Declare variables before using them
It should fix the following stacktrace:

```
[2021-02-18 14:46:14,771] ERROR {cps:1891} Exception on / [GET]
Traceback (most recent call last):
  File "/opt/calibre/vendor/flask/app.py", line 2447, in wsgi_app
    response = self.full_dispatch_request()
  File "/opt/calibre/vendor/flask/app.py", line 1952, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/opt/calibre/vendor/flask/app.py", line 1821, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/opt/calibre/vendor/flask/_compat.py", line 39, in reraise
    raise value
  File "/opt/calibre/vendor/flask/app.py", line 1950, in full_dispatch_request
    rv = self.dispatch_request()
  File "/opt/calibre/vendor/flask/app.py", line 1936, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/opt/calibre/cps/usermanagement.py", line 38, in decorated_view
    return login_required(func)(*args, **kwargs)
  File "/opt/calibre/vendor/flask_login/utils.py", line 272, in decorated_view
    return func(*args, **kwargs)
  File "/opt/calibre/cps/web.py", line 719, in index
    return render_books_list("newest", sort_param, 1, page)
  File "/opt/calibre/cps/web.py", line 422, in render_books_list
    entries, random, pagination = calibre_db.fill_indexpage(page, 0, db.Books, True, order)
  File "/opt/calibre/cps/db.py", line 610, in fill_indexpage
    return self.fill_indexpage_with_archived_books(page, pagesize, database, db_filter, order, False, *join)
  File "/opt/calibre/cps/db.py", line 635, in fill_indexpage_with_archived_books
    #    book = self.order_authors(book)
UnboundLocalError: local variable 'entries' referenced before assignment
```
2021-02-18 17:02:58 +01:00
Ozzieisaacs 4aa1a838ed Removed f-strings, making it compatible with python 3.5 2021-02-15 14:17:05 +01:00
Ozzieisaacs 095a51edd0 Convert tabs to spaces in server.py (related to #1862) 2021-02-15 13:33:42 +01:00
Ozzie Isaacs a3a11bdf3f Changed checkbox states are saved on server 2021-02-14 17:49:40 +01:00
Ozzie Isaacs 70b503f3d4 Merge branch 'master' into development
# Conflicts:
#	test/Calibre-Web TestSummary_Linux.html
2021-02-13 14:36:42 +01:00
Ozzie Isaacs bbf609b880 Fix #1859 (Upload of jpg files without installed imagemagick doesn't work) 2021-02-13 14:34:41 +01:00
Ozzie Isaacs 0992bafe30 Bulk User management 2021-02-13 13:17:02 +01:00
Ozzie Isaacs c810c5275a Merge branch 'master' into development 2021-02-09 18:05:32 +01:00
Ozzie Isaacs 3c1b06872d Updated testresults
Changed optional-requirements
Prevent redirect 308 on /<data> routes (#1854)
2021-02-09 18:04:56 +01:00
Ozzie Isaacs cefdd2f66c suppress asyncio message always on restart or shutdown 2021-02-08 20:39:21 +01:00
Ozzie Isaacs 5dac13b1da Revert change in ub.py 2021-02-08 20:17:02 +01:00
Ozzie Isaacs 8aebf48193 Update handling for package data 2021-02-08 20:09:36 +01:00
Ozzie Isaacs fbb905957b Prevent redirect 308 on /<data> routes 2021-02-08 20:09:36 +01:00
Ozzie Isaacs e0ce135838 Move "create_engine" call 2021-02-08 20:09:36 +01:00
Ozzie Isaacs 60497c60c1 Update creating Home_Config variable for packaging support, added .HOMEDIR to ignored filename during update 2021-02-07 09:20:37 +01:00
Ozzie Isaacs 251a77c8b4 Merge branch 'master' into Development
Improved packaging support
2021-02-07 08:54:29 +01:00
Ozzie Isaacs 7b7494b8a4 Added executable folder to gitignore 2021-02-06 21:42:29 +01:00
Ozzie Isaacs 9390dcdd43 Update optional requirements 2021-02-06 11:33:20 +01:00
Ozzie Isaacs e6fb460071 New restart routine for executable files
Fix log in updater
New source option updater available
2021-02-06 09:42:27 +01:00
Ozzie Isaacs 6137fdeb33 Version bump 2021-02-02 19:11:02 +01:00
Ozzie Isaacs 4a4d02ea6a Added translations 2021-02-02 19:04:55 +01:00
Ozzie Isaacs be26e5f152 Update Teststatus
Update Version
2021-02-02 18:52:31 +01:00
Ozzie Isaacs 127bfba135 fix wrong path in get_update_status js function 2021-02-01 19:00:10 +01:00
Ozzie Isaacs 7efae3c125 Renamed "Recently Added" to "Books" 2021-01-31 19:20:43 +01:00
Ozzie Isaacs 1e5af21000 Fix #1843 (get path to script via html file and known js file instead of randomly picking last one) 2021-01-31 19:01:34 +01:00
Ozzie Isaacs 33a0a4c173 Changed function for getting path in js file 2021-01-31 18:55:32 +01:00
Ozzie Isaacs eeb7974e05 User table:
Added button for single user edit
Added delete-trash-icon (not working)
Roles are displayed correct per user
Guest user is not visible if anonymous browsing is disabled
2021-01-31 14:54:45 +01:00
Ozzie Isaacs f45ea1a31c Merge branch 'master' into development 2021-01-31 13:21:47 +01:00
Ozzie Isaacs a866dbaa80 Fix mimetype in comic reader 2021-01-31 11:21:09 +01:00
Ozzie Isaacs 62447d6b89 Basic User edit in tables 2021-01-31 10:17:57 +01:00
Ozzie Isaacs 93f0724b83 Merge branch 'development' into master 2021-01-31 09:11:35 +01:00
Ozzie Isaacs 724762843d Update Teststatus 2021-01-31 09:10:42 +01:00
Ozzie Isaacs ff16afbf0b Merge branch 'master' into development 2021-01-30 17:55:45 +01:00
Ozzie Isaacs 9d7ef25062 Fix Kobo sync & Update Teststatus 2021-01-30 17:55:17 +01:00
Ozzie Isaacs 88078d65e9 Fix #1482 (do not shorten title in book detail page) 2021-01-30 14:57:59 +01:00
Ozzie Isaacs b07a97c17e Merge remote-tracking branch 'webp/comic-webp' into master 2021-01-30 14:56:26 +01:00
Ozzie Isaacs 41e7d65e2a Merge remote-tracking branch 'caliblur/master' into master 2021-01-30 14:54:16 +01:00
Ozzie Isaacs 7fa5865cf6 Merge remote-tracking branch 'autoconv/kepub_autoconv' into development
# Conflicts:
#	cps/kobo.py
#	cps/kobo_auth.py
2021-01-30 14:47:18 +01:00
Ozzie Isaacs e0b8fe3b1a Merge branch 'master' into development 2021-01-30 14:41:46 +01:00
Ozzie Isaacs 4a11dd1e16 Fix mistyping "option" 2021-01-28 20:20:59 +01:00
Ozzie Isaacs 34a474101f Fix #1838 (Log message on failed OPDS login) 2021-01-28 20:05:58 +01:00
Ozzie Isaacs e6799e7a04 Improved detection of invalid email addresses (#1831) upon registering 2021-01-27 19:18:40 +01:00
Ozzie Isaacs 0f83f9992c Added swipe support for epub reader (fixes #925)
Cleaned reader js include files
2021-01-24 14:19:46 +01:00
Ozzie Isaacs d2ad78eb40 Added swipe support for comic reader and txt reader (#925)
Bugfix for txt file not present on serving file
2021-01-24 11:28:17 +01:00
Ozzie Isaacs 0b32738f4e Fix Store UI settings in flask session for guest user (#1820)
Updated testresults
Fix Filepicker (absolute instead of abs)
2021-01-24 07:31:40 +01:00
Ozzie Isaacs a9cedb3fca Add logger to db.py 2021-01-23 13:35:30 +01:00
Ozzie Isaacs 51f9cd4bb4 Fix english language names 2021-01-18 21:24:13 +01:00
Ozzie Isaacs a1668e2411 Version Bump 2021-01-17 16:37:54 +01:00
mmonkey b4324cd685 Merge remote-tracking branch 'upstream/master' into master 2021-01-04 12:24:11 -06:00
Ozzieisaacs 6fe4ed3e24 Merge branch 'master' into Develop 2021-01-03 19:28:12 +01:00
mmonkey bc8bdfe385 Merge remote-tracking branch 'upstream/master' into master 2021-01-02 20:21:15 -06:00
Ethan Lin ee28e3346b Merge pull request #21 from janeczku/master
merge from janeczku/master
2020-12-29 10:31:41 +08:00
mmonkey 0cf4c7b7b7 Merge remote-tracking branch 'upstream/master' into master 2020-12-24 03:01:55 -06:00
andylizi 123493ee59 Fix #1255 (support webp in comic viewer) 2020-12-17 23:54:06 +08:00
mmonkey 2d498dd138 Preserve publisher links, and fix formatting in custom columns for CaliBlur theme. 2020-12-16 00:58:45 -06:00
verglor 2343c79126 Use djvu_html5 to read djvu format in browser 2020-11-15 13:19:37 +01:00
verglor 09a5a69f86 djvu_html5 0.3.0-beta1 static assets 2020-11-15 13:09:32 +01:00
dickreckard 65929c02bc isolanguage parsing of xmp data 2020-09-11 10:49:45 +00:00
root 22466d6b98 xmp data processing added to the uploader 2020-09-11 10:08:55 +00:00
dickreckard 23fe79c618 Update uploader.py 2020-09-11 03:42:19 +02:00
dickreckard cb7727900c Update uploader.py
default cover changed from none to pdf_preview when metadata parsing fails
2020-09-07 13:30:03 +02:00
norangebit bdf6052388 Conversion to kepub when creating a sync token 2020-09-03 21:57:14 +02:00
norangebit 99d653eece Add automatic conversion in kepub
Automatic conversion from epub to kepub of new book on Kobo sync.
2020-09-01 19:27:14 +02:00
571 changed files with 93958 additions and 86783 deletions
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+2 -1
View File
@@ -10,6 +10,7 @@ env/
venv/ venv/
eggs/ eggs/
dist/ dist/
executable/
build/ build/
vendor/ vendor/
.eggs/ .eggs/
@@ -29,4 +30,4 @@ vendor/
settings.yaml settings.yaml
gdrive_credentials gdrive_credentials
client_secrets.json client_secrets.json
gmail.json
+7 -3
View File
@@ -2,6 +2,8 @@
Calibre-Web is a web app providing a clean interface for browsing, reading and downloading eBooks using an existing [Calibre](https://calibre-ebook.com) database. Calibre-Web is a web app providing a clean interface for browsing, reading and downloading eBooks using an existing [Calibre](https://calibre-ebook.com) database.
![GitHub License](https://img.shields.io/github/license/janeczku/calibre-web) ![GitHub Downloads](https://img.shields.io/github/downloads/janeczku/calibre-web/total) ![GitHub commit activity](https://img.shields.io/github/commit-activity/w/janeczku/calibre-web?logo=github) [![Discord](https://img.shields.io/discord/838810113564344381?label=Discord&logo=discord)](https://discord.gg/h2VsJ2NEfB)
*This software is a fork of [library](https://github.com/mutschler/calibreserver) and licensed under the GPL v3 License.* *This software is a fork of [library](https://github.com/mutschler/calibreserver) and licensed under the GPL v3 License.*
![Main screen](https://github.com/janeczku/calibre-web/wiki/images/main_screen.png) ![Main screen](https://github.com/janeczku/calibre-web/wiki/images/main_screen.png)
@@ -12,7 +14,7 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d
- full graphical setup - full graphical setup
- User management with fine-grained per-user permissions - User management with fine-grained per-user permissions
- Admin interface - Admin interface
- User Interface in czech, dutch, english, finnish, french, german, greek, hungarian, italian, japanese, khmer, polish, russian, simplified chinese, spanish, swedish, turkish, ukrainian - User Interface in brazilian, czech, dutch, english, finnish, french, german, greek, hungarian, italian, japanese, khmer, polish, russian, simplified chinese, spanish, swedish, turkish, ukrainian
- OPDS feed for eBook reader apps - OPDS feed for eBook reader apps
- Filter and search by titles, authors, tags, series and language - Filter and search by titles, authors, tags, series and language
- Create a custom book collection (shelves) - Create a custom book collection (shelves)
@@ -22,7 +24,7 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d
- Support for public user registration - Support for public user registration
- Send eBooks to Kindle devices with the click of a button - Send eBooks to Kindle devices with the click of a button
- Sync your Kobo devices through Calibre-Web with your Calibre library - Sync your Kobo devices through Calibre-Web with your Calibre library
- Support for reading eBooks directly in the browser (.txt, .epub, .pdf, .cbr, .cbt, .cbz) - Support for reading eBooks directly in the browser (.txt, .epub, .pdf, .cbr, .cbt, .cbz, .djvu)
- Upload new books in many formats, including audio formats (.mp3, .m4a, .m4b) - Upload new books in many formats, including audio formats (.mp3, .m4a, .m4b)
- Support for Calibre Custom Columns - Support for Calibre Custom Columns
- Ability to hide content based on categories and Custom Column content per user - Ability to hide content based on categories and Custom Column content per user
@@ -80,7 +82,9 @@ Pre-built Docker images are available in these Docker Hub repositories:
+ The "path to convertertool" should be set to `/usr/bin/ebook-convert` + The "path to convertertool" should be set to `/usr/bin/ebook-convert`
+ The "path to unrar" should be set to `/usr/bin/unrar` + The "path to unrar" should be set to `/usr/bin/unrar`
# Wiki # Contact
Just reach us out on [Discord](https://discord.gg/h2VsJ2NEfB)
For further information, How To's and FAQ please check the [Wiki](https://github.com/janeczku/calibre-web/wiki) For further information, How To's and FAQ please check the [Wiki](https://github.com/janeczku/calibre-web/wiki)
+3 -3
View File
@@ -100,8 +100,8 @@ def create_app():
log.info('Starting Calibre Web...') log.info('Starting Calibre Web...')
if sys.version_info < (3, 0): if sys.version_info < (3, 0):
log.info('Python2 is EOL since end of 2019, this version of Calibre-Web supporting Python2 please consider upgrading to Python3') log.info('Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2 please consider upgrading to Python3')
print('Python2 is EOL since end of 2019, this version of Calibre-Web supporting Python2 please consider upgrading to Python3') print('Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2 please consider upgrading to Python3')
Principal(app) Principal(app)
lm.init_app(app) lm.init_app(app)
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session)) app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
@@ -127,7 +127,7 @@ def get_locale():
user = getattr(g, 'user', None) user = getattr(g, 'user', None)
# user = None # user = None
if user is not None and hasattr(user, "locale"): if user is not None and hasattr(user, "locale"):
if user.nickname != 'Guest': # if the account is the guest account bypass the config lang settings if user.name != 'Guest': # if the account is the guest account bypass the config lang settings
return user.locale return user.locale
preferred = list() preferred = list()
+8 -1
View File
@@ -37,6 +37,7 @@ try:
except ImportError: except ImportError:
from flask_login.__about__ import __version__ as flask_loginVersion from flask_login.__about__ import __version__ as flask_loginVersion
try: try:
# pylint: disable=unused-import
import unidecode import unidecode
# _() necessary to make babel aware of string for translation # _() necessary to make babel aware of string for translation
unidecode_version = _(u'installed') unidecode_version = _(u'installed')
@@ -48,6 +49,11 @@ try:
except ImportError: except ImportError:
flask_danceVersion = None flask_danceVersion = None
try:
from greenlet import __version__ as greenlet_Version
except ImportError:
greenlet_Version = None
from . import services from . import services
about = flask.Blueprint('about', __name__) about = flask.Blueprint('about', __name__)
@@ -77,7 +83,8 @@ _VERSIONS = OrderedDict(
python_LDAP = services.ldapVersion if bool(services.ldapVersion) else None, python_LDAP = services.ldapVersion if bool(services.ldapVersion) else None,
Goodreads = u'installed' if bool(services.goodreads_support) else None, Goodreads = u'installed' if bool(services.goodreads_support) else None,
jsonschema = services.SyncToken.__version__ if bool(services.SyncToken) else None, jsonschema = services.SyncToken.__version__ if bool(services.SyncToken) else None,
flask_dance = flask_danceVersion flask_dance = flask_danceVersion,
greenlet = greenlet_Version
) )
_VERSIONS.update(uploader.get_versions()) _VERSIONS.update(uploader.get_versions())
+516 -155
View File
@@ -31,20 +31,22 @@ from datetime import datetime, timedelta
from babel import Locale as LC from babel import Locale as LC
from babel.dates import format_datetime from babel.dates import format_datetime
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
from flask_login import login_required, current_user, logout_user, confirm_login from flask_login import login_required, current_user, logout_user, confirm_login
from flask_babel import gettext as _ from flask_babel import gettext as _
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
from sqlalchemy.sql.expression import func, or_ from sqlalchemy.sql.expression import func, or_, text
from . import constants, logger, helper, services from . import constants, logger, helper, services
from .cli import filepicker from .cli import filepicker
from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils
from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \
valid_email, check_username
from .gdriveutils import is_gdrive_ready, gdrive_support from .gdriveutils import is_gdrive_ready, gdrive_support
from .render_template import render_title_template, get_sidebar_config from .render_template import render_title_template, get_sidebar_config
from . import debug_info from . import debug_info, _BABEL_TRANSLATIONS
try: try:
from functools import wraps from functools import wraps
@@ -56,11 +58,13 @@ log = logger.create()
feature_support = { feature_support = {
'ldap': bool(services.ldap), 'ldap': bool(services.ldap),
'goodreads': bool(services.goodreads_support), 'goodreads': bool(services.goodreads_support),
'kobo': bool(services.kobo) 'kobo': bool(services.kobo),
'updater': constants.UPDATER_AVAILABLE,
'gmail': bool(services.gmail)
} }
try: try:
import rarfile import rarfile # pylint: disable=unused-import
feature_support['rar'] = True feature_support['rar'] = True
except (ImportError, SyntaxError): except (ImportError, SyntaxError):
feature_support['rar'] = False feature_support['rar'] = False
@@ -148,7 +152,7 @@ def shutdown():
else: else:
showtext['text'] = _(u'Performing shutdown of server, please close window') showtext['text'] = _(u'Performing shutdown of server, please close window')
# stop gevent/tornado server # stop gevent/tornado server
web_server.stop(task == 0) web_server.stop(task==0)
return json.dumps(showtext) return json.dumps(showtext)
if task == 2: if task == 2:
@@ -212,6 +216,278 @@ def view_configuration():
restrictColumns=restrict_columns, restrictColumns=restrict_columns,
title=_(u"UI Configuration"), page="uiconfig") title=_(u"UI Configuration"), page="uiconfig")
@admi.route("/admin/usertable")
@login_required
@admin_required
def edit_user_table():
visibility = current_user.view_settings.get('useredit', {})
languages = calibre_db.speaking_language()
translations = babel.list_translations() + [LC('en')]
allUser = ub.session.query(ub.User)
tags = calibre_db.session.query(db.Tags)\
.join(db.books_tags_link)\
.join(db.Books)\
.filter(calibre_db.common_filters()) \
.group_by(text('books_tags_link.tag'))\
.order_by(db.Tags.name).all()
if config.config_restricted_column:
custom_values = calibre_db.session.query(db.cc_classes[config.config_restricted_column]).all()
else:
custom_values = []
if not config.config_anonbrowse:
allUser = allUser.filter(ub.User.role.op('&')(constants.ROLE_ANONYMOUS) != constants.ROLE_ANONYMOUS)
return render_title_template("user_table.html",
users=allUser.all(),
tags=tags,
custom_values=custom_values,
translations=translations,
languages=languages,
visiblility=visibility,
all_roles=constants.ALL_ROLES,
sidebar_settings=constants.sidebar_settings,
title=_(u"Edit Users"),
page="usertable")
@admi.route("/ajax/listusers")
@login_required
@admin_required
def list_users():
off = int(request.args.get("offset") or 0)
limit = int(request.args.get("limit") or 10)
search = request.args.get("search")
sort = request.args.get("sort", "id")
order = request.args.get("order", "").lower()
state = None
if sort == "state":
state = json.loads(request.args.get("state", "[]"))
if sort != "state" and order:
order = text(sort + " " + order)
elif not state:
order = ub.User.id.asc()
all_user = ub.session.query(ub.User)
if not config.config_anonbrowse:
all_user = all_user.filter(ub.User.role.op('&')(constants.ROLE_ANONYMOUS) != constants.ROLE_ANONYMOUS)
total_count = filtered_count = all_user.count()
if search:
all_user = all_user.filter(or_(func.lower(ub.User.name).ilike("%" + search + "%"),
func.lower(ub.User.kindle_mail).ilike("%" + search + "%"),
func.lower(ub.User.email).ilike("%" + search + "%")))
if state:
users = calibre_db.get_checkbox_sorted(all_user.all(), state, off, limit, request.args.get("order", "").lower())
else:
users = all_user.order_by(order).offset(off).limit(limit).all()
if search:
filtered_count = len(users)
for user in users:
if user.default_language == "all":
user.default = _("All")
else:
user.default = LC.parse(user.default_language).get_language_name(get_locale())
table_entries = {'totalNotFiltered': total_count, 'total': filtered_count, "rows": users}
js_list = json.dumps(table_entries, cls=db.AlchemyEncoder)
response = make_response(js_list)
response.headers["Content-Type"] = "application/json; charset=utf-8"
return response
@admi.route("/ajax/deleteuser", methods=['POST'])
@login_required
@admin_required
def delete_user():
user_ids = request.form.to_dict(flat=False)
users = None
if "userid[]" in user_ids:
users = ub.session.query(ub.User).filter(ub.User.id.in_(user_ids['userid[]'])).all()
elif "userid" in user_ids:
users = ub.session.query(ub.User).filter(ub.User.id == user_ids['userid'][0]).all()
count = 0
errors = list()
success = list()
if not users:
log.error("User not found")
return Response(json.dumps({'type': "danger", 'message': _("User not found")}), mimetype='application/json')
for user in users:
try:
message = _delete_user(user)
count += 1
except Exception as ex:
log.error(ex)
errors.append({'type': "danger", 'message': str(ex)})
if count == 1:
log.info("User {} deleted".format(user_ids))
success = [{'type': "success", 'message': message}]
elif count > 1:
log.info("Users {} deleted".format(user_ids))
success = [{'type': "success", 'message': _("{} users deleted successfully").format(count)}]
success.extend(errors)
return Response(json.dumps(success), mimetype='application/json')
@admi.route("/ajax/getlocale")
@login_required
@admin_required
def table_get_locale():
locale = babel.list_translations() + [LC('en')]
ret = list()
current_locale = get_locale()
for loc in locale:
ret.append({'value': str(loc), 'text': loc.get_language_name(current_locale)})
return json.dumps(ret)
@admi.route("/ajax/getdefaultlanguage")
@login_required
@admin_required
def table_get_default_lang():
languages = calibre_db.speaking_language()
ret = list()
ret.append({'value': 'all', 'text': _('Show All')})
for lang in languages:
ret.append({'value': lang.lang_code, 'text': lang.name})
return json.dumps(ret)
@admi.route("/ajax/editlistusers/<param>", methods=['POST'])
@login_required
@admin_required
def edit_list_user(param):
vals = request.form.to_dict(flat=False)
all_user = ub.session.query(ub.User)
if not config.config_anonbrowse:
all_user = all_user.filter(ub.User.role.op('&')(constants.ROLE_ANONYMOUS) != constants.ROLE_ANONYMOUS)
# only one user is posted
if "pk" in vals:
users = [all_user.filter(ub.User.id == vals['pk'][0]).one_or_none()]
else:
if "pk[]" in vals:
users = all_user.filter(ub.User.id.in_(vals['pk[]'])).all()
else:
return _("Malformed request"), 400
if 'field_index' in vals:
vals['field_index'] = vals['field_index'][0]
if 'value' in vals:
vals['value'] = vals['value'][0]
elif not ('value[]' in vals):
return _("Malformed request"), 400
for user in users:
try:
if param in ['denied_tags', 'allowed_tags', 'allowed_column_value', 'denied_column_value']:
if 'value[]' in vals:
setattr(user, param, prepare_tags(user, vals['action'][0], param, vals['value[]']))
else:
setattr(user, param, vals['value'].strip())
else:
vals['value'] = vals['value'].strip()
if param == 'name':
if user.name == "Guest":
raise Exception(_("Guest Name can't be changed"))
user.name = check_username(vals['value'])
elif param =='email':
user.email = check_email(vals['value'])
elif param == 'kindle_mail':
user.kindle_mail = valid_email(vals['value']) if vals['value'] else ""
elif param.endswith('role'):
value = int(vals['field_index'])
if user.name == "Guest" and value in \
[constants.ROLE_ADMIN, constants.ROLE_PASSWD, constants.ROLE_EDIT_SHELFS]:
raise Exception(_("Guest can't have this role"))
# check for valid value, last on checks for power of 2 value
if value > 0 and value <= constants.ROLE_VIEWER and (value & value-1 == 0 or value == 1):
if vals['value'] == 'true':
user.role |= value
elif vals['value'] == 'false':
if value == constants.ROLE_ADMIN:
if not ub.session.query(ub.User).\
filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
ub.User.id != user.id).count():
return Response(
json.dumps([{'type': "danger",
'message':_(u"No admin user remaining, can't remove admin role",
nick=user.name)}]), mimetype='application/json')
user.role &= ~value
else:
raise Exception(_("Value has to be true or false"))
else:
raise Exception(_("Invalid role"))
elif param.startswith('sidebar'):
value = int(vals['field_index'])
if user.name == "Guest" and value == constants.SIDEBAR_READ_AND_UNREAD:
raise Exception(_("Guest can't have this view"))
# check for valid value, last on checks for power of 2 value
if value > 0 and value <= constants.SIDEBAR_LIST and (value & value-1 == 0 or value == 1):
if vals['value'] == 'true':
user.sidebar_view |= value
elif vals['value'] == 'false':
user.sidebar_view &= ~value
else:
raise Exception(_("Value has to be true or false"))
else:
raise Exception(_("Invalid view"))
elif param == 'locale':
if user.name == "Guest":
raise Exception(_("Guest's Locale is determined automatically and can't be set"))
if vals['value'] in _BABEL_TRANSLATIONS:
user.locale = vals['value']
else:
raise Exception(_("No Valid Locale Given"))
elif param == 'default_language':
languages = calibre_db.session.query(db.Languages) \
.join(db.books_languages_link) \
.join(db.Books) \
.filter(calibre_db.common_filters()) \
.group_by(text('books_languages_link.lang_code')).all()
lang_codes = [lang.lang_code for lang in languages] + ["all"]
if vals['value'] in lang_codes:
user.default_language = vals['value']
else:
raise Exception(_("No Valid Book Language Given"))
else:
return _("Parameter not found"), 400
except Exception as ex:
log.debug_or_exception(ex)
return str(ex), 400
ub.session_commit()
return ""
@admi.route("/ajax/user_table_settings", methods=['POST'])
@login_required
@admin_required
def update_table_settings():
current_user.view_settings['useredit'] = json.loads(request.data)
try:
try:
flag_modified(current_user, "view_settings")
except AttributeError:
pass
ub.session.commit()
except (InvalidRequestError, OperationalError):
log.error("Invalid request received: {}".format(request))
return "Invalid request", 400
return ""
def check_valid_read_column(column):
if column != "0":
if not calibre_db.session.query(db.Custom_Columns).filter(db.Custom_Columns.id == column) \
.filter(and_(db.Custom_Columns.datatype == 'bool', db.Custom_Columns.mark_for_delete == 0)).all():
return False
return True
def check_valid_restricted_column(column):
if column != "0":
if not calibre_db.session.query(db.Custom_Columns).filter(db.Custom_Columns.id == column) \
.filter(and_(db.Custom_Columns.datatype == 'text', db.Custom_Columns.mark_for_delete == 0)).all():
return False
return True
@admi.route("/admin/viewconfig", methods=["POST"]) @admi.route("/admin/viewconfig", methods=["POST"])
@login_required @login_required
@@ -227,12 +503,23 @@ def update_view_configuration():
if _config_string("config_title_regex"): if _config_string("config_title_regex"):
calibre_db.update_title_sort(config) calibre_db.update_title_sort(config)
if not check_valid_read_column(to_save.get("config_read_column", "0")):
flash(_(u"Invalid Read Column"), category="error")
log.debug("Invalid Read column")
return view_configuration()
_config_int("config_read_column") _config_int("config_read_column")
if not check_valid_restricted_column(to_save.get("config_restricted_column", "0")):
flash(_(u"Invalid Restricted Column"), category="error")
log.debug("Invalid Restricted Column")
return view_configuration()
_config_int("config_restricted_column")
_config_int("config_theme") _config_int("config_theme")
_config_int("config_random_books") _config_int("config_random_books")
_config_int("config_books_per_page") _config_int("config_books_per_page")
_config_int("config_authors_max") _config_int("config_authors_max")
_config_int("config_restricted_column")
config.config_default_role = constants.selected_roles(to_save) config.config_default_role = constants.selected_roles(to_save)
config.config_default_role &= ~constants.ROLE_ANONYMOUS config.config_default_role &= ~constants.ROLE_ANONYMOUS
@@ -243,6 +530,7 @@ def update_view_configuration():
config.save() config.save()
flash(_(u"Calibre-Web configuration updated"), category="success") flash(_(u"Calibre-Web configuration updated"), category="success")
log.debug("Calibre-Web configuration updated")
before_request() before_request()
return view_configuration() return view_configuration()
@@ -260,6 +548,16 @@ def load_dialogtexts(element_id):
texts["main"] = _('Do you really want to delete this user?') texts["main"] = _('Do you really want to delete this user?')
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":
texts["main"] = _('Are you sure you want to change locales of selected user(s)?')
elif element_id == "select_default_language":
texts["main"] = _('Are you sure you want to change visible book languages for selected user(s)?')
elif element_id == "role":
texts["main"] = _('Are you sure you want to change the selected role for the selected user(s)?')
elif element_id == "restrictions":
texts["main"] = _('Are you sure you want to change the selected restrictions for the selected user(s)?')
elif element_id == "sidebar_view":
texts["main"] = _('Are you sure you want to change the selected visibility restrictions for the selected user(s)?')
return json.dumps(texts) return json.dumps(texts)
@@ -346,7 +644,7 @@ def edit_restriction(res_type, user_id):
elementlist = usr.list_allowed_tags() elementlist = usr.list_allowed_tags()
elementlist[int(element['id'][1:])] = element['Element'] elementlist[int(element['id'][1:])] = element['Element']
usr.allowed_tags = ','.join(elementlist) usr.allowed_tags = ','.join(elementlist)
ub.session_commit("Changed allowed tags of user {} to {}".format(usr.nickname, usr.allowed_tags)) ub.session_commit("Changed allowed tags of user {} to {}".format(usr.name, usr.allowed_tags))
if res_type == 3: # CColumn per user if res_type == 3: # CColumn per user
if isinstance(user_id, int): if isinstance(user_id, int):
usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
@@ -355,7 +653,7 @@ def edit_restriction(res_type, user_id):
elementlist = usr.list_allowed_column_values() elementlist = usr.list_allowed_column_values()
elementlist[int(element['id'][1:])] = element['Element'] elementlist[int(element['id'][1:])] = element['Element']
usr.allowed_column_value = ','.join(elementlist) usr.allowed_column_value = ','.join(elementlist)
ub.session_commit("Changed allowed columns of user {} to {}".format(usr.nickname, usr.allowed_column_value)) ub.session_commit("Changed allowed columns of user {} to {}".format(usr.name, usr.allowed_column_value))
if element['id'].startswith('d'): if element['id'].startswith('d'):
if res_type == 0: # Tags as template if res_type == 0: # Tags as template
elementlist = config.list_denied_tags() elementlist = config.list_denied_tags()
@@ -375,7 +673,7 @@ def edit_restriction(res_type, user_id):
elementlist = usr.list_denied_tags() elementlist = usr.list_denied_tags()
elementlist[int(element['id'][1:])] = element['Element'] elementlist[int(element['id'][1:])] = element['Element']
usr.denied_tags = ','.join(elementlist) usr.denied_tags = ','.join(elementlist)
ub.session_commit("Changed denied tags of user {} to {}".format(usr.nickname, usr.denied_tags)) ub.session_commit("Changed denied tags of user {} to {}".format(usr.name, usr.denied_tags))
if res_type == 3: # CColumn per user if res_type == 3: # CColumn per user
if isinstance(user_id, int): if isinstance(user_id, int):
usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
@@ -384,7 +682,7 @@ def edit_restriction(res_type, user_id):
elementlist = usr.list_denied_column_values() elementlist = usr.list_denied_column_values()
elementlist[int(element['id'][1:])] = element['Element'] elementlist[int(element['id'][1:])] = element['Element']
usr.denied_column_value = ','.join(elementlist) usr.denied_column_value = ','.join(elementlist)
ub.session_commit("Changed denied columns of user {} to {}".format(usr.nickname, usr.denied_column_value)) ub.session_commit("Changed denied columns of user {} to {}".format(usr.name, usr.denied_column_value))
return "" return ""
@@ -404,6 +702,26 @@ def restriction_deletion(element, list_func):
return ','.join(elementlist) return ','.join(elementlist)
def prepare_tags(user, action, tags_name, id_list):
if "tags" in tags_name:
tags = calibre_db.session.query(db.Tags).filter(db.Tags.id.in_(id_list)).all()
if not tags:
raise Exception(_("Tag not found"))
new_tags_list = [x.name for x in tags]
else:
tags = calibre_db.session.query(db.cc_classes[config.config_restricted_column])\
.filter(db.cc_classes[config.config_restricted_column].id.in_(id_list)).all()
new_tags_list = [x.value for x in tags]
saved_tags_list = user.__dict__[tags_name].split(",") if len(user.__dict__[tags_name]) else []
if action == "remove":
saved_tags_list = [x for x in saved_tags_list if x not in new_tags_list]
elif action == "add":
saved_tags_list.extend(x for x in new_tags_list if x not in saved_tags_list)
else:
raise Exception(_("Invalid Action"))
return ",".join(saved_tags_list)
@admi.route("/ajax/addrestriction/<int:res_type>", defaults={"user_id": 0}, methods=['POST']) @admi.route("/ajax/addrestriction/<int:res_type>", defaults={"user_id": 0}, methods=['POST'])
@admi.route("/ajax/addrestriction/<int:res_type>/<int:user_id>", methods=['POST']) @admi.route("/ajax/addrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@login_required @login_required
@@ -431,10 +749,10 @@ def add_restriction(res_type, user_id):
usr = current_user usr = current_user
if 'submit_allow' in element: if 'submit_allow' in element:
usr.allowed_tags = restriction_addition(element, usr.list_allowed_tags) usr.allowed_tags = restriction_addition(element, usr.list_allowed_tags)
ub.session_commit("Changed allowed tags of user {} to {}".format(usr.nickname, usr.list_allowed_tags)) ub.session_commit("Changed allowed tags of user {} to {}".format(usr.name, usr.list_allowed_tags()))
elif 'submit_deny' in element: elif 'submit_deny' in element:
usr.denied_tags = restriction_addition(element, usr.list_denied_tags) usr.denied_tags = restriction_addition(element, usr.list_denied_tags)
ub.session_commit("Changed denied tags of user {} to {}".format(usr.nickname, usr.list_denied_tags)) ub.session_commit("Changed denied tags of user {} to {}".format(usr.name, usr.list_denied_tags()))
if res_type == 3: # CustomC per user if res_type == 3: # CustomC per user
if isinstance(user_id, int): if isinstance(user_id, int):
usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
@@ -442,12 +760,12 @@ def add_restriction(res_type, user_id):
usr = current_user usr = current_user
if 'submit_allow' in element: if 'submit_allow' in element:
usr.allowed_column_value = restriction_addition(element, usr.list_allowed_column_values) usr.allowed_column_value = restriction_addition(element, usr.list_allowed_column_values)
ub.session_commit("Changed allowed columns of user {} to {}".format(usr.nickname, ub.session_commit("Changed allowed columns of user {} to {}".format(usr.name,
usr.list_allowed_column_values)) usr.list_allowed_column_values()))
elif 'submit_deny' in element: elif 'submit_deny' in element:
usr.denied_column_value = restriction_addition(element, usr.list_denied_column_values) usr.denied_column_value = restriction_addition(element, usr.list_denied_column_values)
ub.session_commit("Changed denied columns of user {} to {}".format(usr.nickname, ub.session_commit("Changed denied columns of user {} to {}".format(usr.name,
usr.list_denied_column_values)) usr.list_denied_column_values()))
return "" return ""
@@ -478,10 +796,10 @@ def delete_restriction(res_type, user_id):
usr = current_user usr = current_user
if element['id'].startswith('a'): if element['id'].startswith('a'):
usr.allowed_tags = restriction_deletion(element, usr.list_allowed_tags) usr.allowed_tags = restriction_deletion(element, usr.list_allowed_tags)
ub.session_commit("Deleted allowed tags of user {}: {}".format(usr.nickname, usr.list_allowed_tags)) ub.session_commit("Deleted allowed tags of user {}: {}".format(usr.name, usr.list_allowed_tags))
elif element['id'].startswith('d'): elif element['id'].startswith('d'):
usr.denied_tags = restriction_deletion(element, usr.list_denied_tags) usr.denied_tags = restriction_deletion(element, usr.list_denied_tags)
ub.session_commit("Deleted denied tags of user {}: {}".format(usr.nickname, usr.list_allowed_tags)) ub.session_commit("Deleted denied tags of user {}: {}".format(usr.name, usr.list_allowed_tags))
elif res_type == 3: # Columns per user elif res_type == 3: # Columns per user
if isinstance(user_id, int): if isinstance(user_id, int):
usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
@@ -489,12 +807,12 @@ def delete_restriction(res_type, user_id):
usr = current_user usr = current_user
if element['id'].startswith('a'): if element['id'].startswith('a'):
usr.allowed_column_value = restriction_deletion(element, usr.list_allowed_column_values) usr.allowed_column_value = restriction_deletion(element, usr.list_allowed_column_values)
ub.session_commit("Deleted allowed columns of user {}: {}".format(usr.nickname, ub.session_commit("Deleted allowed columns of user {}: {}".format(usr.name,
usr.list_allowed_column_values)) usr.list_allowed_column_values))
elif element['id'].startswith('d'): elif element['id'].startswith('d'):
usr.denied_column_value = restriction_deletion(element, usr.list_denied_column_values) usr.denied_column_value = restriction_deletion(element, usr.list_denied_column_values)
ub.session_commit("Deleted denied columns of user {}: {}".format(usr.nickname, ub.session_commit("Deleted denied columns of user {}: {}".format(usr.name,
usr.list_denied_column_values)) usr.list_denied_column_values))
return "" return ""
@@ -600,7 +918,6 @@ def pathchooser():
folders = [] folders = []
files = [] files = []
# locale = get_locale()
for f in folders: for f in folders:
try: try:
data = {"name": f, "fullpath": os.path.join(cwd, f)} data = {"name": f, "fullpath": os.path.join(cwd, f)}
@@ -636,7 +953,7 @@ def pathchooser():
"parentdir": parentdir, "parentdir": parentdir,
"type": browse_for, "type": browse_for,
"oldfile": oldfile, "oldfile": oldfile,
"absolute": abs, "absolute": absolute,
} }
return json.dumps(context) return json.dumps(context)
@@ -646,6 +963,7 @@ def pathchooser():
def basic_configuration(): def basic_configuration():
logout_user() logout_user()
if request.method == "POST": if request.method == "POST":
log.debug("Basic Configuration send")
return _configuration_update_helper(configured=filepicker) return _configuration_update_helper(configured=filepicker)
return _configuration_result(configured=filepicker) return _configuration_result(configured=filepicker)
@@ -667,10 +985,13 @@ def _config_string(to_save, x):
def _configuration_gdrive_helper(to_save): def _configuration_gdrive_helper(to_save):
gdrive_error = None
gdrive_secrets = {}
if not os.path.isfile(gdriveutils.SETTINGS_YAML): if not os.path.isfile(gdriveutils.SETTINGS_YAML):
config.config_use_google_drive = False config.config_use_google_drive = False
gdrive_secrets = {} if gdrive_support:
gdrive_error = gdriveutils.get_error_text(gdrive_secrets) gdrive_error = gdriveutils.get_error_text(gdrive_secrets)
if "config_use_google_drive" in to_save and not config.config_use_google_drive and not gdrive_error: if "config_use_google_drive" in to_save and not config.config_use_google_drive and not gdrive_error:
with open(gdriveutils.CLIENT_SECRETS, 'r') as settings: with open(gdriveutils.CLIENT_SECRETS, 'r') as settings:
@@ -684,7 +1005,10 @@ def _configuration_gdrive_helper(to_save):
) )
# always show google drive settings, but in case of error deny support # always show google drive settings, but in case of error deny support
config.config_use_google_drive = (not gdrive_error) and ("config_use_google_drive" in to_save) new_gdrive_value = (not gdrive_error) and ("config_use_google_drive" in to_save)
if config.config_use_google_drive and not new_gdrive_value:
config.config_google_drive_watch_changes_response = {}
config.config_use_google_drive = new_gdrive_value
if _config_string(to_save, "config_google_drive_folder"): if _config_string(to_save, "config_google_drive_folder"):
gdriveutils.deleteDatabaseOnChange() gdriveutils.deleteDatabaseOnChange()
return gdrive_error return gdrive_error
@@ -745,7 +1069,7 @@ def _configuration_ldap_helper(to_save, gdrive_error):
reboot_required |= _config_string(to_save, "config_ldap_cert_path") reboot_required |= _config_string(to_save, "config_ldap_cert_path")
reboot_required |= _config_string(to_save, "config_ldap_key_path") reboot_required |= _config_string(to_save, "config_ldap_key_path")
_config_string(to_save, "config_ldap_group_name") _config_string(to_save, "config_ldap_group_name")
if "config_ldap_serv_password" in to_save and to_save["config_ldap_serv_password"] != "": if to_save.get("config_ldap_serv_password", "") != "":
reboot_required |= 1 reboot_required |= 1
config.set_from_dictionary(to_save, "config_ldap_serv_password", base64.b64encode, encode='UTF-8') config.set_from_dictionary(to_save, "config_ldap_serv_password", base64.b64encode, encode='UTF-8')
config.save() config.save()
@@ -783,7 +1107,7 @@ def _configuration_ldap_helper(to_save, gdrive_error):
return reboot_required, _configuration_result(_('LDAP User Object Filter Has Unmatched Parenthesis'), return reboot_required, _configuration_result(_('LDAP User Object Filter Has Unmatched Parenthesis'),
gdrive_error) gdrive_error)
if to_save["ldap_import_user_filter"] == '0': if to_save.get("ldap_import_user_filter") == '0':
config.config_ldap_member_user_object = "" config.config_ldap_member_user_object = ""
else: else:
if config.config_ldap_member_user_object.count("%s") != 1: if config.config_ldap_member_user_object.count("%s") != 1:
@@ -901,15 +1225,16 @@ def _configuration_update_helper(configured):
return _configuration_result(unrar_status, gdrive_error, configured) return _configuration_result(unrar_status, gdrive_error, configured)
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError):
ub.session.rollback() ub.session.rollback()
_configuration_result(_(u"Settings DB is not Writeable"), gdrive_error, configured) log.error("Settings DB is not Writeable")
_configuration_result(_("Settings DB is not Writeable"), gdrive_error, configured)
try: try:
metadata_db = os.path.join(config.config_calibre_dir, "metadata.db") metadata_db = os.path.join(config.config_calibre_dir, "metadata.db")
if config.config_use_google_drive and is_gdrive_ready() and not os.path.exists(metadata_db): if config.config_use_google_drive and is_gdrive_ready() and not os.path.exists(metadata_db):
gdriveutils.downloadFile(None, "metadata.db", metadata_db) gdriveutils.downloadFile(None, "metadata.db", metadata_db)
db_change = True db_change = True
except Exception as e: except Exception as ex:
return _configuration_result('%s' % e, gdrive_error, configured) return _configuration_result('%s' % ex, gdrive_error, configured)
if db_change: if db_change:
if not calibre_db.setup_db(config, ub.app_DB_path): if not calibre_db.setup_db(config, ub.app_DB_path):
@@ -932,16 +1257,17 @@ def _configuration_result(error_flash=None, gdrive_error=None, configured=True):
gdrivefolders = [] gdrivefolders = []
if gdrive_error is None: if gdrive_error is None:
gdrive_error = gdriveutils.get_error_text() gdrive_error = gdriveutils.get_error_text()
if gdrive_error: if gdrive_error and gdrive_support:
log.error(gdrive_error)
gdrive_error = _(gdrive_error) gdrive_error = _(gdrive_error)
else: else:
# if config.config_use_google_drive and\
if not gdrive_authenticate and gdrive_support: if not gdrive_authenticate and gdrive_support:
gdrivefolders = gdriveutils.listRootFolders() gdrivefolders = gdriveutils.listRootFolders()
show_back_button = current_user.is_authenticated show_back_button = current_user.is_authenticated
show_login_button = config.db_configured and not current_user.is_authenticated show_login_button = config.db_configured and not current_user.is_authenticated
if error_flash: if error_flash:
log.error(error_flash)
config.load() config.load()
flash(error_flash, category="error") flash(error_flash, category="error")
show_login_button = False show_login_button = False
@@ -961,7 +1287,6 @@ def _configuration_result(error_flash=None, gdrive_error=None, configured=True):
def _handle_new_user(to_save, content, languages, translations, kobo_support): def _handle_new_user(to_save, content, languages, translations, kobo_support):
content.default_language = to_save["default_language"] content.default_language = to_save["default_language"]
# content.mature_content = "Show_mature_content" in to_save
content.locale = to_save.get("locale", content.locale) content.locale = to_save.get("locale", content.locale)
content.sidebar_view = sum(int(key[5:]) for key in to_save if key.startswith('show_')) content.sidebar_view = sum(int(key[5:]) for key in to_save if key.startswith('show_'))
@@ -969,28 +1294,21 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support):
content.sidebar_view |= constants.DETAIL_RANDOM content.sidebar_view |= constants.DETAIL_RANDOM
content.role = constants.selected_roles(to_save) content.role = constants.selected_roles(to_save)
if not to_save["nickname"] or not to_save["email"] or not to_save["password"]:
flash(_(u"Please fill out all fields!"), category="error")
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
registered_oauth=oauth_check, kobo_support=kobo_support,
title=_(u"Add new user"))
content.password = generate_password_hash(to_save["password"]) content.password = generate_password_hash(to_save["password"])
existing_user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == to_save["nickname"].lower()) \ try:
.first() if not to_save["name"] or not to_save["email"] or not to_save["password"]:
existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()) \ log.info("Missing entries on new user")
.first() raise Exception(_(u"Please fill out all fields!"))
if not existing_user and not existing_email: content.email = check_email(to_save["email"])
content.nickname = to_save["nickname"] # Query User name, if not existing, change
if config.config_public_reg and not check_valid_domain(to_save["email"]): content.name = check_username(to_save["name"])
flash(_(u"E-mail is not from valid domain"), category="error") if to_save.get("kindle_mail"):
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, content.kindle_mail = valid_email(to_save["kindle_mail"])
registered_oauth=oauth_check, kobo_support=kobo_support, if config.config_public_reg and not check_valid_domain(content.email):
title=_(u"Add new user")) log.info("E-mail: {} for new user is not from valid domain".format(content.email))
else: raise Exception(_(u"E-mail is not from valid domain"))
content.email = to_save["email"] except Exception as ex:
else: flash(str(ex), category="error")
flash(_(u"Found an existing account for this e-mail address or nickname."), category="error")
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
languages=languages, title=_(u"Add new user"), page="newuser", languages=languages, title=_(u"Add new user"), page="newuser",
kobo_support=kobo_support, registered_oauth=oauth_check) kobo_support=kobo_support, registered_oauth=oauth_check)
@@ -1001,34 +1319,49 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support):
content.denied_column_value = config.config_denied_column_value content.denied_column_value = config.config_denied_column_value
ub.session.add(content) ub.session.add(content)
ub.session.commit() ub.session.commit()
flash(_(u"User '%(user)s' created", user=content.nickname), category="success") flash(_(u"User '%(user)s' created", user=content.name), category="success")
log.debug("User {} created".format(content.name))
return redirect(url_for('admin.admin')) return redirect(url_for('admin.admin'))
except IntegrityError: except IntegrityError:
ub.session.rollback() ub.session.rollback()
flash(_(u"Found an existing account for this e-mail address or nickname."), category="error") log.error("Found an existing account for {} or {}".format(content.name, content.email))
flash(_("Found an existing account for this e-mail address or name."), category="error")
except OperationalError: except OperationalError:
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
def _delete_user(content):
if ub.session.query(ub.User).filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
ub.User.id != content.id).count():
if content.name != "Guest":
ub.session.query(ub.User).filter(ub.User.id == content.id).delete()
ub.session_commit()
log.info(u"User {} deleted".format(content.name))
return(_(u"User '%(nick)s' deleted", nick=content.name))
else:
log.warning(_(u"Can't delete Guest User"))
raise Exception(_(u"Can't delete Guest User"))
else:
log.warning(u"No admin user remaining, can't delete user")
raise Exception(_(u"No admin user remaining, can't delete user"))
def _handle_edit_user(to_save, content, languages, translations, kobo_support): def _handle_edit_user(to_save, content, languages, translations, kobo_support):
if "delete" in to_save: if to_save.get("delete"):
if ub.session.query(ub.User).filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN, try:
ub.User.id != content.id).count(): flash(_delete_user(content), category="success")
ub.session.query(ub.User).filter(ub.User.id == content.id).delete() except Exception as ex:
ub.session_commit() log.error(ex)
flash(_(u"User '%(nick)s' deleted", nick=content.nickname), category="success") flash(str(ex), category="error")
return redirect(url_for('admin.admin'))
else:
flash(_(u"No admin user remaining, can't delete user", nick=content.nickname), category="error")
return redirect(url_for('admin.admin')) return redirect(url_for('admin.admin'))
else: else:
if not ub.session.query(ub.User).filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN, if not ub.session.query(ub.User).filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
ub.User.id != content.id).count() and 'admin_role' not in to_save: ub.User.id != content.id).count() and 'admin_role' not in to_save:
flash(_(u"No admin user remaining, can't remove admin role", nick=content.nickname), category="error") log.warning("No admin user remaining, can't remove admin role from {}".format(content.name))
flash(_("No admin user remaining, can't remove admin role"), category="error")
return redirect(url_for('admin.admin')) return redirect(url_for('admin.admin'))
if to_save.get("password"):
if "password" in to_save and to_save["password"]:
content.password = generate_password_hash(to_save["password"]) content.password = generate_password_hash(to_save["password"])
anonymous = content.is_anonymous anonymous = content.is_anonymous
content.role = constants.selected_roles(to_save) content.role = constants.selected_roles(to_save)
@@ -1046,22 +1379,28 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
elif value not in val and content.check_visibility(value): elif value not in val and content.check_visibility(value):
content.sidebar_view &= ~value content.sidebar_view &= ~value
if "Show_detail_random" in to_save: if to_save.get("Show_detail_random"):
content.sidebar_view |= constants.DETAIL_RANDOM content.sidebar_view |= constants.DETAIL_RANDOM
else: else:
content.sidebar_view &= ~constants.DETAIL_RANDOM content.sidebar_view &= ~constants.DETAIL_RANDOM
if "default_language" in to_save: if to_save.get("default_language"):
content.default_language = to_save["default_language"] content.default_language = to_save["default_language"]
if "locale" in to_save and to_save["locale"]: if to_save.get("locale"):
content.locale = to_save["locale"] content.locale = to_save["locale"]
if to_save["email"] and to_save["email"] != content.email: try:
existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()) \ if to_save.get("email", content.email) != content.email:
.first() content.email = check_email(to_save["email"])
if not existing_email: # Query User name, if not existing, change
content.email = to_save["email"] if to_save.get("name", content.name) != content.name:
else: if to_save.get("name") == "Guest":
flash(_(u"Found an existing account for this e-mail address."), category="error") raise Exception(_("Guest Name can't be changed"))
content.name = check_username(to_save["name"])
if to_save.get("kindle_mail") != content.kindle_mail:
content.kindle_mail = valid_email(to_save["kindle_mail"]) if to_save["kindle_mail"] else ""
except Exception as ex:
log.error(ex)
flash(str(ex), category="error")
return render_title_template("user_edit.html", return render_title_template("user_edit.html",
translations=translations, translations=translations,
languages=languages, languages=languages,
@@ -1070,34 +1409,20 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
new_user=0, new_user=0,
content=content, content=content,
registered_oauth=oauth_check, registered_oauth=oauth_check,
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser") title=_(u"Edit User %(nick)s", nick=content.name),
if "nickname" in to_save and to_save["nickname"] != content.nickname:
# Query User nickname, if not existing, change
if not ub.session.query(ub.User).filter(ub.User.nickname == to_save["nickname"]).scalar():
content.nickname = to_save["nickname"]
else:
flash(_(u"This username is already taken"), category="error")
return render_title_template("user_edit.html",
translations=translations,
languages=languages,
mail_configured=config.get_mail_server_configured(),
new_user=0, content=content,
registered_oauth=oauth_check,
kobo_support=kobo_support,
title=_(u"Edit User %(nick)s", nick=content.nickname),
page="edituser") page="edituser")
if "kindle_mail" in to_save and to_save["kindle_mail"] != content.kindle_mail:
content.kindle_mail = to_save["kindle_mail"]
try: try:
ub.session_commit() ub.session_commit()
flash(_(u"User '%(nick)s' updated", nick=content.nickname), category="success") flash(_(u"User '%(nick)s' updated", nick=content.name), category="success")
except IntegrityError: except IntegrityError as ex:
ub.session.rollback() ub.session.rollback()
flash(_(u"An unknown error occured."), category="error") log.error("An unknown error occurred while changing user: {}".format(str(ex)))
flash(_(u"An unknown error occurred. Please try again later."), category="error")
except OperationalError: except OperationalError:
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
return ""
@admi.route("/admin/user/new", methods=["GET", "POST"]) @admi.route("/admin/user/new", methods=["GET", "POST"])
@@ -1125,7 +1450,7 @@ def new_user():
def edit_mailsettings(): def edit_mailsettings():
content = config.get_mail_settings() content = config.get_mail_settings()
return render_title_template("email_edit.html", content=content, title=_(u"Edit E-mail Server Settings"), return render_title_template("email_edit.html", content=content, title=_(u"Edit E-mail Server Settings"),
page="mailset") page="mailset", feature_support=feature_support)
@admi.route("/admin/mailsettings", methods=["POST"]) @admi.route("/admin/mailsettings", methods=["POST"])
@@ -1133,8 +1458,23 @@ def edit_mailsettings():
@admin_required @admin_required
def update_mailsettings(): def update_mailsettings():
to_save = request.form.to_dict() to_save = request.form.to_dict()
# log.debug("update_mailsettings %r", to_save) _config_int(to_save, "mail_server_type")
if to_save.get("invalidate"):
config.mail_gmail_token = {}
try:
flag_modified(config, "mail_gmail_token")
except AttributeError:
pass
elif to_save.get("gmail"):
try:
config.mail_gmail_token = services.gmail.setup_gmail(config.mail_gmail_token)
flash(_(u"Gmail Account Verification Successful"), category="success")
except Exception as ex:
flash(str(ex), category="error")
log.error(ex)
return edit_mailsettings()
else:
_config_string(to_save, "mail_server") _config_string(to_save, "mail_server")
_config_int(to_save, "mail_port") _config_int(to_save, "mail_port")
_config_int(to_save, "mail_use_ssl") _config_int(to_save, "mail_use_ssl")
@@ -1146,15 +1486,16 @@ def update_mailsettings():
config.save() config.save()
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError):
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
return edit_mailsettings() return edit_mailsettings()
if to_save.get("test"): if to_save.get("test"):
if current_user.email: if current_user.email:
result = send_test_mail(current_user.email, current_user.nickname) result = send_test_mail(current_user.email, current_user.name)
if result is None: if result is None:
flash(_(u"Test e-mail successfully send to %(kindlemail)s", kindlemail=current_user.email), flash(_(u"Test e-mail queued for sending to %(email)s, please check Tasks for result",
category="success") email=current_user.email), category="info")
else: else:
flash(_(u"There was an error sending the Test e-mail: %(res)s", res=result), category="error") flash(_(u"There was an error sending the Test e-mail: %(res)s", res=result), category="error")
else: else:
@@ -1170,7 +1511,7 @@ def update_mailsettings():
@admin_required @admin_required
def edit_user(user_id): def edit_user(user_id):
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
if not content or (not config.config_anonbrowse and content.nickname == "Guest"): if not content or (not config.config_anonbrowse and content.name == "Guest"):
flash(_(u"User not found"), category="error") flash(_(u"User not found"), category="error")
return redirect(url_for('admin.admin')) return redirect(url_for('admin.admin'))
languages = calibre_db.speaking_language() languages = calibre_db.speaking_language()
@@ -1178,7 +1519,9 @@ def edit_user(user_id):
kobo_support = feature_support['kobo'] and config.config_kobo_sync kobo_support = feature_support['kobo'] and config.config_kobo_sync
if request.method == "POST": if request.method == "POST":
to_save = request.form.to_dict() to_save = request.form.to_dict()
_handle_edit_user(to_save, content, languages, translations, kobo_support) resp = _handle_edit_user(to_save, content, languages, translations, kobo_support)
if resp:
return resp
return render_title_template("user_edit.html", return render_title_template("user_edit.html",
translations=translations, translations=translations,
languages=languages, languages=languages,
@@ -1187,7 +1530,8 @@ def edit_user(user_id):
registered_oauth=oauth_check, registered_oauth=oauth_check,
mail_configured=config.get_mail_server_configured(), mail_configured=config.get_mail_server_configured(),
kobo_support=kobo_support, kobo_support=kobo_support,
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser") title=_(u"Edit User %(nick)s", nick=content.name),
page="edituser")
@admi.route("/admin/resetpassword/<int:user_id>") @admi.route("/admin/resetpassword/<int:user_id>")
@@ -1264,8 +1608,11 @@ def download_debug():
@login_required @login_required
@admin_required @admin_required
def get_update_status(): def get_update_status():
if feature_support['updater']:
log.info(u"Update status requested") log.info(u"Update status requested")
return updater_thread.get_available_updates(request.method, locale=get_locale()) return updater_thread.get_available_updates(request.method, locale=get_locale())
else:
return ''
@admi.route("/get_updater_status", methods=['GET', 'POST']) @admi.route("/get_updater_status", methods=['GET', 'POST'])
@@ -1273,6 +1620,7 @@ def get_update_status():
@admin_required @admin_required
def get_updater_status(): def get_updater_status():
status = {} status = {}
if feature_support['updater']:
if request.method == "POST": if request.method == "POST":
commit = request.form.to_dict() commit = request.form.to_dict()
if "start" in commit and commit['start'] == 'True': if "start" in commit and commit['start'] == 'True':
@@ -1302,6 +1650,55 @@ def get_updater_status():
except Exception: except Exception:
status['status'] = 11 status['status'] = 11
return json.dumps(status) return json.dumps(status)
return ''
def ldap_import_create_user(user, user_data):
user_login_field = extract_dynamic_field_from_filter(user, config.config_ldap_user_object)
username = user_data[user_login_field][0].decode('utf-8')
# check for duplicate username
if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).first():
# if ub.session.query(ub.User).filter(ub.User.name == username).first():
log.warning("LDAP User %s Already in Database", user_data)
return 0, None
kindlemail = ''
if 'mail' in user_data:
useremail = user_data['mail'][0].decode('utf-8')
if len(user_data['mail']) > 1:
kindlemail = user_data['mail'][1].decode('utf-8')
else:
log.debug('No Mail Field Found in LDAP Response')
useremail = username + '@email.com'
try:
# check for duplicate email
useremail = check_email(useremail)
except Exception as ex:
log.warning("LDAP Email Error: {}, {}".format(user_data, ex))
return 0, None
content = ub.User()
content.name = username
content.password = '' # dummy password which will be replaced by ldap one
content.email = useremail
content.kindle_mail = kindlemail
content.role = config.config_default_role
content.sidebar_view = config.config_default_show
content.allowed_tags = config.config_allowed_tags
content.denied_tags = config.config_denied_tags
content.allowed_column_value = config.config_allowed_column_value
content.denied_column_value = config.config_denied_column_value
ub.session.add(content)
try:
ub.session.commit()
return 1, None # increase no of users
except Exception as ex:
log.warning("Failed to create LDAP user: %s - %s", user, ex)
ub.session.rollback()
message = _(u'Failed to Create at Least One LDAP User')
return 0, message
@admi.route('/import_ldap_users') @admi.route('/import_ldap_users')
@@ -1331,59 +1728,23 @@ def import_ldap_users():
query_filter = config.config_ldap_user_object query_filter = config.config_ldap_user_object
try: try:
user_identifier = extract_user_identifier(user, query_filter) user_identifier = extract_user_identifier(user, query_filter)
except Exception as e: except Exception as ex:
log.warning(e) log.warning(ex)
continue continue
else: else:
user_identifier = user user_identifier = user
query_filter = None query_filter = None
try: try:
user_data = services.ldap.get_object_details(user=user_identifier, query_filter=query_filter) user_data = services.ldap.get_object_details(user=user_identifier, query_filter=query_filter)
except AttributeError as e: except AttributeError as ex:
log.debug_or_exception(e) log.debug_or_exception(ex)
continue continue
if user_data: if user_data:
user_login_field = extract_dynamic_field_from_filter(user, config.config_ldap_user_object) user_count, message = ldap_import_create_user(user, user_data)
if message:
username = user_data[user_login_field][0].decode('utf-8') showtext['text'] = message
# check for duplicate username
if ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == username.lower()).first():
# if ub.session.query(ub.User).filter(ub.User.nickname == username).first():
log.warning("LDAP User %s Already in Database", user_data)
continue
kindlemail = ''
if 'mail' in user_data:
useremail = user_data['mail'][0].decode('utf-8')
if len(user_data['mail']) > 1:
kindlemail = user_data['mail'][1].decode('utf-8')
else: else:
log.debug('No Mail Field Found in LDAP Response') imported += user_count
useremail = username + '@email.com'
# check for duplicate email
if ub.session.query(ub.User).filter(func.lower(ub.User.email) == useremail.lower()).first():
log.warning("LDAP Email %s Already in Database", user_data)
continue
content = ub.User()
content.nickname = username
content.password = '' # dummy password which will be replaced by ldap one
content.email = useremail
content.kindle_mail = kindlemail
content.role = config.config_default_role
content.sidebar_view = config.config_default_show
content.allowed_tags = config.config_allowed_tags
content.denied_tags = config.config_denied_tags
content.allowed_column_value = config.config_allowed_column_value
content.denied_column_value = config.config_denied_column_value
ub.session.add(content)
try:
ub.session.commit()
imported += 1
except Exception as e:
log.warning("Failed to create LDAP user: %s - %s", user, e)
ub.session.rollback()
showtext['text'] = _(u'Failed to Create at Least One LDAP User')
else: else:
log.warning("LDAP User: %s Not Found", user) log.warning("LDAP User: %s Not Found", user)
showtext['text'] = _(u'At Least One LDAP User Not Found in Database') showtext['text'] = _(u'At Least One LDAP User Not Found in Database')
+2 -1
View File
@@ -49,7 +49,7 @@ def init_cache_busting(app):
# compute version component # compute version component
rooted_filename = os.path.join(dirpath, filename) rooted_filename = os.path.join(dirpath, filename)
with open(rooted_filename, 'rb') as f: with open(rooted_filename, 'rb') as f:
file_hash = hashlib.md5(f.read()).hexdigest()[:7] file_hash = hashlib.md5(f.read()).hexdigest()[:7] # nosec
# save version to tables # save version to tables
file_path = rooted_filename.replace(static_folder, "") file_path = rooted_filename.replace(static_folder, "")
@@ -64,6 +64,7 @@ def init_cache_busting(app):
return filename.split("?", 1)[0] return filename.split("?", 1)[0]
@app.url_defaults @app.url_defaults
# pylint: disable=unused-variable
def reverse_to_cache_busted_url(endpoint, values): def reverse_to_cache_busted_url(endpoint, values):
""" """
Make `url_for` produce busted filenames when using the 'static' endpoint. Make `url_for` produce busted filenames when using the 'static' endpoint.
+12 -12
View File
@@ -71,7 +71,7 @@ if args.c:
if os.path.isfile(args.c): if os.path.isfile(args.c):
certfilepath = args.c certfilepath = args.c
else: else:
print("Certfilepath is invalid. Exiting...") print("Certfile path is invalid. Exiting...")
sys.exit(1) sys.exit(1)
if args.c == "": if args.c == "":
@@ -81,7 +81,7 @@ if args.k:
if os.path.isfile(args.k): if os.path.isfile(args.k):
keyfilepath = args.k keyfilepath = args.k
else: else:
print("Keyfilepath is invalid. Exiting...") print("Keyfile path is invalid. Exiting...")
sys.exit(1) sys.exit(1)
if (args.k and not args.c) or (not args.k and args.c): if (args.k and not args.c) or (not args.k and args.c):
@@ -91,29 +91,29 @@ if (args.k and not args.c) or (not args.k and args.c):
if args.k == "": if args.k == "":
keyfilepath = "" keyfilepath = ""
# handle and check ipadress argument # handle and check ip address argument
ipadress = args.i or None ip_address = args.i or None
if ipadress: if ip_address:
try: try:
# try to parse the given ip address with socket # try to parse the given ip address with socket
if hasattr(socket, 'inet_pton'): if hasattr(socket, 'inet_pton'):
if ':' in ipadress: if ':' in ip_address:
socket.inet_pton(socket.AF_INET6, ipadress) socket.inet_pton(socket.AF_INET6, ip_address)
else: else:
socket.inet_pton(socket.AF_INET, ipadress) socket.inet_pton(socket.AF_INET, ip_address)
else: else:
# on windows python < 3.4, inet_pton is not available # on windows python < 3.4, inet_pton is not available
# inet_atom only handles IPv4 addresses # inet_atom only handles IPv4 addresses
socket.inet_aton(ipadress) socket.inet_aton(ip_address)
except socket.error as err: except socket.error as err:
print(ipadress, ':', err) print(ip_address, ':', err)
sys.exit(1) sys.exit(1)
# handle and check user password argument # handle and check user password argument
user_credentials = args.s or None user_credentials = args.s or None
if user_credentials and ":" not in user_credentials: if user_credentials and ":" not in user_credentials:
print("No valid username:password format") print("No valid 'username:password' format")
sys.exit(3) sys.exit(3)
# Handles enableing of filepicker # Handles enabling of filepicker
filepicker = args.f or None filepicker = args.f or None
+26 -18
View File
@@ -74,19 +74,8 @@ def _cover_processing(tmp_file_name, img, extension):
return tmp_cover_name return tmp_cover_name
def _extract_Cover_from_archive(original_file_extension, tmp_file_name, rarExecutable):
def _extractCover(tmp_file_name, original_file_extension, rarExecutable): cover_data = None
cover_data = extension = None
if use_comic_meta:
archive = ComicArchive(tmp_file_name, rar_exe_path=rarExecutable)
for index, name in enumerate(archive.getPageNameList()):
ext = os.path.splitext(name)
if len(ext) > 1:
extension = ext[1].lower()
if extension in COVER_EXTENSIONS:
cover_data = archive.getPage(index)
break
else:
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 cf.namelist():
@@ -116,8 +105,24 @@ def _extractCover(tmp_file_name, original_file_extension, rarExecutable):
if extension in COVER_EXTENSIONS: if extension in COVER_EXTENSIONS:
cover_data = cf.read(name) cover_data = cf.read(name)
break break
except Exception as e: except Exception as ex:
log.debug('Rarfile failed with error: %s', e) log.debug('Rarfile failed with error: %s', ex)
return cover_data
def _extractCover(tmp_file_name, original_file_extension, rarExecutable):
cover_data = extension = None
if use_comic_meta:
archive = ComicArchive(tmp_file_name, rar_exe_path=rarExecutable)
for index, name in enumerate(archive.getPageNameList()):
ext = os.path.splitext(name)
if len(ext) > 1:
extension = ext[1].lower()
if extension in COVER_EXTENSIONS:
cover_data = archive.getPage(index)
break
else:
cover_data = _extract_Cover_from_archive(original_file_extension, tmp_file_name, rarExecutable)
return _cover_processing(tmp_file_name, cover_data, extension) return _cover_processing(tmp_file_name, cover_data, extension)
@@ -142,13 +147,15 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
file_path=tmp_file_path, file_path=tmp_file_path,
extension=original_file_extension, extension=original_file_extension,
title=loadedMetadata.title or original_file_name, title=loadedMetadata.title or original_file_name,
author=" & ".join([credit["person"] for credit in loadedMetadata.credits if credit["role"] == "Writer"]) or u'Unknown', author=" & ".join([credit["person"]
for credit in loadedMetadata.credits if credit["role"] == "Writer"]) or u'Unknown',
cover=_extractCover(tmp_file_path, original_file_extension, rarExecutable), cover=_extractCover(tmp_file_path, original_file_extension, rarExecutable),
description=loadedMetadata.comments or "", description=loadedMetadata.comments or "",
tags="", tags="",
series=loadedMetadata.series or "", series=loadedMetadata.series or "",
series_id=loadedMetadata.issue or "", series_id=loadedMetadata.issue or "",
languages=loadedMetadata.language) languages=loadedMetadata.language,
publisher="")
return BookMeta( return BookMeta(
file_path=tmp_file_path, file_path=tmp_file_path,
@@ -160,4 +167,5 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
tags="", tags="",
series="", series="",
series_id="", series_id="",
languages="") languages="",
publisher="")
+47 -27
View File
@@ -20,12 +20,18 @@
from __future__ import division, print_function, unicode_literals from __future__ import division, print_function, unicode_literals
import os import os
import sys import sys
import json
from sqlalchemy import exc, Column, String, Integer, SmallInteger, Boolean, BLOB, JSON from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.sql.expression import text
try:
# Compatibility with sqlalchemy 2.0
from sqlalchemy.orm import declarative_base
except ImportError:
from sqlalchemy.ext.declarative import declarative_base
from . import constants, cli, logger, ub from . import constants, cli, logger
log = logger.create() log = logger.create()
@@ -35,7 +41,7 @@ class _Flask_Settings(_Base):
__tablename__ = 'flask_settings' __tablename__ = 'flask_settings'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
flask_session_key = Column(BLOB, default="") flask_session_key = Column(BLOB, default=b"")
def __init__(self, key): def __init__(self, key):
self.flask_session_key = key self.flask_session_key = key
@@ -54,6 +60,8 @@ class _Settings(_Base):
mail_password = Column(String, default='mypassword') mail_password = Column(String, default='mypassword')
mail_from = Column(String, default='automailer <mail@example.com>') mail_from = Column(String, default='automailer <mail@example.com>')
mail_size = Column(Integer, default=25*1024*1024) mail_size = Column(Integer, default=25*1024*1024)
mail_server_type = Column(SmallInteger, default=0)
mail_gmail_token = Column(JSON, default={})
config_calibre_dir = Column(String) config_calibre_dir = Column(String)
config_port = Column(Integer, default=constants.DEFAULT_PORT) config_port = Column(Integer, default=constants.DEFAULT_PORT)
@@ -66,7 +74,7 @@ class _Settings(_Base):
config_random_books = Column(Integer, default=4) config_random_books = Column(Integer, default=4)
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, default=u'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines)\s+') config_title_regex = Column(String, default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines)\s+')
config_mature_content_tags = Column(String, default='') config_mature_content_tags = Column(String, default='')
config_theme = Column(Integer, default=0) config_theme = Column(Integer, default=0)
@@ -146,15 +154,16 @@ class _ConfigSQL(object):
self.load() self.load()
change = False change = False
if self.config_converterpath == None: if self.config_converterpath == None: # pylint: disable=access-member-before-definition
change = True change = True
self.config_converterpath = autodetect_calibre_binary() self.config_converterpath = autodetect_calibre_binary()
if self.config_kepubifypath == None: if self.config_kepubifypath == None: # pylint: disable=access-member-before-definition
change = True change = True
self.config_kepubifypath = autodetect_kepubify_binary() self.config_kepubifypath = autodetect_kepubify_binary()
if self.config_rarfile_location == None: if self.config_rarfile_location == None: # pylint: disable=access-member-before-definition
change = True change = True
self.config_rarfile_location = autodetect_unrar_binary() self.config_rarfile_location = autodetect_unrar_binary()
if change: if change:
@@ -181,8 +190,9 @@ class _ConfigSQL(object):
return None return None
return self.config_keyfile return self.config_keyfile
def get_config_ipaddress(self): @staticmethod
return cli.ipadress or "" def get_config_ipaddress():
return cli.ip_address or ""
def _has_role(self, role_flag): def _has_role(self, role_flag):
return constants.has_flag(self.config_default_role, role_flag) return constants.has_flag(self.config_default_role, role_flag)
@@ -240,18 +250,18 @@ class _ConfigSQL(object):
return {k:v for k, v in self.__dict__.items() if k.startswith('mail_')} return {k:v for k, v in self.__dict__.items() if k.startswith('mail_')}
def get_mail_server_configured(self): def get_mail_server_configured(self):
return not bool(self.mail_server == constants.DEFAULT_MAIL_SERVER) return bool((self.mail_server != constants.DEFAULT_MAIL_SERVER and self.mail_server_type == 0)
or (self.mail_gmail_token != {} and self.mail_server_type == 1))
def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None): def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None):
'''Possibly updates a field of this object. """Possibly updates a field of this object.
The new value, if present, is grabbed from the given dictionary, and optionally passed through a convertor. The new value, if present, is grabbed from the given dictionary, and optionally passed through a convertor.
:returns: `True` if the field has changed value :returns: `True` if the field has changed value
''' """
new_value = dictionary.get(field, default) new_value = dictionary.get(field, default)
if new_value is None: if new_value is None:
# log.debug("_ConfigSQL set_from_dictionary field '%s' not found", field)
return False return False
if field not in self.__dict__: if field not in self.__dict__:
@@ -268,7 +278,6 @@ class _ConfigSQL(object):
if current_value == new_value: if current_value == new_value:
return False return False
# log.debug("_ConfigSQL set_from_dictionary '%s' = %r (was %r)", field, new_value, current_value)
setattr(self, field, new_value) setattr(self, field, new_value)
return True return True
@@ -299,6 +308,10 @@ class _ConfigSQL(object):
have_metadata_db = os.path.isfile(db_file) have_metadata_db = os.path.isfile(db_file)
self.db_configured = have_metadata_db self.db_configured = have_metadata_db
constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')] constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
if os.environ.get('FLASK_DEBUG'):
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
else:
# pylint: disable=access-member-before-definition
logfile = logger.setup(self.config_logfile, self.config_log_level) logfile = logger.setup(self.config_logfile, self.config_log_level)
if logfile != self.config_logfile: if logfile != self.config_logfile:
log.warning("Log path %s not valid, falling back to default", self.config_logfile) log.warning("Log path %s not valid, falling back to default", self.config_logfile)
@@ -345,7 +358,7 @@ def _migrate_table(session, orm_class):
if column_name[0] != '_': if column_name[0] != '_':
try: try:
session.query(column).first() session.query(column).first()
except exc.OperationalError as err: except OperationalError as err:
log.debug("%s: %s", column_name, err.args[0]) log.debug("%s: %s", column_name, err.args[0])
if column.default is not None: if column.default is not None:
if sys.version_info < (3, 0): if sys.version_info < (3, 0):
@@ -355,16 +368,23 @@ def _migrate_table(session, orm_class):
column_default = "" column_default = ""
else: else:
if isinstance(column.default.arg, bool): if isinstance(column.default.arg, bool):
column_default = ("DEFAULT %r" % int(column.default.arg)) column_default = "DEFAULT {}".format(int(column.default.arg))
else: else:
column_default = ("DEFAULT %r" % column.default.arg) column_default = "DEFAULT `{}`".format(column.default.arg)
alter_table = "ALTER TABLE %s ADD COLUMN `%s` %s %s" % (orm_class.__tablename__, if isinstance(column.type, JSON):
column_type = "JSON"
else:
column_type = column.type
alter_table = text("ALTER TABLE %s ADD COLUMN `%s` %s %s" % (orm_class.__tablename__,
column_name, column_name,
column.type, column_type,
column_default) column_default))
log.debug(alter_table) log.debug(alter_table)
session.execute(alter_table) session.execute(alter_table)
changed = True changed = True
except json.decoder.JSONDecodeError as e:
log.error("Database corrupt column: {}".format(column_name))
log.debug(e)
if changed: if changed:
try: try:
@@ -423,12 +443,12 @@ def load_configuration(session):
session.commit() session.commit()
conf = _ConfigSQL(session) conf = _ConfigSQL(session)
# Migrate from global restrictions to user based restrictions # Migrate from global restrictions to user based restrictions
if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "": #if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "":
conf.config_denied_tags = conf.config_mature_content_tags # conf.config_denied_tags = conf.config_mature_content_tags
conf.save() # conf.save()
session.query(ub.User).filter(ub.User.mature_content != True). \ # session.query(ub.User).filter(ub.User.mature_content != True). \
update({"denied_tags": conf.config_mature_content_tags}, synchronize_session=False) # update({"denied_tags": conf.config_mature_content_tags}, synchronize_session=False)
session.commit() # session.commit()
return conf return conf
def get_flask_session_key(session): def get_flask_session_key(session):
+28 -4
View File
@@ -21,7 +21,11 @@ import sys
import os import os
from collections import namedtuple from collections import namedtuple
HOME_CONFIG = False # if installed via pip this variable is set to true (empty file with name .HOMEDIR present)
HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR'))
#In executables updater is not available, so variable is set to False there
UPDATER_AVAILABLE = True
# Base dir is parent of current file, necessary if called from different folder # Base dir is parent of current file, necessary if called from different folder
if sys.version_info < (3, 0): if sys.version_info < (3, 0):
@@ -84,6 +88,26 @@ SIDEBAR_ARCHIVED = 1 << 15
SIDEBAR_DOWNLOAD = 1 << 16 SIDEBAR_DOWNLOAD = 1 << 16
SIDEBAR_LIST = 1 << 17 SIDEBAR_LIST = 1 << 17
sidebar_settings = {
"detail_random": DETAIL_RANDOM,
"sidebar_language": SIDEBAR_LANGUAGE,
"sidebar_series": SIDEBAR_SERIES,
"sidebar_category": SIDEBAR_CATEGORY,
"sidebar_random": SIDEBAR_RANDOM,
"sidebar_author": SIDEBAR_AUTHOR,
"sidebar_best_rated": SIDEBAR_BEST_RATED,
"sidebar_read_and_unread": SIDEBAR_READ_AND_UNREAD,
"sidebar_recent": SIDEBAR_RECENT,
"sidebar_sorted": SIDEBAR_SORTED,
"sidebar_publisher": SIDEBAR_PUBLISHER,
"sidebar_rating": SIDEBAR_RATING,
"sidebar_format": SIDEBAR_FORMAT,
"sidebar_archived": SIDEBAR_ARCHIVED,
"sidebar_download": SIDEBAR_DOWNLOAD,
"sidebar_list": SIDEBAR_LIST,
}
ADMIN_USER_ROLES = sum(r for r in ALL_ROLES.values()) & ~ROLE_ANONYMOUS ADMIN_USER_ROLES = sum(r for r in ALL_ROLES.values()) & ~ROLE_ANONYMOUS
ADMIN_USER_SIDEBAR = (SIDEBAR_LIST << 1) - 1 ADMIN_USER_SIDEBAR = (SIDEBAR_LIST << 1) - 1
@@ -102,7 +126,7 @@ LDAP_AUTH_SIMPLE = 0
DEFAULT_MAIL_SERVER = "mail.example.org" DEFAULT_MAIL_SERVER = "mail.example.org"
DEFAULT_PASSWORD = "admin123" DEFAULT_PASSWORD = "admin123" # nosec
DEFAULT_PORT = 8083 DEFAULT_PORT = 8083
env_CALIBRE_PORT = os.environ.get("CALIBRE_PORT", DEFAULT_PORT) env_CALIBRE_PORT = os.environ.get("CALIBRE_PORT", DEFAULT_PORT)
try: try:
@@ -128,9 +152,9 @@ def selected_roles(dictionary):
# :rtype: BookMeta # :rtype: BookMeta
BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, ' BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, '
'series_id, languages') 'series_id, languages, publisher')
STABLE_VERSION = {'version': '0.6.10'} STABLE_VERSION = {'version': '0.6.12'}
NIGHTLY_VERSION = {} NIGHTLY_VERSION = {}
NIGHTLY_VERSION[0] = '$Format:%H$' NIGHTLY_VERSION[0] = '$Format:%H$'
+164 -101
View File
@@ -30,7 +30,13 @@ 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
from sqlalchemy.orm.collections import InstrumentedList from sqlalchemy.orm.collections import InstrumentedList
from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta from sqlalchemy.ext.declarative import DeclarativeMeta
from sqlalchemy.exc import OperationalError
try:
# Compatibility with sqlalchemy 2.0
from sqlalchemy.orm import declarative_base
except ImportError:
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from sqlalchemy.sql.expression import and_, true, false, text, func, or_ from sqlalchemy.sql.expression import and_, true, false, text, func, or_
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
@@ -38,6 +44,7 @@ from flask_login import current_user
from babel import Locale as LC from babel import Locale as LC
from babel.core import UnknownLocaleError from babel.core import UnknownLocaleError
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask import flash
from . import logger, ub, isoLanguages from . import logger, ub, isoLanguages
from .pagination import Pagination from .pagination import Pagination
@@ -50,7 +57,9 @@ try:
except ImportError: except ImportError:
use_unidecode = False use_unidecode = False
cc_exceptions = ['datetime', 'comments', 'composite', 'series'] log = logger.create()
cc_exceptions = ['composite', 'series']
cc_classes = {} cc_classes = {}
Base = declarative_base() Base = declarative_base()
@@ -113,6 +122,8 @@ class Identifiers(Base):
return u"Douban" return u"Douban"
elif format_type == "goodreads": elif format_type == "goodreads":
return u"Goodreads" return u"Goodreads"
elif format_type == "babelio":
return u"Babelio"
elif format_type == "google": elif format_type == "google":
return u"Google Books" return u"Google Books"
elif format_type == "kobo": elif format_type == "kobo":
@@ -140,6 +151,8 @@ class Identifiers(Base):
return u"https://dx.doi.org/{0}".format(self.val) return u"https://dx.doi.org/{0}".format(self.val)
elif format_type == "goodreads": elif format_type == "goodreads":
return u"https://www.goodreads.com/book/show/{0}".format(self.val) return u"https://www.goodreads.com/book/show/{0}".format(self.val)
elif format_type == "babelio":
return u"https://www.babelio.com/livres/titre/{0}".format(self.val)
elif format_type == "douban": elif format_type == "douban":
return u"https://book.douban.com/subject/{0}".format(self.val) return u"https://book.douban.com/subject/{0}".format(self.val)
elif format_type == "google": elif format_type == "google":
@@ -154,10 +167,8 @@ class Identifiers(Base):
return u"https://portal.issn.org/resource/ISSN/{0}".format(self.val) return u"https://portal.issn.org/resource/ISSN/{0}".format(self.val)
elif format_type == "isfdb": elif format_type == "isfdb":
return u"http://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val) return u"http://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val)
elif format_type == "url":
return u"{0}".format(self.val)
else: else:
return u"" return u"{0}".format(self.val)
class Comments(Base): class Comments(Base):
@@ -326,7 +337,6 @@ class Books(Base):
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="")
# Iccn = 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')
@@ -384,14 +394,14 @@ class Custom_Columns(Base):
class AlchemyEncoder(json.JSONEncoder): class AlchemyEncoder(json.JSONEncoder):
def default(self, obj): def default(self, o):
if isinstance(obj.__class__, DeclarativeMeta): if isinstance(o.__class__, DeclarativeMeta):
# an SQLAlchemy class # an SQLAlchemy class
fields = {} fields = {}
for field in [x for x in dir(obj) if not x.startswith('_') and x != 'metadata']: for field in [x for x in dir(o) if not x.startswith('_') and x != 'metadata' and x!="password"]:
if field == 'books': if field == 'books':
continue continue
data = obj.__getattribute__(field) data = o.__getattribute__(field)
try: try:
if isinstance(data, str): if isinstance(data, str):
data = data.replace("'", "\'") data = data.replace("'", "\'")
@@ -411,12 +421,12 @@ class AlchemyEncoder(json.JSONEncoder):
else: else:
json.dumps(data) json.dumps(data)
fields[field] = data fields[field] = data
except: except Exception:
fields[field] = "" fields[field] = ""
# a json-encodable dict # a json-encodable dict
return fields return fields
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, o)
class CalibreDB(): class CalibreDB():
@@ -437,12 +447,82 @@ class CalibreDB():
self.instances.add(self) self.instances.add(self)
def initSession(self, expire_on_commit=True): def initSession(self, expire_on_commit=True):
self.session = self.session_factory() self.session = self.session_factory()
self.session.expire_on_commit = expire_on_commit self.session.expire_on_commit = expire_on_commit
self.update_title_sort(self.config) self.update_title_sort(self.config)
@classmethod
def setup_db_cc_classes(self, cc):
cc_ids = []
books_custom_column_links = {}
for row in cc:
if row.datatype not in cc_exceptions:
if row.datatype == 'series':
dicttable = {'__tablename__': 'books_custom_column_' + str(row.id) + '_link',
'id': Column(Integer, primary_key=True),
'book': Column(Integer, ForeignKey('books.id'),
primary_key=True),
'map_value': Column('value', Integer,
ForeignKey('custom_column_' +
str(row.id) + '.id'),
primary_key=True),
'extra': Column(Float),
'asoc': relationship('custom_column_' + str(row.id), uselist=False),
'value': association_proxy('asoc', 'value')
}
books_custom_column_links[row.id] = type(str('books_custom_column_' + str(row.id) + '_link'),
(Base,), dicttable)
if row.datatype in ['rating', 'text', 'enumeration']:
books_custom_column_links[row.id] = Table('books_custom_column_' + str(row.id) + '_link',
Base.metadata,
Column('book', Integer, ForeignKey('books.id'),
primary_key=True),
Column('value', Integer,
ForeignKey('custom_column_' +
str(row.id) + '.id'),
primary_key=True)
)
cc_ids.append([row.id, row.datatype])
ccdict = {'__tablename__': 'custom_column_' + str(row.id),
'id': Column(Integer, primary_key=True)}
if row.datatype == 'float':
ccdict['value'] = Column(Float)
elif row.datatype == 'int':
ccdict['value'] = Column(Integer)
elif row.datatype == 'datetime':
ccdict['value'] = Column(TIMESTAMP)
elif row.datatype == 'bool':
ccdict['value'] = Column(Boolean)
else:
ccdict['value'] = Column(String)
if row.datatype in ['float', 'int', 'bool', 'datetime', 'comments']:
ccdict['book'] = Column(Integer, ForeignKey('books.id'))
cc_classes[row.id] = type(str('custom_column_' + str(row.id)), (Base,), ccdict)
for cc_id in cc_ids:
if cc_id[1] in ['bool', 'int', 'float', 'datetime', 'comments']:
setattr(Books,
'custom_column_' + str(cc_id[0]),
relationship(cc_classes[cc_id[0]],
primaryjoin=(
Books.id == cc_classes[cc_id[0]].book),
backref='books'))
elif cc_id[1] == 'series':
setattr(Books,
'custom_column_' + str(cc_id[0]),
relationship(books_custom_column_links[cc_id[0]],
backref='books'))
else:
setattr(Books,
'custom_column_' + str(cc_id[0]),
relationship(cc_classes[cc_id[0]],
secondary=books_custom_column_links[cc_id[0]],
backref='books'))
return cc_classes
@classmethod @classmethod
def setup_db(cls, config, app_db_path): def setup_db(cls, config, app_db_path):
cls.config = config cls.config = config
@@ -465,84 +545,24 @@ class CalibreDB():
isolation_level="SERIALIZABLE", isolation_level="SERIALIZABLE",
connect_args={'check_same_thread': False}, connect_args={'check_same_thread': False},
poolclass=StaticPool) poolclass=StaticPool)
cls.engine.execute("attach database '{}' as calibre;".format(dbpath)) with cls.engine.begin() as connection:
cls.engine.execute("attach database '{}' as app_settings;".format(app_db_path)) connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
conn = cls.engine.connect() conn = cls.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
except Exception as e: except Exception as ex:
config.invalidate(e) config.invalidate(ex)
return False return False
config.db_configured = True config.db_configured = True
if not cc_classes: if not cc_classes:
try:
cc = conn.execute("SELECT id, datatype FROM custom_columns") cc = conn.execute("SELECT id, datatype FROM custom_columns")
cls.setup_db_cc_classes(cc)
cc_ids = [] except OperationalError as e:
books_custom_column_links = {} log.debug_or_exception(e)
for row in cc:
if row.datatype not in cc_exceptions:
if row.datatype == 'series':
dicttable = {'__tablename__': 'books_custom_column_' + str(row.id) + '_link',
'id': Column(Integer, primary_key=True),
'book': Column(Integer, ForeignKey('books.id'),
primary_key=True),
'map_value': Column('value', Integer,
ForeignKey('custom_column_' +
str(row.id) + '.id'),
primary_key=True),
'extra': Column(Float),
'asoc': relationship('custom_column_' + str(row.id), uselist=False),
'value': association_proxy('asoc', 'value')
}
books_custom_column_links[row.id] = type(str('books_custom_column_' + str(row.id) + '_link'),
(Base,), dicttable)
else:
books_custom_column_links[row.id] = Table('books_custom_column_' + str(row.id) + '_link',
Base.metadata,
Column('book', Integer, ForeignKey('books.id'),
primary_key=True),
Column('value', Integer,
ForeignKey('custom_column_' +
str(row.id) + '.id'),
primary_key=True)
)
cc_ids.append([row.id, row.datatype])
ccdict = {'__tablename__': 'custom_column_' + str(row.id),
'id': Column(Integer, primary_key=True)}
if row.datatype == 'float':
ccdict['value'] = Column(Float)
elif row.datatype == 'int':
ccdict['value'] = Column(Integer)
elif row.datatype == 'bool':
ccdict['value'] = Column(Boolean)
else:
ccdict['value'] = Column(String)
if row.datatype in ['float', 'int', 'bool']:
ccdict['book'] = Column(Integer, ForeignKey('books.id'))
cc_classes[row.id] = type(str('custom_column_' + str(row.id)), (Base,), ccdict)
for cc_id in cc_ids:
if (cc_id[1] == 'bool') or (cc_id[1] == 'int') or (cc_id[1] == 'float'):
setattr(Books,
'custom_column_' + str(cc_id[0]),
relationship(cc_classes[cc_id[0]],
primaryjoin=(
Books.id == cc_classes[cc_id[0]].book),
backref='books'))
elif (cc_id[1] == 'series'):
setattr(Books,
'custom_column_' + str(cc_id[0]),
relationship(books_custom_column_links[cc_id[0]],
backref='books'))
else:
setattr(Books,
'custom_column_' + str(cc_id[0]),
relationship(cc_classes[cc_id[0]],
secondary=books_custom_column_links[cc_id[0]],
backref='books'))
cls.session_factory = scoped_session(sessionmaker(autocommit=False, cls.session_factory = scoped_session(sessionmaker(autocommit=False,
autoflush=True, autoflush=True,
@@ -563,8 +583,8 @@ class CalibreDB():
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).first()
def get_book_format(self, book_id, format): def get_book_format(self, book_id, file_format):
return self.session.query(Data).filter(Data.book == book_id).filter(Data.format == format).first() return self.session.query(Data).filter(Data.book == book_id).filter(Data.format == file_format).first()
# Language and content filters for displaying in the UI # Language and content filters for displaying in the UI
def common_filters(self, allow_show_archived=False): def common_filters(self, allow_show_archived=False):
@@ -589,6 +609,7 @@ class CalibreDB():
neg_content_tags_filter = false() if negtags_list == [''] else Books.tags.any(Tags.name.in_(negtags_list)) neg_content_tags_filter = false() if negtags_list == [''] else Books.tags.any(Tags.name.in_(negtags_list))
pos_content_tags_filter = true() if postags_list == [''] else Books.tags.any(Tags.name.in_(postags_list)) pos_content_tags_filter = true() if postags_list == [''] else Books.tags.any(Tags.name.in_(postags_list))
if self.config.config_restricted_column: if self.config.config_restricted_column:
try:
pos_cc_list = current_user.allowed_column_value.split(',') pos_cc_list = current_user.allowed_column_value.split(',')
pos_content_cc_filter = true() if pos_cc_list == [''] else \ pos_content_cc_filter = true() if pos_cc_list == [''] else \
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \ getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
@@ -597,12 +618,37 @@ class CalibreDB():
neg_content_cc_filter = false() if neg_cc_list == [''] else \ neg_content_cc_filter = false() if neg_cc_list == [''] else \
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \ getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
any(cc_classes[self.config.config_restricted_column].value.in_(neg_cc_list)) any(cc_classes[self.config.config_restricted_column].value.in_(neg_cc_list))
except (KeyError, AttributeError):
pos_content_cc_filter = false()
neg_content_cc_filter = true()
log.error(u"Custom Column No.%d is not existing in calibre database",
self.config.config_restricted_column)
flash(_("Custom Column No.%(column)d is not existing in calibre database",
column=self.config.config_restricted_column),
category="error")
else: else:
pos_content_cc_filter = true() pos_content_cc_filter = true()
neg_content_cc_filter = false() neg_content_cc_filter = false()
return and_(lang_filter, pos_content_tags_filter, ~neg_content_tags_filter, return and_(lang_filter, pos_content_tags_filter, ~neg_content_tags_filter,
pos_content_cc_filter, ~neg_content_cc_filter, archived_filter) pos_content_cc_filter, ~neg_content_cc_filter, archived_filter)
@staticmethod
def get_checkbox_sorted(inputlist, state, offset, limit, order):
outcome = list()
elementlist = {ele.id: ele for ele in inputlist}
for entry in state:
try:
outcome.append(elementlist[entry])
except KeyError:
pass
del elementlist[entry]
for entry in elementlist:
outcome.append(elementlist[entry])
if order == "asc":
outcome.reverse()
return outcome[offset:offset + limit]
# Fill indexpage with all requested data from database # Fill indexpage with all requested data from database
def fill_indexpage(self, page, pagesize, database, db_filter, order, *join): def fill_indexpage(self, page, pagesize, database, db_filter, order, *join):
return self.fill_indexpage_with_archived_books(page, pagesize, database, db_filter, order, False, *join) return self.fill_indexpage_with_archived_books(page, pagesize, database, db_filter, order, False, *join)
@@ -614,22 +660,29 @@ class CalibreDB():
randm = self.session.query(Books) \ randm = self.session.query(Books) \
.filter(self.common_filters(allow_show_archived)) \ .filter(self.common_filters(allow_show_archived)) \
.order_by(func.random()) \ .order_by(func.random()) \
.limit(self.config.config_random_books) .limit(self.config.config_random_books).all()
else: else:
randm = false() randm = false()
off = int(int(pagesize) * (page - 1)) off = int(int(pagesize) * (page - 1))
query = self.session.query(database) \ query = self.session.query(database)
.join(*join, isouter=True) \ if len(join) == 3:
.filter(db_filter) \ 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])
query = query.filter(db_filter)\
.filter(self.common_filters(allow_show_archived)) .filter(self.common_filters(allow_show_archived))
entries = list()
pagination = list()
try: try:
pagination = Pagination(page, pagesize, pagination = Pagination(page, pagesize,
len(query.all())) len(query.all()))
entries = query.order_by(*order).offset(off).limit(pagesize).all() entries = query.order_by(*order).offset(off).limit(pagesize).all()
except Exception as e: except Exception as ex:
logger.debug(e) log.debug_or_exception(ex)
for book in entries: #for book in entries:
book = self.order_authors(book) # book = self.order_authors(book)
return entries, randm, pagination return entries, randm, pagination
# Orders all Authors in the list according to authors sort # Orders all Authors in the list according to authors sort
@@ -669,23 +722,33 @@ class CalibreDB():
return self.session.query(Books) \ return self.session.query(Books) \
.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()
# read search results from calibre-database and return it (function is used for feed and simple search def search_query(self, term, *join):
def get_search_results(self, term, offset=None, order=None, limit=None):
order = order or [Books.sort]
pagination = None
term.strip().lower() term.strip().lower()
self.session.connection().connection.connection.create_function("lower", 1, lcase) self.session.connection().connection.connection.create_function("lower", 1, lcase)
q = list() q = list()
authorterms = re.split("[, ]+", term) authorterms = re.split("[, ]+", term)
for authorterm in authorterms: for authorterm in authorterms:
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + authorterm + "%"))) q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + authorterm + "%")))
result = self.session.query(Books).filter(self.common_filters(True)).filter( query = self.session.query(Books)
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])
return query.filter(self.common_filters(True)).filter(
or_(Books.tags.any(func.lower(Tags.name).ilike("%" + term + "%")), or_(Books.tags.any(func.lower(Tags.name).ilike("%" + term + "%")),
Books.series.any(func.lower(Series.name).ilike("%" + term + "%")), Books.series.any(func.lower(Series.name).ilike("%" + term + "%")),
Books.authors.any(and_(*q)), Books.authors.any(and_(*q)),
Books.publishers.any(func.lower(Publishers.name).ilike("%" + term + "%")), Books.publishers.any(func.lower(Publishers.name).ilike("%" + term + "%")),
func.lower(Books.title).ilike("%" + term + "%") func.lower(Books.title).ilike("%" + term + "%")
)).order_by(*order).all() ))
# read search results from calibre-database and return it (function is used for feed and simple search
def get_search_results(self, term, offset=None, order=None, limit=None, *join):
order = order or [Books.sort]
pagination = None
result = self.search_query(term, *join).order_by(*order).all()
result_count = len(result) result_count = len(result)
if offset != None and limit != None: if offset != None and limit != None:
offset = int(offset) offset = int(offset)
@@ -740,7 +803,7 @@ class CalibreDB():
if old_session: if old_session:
try: try:
old_session.close() old_session.close()
except: except Exception:
pass pass
if old_session.bind: if old_session.bind:
try: try:
@@ -771,7 +834,7 @@ class CalibreDB():
def lcase(s): def lcase(s):
try: try:
return unidecode.unidecode(s.lower()) return unidecode.unidecode(s.lower())
except Exception as e: except Exception as ex:
log = logger.create() log = logger.create()
log.debug_or_exception(e) log.debug_or_exception(ex)
return s.lower() return s.lower()
+326 -198
View File
@@ -27,6 +27,8 @@ import json
from shutil import copyfile from shutil import copyfile
from uuid import uuid4 from uuid import uuid4
from babel import Locale as LC
from babel.core import UnknownLocaleError
from flask import Blueprint, request, flash, redirect, url_for, abort, Markup, Response from flask import Blueprint, request, flash, redirect, url_for, abort, Markup, Response
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
@@ -68,17 +70,7 @@ def edit_required(f):
return inner return inner
def search_objects_remove(db_book_object, db_type, input_elements):
# Modifies different Database objects, first check if elements have to be added to database, than check
# if elements have to be deleted, because they are no longer used
def modify_database_object(input_elements, db_book_object, db_object, db_session, db_type):
# passing input_elements not as a list may lead to undesired results
if not isinstance(input_elements, list):
raise TypeError(str(input_elements) + " should be passed as a list")
changed = False
input_elements = [x for x in input_elements if x != '']
# we have all input element (authors, series, tags) names now
# 1. search for elements to remove
del_elements = [] del_elements = []
for c_elements in db_book_object: for c_elements in db_book_object:
found = False found = False
@@ -96,7 +88,10 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
# if the element was not found in the new list, add it to remove list # if the element was not found in the new list, add it to remove list
if not found: if not found:
del_elements.append(c_elements) del_elements.append(c_elements)
# 2. search for elements that need to be added return del_elements
def search_objects_add(db_book_object, db_type, input_elements):
add_elements = [] add_elements = []
for inp_element in input_elements: for inp_element in input_elements:
found = False found = False
@@ -112,15 +107,21 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
break break
if not found: if not found:
add_elements.append(inp_element) add_elements.append(inp_element)
# if there are elements to remove, we remove them now return add_elements
def remove_objects(db_book_object, db_session, del_elements):
changed = False
if len(del_elements) > 0: if len(del_elements) > 0:
for del_element in del_elements: for del_element in del_elements:
db_book_object.remove(del_element) db_book_object.remove(del_element)
changed = True changed = True
if len(del_element.books) == 0: if len(del_element.books) == 0:
db_session.delete(del_element) db_session.delete(del_element)
# if there are elements to add, we add them now! return changed
if len(add_elements) > 0:
def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
changed = False
if db_type == 'languages': if db_type == 'languages':
db_filter = db_object.lang_code db_filter = db_object.lang_code
elif db_type == 'custom': elif db_type == 'custom':
@@ -147,9 +148,18 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
db_session.add(new_element) db_session.add(new_element)
db_book_object.append(new_element) db_book_object.append(new_element)
else: else:
db_element = create_objects_for_addition(db_element, add_element, db_type)
changed = True
# add element to book
changed = True
db_book_object.append(db_element)
return changed
def create_objects_for_addition(db_element, add_element, db_type):
if db_type == 'custom': if db_type == 'custom':
if db_element.value != add_element: if db_element.value != add_element:
new_element.value = add_element db_element.value = add_element # ToDo: Before new_element, but this is not plausible
elif db_type == 'languages': elif db_type == 'languages':
if db_element.lang_code != add_element: if db_element.lang_code != add_element:
db_element.lang_code = add_element db_element.lang_code = add_element
@@ -167,9 +177,26 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
db_element.sort = None db_element.sort = None
elif db_element.name != add_element: elif db_element.name != add_element:
db_element.name = add_element db_element.name = add_element
# add element to book return db_element
changed = True
db_book_object.append(db_element)
# Modifies different Database objects, first check if elements if elements have to be deleted,
# because they are no longer used, than check if elements have to be added to database
def modify_database_object(input_elements, db_book_object, db_object, db_session, db_type):
# passing input_elements not as a list may lead to undesired results
if not isinstance(input_elements, list):
raise TypeError(str(input_elements) + " should be passed as a list")
input_elements = [x for x in input_elements if x != '']
# we have all input element (authors, series, tags) names now
# 1. search for elements to remove
del_elements = search_objects_remove(db_book_object, db_type, input_elements)
# 2. search for elements that need to be added
add_elements = search_objects_add(db_book_object, db_type, input_elements)
# if there are elements to remove, we remove them now
changed = remove_objects(db_book_object, db_session, del_elements)
# if there are elements to add, we add them now!
if len(add_elements) > 0:
changed |= add_objects(db_book_object, db_object, db_session, db_type, add_elements)
return changed return changed
@@ -202,40 +229,17 @@ def modify_identifiers(input_identifiers, db_identifiers, db_session):
@editbook.route("/ajax/delete/<int:book_id>") @editbook.route("/ajax/delete/<int:book_id>")
@login_required @login_required
def delete_book_from_details(book_id): def delete_book_from_details(book_id):
return Response(delete_book(book_id,"", True), mimetype='application/json') return Response(delete_book(book_id, "", True), mimetype='application/json')
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}) @editbook.route("/delete/<int:book_id>", defaults={'book_format': ""})
@editbook.route("/delete/<int:book_id>/<string:book_format>") @editbook.route("/delete/<int:book_id>/<string:book_format>")
@login_required @login_required
def delete_book_ajax(book_id, book_format): def delete_book_ajax(book_id, book_format):
return delete_book(book_id,book_format, False) return delete_book(book_id, book_format, False)
def delete_book(book_id, book_format, jsonResponse):
warning = {} def delete_whole_book(book_id, book):
if current_user.role_delete_books():
book = calibre_db.get_book(book_id)
if book:
try:
result, error = helper.delete_book(book, config.config_calibre_dir, book_format=book_format.upper())
if not result:
if jsonResponse:
return json.dumps({"location": url_for("editbook.edit_book"),
"type": "alert",
"format": "",
"error": error}),
else:
flash(error, category="error")
return redirect(url_for('editbook.edit_book', book_id=book_id))
if error:
if jsonResponse:
warning = {"location": url_for("editbook.edit_book"),
"type": "warning",
"format": "",
"error": error}
else:
flash(error, category="warning")
if not book_format:
# delete book from Shelfs, Downloads, Read list # delete book from Shelfs, Downloads, Read list
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book_id).delete() ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book_id).delete()
ub.session.query(ub.ReadBook).filter(ub.ReadBook.book_id == book_id).delete() ub.session.query(ub.ReadBook).filter(ub.ReadBook.book_id == book_id).delete()
@@ -250,7 +254,7 @@ def delete_book(book_id, book_format, jsonResponse):
modify_database_object([u''], book.languages, db.Languages, calibre_db.session, 'languages') modify_database_object([u''], book.languages, db.Languages, calibre_db.session, 'languages')
modify_database_object([u''], book.publishers, db.Publishers, calibre_db.session, 'publishers') modify_database_object([u''], book.publishers, db.Publishers, calibre_db.session, 'publishers')
cc = calibre_db.session.query(db.Custom_Columns).\ cc = calibre_db.session.query(db.Custom_Columns). \
filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all() filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all()
for c in cc: for c in cc:
cc_string = "custom_column_" + str(c.id) cc_string = "custom_column_" + str(c.id)
@@ -279,16 +283,9 @@ def delete_book(book_id, book_format, jsonResponse):
modify_database_object([u''], getattr(book, cc_string), db.cc_classes[c.id], modify_database_object([u''], getattr(book, cc_string), db.cc_classes[c.id],
calibre_db.session, 'custom') calibre_db.session, 'custom')
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()
else:
calibre_db.session.query(db.Data).filter(db.Data.book == book.id).\
filter(db.Data.format == book_format).delete() def render_delete_book_result(book_format, jsonResponse, warning, book_id):
calibre_db.session.commit()
except Exception as e:
log.debug_or_exception(e)
calibre_db.session.rollback()
else:
# book not found
log.error('Book with id "%s" could not be deleted: not found', book_id)
if book_format: if book_format:
if jsonResponse: if jsonResponse:
return json.dumps([warning, {"location": url_for("editbook.edit_book", book_id=book_id), return json.dumps([warning, {"location": url_for("editbook.edit_book", book_id=book_id),
@@ -309,11 +306,59 @@ def delete_book(book_id, book_format, jsonResponse):
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
def delete_book(book_id, book_format, jsonResponse):
warning = {}
if current_user.role_delete_books():
book = calibre_db.get_book(book_id)
if book:
try:
result, error = helper.delete_book(book, config.config_calibre_dir, book_format=book_format.upper())
if not result:
if jsonResponse:
return json.dumps([{"location": url_for("editbook.edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": error}])
else:
flash(error, category="error")
return redirect(url_for('editbook.edit_book', book_id=book_id))
if error:
if jsonResponse:
warning = {"location": url_for("editbook.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()
calibre_db.session.commit()
except Exception as ex:
log.debug_or_exception(ex)
calibre_db.session.rollback()
if jsonResponse:
return json.dumps([{"location": url_for("editbook.edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": ex}])
else:
flash(str(ex), category="error")
return redirect(url_for('editbook.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, jsonResponse, warning, book_id)
def render_edit_book(book_id): def render_edit_book(book_id):
cc = calibre_db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all() cc = calibre_db.session.query(db.Custom_Columns).filter(db.Custom_Columns.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)
if not book: if not book:
flash(_(u"Error opening eBook. File does not exist or file is not accessible"), category="error") flash(_(u"Oops! Selected book title is unavailable. File does not exist or is not accessible"), category="error")
return redirect(url_for("web.index")) return redirect(url_for("web.index"))
for lang in book.languages: for lang in book.languages:
@@ -394,6 +439,9 @@ def edit_book_series_index(series_index, book):
# Add default series_index to book # Add default series_index to book
modif_date = False modif_date = False
series_index = series_index or '1' series_index = series_index or '1'
if not series_index.replace('.', '', 1).isdigit():
flash(_("%(seriesindex)s is not a valid number, skipping", seriesindex=series_index), category="warning")
return False
if book.series_index != series_index: if book.series_index != series_index:
book.series_index = series_index book.series_index = series_index
modif_date = True modif_date = True
@@ -413,7 +461,7 @@ def edit_book_comments(comments, book):
return modif_date return modif_date
def edit_book_languages(languages, book, upload=False): def edit_book_languages(languages, book, upload=False, invalid=None):
input_languages = languages.split(',') input_languages = languages.split(',')
unknown_languages = [] unknown_languages = []
if not upload: if not upload:
@@ -422,6 +470,9 @@ def edit_book_languages(languages, book, upload=False):
input_l = isoLanguages.get_valid_language_codes(get_locale(), input_languages, unknown_languages) input_l = isoLanguages.get_valid_language_codes(get_locale(), input_languages, unknown_languages)
for l in unknown_languages: for l in unknown_languages:
log.error('%s is not a valid language', l) log.error('%s is not a valid language', l)
if isinstance(invalid, list):
invalid.append(l)
else:
flash(_(u"%(langname)s is not a valid language", langname=l), category="warning") flash(_(u"%(langname)s is not a valid language", langname=l), category="warning")
# ToDo: Not working correct # ToDo: Not working correct
if upload and len(input_l) == 1: if upload and len(input_l) == 1:
@@ -435,10 +486,10 @@ def edit_book_languages(languages, book, upload=False):
return modify_database_object(input_l, book.languages, db.Languages, calibre_db.session, 'languages') return modify_database_object(input_l, book.languages, db.Languages, calibre_db.session, 'languages')
def edit_book_publisher(to_save, book): def edit_book_publisher(publishers, book):
changed = False changed = False
if to_save["publisher"]: if publishers:
publisher = to_save["publisher"].rstrip().strip() publisher = publishers.rstrip().strip()
if len(book.publishers) == 0 or (len(book.publishers) > 0 and publisher != book.publishers[0].name): if len(book.publishers) == 0 or (len(book.publishers) > 0 and publisher != book.publishers[0].name):
changed |= modify_database_object([publisher], book.publishers, db.Publishers, calibre_db.session, changed |= modify_database_object([publisher], book.publishers, db.Publishers, calibre_db.session,
'publisher') 'publisher')
@@ -447,22 +498,19 @@ def edit_book_publisher(to_save, book):
return changed return changed
def edit_cc_data(book_id, book, to_save): def edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string):
changed = False changed = False
cc = calibre_db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all()
for c in cc:
cc_string = "custom_column_" + str(c.id)
if not c.is_multiple:
if len(getattr(book, cc_string)) > 0:
cc_db_value = getattr(book, cc_string)[0].value
else:
cc_db_value = None
if to_save[cc_string].strip():
if c.datatype == 'int' or c.datatype == 'bool' or c.datatype == 'float':
if to_save[cc_string] == 'None': if to_save[cc_string] == 'None':
to_save[cc_string] = None to_save[cc_string] = None
elif c.datatype == 'bool': elif c.datatype == 'bool':
to_save[cc_string] = 1 if to_save[cc_string] == 'True' else 0 to_save[cc_string] = 1 if to_save[cc_string] == 'True' else 0
elif c.datatype == 'comments':
to_save[cc_string] = Markup(to_save[cc_string]).unescape()
elif c.datatype == 'datetime':
try:
to_save[cc_string] = datetime.strptime(to_save[cc_string], "%Y-%m-%d")
except ValueError:
to_save[cc_string] = db.Books.DEFAULT_PUBDATE
if to_save[cc_string] != cc_db_value: if to_save[cc_string] != cc_db_value:
if cc_db_value is not None: if cc_db_value is not None:
@@ -479,8 +527,11 @@ def edit_cc_data(book_id, book, to_save):
new_cc = cc_class(value=to_save[cc_string], book=book_id) new_cc = cc_class(value=to_save[cc_string], book=book_id)
calibre_db.session.add(new_cc) calibre_db.session.add(new_cc)
changed = True changed = True
return changed, to_save
else:
def edit_cc_data_string(book, c, to_save, cc_db_value, cc_string):
changed = False
if c.datatype == 'rating': if c.datatype == 'rating':
to_save[cc_string] = str(int(float(to_save[cc_string]) * 2)) to_save[cc_string] = str(int(float(to_save[cc_string]) * 2))
if to_save[cc_string].strip() != cc_db_value: if to_save[cc_string].strip() != cc_db_value:
@@ -504,6 +555,24 @@ def edit_cc_data(book_id, book, to_save):
cc_class.value == to_save[cc_string].strip()).first() cc_class.value == to_save[cc_string].strip()).first()
# add cc value to book # add cc value to book
getattr(book, cc_string).append(new_cc) getattr(book, cc_string).append(new_cc)
return changed, to_save
def edit_cc_data(book_id, book, to_save):
changed = False
cc = calibre_db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all()
for c in cc:
cc_string = "custom_column_" + str(c.id)
if not c.is_multiple:
if len(getattr(book, cc_string)) > 0:
cc_db_value = getattr(book, cc_string)[0].value
else:
cc_db_value = None
if to_save[cc_string].strip():
if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]:
changed, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string)
else:
changed, to_save = edit_cc_data_string(book, c, to_save, cc_db_value, cc_string)
else: else:
if cc_db_value is not None: if cc_db_value is not None:
# remove old cc_val # remove old cc_val
@@ -577,7 +646,7 @@ def upload_single_file(request, book, book_id):
# Queue uploader info # Queue uploader info
uploadText=_(u"File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=book.title) uploadText=_(u"File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=book.title)
WorkerThread.add(current_user.nickname, TaskUpload( WorkerThread.add(current_user.name, TaskUpload(
"<a href=\"" + url_for('web.show_book', book_id=book.id) + "\">" + uploadText + "</a>")) "<a href=\"" + url_for('web.show_book', book_id=book.id) + "\">" + uploadText + "</a>"))
return uploader.process( return uploader.process(
@@ -601,6 +670,46 @@ def upload_cover(request, book):
return None return None
def handle_title_on_edit(book, book_title):
# handle book title
book_title = book_title.rstrip().strip()
if book.title != book_title:
if book_title == '':
book_title = _(u'Unknown')
book.title = book_title
return True
return False
def handle_author_on_edit(book, author_name, update_stored=True):
# handle author(s)
input_authors = author_name.split('&')
input_authors = list(map(lambda it: it.strip().replace(',', '|'), input_authors))
# Remove duplicates in authors list
input_authors = helper.uniq(input_authors)
# we have all author names now
if input_authors == ['']:
input_authors = [_(u'Unknown')] # prevent empty Author
change = modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
# Search for each author if author is in database, if not, author name and sorted author name is generated new
# everything then is assembled for sorted author field in database
sort_authors_list = list()
for inp in input_authors:
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
if not stored_author:
stored_author = helper.get_sorted_author(inp)
else:
stored_author = stored_author.sort
sort_authors_list.append(helper.get_sorted_author(stored_author))
sort_authors = ' & '.join(sort_authors_list)
if book.author_sort != sort_authors and update_stored:
book.author_sort = sort_authors
change = True
return input_authors, change
@editbook.route("/admin/book/<int:book_id>", methods=['GET', 'POST']) @editbook.route("/admin/book/<int:book_id>", methods=['GET', 'POST'])
@login_required_if_no_ano @login_required_if_no_ano
@edit_required @edit_required
@@ -618,12 +727,11 @@ def edit_book(book_id):
if request.method != 'POST': if request.method != 'POST':
return render_edit_book(book_id) return render_edit_book(book_id)
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
# Book not found # Book not found
if not book: if not book:
flash(_(u"Error opening eBook. File does not exist or file is not accessible"), category="error") flash(_(u"Oops! Selected book title is unavailable. File does not exist or is not accessible"), category="error")
return redirect(url_for("web.index")) return redirect(url_for("web.index"))
meta = upload_single_file(request, book, book_id) meta = upload_single_file(request, book, book_id)
@@ -636,41 +744,14 @@ def edit_book(book_id):
# Update book # Update book
edited_books_id = None edited_books_id = None
#handle book title # handle book title
if book.title != to_save["book_title"].rstrip().strip(): title_change = handle_title_on_edit(book, to_save["book_title"])
if to_save["book_title"] == '':
to_save["book_title"] = _(u'Unknown') input_authors, authorchange = handle_author_on_edit(book, to_save["author_name"])
book.title = to_save["book_title"].rstrip().strip() if authorchange or title_change:
edited_books_id = book.id edited_books_id = book.id
modif_date = True modif_date = True
# handle author(s)
input_authors = to_save["author_name"].split('&')
input_authors = list(map(lambda it: it.strip().replace(',', '|'), input_authors))
# Remove duplicates in authors list
input_authors = helper.uniq(input_authors)
# we have all author names now
if input_authors == ['']:
input_authors = [_(u'Unknown')] # prevent empty Author
modif_date |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
# Search for each author if author is in database, if not, authorname and sorted authorname is generated new
# everything then is assembled for sorted author field in database
sort_authors_list = list()
for inp in input_authors:
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
if not stored_author:
stored_author = helper.get_sorted_author(inp)
else:
stored_author = stored_author.sort
sort_authors_list.append(helper.get_sorted_author(stored_author))
sort_authors = ' & '.join(sort_authors_list)
if book.author_sort != sort_authors:
edited_books_id = book.id
book.author_sort = sort_authors
modif_date = True
if config.config_use_google_drive: if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal() gdriveutils.updateGdriveCalibreFromLocal()
@@ -695,10 +776,8 @@ def edit_book(book_id):
# Add default series_index to book # Add default series_index to book
modif_date |= edit_book_series_index(to_save["series_index"], book) modif_date |= edit_book_series_index(to_save["series_index"], book)
# Handle book comments/description # Handle book comments/description
modif_date |= edit_book_comments(to_save["description"], book) modif_date |= edit_book_comments(Markup(to_save['description']).unescape(), book)
# Handle identifiers # Handle identifiers
input_identifiers = identifier_list(to_save, book) input_identifiers = identifier_list(to_save, book)
modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session) modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session)
@@ -707,9 +786,16 @@ def edit_book(book_id):
modif_date |= modification modif_date |= modification
# Handle book tags # Handle book tags
modif_date |= edit_book_tags(to_save['tags'], book) modif_date |= edit_book_tags(to_save['tags'], book)
# Handle book series # Handle book series
modif_date |= edit_book_series(to_save["series"], book) modif_date |= edit_book_series(to_save["series"], book)
# handle book publisher
modif_date |= edit_book_publisher(to_save['publisher'], book)
# handle book languages
modif_date |= edit_book_languages(to_save['languages'], book)
# handle book ratings
modif_date |= edit_book_ratings(to_save, book)
# handle cc data
modif_date |= edit_cc_data(book_id, book, to_save)
if to_save["pubdate"]: if to_save["pubdate"]:
try: try:
@@ -719,18 +805,6 @@ def edit_book(book_id):
else: else:
book.pubdate = db.Books.DEFAULT_PUBDATE book.pubdate = db.Books.DEFAULT_PUBDATE
# handle book publisher
modif_date |= edit_book_publisher(to_save, book)
# handle book languages
modif_date |= edit_book_languages(to_save['languages'], book)
# handle book ratings
modif_date |= edit_book_ratings(to_save, book)
# handle cc data
modif_date |= edit_cc_data(book_id, book, to_save)
if modif_date: if modif_date:
book.last_modified = datetime.utcnow() book.last_modified = datetime.utcnow()
calibre_db.session.merge(book) calibre_db.session.merge(book)
@@ -746,8 +820,8 @@ def edit_book(book_id):
calibre_db.session.rollback() calibre_db.session.rollback()
flash(error, category="error") flash(error, category="error")
return render_edit_book(book_id) return render_edit_book(book_id)
except Exception as e: except Exception as ex:
log.debug_or_exception(e) log.debug_or_exception(ex)
calibre_db.session.rollback() calibre_db.session.rollback()
flash(_("Error editing book, please check logfile for details"), category="error") flash(_("Error editing book, please check logfile for details"), category="error")
return redirect(url_for('web.show_book', book_id=book.id)) return redirect(url_for('web.show_book', book_id=book.id))
@@ -766,6 +840,7 @@ def merge_metadata(to_save, meta):
to_save["description"] = to_save["description"] or Markup( to_save["description"] = to_save["description"] or Markup(
getattr(meta, 'description', '')).unescape() getattr(meta, 'description', '')).unescape()
def identifier_list(to_save, book): def identifier_list(to_save, book):
"""Generate a list of Identifiers from form information""" """Generate a list of Identifiers from form information"""
id_type_prefix = 'identifier-type-' id_type_prefix = 'identifier-type-'
@@ -780,43 +855,8 @@ def identifier_list(to_save, book):
result.append(db.Identifiers(to_save[val_key], type_value, book.id)) result.append(db.Identifiers(to_save[val_key], type_value, book.id))
return result return result
@editbook.route("/upload", methods=["GET", "POST"])
@login_required_if_no_ano
@upload_required
def upload():
if not config.config_uploading:
abort(404)
if request.method == 'POST' and 'btn-upload' in request.files:
for requested_file in request.files.getlist("btn-upload"):
try:
modif_date = False
# create the function for sorting...
calibre_db.update_title_sort(config)
calibre_db.session.connection().connection.connection.create_function('uuid4', 0, lambda: str(uuid4()))
# check if file extension is correct
if '.' in requested_file.filename:
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
if file_ext not in constants.EXTENSIONS_UPLOAD and '' not in constants.EXTENSIONS_UPLOAD:
flash(
_("File extension '%(ext)s' is not allowed to be uploaded to this server",
ext=file_ext), category="error")
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
else:
flash(_('File to be uploaded must have an extension'), category="error")
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
# extract metadata from file
try:
meta = uploader.upload(requested_file, config.config_rarfile_location)
except (IOError, OSError):
log.error("File %s could not saved to temp dir", requested_file.filename)
flash(_(u"File %(filename)s could not saved to temp dir",
filename= requested_file.filename), category="error")
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
title = meta.title
authr = meta.author
def prepare_authors_on_upload(title, authr):
if title != _(u'Unknown') and authr != _(u'Unknown'): if title != _(u'Unknown') and authr != _(u'Unknown'):
entry = calibre_db.check_exists_book(authr, title) entry = calibre_db.check_exists_book(authr, title)
if entry: if entry:
@@ -835,7 +875,7 @@ def upload():
if input_authors == ['']: if input_authors == ['']:
input_authors = [_(u'Unknown')] # prevent empty Author input_authors = [_(u'Unknown')] # prevent empty Author
sort_authors_list=list() sort_authors_list = list()
db_author = None db_author = None
for inp in input_authors: for inp in input_authors:
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first() stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
@@ -851,12 +891,20 @@ def upload():
sort_author = stored_author.sort sort_author = stored_author.sort
sort_authors_list.append(sort_author) sort_authors_list.append(sort_author)
sort_authors = ' & '.join(sort_authors_list) sort_authors = ' & '.join(sort_authors_list)
return sort_authors, input_authors, db_author
def create_book_on_upload(modif_date, meta):
title = meta.title
authr = meta.author
sort_authors, input_authors, db_author = prepare_authors_on_upload(title, authr)
title_dir = helper.get_valid_filename(title) title_dir = helper.get_valid_filename(title)
author_dir = helper.get_valid_filename(db_author.name) author_dir = helper.get_valid_filename(db_author.name)
# combine path and normalize path from windows systems # combine path and normalize path from windows systems
path = os.path.join(author_dir, title_dir).replace('\\', '/') path = os.path.join(author_dir, title_dir).replace('\\', '/')
# Calibre adds books with utc as timezone # Calibre adds books with utc as timezone
db_book = db.Books(title, "", sort_authors, datetime.utcnow(), datetime(101, 1, 1), db_book = db.Books(title, "", sort_authors, datetime.utcnow(), datetime(101, 1, 1),
'1', datetime.utcnow(), path, meta.cover, db_author, [], "") '1', datetime.utcnow(), path, meta.cover, db_author, [], "")
@@ -873,6 +921,9 @@ def upload():
# handle tags # handle tags
modif_date |= edit_book_tags(meta.tags, db_book) modif_date |= edit_book_tags(meta.tags, db_book)
# handle publisher
modif_date |= edit_book_publisher(meta.publisher, db_book)
# handle series # handle series
modif_date |= edit_book_series(meta.series, db_book) modif_date |= edit_book_series(meta.series, db_book)
@@ -884,19 +935,33 @@ def upload():
# flush content, get db_book.id available # flush content, get db_book.id available
calibre_db.session.flush() calibre_db.session.flush()
return db_book, input_authors, title_dir
# Comments needs book id therfore only possible after flush def file_handling_on_upload(requested_file):
modif_date |= edit_book_comments(Markup(meta.description).unescape(), db_book) # check if file extension is correct
if '.' in requested_file.filename:
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
if file_ext not in constants.EXTENSIONS_UPLOAD and '' not in constants.EXTENSIONS_UPLOAD:
flash(
_("File extension '%(ext)s' is not allowed to be uploaded to this server",
ext=file_ext), category="error")
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
else:
flash(_('File to be uploaded must have an extension'), category="error")
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
book_id = db_book.id # extract metadata from file
title = db_book.title try:
meta = uploader.upload(requested_file, config.config_rarfile_location)
except (IOError, OSError):
log.error("File %s could not saved to temp dir", requested_file.filename)
flash(_(u"File %(filename)s could not saved to temp dir",
filename=requested_file.filename), category="error")
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
return meta, None
error = helper.update_dir_structure_file(book_id,
config.config_calibre_dir,
input_authors[0],
meta.file_path,
title_dir + meta.extension)
def move_coverfile(meta, db_book):
# move cover to final directory, including book id # move cover to final directory, including book id
if meta.cover: if meta.cover:
coverfile = meta.cover coverfile = meta.cover
@@ -913,6 +978,41 @@ def upload():
error=e), error=e),
category="error") category="error")
@editbook.route("/upload", methods=["GET", "POST"])
@login_required_if_no_ano
@upload_required
def upload():
if not config.config_uploading:
abort(404)
if request.method == 'POST' and 'btn-upload' in request.files:
for requested_file in request.files.getlist("btn-upload"):
try:
modif_date = False
# create the function for sorting...
calibre_db.update_title_sort(config)
calibre_db.session.connection().connection.connection.create_function('uuid4', 0, lambda: str(uuid4()))
meta, error = file_handling_on_upload(requested_file)
if error:
return error
db_book, input_authors, title_dir = create_book_on_upload(modif_date, meta)
# Comments needs book id therefore only possible after flush
modif_date |= edit_book_comments(Markup(meta.description).unescape(), db_book)
book_id = db_book.id
title = db_book.title
error = helper.update_dir_structure_file(book_id,
config.config_calibre_dir,
input_authors[0],
meta.file_path,
title_dir + meta.extension)
move_coverfile(meta, db_book)
# save data to database, reread data # save data to database, reread data
calibre_db.session.commit() calibre_db.session.commit()
@@ -921,7 +1021,7 @@ def upload():
if error: if error:
flash(error, category="error") flash(error, category="error")
uploadText=_(u"File %(file)s uploaded", file=title) uploadText=_(u"File %(file)s uploaded", file=title)
WorkerThread.add(current_user.nickname, TaskUpload( WorkerThread.add(current_user.name, TaskUpload(
"<a href=\"" + url_for('web.show_book', book_id=book_id) + "\">" + uploadText + "</a>")) "<a href=\"" + url_for('web.show_book', book_id=book_id) + "\">" + uploadText + "</a>"))
if len(request.files.getlist("btn-upload")) < 2: if len(request.files.getlist("btn-upload")) < 2:
@@ -951,7 +1051,7 @@ def convert_bookformat(book_id):
log.info('converting: book id: %s from: %s to: %s', book_id, book_format_from, book_format_to) log.info('converting: book id: %s from: %s to: %s', book_id, book_format_from, book_format_to)
rtn = helper.convert_book_format(book_id, config.config_calibre_dir, book_format_from.upper(), rtn = helper.convert_book_format(book_id, config.config_calibre_dir, book_format_from.upper(),
book_format_to.upper(), current_user.nickname) book_format_to.upper(), current_user.name)
if rtn is None: if rtn is None:
flash(_(u"Book successfully queued for converting to %(book_format)s", flash(_(u"Book successfully queued for converting to %(book_format)s",
@@ -961,61 +1061,89 @@ def convert_bookformat(book_id):
flash(_(u"There was an error converting this book: %(res)s", res=rtn), category="error") flash(_(u"There was an error converting this book: %(res)s", res=rtn), category="error")
return redirect(url_for('editbook.edit_book', book_id=book_id)) return redirect(url_for('editbook.edit_book', book_id=book_id))
@editbook.route("/ajax/editbooks/<param>", methods=['POST']) @editbook.route("/ajax/editbooks/<param>", methods=['POST'])
@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.form.to_dict()
book = calibre_db.get_book(vals['pk']) book = calibre_db.get_book(vals['pk'])
ret = ""
if param =='series_index': if param =='series_index':
edit_book_series_index(vals['value'], book) edit_book_series_index(vals['value'], book)
ret = Response(json.dumps({'success': True, 'newValue': book.series_index}), mimetype='application/json')
elif param =='tags': elif param =='tags':
edit_book_tags(vals['value'], book) edit_book_tags(vals['value'], book)
ret = Response(json.dumps({'success': True, 'newValue': ', '.join([tag.name for tag in book.tags])}),
mimetype='application/json')
elif param =='series': elif param =='series':
edit_book_series(vals['value'], book) edit_book_series(vals['value'], book)
ret = Response(json.dumps({'success': True, 'newValue': ', '.join([serie.name for serie in book.series])}),
mimetype='application/json')
elif param =='publishers': elif param =='publishers':
vals['publisher'] = vals['value'] edit_book_publisher(vals['value'], book)
edit_book_publisher(vals, book) ret = Response(json.dumps({'success': True,
'newValue': ', '.join([publisher.name for publisher in book.publishers])}),
mimetype='application/json')
elif param =='languages': elif param =='languages':
edit_book_languages(vals['value'], book) invalid = list()
edit_book_languages(vals['value'], book, invalid=invalid)
if invalid:
ret = Response(json.dumps({'success': False,
'msg': 'Invalid languages in request: {}'.format(','.join(invalid))}),
mimetype='application/json')
else:
lang_names = list()
for lang in book.languages:
try:
lang_names.append(LC.parse(lang.lang_code).get_language_name(get_locale()))
except UnknownLocaleError:
lang_names.append(_(isoLanguages.get(part3=lang.lang_code).name))
ret = Response(json.dumps({'success': True, 'newValue': ', '.join(lang_names)}),
mimetype='application/json')
elif param =='author_sort': elif param =='author_sort':
book.author_sort = vals['value'] book.author_sort = vals['value']
elif param =='title': ret = Response(json.dumps({'success': True, 'newValue': book.author_sort}),
book.title = vals['value'] mimetype='application/json')
elif param == 'title':
sort = book.sort
handle_title_on_edit(book, vals.get('value', ""))
helper.update_dir_stucture(book.id, config.config_calibre_dir) helper.update_dir_stucture(book.id, config.config_calibre_dir)
ret = Response(json.dumps({'success': True, 'newValue': book.title}),
mimetype='application/json')
elif param =='sort': elif param =='sort':
book.sort = vals['value'] book.sort = vals['value']
# ToDo: edit books ret = Response(json.dumps({'success': True, 'newValue': book.sort}),
mimetype='application/json')
elif param =='authors': elif param =='authors':
input_authors = vals['value'].split('&') input_authors, __ = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true")
input_authors = list(map(lambda it: it.strip().replace(',', '|'), input_authors))
modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
sort_authors_list = list()
for inp in input_authors:
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
if not stored_author:
stored_author = helper.get_sorted_author(inp)
else:
stored_author = stored_author.sort
sort_authors_list.append(helper.get_sorted_author(stored_author))
sort_authors = ' & '.join(sort_authors_list)
if book.author_sort != sort_authors:
book.author_sort = sort_authors
helper.update_dir_stucture(book.id, config.config_calibre_dir, input_authors[0]) helper.update_dir_stucture(book.id, config.config_calibre_dir, input_authors[0])
ret = Response(json.dumps({'success': True,
'newValue': ' & '.join([author.replace('|',',') for author in input_authors])}),
mimetype='application/json')
book.last_modified = datetime.utcnow() book.last_modified = datetime.utcnow()
calibre_db.session.commit() calibre_db.session.commit()
return "" # revert change for sort if automatic fields link is deactivated
if param == 'title' and vals.get('checkT') == "false":
book.sort = sort
calibre_db.session.commit()
return ret
@editbook.route("/ajax/sort_value/<field>/<int:bookid>") @editbook.route("/ajax/sort_value/<field>/<int:bookid>")
@login_required @login_required
def get_sorted_entry(field, bookid): def get_sorted_entry(field, bookid):
if field == 'title' or field == 'authors': if field in ['title', 'authors', 'sort', 'author_sort']:
book = calibre_db.get_filtered_book(bookid) book = calibre_db.get_filtered_book(bookid)
if book: if book:
if field == 'title': if field == 'title':
return json.dumps({'sort': book.sort}) return json.dumps({'sort': book.sort})
elif field == 'authors': elif field == 'authors':
return json.dumps({'author_sort': book.author_sort}) return json.dumps({'author_sort': book.author_sort})
if field == 'sort':
return json.dumps({'sort': book.title})
if field == 'author_sort':
return json.dumps({'author_sort': book.author})
return "" return ""
@@ -1067,6 +1195,6 @@ def merge_list_book():
element.format, element.format,
element.uncompressed_size, element.uncompressed_size,
to_name)) to_name))
delete_book(from_book.id,"", True) # json_resp = delete_book(from_book.id,"", True)
return json.dumps({'success': True}) return json.dumps({'success': True})
return "" return ""
+33 -24
View File
@@ -87,18 +87,29 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
lang = epub_metadata['language'].split('-', 1)[0].lower() lang = epub_metadata['language'].split('-', 1)[0].lower()
epub_metadata['language'] = isoLanguages.get_lang3(lang) epub_metadata['language'] = isoLanguages.get_lang3(lang)
series = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series']/@content", namespaces=ns) epub_metadata = parse_epbub_series(ns, tree, epub_metadata)
if len(series) > 0:
epub_metadata['series'] = series[0]
else:
epub_metadata['series'] = ''
series_id = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series_index']/@content", namespaces=ns) coverfile = parse_ebpub_cover(ns, tree, epubZip, coverpath, tmp_file_path)
if len(series_id) > 0:
epub_metadata['series_id'] = series_id[0]
else:
epub_metadata['series_id'] = '1'
if not epub_metadata['title']:
title = original_file_name
else:
title = epub_metadata['title']
return BookMeta(
file_path=tmp_file_path,
extension=original_file_extension,
title=title.encode('utf-8').decode('utf-8'),
author=epub_metadata['creator'].encode('utf-8').decode('utf-8'),
cover=coverfile,
description=epub_metadata['description'],
tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'),
series=epub_metadata['series'].encode('utf-8').decode('utf-8'),
series_id=epub_metadata['series_id'].encode('utf-8').decode('utf-8'),
languages=epub_metadata['language'],
publisher="")
def parse_ebpub_cover(ns, tree, epubZip, coverpath, tmp_file_path):
coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns) coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns)
coverfile = None coverfile = None
if len(coversection) > 0: if len(coversection) > 0:
@@ -126,20 +137,18 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
coverfile = extractCover(epubZip, filename, "", tmp_file_path) coverfile = extractCover(epubZip, filename, "", tmp_file_path)
else: else:
coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path) coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path)
return coverfile
if not epub_metadata['title']: def parse_epbub_series(ns, tree, epub_metadata):
title = original_file_name series = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series']/@content", namespaces=ns)
if len(series) > 0:
epub_metadata['series'] = series[0]
else: else:
title = epub_metadata['title'] epub_metadata['series'] = ''
return BookMeta( series_id = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series_index']/@content", namespaces=ns)
file_path=tmp_file_path, if len(series_id) > 0:
extension=original_file_extension, epub_metadata['series_id'] = series_id[0]
title=title.encode('utf-8').decode('utf-8'), else:
author=epub_metadata['creator'].encode('utf-8').decode('utf-8'), epub_metadata['series_id'] = '1'
cover=coverfile, return epub_metadata
description=epub_metadata['description'],
tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'),
series=epub_metadata['series'].encode('utf-8').decode('utf-8'),
series_id=epub_metadata['series_id'].encode('utf-8').decode('utf-8'),
languages=epub_metadata['language'])
+1 -7
View File
@@ -60,14 +60,8 @@ def init_errorhandler():
if services.ldap: if services.ldap:
# Only way of catching the LDAPException upon logging in with LDAP server down # Only way of catching the LDAPException upon logging in with LDAP server down
@app.errorhandler(services.ldap.LDAPException) @app.errorhandler(services.ldap.LDAPException)
# pylint: disable=unused-variable
def handle_exception(e): def handle_exception(e):
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(InvalidRequestError)
#@app.errorhandler(OperationalError)
#def handle_db_exception(e):
# db.session.rollback()
# log.error('Database request error: %s',e)
# return internal_error(InternalServerError(e))
+15 -14
View File
@@ -29,52 +29,53 @@ def get_fb2_info(tmp_file_path, original_file_extension):
'l': 'http://www.w3.org/1999/xlink', 'l': 'http://www.w3.org/1999/xlink',
} }
fb2_file = open(tmp_file_path) fb2_file = open(tmp_file_path, encoding="utf-8")
tree = etree.fromstring(fb2_file.read()) tree = etree.fromstring(fb2_file.read().encode())
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)
def get_author(element): def get_author(element):
last_name = element.xpath('fb:last-name/text()', namespaces=ns) last_name = element.xpath('fb:last-name/text()', namespaces=ns)
if len(last_name): if len(last_name):
last_name = last_name[0].encode('utf-8') last_name = last_name[0]
else: else:
last_name = u'' last_name = u''
middle_name = element.xpath('fb:middle-name/text()', namespaces=ns) middle_name = element.xpath('fb:middle-name/text()', namespaces=ns)
if len(middle_name): if len(middle_name):
middle_name = middle_name[0].encode('utf-8') middle_name = middle_name[0]
else: else:
middle_name = u'' middle_name = u''
first_name = element.xpath('fb:first-name/text()', namespaces=ns) first_name = element.xpath('fb:first-name/text()', namespaces=ns)
if len(first_name): if len(first_name):
first_name = first_name[0].encode('utf-8') first_name = first_name[0]
else: else:
first_name = u'' first_name = u''
return (first_name.decode('utf-8') + u' ' return (first_name + u' '
+ middle_name.decode('utf-8') + u' ' + middle_name + u' '
+ last_name.decode('utf-8')).encode('utf-8') + last_name)
author = str(", ".join(map(get_author, authors))) author = str(", ".join(map(get_author, authors)))
title = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:book-title/text()', namespaces=ns) title = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:book-title/text()', namespaces=ns)
if len(title): if len(title):
title = str(title[0].encode('utf-8')) title = str(title[0])
else: else:
title = u'' title = u''
description = tree.xpath('/fb:FictionBook/fb:description/fb:publish-info/fb:book-name/text()', namespaces=ns) description = tree.xpath('/fb:FictionBook/fb:description/fb:publish-info/fb:book-name/text()', namespaces=ns)
if len(description): if len(description):
description = str(description[0].encode('utf-8')) description = str(description[0])
else: else:
description = u'' description = u''
return BookMeta( return BookMeta(
file_path=tmp_file_path, file_path=tmp_file_path,
extension=original_file_extension, extension=original_file_extension,
title=title.decode('utf-8'), title=title,
author=author.decode('utf-8'), author=author,
cover=None, cover=None,
description=description.decode('utf-8'), description=description,
tags="", tags="",
series="", series="",
series_id="", series_id="",
languages="") languages="",
publisher="")
+4 -4
View File
@@ -47,7 +47,7 @@ except ImportError as err:
current_milli_time = lambda: int(round(time() * 1000)) current_milli_time = lambda: int(round(time() * 1000))
gdrive_watch_callback_token = 'target=calibreweb-watch_files' gdrive_watch_callback_token = 'target=calibreweb-watch_files' #nosec
@gdrive.route("/authenticate") @gdrive.route("/authenticate")
@@ -142,7 +142,7 @@ def on_received_watch_confirmation():
else: else:
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): and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec
tmp_dir = os.path.join(tempfile.gettempdir(), 'calibre_web') tmp_dir = os.path.join(tempfile.gettempdir(), 'calibre_web')
if not os.path.isdir(tmp_dir): if not os.path.isdir(tmp_dir):
os.mkdir(tmp_dir) os.mkdir(tmp_dir)
@@ -155,6 +155,6 @@ def on_received_watch_confirmation():
# prevent error on windows, as os.rename does on existing files, also allow cross hdd move # prevent error on windows, as os.rename does on existing files, also allow cross hdd move
move(os.path.join(tmp_dir, "tmp_metadata.db"), dbpath) move(os.path.join(tmp_dir, "tmp_metadata.db"), dbpath)
calibre_db.reconnect_db(config, ub.app_DB_path) calibre_db.reconnect_db(config, ub.app_DB_path)
except Exception as e: except Exception as ex:
log.debug_or_exception(e) log.debug_or_exception(ex)
return '' return ''
+39 -9
View File
@@ -28,7 +28,11 @@ from sqlalchemy import create_engine
from sqlalchemy import Column, UniqueConstraint from sqlalchemy import Column, UniqueConstraint
from sqlalchemy import String, Integer from sqlalchemy import String, Integer
from sqlalchemy.orm import sessionmaker, scoped_session from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base try:
# Compatibility with sqlalchemy 2.0
from sqlalchemy.orm import declarative_base
except ImportError:
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.exc import OperationalError, InvalidRequestError from sqlalchemy.exc import OperationalError, InvalidRequestError
try: try:
@@ -198,8 +202,8 @@ def getDrive(drive=None, gauth=None):
gauth.Refresh() gauth.Refresh()
except RefreshError as e: except RefreshError as e:
log.error("Google Drive error: %s", e) log.error("Google Drive error: %s", e)
except Exception as e: except Exception as ex:
log.debug_or_exception(e) log.debug_or_exception(ex)
else: else:
# Initialize the saved creds # Initialize the saved creds
gauth.Authorize() gauth.Authorize()
@@ -217,7 +221,7 @@ def listRootFolders():
drive = getDrive(Gdrive.Instance().drive) drive = getDrive(Gdrive.Instance().drive)
folder = "'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false" folder = "'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
fileList = drive.ListFile({'q': folder}).GetList() fileList = drive.ListFile({'q': folder}).GetList()
except (ServerNotFoundError, ssl.SSLError) as e: except (ServerNotFoundError, ssl.SSLError, RefreshError) as e:
log.info("GDrive Error %s" % e) log.info("GDrive Error %s" % e)
fileList = [] fileList = []
return fileList return fileList
@@ -253,7 +257,12 @@ def getEbooksFolderId(drive=None):
log.error('Error gDrive, root ID not found') log.error('Error gDrive, root ID not found')
gDriveId.path = '/' gDriveId.path = '/'
session.merge(gDriveId) session.merge(gDriveId)
try:
session.commit() session.commit()
except OperationalError as ex:
log.error("gdrive.db DB is not Writeable")
log.debug('Database error: %s', ex)
session.rollback()
return gDriveId.gdrive_id return gDriveId.gdrive_id
@@ -268,6 +277,7 @@ def getFile(pathId, fileName, drive):
def getFolderId(path, drive): def getFolderId(path, drive):
# drive = getDrive(drive) # drive = getDrive(drive)
try:
currentFolderId = getEbooksFolderId(drive) currentFolderId = getEbooksFolderId(drive)
sqlCheckPath = path if path[-1] == '/' else path + '/' sqlCheckPath = path if path[-1] == '/' else path + '/'
storedPathName = session.query(GdriveId).filter(GdriveId.path == sqlCheckPath).first() storedPathName = session.query(GdriveId).filter(GdriveId.path == sqlCheckPath).first()
@@ -299,6 +309,10 @@ def getFolderId(path, drive):
session.commit() session.commit()
else: else:
currentFolderId = storedPathName.gdrive_id currentFolderId = storedPathName.gdrive_id
except OperationalError as ex:
log.error("gdrive.db DB is not Writeable")
log.debug('Database error: %s', ex)
session.rollback()
return currentFolderId return currentFolderId
@@ -342,7 +356,7 @@ def moveGdriveFolderRemote(origin_file, target_folder):
addParents=gFileTargetDir['id'], addParents=gFileTargetDir['id'],
removeParents=previous_parents, removeParents=previous_parents,
fields='id, parents').execute() fields='id, parents').execute()
# if previous_parents has no childs anymore, delete original fileparent # if previous_parents has no children anymore, delete original fileparent
if len(children['items']) == 1: if len(children['items']) == 1:
deleteDatabaseEntry(previous_parents) deleteDatabaseEntry(previous_parents)
drive.auth.service.files().delete(fileId=previous_parents).execute() drive.auth.service.files().delete(fileId=previous_parents).execute()
@@ -493,8 +507,8 @@ def getChangeById (drive, change_id):
except (errors.HttpError) as error: except (errors.HttpError) as error:
log.error(error) log.error(error)
return None return None
except Exception as e: except Exception as ex:
log.error(e) log.error(ex)
return None return None
@@ -503,9 +517,10 @@ def deleteDatabaseOnChange():
try: try:
session.query(GdriveId).delete() session.query(GdriveId).delete()
session.commit() session.commit()
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError) as ex:
session.rollback() session.rollback()
log.info(u"GDrive DB is not Writeable") log.debug('Database error: %s', ex)
log.error(u"GDrive DB is not Writeable")
def updateGdriveCalibreFromLocal(): def updateGdriveCalibreFromLocal():
@@ -520,13 +535,23 @@ def updateDatabaseOnEdit(ID,newPath):
storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first() storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first()
if storedPathName: if storedPathName:
storedPathName.path = sqlCheckPath storedPathName.path = sqlCheckPath
try:
session.commit() session.commit()
except OperationalError as ex:
log.error("gdrive.db DB is not Writeable")
log.debug('Database error: %s', ex)
session.rollback()
# Deletes the hashes in database of deleted book # Deletes the hashes in database of deleted book
def deleteDatabaseEntry(ID): def deleteDatabaseEntry(ID):
session.query(GdriveId).filter(GdriveId.gdrive_id == ID).delete() session.query(GdriveId).filter(GdriveId.gdrive_id == ID).delete()
try:
session.commit() session.commit()
except OperationalError as ex:
log.error("gdrive.db DB is not Writeable")
log.debug('Database error: %s', ex)
session.rollback()
# Gets cover file from gdrive # Gets cover file from gdrive
@@ -543,7 +568,12 @@ def get_cover_via_gdrive(cover_path):
permissionAdded = PermissionAdded() permissionAdded = PermissionAdded()
permissionAdded.gdrive_id = df['id'] permissionAdded.gdrive_id = df['id']
session.add(permissionAdded) session.add(permissionAdded)
try:
session.commit() session.commit()
except OperationalError as ex:
log.error("gdrive.db DB is not Writeable")
log.debug('Database error: %s', ex)
session.rollback()
return df.metadata.get('webContentLink') return df.metadata.get('webContentLink')
else: else:
return None return None
+78 -55
View File
@@ -35,7 +35,7 @@ from babel.units import format_unit
from flask import send_from_directory, make_response, redirect, abort, url_for from flask import send_from_directory, make_response, redirect, abort, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user from flask_login import current_user
from sqlalchemy.sql.expression import true, false, and_, text from sqlalchemy.sql.expression import true, false, and_, text, func
from werkzeug.datastructures import Headers from werkzeug.datastructures import Headers
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@@ -68,6 +68,7 @@ try:
except (ImportError, RuntimeError) as e: except (ImportError, RuntimeError) as e:
log.debug('Cannot import Image, generating covers from non jpg files will not work: %s', e) log.debug('Cannot import Image, generating covers from non jpg files will not work: %s', e)
use_IM = False use_IM = False
MissingDelegateError = BaseException
# Convert existing book entry to new format # Convert existing book entry to new format
@@ -133,53 +134,14 @@ def send_registration_mail(e_mail, user_name, default_password, resend=False):
taskMessage=_(u"Registration e-mail for user: %(name)s", name=user_name), taskMessage=_(u"Registration e-mail for user: %(name)s", name=user_name),
text=txt text=txt
)) ))
return return
def check_send_to_kindle(entry): def check_send_to_kindle_with_converter(formats):
"""
returns all available book formats for sending to Kindle
"""
if len(entry.data):
bookformats = list() bookformats = list()
if not config.config_converterpath:
# no converter - only for mobi and pdf formats
for ele in iter(entry.data):
if ele.uncompressed_size < config.mail_size:
if 'MOBI' in ele.format:
bookformats.append({'format': 'Mobi',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Mobi')})
if 'PDF' in ele.format:
bookformats.append({'format': 'Pdf',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Pdf')})
if 'AZW' in ele.format:
bookformats.append({'format': 'Azw',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Azw')})
else:
formats = list()
for ele in iter(entry.data):
if ele.uncompressed_size < config.mail_size:
formats.append(ele.format)
if 'MOBI' in formats:
bookformats.append({'format': 'Mobi',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Mobi')})
if 'AZW' in formats:
bookformats.append({'format': 'Azw',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Azw')})
if 'PDF' in formats:
bookformats.append({'format': 'Pdf',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Pdf')})
if config.config_converterpath:
if 'EPUB' in formats and 'MOBI' not in formats: if 'EPUB' in formats and 'MOBI' not in formats:
bookformats.append({'format': 'Mobi', bookformats.append({'format': 'Mobi',
'convert':1, 'convert': 1,
'text': _('Convert %(orig)s to %(format)s and send to Kindle', 'text': _('Convert %(orig)s to %(format)s and send to Kindle',
orig='Epub', orig='Epub',
format='Mobi')}) format='Mobi')})
@@ -190,6 +152,33 @@ def check_send_to_kindle(entry):
orig='Azw3', orig='Azw3',
format='Mobi')}) format='Mobi')})
return bookformats return bookformats
def check_send_to_kindle(entry):
"""
returns all available book formats for sending to Kindle
"""
formats = list()
bookformats = list()
if len(entry.data):
for ele in iter(entry.data):
if ele.uncompressed_size < config.mail_size:
formats.append(ele.format)
if 'MOBI' in formats:
bookformats.append({'format': 'Mobi',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Mobi')})
if 'PDF' in formats:
bookformats.append({'format': 'Pdf',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Pdf')})
if 'AZW' in formats:
bookformats.append({'format': 'Azw',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Azw')})
if config.config_converterpath:
bookformats.extend(check_send_to_kindle_with_converter(formats))
return bookformats
else: else:
log.error(u'Cannot find book entry %d', entry.id) log.error(u'Cannot find book entry %d', entry.id)
return None return None
@@ -198,7 +187,7 @@ def check_send_to_kindle(entry):
# Check if a reader is existing for any of the book formats, if not, return empty list, otherwise return # Check if a reader is existing for any of the book formats, if not, return empty list, otherwise return
# list with supported formats # list with supported formats
def check_read_formats(entry): def check_read_formats(entry):
EXTENSIONS_READER = {'TXT', 'PDF', 'EPUB', 'CBZ', 'CBT', 'CBR'} EXTENSIONS_READER = {'TXT', 'PDF', 'EPUB', 'CBZ', 'CBT', 'CBR', 'DJVU'}
bookformats = list() bookformats = list()
if len(entry.data): if len(entry.data):
for ele in iter(entry.data): for ele in iter(entry.data):
@@ -491,8 +480,8 @@ def reset_password(user_id):
password = generate_random_password() password = generate_random_password()
existing_user.password = generate_password_hash(password) existing_user.password = generate_password_hash(password)
ub.session.commit() ub.session.commit()
send_registration_mail(existing_user.email, existing_user.nickname, password, True) send_registration_mail(existing_user.email, existing_user.name, password, True)
return 1, existing_user.nickname return 1, existing_user.name
except Exception: except Exception:
ub.session.rollback() ub.session.rollback()
return 0, None return 0, None
@@ -509,11 +498,37 @@ def generate_random_password():
def uniq(inpt): def uniq(inpt):
output = [] output = []
inpt = [ " ".join(inp.split()) for inp in inpt]
for x in inpt: for x in inpt:
if x not in output: if x not in output:
output.append(x) output.append(x)
return output return output
def check_email(email):
email = valid_email(email)
if ub.session.query(ub.User).filter(func.lower(ub.User.email) == email.lower()).first():
log.error(u"Found an existing account for this e-mail address")
raise Exception(_(u"Found an existing account for this e-mail address"))
return email
def check_username(username):
username = username.strip()
if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).scalar():
log.error(u"This username is already taken")
raise Exception (_(u"This username is already taken"))
return username
def valid_email(email):
email = email.strip()
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
email):
log.error(u"Invalid e-mail address format")
raise Exception(_(u"Invalid e-mail address format"))
return email
# ################################# External interface ################################# # ################################# External interface #################################
@@ -561,8 +576,8 @@ def get_book_cover_internal(book, use_generic_cover_on_failure):
else: else:
log.error('%s/cover.jpg not found on Google Drive', book.path) log.error('%s/cover.jpg not found on Google Drive', book.path)
return get_cover_on_failure(use_generic_cover_on_failure) return get_cover_on_failure(use_generic_cover_on_failure)
except Exception as e: except Exception as ex:
log.debug_or_exception(e) log.debug_or_exception(ex)
return get_cover_on_failure(use_generic_cover_on_failure) return get_cover_on_failure(use_generic_cover_on_failure)
else: else:
cover_file_path = os.path.join(config.config_calibre_dir, book.path) cover_file_path = os.path.join(config.config_calibre_dir, book.path)
@@ -591,10 +606,6 @@ def save_cover_from_url(url, book_path):
def save_cover_from_filestorage(filepath, saved_filename, img): def save_cover_from_filestorage(filepath, saved_filename, img):
if hasattr(img,"metadata"):
img.save(filename=os.path.join(filepath, saved_filename))
img.close()
else:
# check if file path exists, otherwise create it, copy file to calibre path and delete temp file # check if file path exists, otherwise create it, copy file to calibre path and delete temp file
if not os.path.exists(filepath): if not os.path.exists(filepath):
try: try:
@@ -603,6 +614,17 @@ def save_cover_from_filestorage(filepath, saved_filename, img):
log.error(u"Failed to create path for cover") log.error(u"Failed to create path for cover")
return False, _(u"Failed to create path for cover") return False, _(u"Failed to create path for cover")
try: try:
# upload of jgp file without wand
if isinstance(img, requests.Response):
with open(os.path.join(filepath, saved_filename), 'wb') as f:
f.write(img.content)
else:
if hasattr(img, "metadata"):
# upload of jpg/png... via url
img.save(filename=os.path.join(filepath, saved_filename))
img.close()
else:
# upload of jpg/png... from hdd
img.save(os.path.join(filepath, saved_filename)) img.save(os.path.join(filepath, saved_filename))
except (IOError, OSError): except (IOError, OSError):
log.error(u"Cover-file is not a valid image file, or could not be stored") log.error(u"Cover-file is not a valid image file, or could not be stored")
@@ -671,6 +693,7 @@ def do_download_file(book, book_format, client, data, headers):
# ToDo Check headers parameter # ToDo Check headers parameter
for element in headers: for element in headers:
response.headers[element[0]] = element[1] response.headers[element[0]] = element[1]
log.info('Downloading file: {}'.format(os.path.join(filename, data.name + "." + book_format)))
return response return response
################################## ##################################
@@ -710,7 +733,6 @@ def json_serial(obj):
'seconds': obj.seconds, 'seconds': obj.seconds,
'microseconds': obj.microseconds, 'microseconds': obj.microseconds,
} }
# return obj.isoformat()
raise TypeError("Type %s not serializable" % type(obj)) raise TypeError("Type %s not serializable" % type(obj))
@@ -734,8 +756,8 @@ def format_runtime(runtime):
# helper function to apply localize status information in tasklist entries # helper function to apply localize status information in tasklist entries
def render_task_status(tasklist): def render_task_status(tasklist):
renderedtasklist = list() renderedtasklist = list()
for num, user, added, task in tasklist: for __, user, __, task in tasklist:
if user == current_user.nickname or current_user.role_admin(): if user == current_user.name or current_user.role_admin():
ret = {} ret = {}
if task.start_time: if task.start_time:
ret['starttime'] = format_datetime(task.start_time, format='short', locale=get_locale()) ret['starttime'] = format_datetime(task.start_time, format='short', locale=get_locale())
@@ -773,8 +795,8 @@ def tags_filters():
# checks if domain is in database (including wildcards) # checks if domain is in database (including wildcards)
# example SELECT * FROM @TABLE WHERE 'abcdefg' LIKE Name; # example SELECT * FROM @TABLE WHERE 'abcdefg' LIKE Name;
# from https://code.luasoftware.com/tutorials/flask/execute-raw-sql-in-flask-sqlalchemy/ # from https://code.luasoftware.com/tutorials/flask/execute-raw-sql-in-flask-sqlalchemy/
# in all calls the email address is checked for validity
def check_valid_domain(domain_text): def check_valid_domain(domain_text):
# domain_text = domain_text.split('@', 1)[-1].lower()
sql = "SELECT * FROM registration WHERE (:domain LIKE domain and allow = 1);" sql = "SELECT * FROM registration WHERE (:domain LIKE domain and allow = 1);"
result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all() result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all()
if not len(result): if not len(result):
@@ -808,6 +830,7 @@ def get_download_link(book_id, book_format, client):
if book: if book:
data1 = calibre_db.get_book_format(book.id, book_format.upper()) data1 = calibre_db.get_book_format(book.id, book_format.upper())
else: else:
log.error("Book id {} not found for downloading".format(book_id))
abort(404) abort(404)
if data1: if data1:
# collect downloaded books only for registered user and not for anonymous user # collect downloaded books only for registered user and not for anonymous user
+11 -8
View File
@@ -57,27 +57,30 @@ def get_language_name(locale, lang_code):
def get_language_codes(locale, language_names, remainder=None): def get_language_codes(locale, language_names, remainder=None):
language_names = set(x.strip().lower() for x in language_names if x) language_names = set(x.strip().lower() for x in language_names if x)
languages = list() lang = list()
for k, v in get_language_names(locale).items(): for k, v in get_language_names(locale).items():
v = v.lower() v = v.lower()
if v in language_names: if v in language_names:
languages.append(k) lang.append(k)
language_names.remove(v) language_names.remove(v)
if remainder is not None: if remainder is not None and language_names:
remainder.extend(language_names) remainder.extend(language_names)
return languages return lang
def get_valid_language_codes(locale, language_names, remainder=None): def get_valid_language_codes(locale, language_names, remainder=None):
languages = list() lang = list()
if "" in language_names: if "" in language_names:
language_names.remove("") language_names.remove("")
for k, v in get_language_names(locale).items(): for k, __ in get_language_names(locale).items():
if k in language_names: if k in language_names:
languages.append(k) lang.append(k)
language_names.remove(k) language_names.remove(k)
if remainder is not None and len(language_names): if remainder is not None and len(language_names):
remainder.extend(language_names) remainder.extend(language_names)
return languages return lang
def get_lang3(lang): def get_lang3(lang):
try: try:
+790 -417
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -82,7 +82,7 @@ def formatdate_filter(val):
except AttributeError as e: except AttributeError as e:
log.error('Babel error: %s, Current user locale: %s, Current User: %s', e, log.error('Babel error: %s, Current user locale: %s, Current User: %s', e,
current_user.locale, current_user.locale,
current_user.nickname current_user.name
) )
return val return val
@@ -122,10 +122,13 @@ def formatfloat(value, decimals=1):
@jinjia.app_template_filter('formatseriesindex') @jinjia.app_template_filter('formatseriesindex')
def formatseriesindex_filter(series_index): def formatseriesindex_filter(series_index):
if series_index: if series_index:
try:
if int(series_index) - series_index == 0: if int(series_index) - series_index == 0:
return int(series_index) return int(series_index)
else: else:
return series_index return series_index
except ValueError:
return series_index
return 0 return 0
@jinjia.app_template_filter('uuidfilter') @jinjia.app_template_filter('uuidfilter')
+13 -19
View File
@@ -42,8 +42,7 @@ from flask import (
from flask_login import current_user from flask_login import current_user
from werkzeug.datastructures import Headers 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_
from sqlalchemy.orm import load_only
from sqlalchemy.exc import StatementError from sqlalchemy.exc import StatementError
import requests import requests
@@ -159,6 +158,7 @@ def HandleSyncRequest():
.filter(db.Books.last_modified >= sync_token.books_last_modified) .filter(db.Books.last_modified >= sync_token.books_last_modified)
.filter(db.Books.id>sync_token.books_last_id) .filter(db.Books.id>sync_token.books_last_id)
.filter(db.Data.format.in_(KOBO_FORMATS)) .filter(db.Data.format.in_(KOBO_FORMATS))
.filter(calibre_db.common_filters())
.order_by(db.Books.last_modified) .order_by(db.Books.last_modified)
.order_by(db.Books.id) .order_by(db.Books.id)
.limit(SYNC_ITEM_LIMIT) .limit(SYNC_ITEM_LIMIT)
@@ -169,6 +169,7 @@ def HandleSyncRequest():
.join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id) .join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id)
.filter(db.Books.last_modified > sync_token.books_last_modified) .filter(db.Books.last_modified > sync_token.books_last_modified)
.filter(db.Data.format.in_(KOBO_FORMATS)) .filter(db.Data.format.in_(KOBO_FORMATS))
.filter(calibre_db.common_filters())
.order_by(db.Books.last_modified) .order_by(db.Books.last_modified)
.order_by(db.Books.id) .order_by(db.Books.id)
.limit(SYNC_ITEM_LIMIT) .limit(SYNC_ITEM_LIMIT)
@@ -176,6 +177,10 @@ def HandleSyncRequest():
reading_states_in_new_entitlements = [] reading_states_in_new_entitlements = []
for book in changed_entries: for book in changed_entries:
formats = [data.format for data in book.Books.data]
if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats:
helper.convert_book_format(book.Books.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
kobo_reading_state = get_or_create_reading_state(book.Books.id) kobo_reading_state = get_or_create_reading_state(book.Books.id)
entitlement = { entitlement = {
"BookEntitlement": create_book_entitlement(book.Books, archived=(book.is_archived == True)), "BookEntitlement": create_book_entitlement(book.Books, archived=(book.is_archived == True)),
@@ -259,8 +264,8 @@ def generate_sync_response(sync_token, sync_results, set_cont=False):
extra_headers["x-kobo-sync-mode"] = store_response.headers.get("x-kobo-sync-mode") extra_headers["x-kobo-sync-mode"] = store_response.headers.get("x-kobo-sync-mode")
extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads") extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads")
except Exception as e: except Exception as ex:
log.error("Failed to receive or parse response from Kobo's sync endpoint: " + str(e)) log.error("Failed to receive or parse response from Kobo's sync endpoint: {}".format(ex))
if set_cont: if set_cont:
extra_headers["x-kobo-sync"] = "continue" extra_headers["x-kobo-sync"] = "continue"
sync_token.to_headers(extra_headers) sync_token.to_headers(extra_headers)
@@ -302,7 +307,8 @@ def get_download_url_for_book(book, book_format):
book_format=book_format.lower() book_format=book_format.lower()
) )
return url_for( return url_for(
"web.download_link", "kobo.download_book",
auth_token=kobo_auth.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,
@@ -889,17 +895,6 @@ def HandleProductsRequest(dummy=None):
return redirect_or_proxy_request() return redirect_or_proxy_request()
'''@kobo.errorhandler(404)
def handle_404(err):
# This handler acts as a catch-all for endpoints that we don't have an interest in
# implementing (e.g: v1/analytics/gettests, v1/user/recommendations, etc)
if err:
print('404')
return jsonify(error=str(err)), 404
log.debug("Unknown Request received: %s, method: %s, data: %s", request.base_url, request.method, request.data)
return redirect_or_proxy_request()'''
def make_calibre_web_auth_response(): def make_calibre_web_auth_response():
# As described in kobo_auth.py, CalibreWeb doesn't make use practical use of this auth/device API call for # As described in kobo_auth.py, CalibreWeb doesn't make use practical use of this auth/device API call for
# authentation (nor for authorization). We return a dummy response just to keep the device happy. # authentation (nor for authorization). We return a dummy response just to keep the device happy.
@@ -926,7 +921,7 @@ def HandleAuthRequest():
if config.config_kobo_proxy: if config.config_kobo_proxy:
try: try:
return redirect_or_proxy_request() return redirect_or_proxy_request()
except: except Exception:
log.error("Failed to receive or parse response from Kobo's auth endpoint. Falling back to un-proxied mode.") log.error("Failed to receive or parse response from Kobo's auth endpoint. Falling back to un-proxied mode.")
return make_calibre_web_auth_response() return make_calibre_web_auth_response()
@@ -943,7 +938,7 @@ def HandleInitRequest():
store_response_json = store_response.json() store_response_json = store_response.json()
if "Resources" in store_response_json: if "Resources" in store_response_json:
kobo_resources = store_response_json["Resources"] kobo_resources = store_response_json["Resources"]
except: except Exception:
log.error("Failed to receive or parse response from Kobo's init endpoint. Falling back to un-proxied mode.") log.error("Failed to receive or parse response from Kobo's init endpoint. Falling back to un-proxied mode.")
if not kobo_resources: if not kobo_resources:
kobo_resources = NATIVE_KOBO_RESOURCES() kobo_resources = NATIVE_KOBO_RESOURCES()
@@ -1004,7 +999,6 @@ def HandleInitRequest():
@requires_kobo_auth @requires_kobo_auth
@download_required @download_required
def download_book(book_id, book_format): def download_book(book_id, book_format):
return get_download_link(book_id, book_format, "kobo") return get_download_link(book_id, book_format, "kobo")
+11 -2
View File
@@ -64,10 +64,10 @@ from datetime import datetime
from os import urandom from os import urandom
from flask import g, Blueprint, url_for, abort, request from flask import g, Blueprint, url_for, abort, request
from flask_login import login_user, login_required from flask_login import login_user, current_user, login_required
from flask_babel import gettext as _ from flask_babel import gettext as _
from . import logger, ub, lm from . import logger, config, calibre_db, db, helper, ub, lm
from .render_template import render_title_template from .render_template import render_title_template
try: try:
@@ -81,6 +81,7 @@ log = logger.create()
def register_url_value_preprocessor(kobo): def register_url_value_preprocessor(kobo):
@kobo.url_value_preprocessor @kobo.url_value_preprocessor
# pylint: disable=unused-variable
def pop_auth_token(__, values): def pop_auth_token(__, values):
g.auth_token = values.pop("auth_token") g.auth_token = values.pop("auth_token")
@@ -148,6 +149,14 @@ def generate_auth_token(user_id):
ub.session.add(auth_token) ub.session.add(auth_token)
ub.session_commit() ub.session_commit()
books = calibre_db.session.query(db.Books).join(db.Data).all()
for book in books:
formats = [data.format for data in book.data]
if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats:
helper.convert_book_format(book.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
return render_title_template( return render_title_template(
"generate_kobo_auth_url.html", "generate_kobo_auth_url.html",
title=_(u"Kobo Setup"), title=_(u"Kobo Setup"),
+5 -5
View File
@@ -62,11 +62,11 @@ class _Logger(logging.Logger):
def debug_no_auth(self, message, *args, **kwargs): def debug_no_auth(self, message, *args, **kwargs):
message = message.strip("\r\n")
if message.startswith("send: AUTH"): if message.startswith("send: AUTH"):
self.debug(message[:16], stacklevel=2, *args, **kwargs) self.debug(message[:16], *args, **kwargs)
else: else:
self.debug(message, stacklevel=2, *args, **kwargs) self.debug(message, *args, **kwargs)
def get(name=None): def get(name=None):
@@ -153,11 +153,11 @@ def setup(log_file, log_level=None):
file_handler.baseFilename = log_file file_handler.baseFilename = log_file
else: else:
try: try:
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2, encoding='utf-8') file_handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=2, encoding='utf-8')
except IOError: except IOError:
if log_file == DEFAULT_LOG_FILE: if log_file == DEFAULT_LOG_FILE:
raise raise
file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=50000, backupCount=2, encoding='utf-8') file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=100000, backupCount=2, encoding='utf-8')
log_file = "" log_file = ""
file_handler.setFormatter(FORMATTER) file_handler.setFormatter(FORMATTER)
+2 -6
View File
@@ -19,7 +19,6 @@
from __future__ import division, print_function, unicode_literals from __future__ import division, print_function, unicode_literals
from flask import session from flask import session
try: try:
from flask_dance.consumer.backend.sqla import SQLAlchemyBackend, first, _get_real_user from flask_dance.consumer.backend.sqla import SQLAlchemyBackend, first, _get_real_user
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
@@ -34,8 +33,8 @@ except ImportError:
except ImportError: except ImportError:
pass pass
try:
class OAuthBackend(SQLAlchemyBackend): class OAuthBackend(SQLAlchemyBackend):
""" """
Stores and retrieves OAuth tokens using a relational database through Stores and retrieves OAuth tokens using a relational database through
the `SQLAlchemy`_ ORM. the `SQLAlchemy`_ ORM.
@@ -162,6 +161,3 @@ try:
self.cache.delete(self.make_cache_key( self.cache.delete(self.make_cache_key(
blueprint=blueprint, user=user, user_id=user_id, blueprint=blueprint, user=user, user_id=user_id,
)) ))
except Exception:
pass
+169 -147
View File
@@ -30,15 +30,20 @@ from flask_babel import gettext as _
from flask_dance.consumer import oauth_authorized, oauth_error from flask_dance.consumer import oauth_authorized, oauth_error
from flask_dance.contrib.github import make_github_blueprint, github from flask_dance.contrib.github import make_github_blueprint, github
from flask_dance.contrib.google import make_google_blueprint, google from flask_dance.contrib.google import make_google_blueprint, google
from oauthlib.oauth2 import TokenExpiredError, InvalidGrantError
from flask_login import login_user, current_user, login_required from flask_login import login_user, current_user, login_required
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
from . import constants, logger, config, app, ub from . import constants, logger, config, app, ub
from .oauth import OAuthBackend, backend_resultcode try:
from .oauth import OAuthBackend, backend_resultcode
except NameError:
pass
oauth_check = {} oauth_check = {}
oauthblueprints = []
oauth = Blueprint('oauth', __name__) oauth = Blueprint('oauth', __name__)
log = logger.create() log = logger.create()
@@ -84,7 +89,7 @@ def register_user_with_oauth(user=None):
except NoResultFound: except NoResultFound:
# no found, return error # no found, return error
return return
ub.session_commit("User {} with OAuth for provider {} registered".format(user.nickname, oauth_key)) ub.session_commit("User {} with OAuth for provider {} registered".format(user.name, oauth_key))
def logout_oauth_user(): def logout_oauth_user():
@@ -93,8 +98,115 @@ def logout_oauth_user():
session.pop(str(oauth_key) + '_oauth_user_id') session.pop(str(oauth_key) + '_oauth_user_id')
if ub.oauth_support: def oauth_update_token(provider_id, token, provider_user_id):
oauthblueprints = [] session[provider_id + "_oauth_user_id"] = provider_user_id
session[provider_id + "_oauth_token"] = token
# Find this OAuth token in the database, or create it
query = ub.session.query(ub.OAuth).filter_by(
provider=provider_id,
provider_user_id=provider_user_id,
)
try:
oauth_entry = query.one()
# update token
oauth_entry.token = token
except NoResultFound:
oauth_entry = ub.OAuth(
provider=provider_id,
provider_user_id=provider_user_id,
token=token,
)
ub.session.add(oauth_entry)
ub.session_commit()
# Disable Flask-Dance's default behavior for saving the OAuth token
# Value differrs depending on flask-dance version
return backend_resultcode
def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider_name):
query = ub.session.query(ub.OAuth).filter_by(
provider=provider_id,
provider_user_id=provider_user_id,
)
try:
oauth_entry = query.first()
# already bind with user, just login
if oauth_entry.user:
login_user(oauth_entry.user)
log.debug(u"You are now logged in as: '%s'", oauth_entry.user.name)
flash(_(u"you are now logged in as: '%(nickname)s'", nickname= oauth_entry.user.name),
category="success")
return redirect(url_for('web.index'))
else:
# bind to current user
if current_user and current_user.is_authenticated:
oauth_entry.user = current_user
try:
ub.session.add(oauth_entry)
ub.session.commit()
flash(_(u"Link to %(oauth)s Succeeded", oauth=provider_name), category="success")
log.info("Link to {} Succeeded".format(provider_name))
return redirect(url_for('web.profile'))
except Exception as ex:
log.debug_or_exception(ex)
ub.session.rollback()
else:
flash(_(u"Login failed, No User Linked With OAuth Account"), category="error")
log.info('Login failed, No User Linked With OAuth Account')
return redirect(url_for('web.login'))
# return redirect(url_for('web.login'))
# if config.config_public_reg:
# return redirect(url_for('web.register'))
# else:
# flash(_(u"Public registration is not enabled"), category="error")
# return redirect(url_for(redirect_url))
except (NoResultFound, AttributeError):
return redirect(url_for(redirect_url))
def get_oauth_status():
status = []
query = ub.session.query(ub.OAuth).filter_by(
user_id=current_user.id,
)
try:
oauths = query.all()
for oauth_entry in oauths:
status.append(int(oauth_entry.provider))
return status
except NoResultFound:
return None
def unlink_oauth(provider):
if request.host_url + 'me' != request.referrer:
pass
query = ub.session.query(ub.OAuth).filter_by(
provider=provider,
user_id=current_user.id,
)
try:
oauth_entry = query.one()
if current_user and current_user.is_authenticated:
oauth_entry.user = current_user
try:
ub.session.delete(oauth_entry)
ub.session.commit()
logout_oauth_user()
flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success")
log.info("Unlink to {} Succeeded".format(oauth_check[provider]))
except Exception as ex:
log.debug_or_exception(ex)
ub.session.rollback()
flash(_(u"Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
except NoResultFound:
log.warning("oauth %s for user %d not found", provider, current_user.id)
flash(_(u"Not Linked to %(oauth)s", oauth=provider), category="error")
return redirect(url_for('web.profile'))
def generate_oauth_blueprints():
if not ub.session.query(ub.OAuthProvider).count(): if not ub.session.query(ub.OAuthProvider).count():
for provider in ("github", "google"): for provider in ("github", "google"):
oauthProvider = ub.OAuthProvider() oauthProvider = ub.OAuthProvider()
@@ -138,17 +250,23 @@ if ub.oauth_support:
app.register_blueprint(blueprint, url_prefix="/login") app.register_blueprint(blueprint, url_prefix="/login")
if element['active']: if element['active']:
register_oauth_blueprint(element['id'], element['provider_name']) register_oauth_blueprint(element['id'], element['provider_name'])
return oauthblueprints
if ub.oauth_support:
oauthblueprints = generate_oauth_blueprints()
@oauth_authorized.connect_via(oauthblueprints[0]['blueprint']) @oauth_authorized.connect_via(oauthblueprints[0]['blueprint'])
def github_logged_in(blueprint, token): def github_logged_in(blueprint, token):
if not token: if not token:
flash(_(u"Failed to log in with GitHub."), category="error") flash(_(u"Failed to log in with GitHub."), category="error")
log.error("Failed to log in with GitHub")
return False return False
resp = blueprint.session.get("/user") resp = blueprint.session.get("/user")
if not resp.ok: if not resp.ok:
flash(_(u"Failed to fetch user info from GitHub."), category="error") flash(_(u"Failed to fetch user info from GitHub."), category="error")
log.error("Failed to fetch user info from GitHub")
return False return False
github_info = resp.json() github_info = resp.json()
@@ -160,11 +278,13 @@ if ub.oauth_support:
def google_logged_in(blueprint, token): def google_logged_in(blueprint, token):
if not token: if not token:
flash(_(u"Failed to log in with Google."), category="error") flash(_(u"Failed to log in with Google."), category="error")
log.error("Failed to log in with Google")
return False return False
resp = blueprint.session.get("/oauth2/v2/userinfo") resp = blueprint.session.get("/oauth2/v2/userinfo")
if not resp.ok: if not resp.ok:
flash(_(u"Failed to fetch user info from Google."), category="error") flash(_(u"Failed to fetch user info from Google."), category="error")
log.error("Failed to fetch user info from Google")
return False return False
google_info = resp.json() google_info = resp.json()
@@ -172,113 +292,6 @@ if ub.oauth_support:
return oauth_update_token(str(oauthblueprints[1]['id']), token, google_user_id) return oauth_update_token(str(oauthblueprints[1]['id']), token, google_user_id)
def oauth_update_token(provider_id, token, provider_user_id):
session[provider_id + "_oauth_user_id"] = provider_user_id
session[provider_id + "_oauth_token"] = token
# Find this OAuth token in the database, or create it
query = ub.session.query(ub.OAuth).filter_by(
provider=provider_id,
provider_user_id=provider_user_id,
)
try:
oauth_entry = query.one()
# update token
oauth_entry.token = token
except NoResultFound:
oauth_entry = ub.OAuth(
provider=provider_id,
provider_user_id=provider_user_id,
token=token,
)
ub.session.add(oauth_entry)
ub.session_commit()
# Disable Flask-Dance's default behavior for saving the OAuth token
# Value differrs depending on flask-dance version
return backend_resultcode
def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider_name):
query = ub.session.query(ub.OAuth).filter_by(
provider=provider_id,
provider_user_id=provider_user_id,
)
try:
oauth_entry = query.first()
# already bind with user, just login
if oauth_entry.user:
login_user(oauth_entry.user)
log.debug(u"You are now logged in as: '%s'", oauth_entry.user.nickname)
flash(_(u"you are now logged in as: '%(nickname)s'", nickname= oauth_entry.user.nickname),
category="success")
return redirect(url_for('web.index'))
else:
# bind to current user
if current_user and current_user.is_authenticated:
oauth_entry.user = current_user
try:
ub.session.add(oauth_entry)
ub.session.commit()
flash(_(u"Link to %(oauth)s Succeeded", oauth=provider_name), category="success")
return redirect(url_for('web.profile'))
except Exception as e:
log.debug_or_exception(e)
ub.session.rollback()
else:
flash(_(u"Login failed, No User Linked With OAuth Account"), category="error")
log.info('Login failed, No User Linked With OAuth Account')
return redirect(url_for('web.login'))
# return redirect(url_for('web.login'))
# if config.config_public_reg:
# return redirect(url_for('web.register'))
# else:
# flash(_(u"Public registration is not enabled"), category="error")
# return redirect(url_for(redirect_url))
except (NoResultFound, AttributeError):
return redirect(url_for(redirect_url))
def get_oauth_status():
status = []
query = ub.session.query(ub.OAuth).filter_by(
user_id=current_user.id,
)
try:
oauths = query.all()
for oauth_entry in oauths:
status.append(int(oauth_entry.provider))
return status
except NoResultFound:
return None
def unlink_oauth(provider):
if request.host_url + 'me' != request.referrer:
pass
query = ub.session.query(ub.OAuth).filter_by(
provider=provider,
user_id=current_user.id,
)
try:
oauth_entry = query.one()
if current_user and current_user.is_authenticated:
oauth_entry.user = current_user
try:
ub.session.delete(oauth_entry)
ub.session.commit()
logout_oauth_user()
flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success")
except Exception as e:
log.debug_or_exception(e)
ub.session.rollback()
flash(_(u"Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
except NoResultFound:
log.warning("oauth %s for user %d not found", provider, current_user.id)
flash(_(u"Not Linked to %(oauth)s", oauth=provider), category="error")
return redirect(url_for('web.profile'))
# notify on OAuth provider error # notify on OAuth provider error
@oauth_error.connect_via(oauthblueprints[0]['blueprint']) @oauth_error.connect_via(oauthblueprints[0]['blueprint'])
def github_error(blueprint, error, error_description=None, error_uri=None): def github_error(blueprint, error, error_description=None, error_uri=None):
@@ -293,39 +306,6 @@ if ub.oauth_support:
) # ToDo: Translate ) # ToDo: Translate
flash(msg, category="error") flash(msg, category="error")
@oauth.route('/link/github')
@oauth_required
def github_login():
if not github.authorized:
return redirect(url_for('github.login'))
account_info = github.get('/user')
if account_info.ok:
account_info_json = account_info.json()
return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github')
flash(_(u"GitHub Oauth error, please retry later."), category="error")
return redirect(url_for('web.login'))
@oauth.route('/unlink/github', methods=["GET"])
@login_required
def github_login_unlink():
return unlink_oauth(oauthblueprints[0]['id'])
@oauth.route('/link/google')
@oauth_required
def google_login():
if not google.authorized:
return redirect(url_for("google.login"))
resp = google.get("/oauth2/v2/userinfo")
if resp.ok:
account_info_json = resp.json()
return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google')
flash(_(u"Google Oauth error, please retry later."), category="error")
return redirect(url_for('web.login'))
@oauth_error.connect_via(oauthblueprints[1]['blueprint']) @oauth_error.connect_via(oauthblueprints[1]['blueprint'])
def google_error(blueprint, error, error_description=None, error_uri=None): def google_error(blueprint, error, error_description=None, error_uri=None):
msg = ( msg = (
@@ -340,7 +320,49 @@ if ub.oauth_support:
flash(msg, category="error") flash(msg, category="error")
@oauth.route('/unlink/google', methods=["GET"]) @oauth.route('/link/github')
@login_required @oauth_required
def google_login_unlink(): def github_login():
if not github.authorized:
return redirect(url_for('github.login'))
try:
account_info = github.get('/user')
if account_info.ok:
account_info_json = account_info.json()
return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github')
flash(_(u"GitHub Oauth error, please retry later."), category="error")
log.error("GitHub Oauth error, please retry later")
except (InvalidGrantError, TokenExpiredError) as e:
flash(_(u"GitHub Oauth error: {}").format(e), category="error")
log.error(e)
return redirect(url_for('web.login'))
@oauth.route('/unlink/github', methods=["GET"])
@login_required
def github_login_unlink():
return unlink_oauth(oauthblueprints[0]['id'])
@oauth.route('/link/google')
@oauth_required
def google_login():
if not google.authorized:
return redirect(url_for("google.login"))
try:
resp = google.get("/oauth2/v2/userinfo")
if resp.ok:
account_info_json = resp.json()
return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google')
flash(_(u"Google Oauth error, please retry later."), category="error")
log.error("Google Oauth error, please retry later")
except (InvalidGrantError, TokenExpiredError) as e:
flash(_(u"Google Oauth error: {}").format(e), category="error")
log.error(e)
return redirect(url_for('web.login'))
@oauth.route('/unlink/google', methods=["GET"])
@login_required
def google_login_unlink():
return unlink_oauth(oauthblueprints[1]['id']) return unlink_oauth(oauthblueprints[1]['id'])
+139 -21
View File
@@ -27,7 +27,7 @@ from functools import wraps
from flask import Blueprint, request, render_template, Response, g, make_response, abort from flask import Blueprint, request, render_template, Response, g, make_response, abort
from flask_login import current_user from flask_login import current_user
from sqlalchemy.sql.expression import func, text, or_, and_ from sqlalchemy.sql.expression import func, text, or_, and_, true
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from . import constants, logger, config, db, calibre_db, ub, services, get_locale, isoLanguages from . import constants, logger, config, db, calibre_db, ub, services, get_locale, isoLanguages
@@ -94,7 +94,45 @@ def feed_cc_search(query):
@opds.route("/opds/search", methods=["GET"]) @opds.route("/opds/search", methods=["GET"])
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_normal_search(): def feed_normal_search():
return feed_search(request.args.get("query").strip()) return feed_search(request.args.get("query", "").strip())
@opds.route("/opds/books")
@requires_basic_auth_if_no_ano
def feed_booksindex():
shift = 0
off = int(request.args.get("offset") or 0)
entries = calibre_db.session.query(func.upper(func.substr(db.Books.sort, 1, 1)).label('id'))\
.filter(calibre_db.common_filters()).group_by(func.upper(func.substr(db.Books.sort, 1, 1))).all()
elements = []
if off == 0:
elements.append({'id': "00", 'name':_("All")})
shift = 1
for entry in entries[
off + shift - 1:
int(off + int(config.config_books_per_page) - shift)]:
elements.append({'id': entry.id, 'name': entry.id})
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(entries) + 1)
return render_xml_template('feed.xml',
letterelements=elements,
folder='opds.feed_letter_books',
pagination=pagination)
@opds.route("/opds/books/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_books(book_id):
off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Books.sort).startswith(book_id)
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
db.Books,
letter,
[db.Books.sort])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/new") @opds.route("/opds/new")
@@ -150,14 +188,41 @@ def feed_hot():
@opds.route("/opds/author") @opds.route("/opds/author")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_authorindex(): def feed_authorindex():
off = request.args.get("offset") or 0 shift = 0
entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\ off = int(request.args.get("offset") or 0)
.filter(calibre_db.common_filters())\ entries = calibre_db.session.query(func.upper(func.substr(db.Authors.sort, 1, 1)).label('id'))\
.group_by(text('books_authors_link.author'))\ .join(db.books_authors_link).join(db.Books).filter(calibre_db.common_filters())\
.order_by(db.Authors.sort).limit(config.config_books_per_page)\ .group_by(func.upper(func.substr(db.Authors.sort, 1, 1))).all()
.offset(off)
elements = []
if off == 0:
elements.append({'id': "00", 'name':_("All")})
shift = 1
for entry in entries[
off + shift - 1:
int(off + int(config.config_books_per_page) - shift)]:
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(calibre_db.session.query(db.Authors).all())) len(entries) + 1)
return render_xml_template('feed.xml',
letterelements=elements,
folder='opds.feed_letter_author',
pagination=pagination)
@opds.route("/opds/author/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_author(book_id):
off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id)
entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\
.filter(calibre_db.common_filters()).filter(letter)\
.group_by(text('books_authors_link.author'))\
.order_by(db.Authors.sort)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
entries.count())
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) return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination)
@@ -201,17 +266,41 @@ def feed_publisher(book_id):
@opds.route("/opds/category") @opds.route("/opds/category")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_categoryindex(): def feed_categoryindex():
shift = 0
off = int(request.args.get("offset") or 0)
entries = calibre_db.session.query(func.upper(func.substr(db.Tags.name, 1, 1)).label('id'))\
.join(db.books_tags_link).join(db.Books).filter(calibre_db.common_filters())\
.group_by(func.upper(func.substr(db.Tags.name, 1, 1))).all()
elements = []
if off == 0:
elements.append({'id': "00", 'name':_("All")})
shift = 1
for entry in entries[
off + shift - 1:
int(off + int(config.config_books_per_page) - shift)]:
elements.append({'id': entry.id, 'name': entry.id})
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(entries) + 1)
return render_xml_template('feed.xml',
letterelements=elements,
folder='opds.feed_letter_category',
pagination=pagination)
@opds.route("/opds/category/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_category(book_id):
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
entries = calibre_db.session.query(db.Tags)\ entries = calibre_db.session.query(db.Tags)\
.join(db.books_tags_link)\ .join(db.books_tags_link)\
.join(db.Books)\ .join(db.Books)\
.filter(calibre_db.common_filters())\ .filter(calibre_db.common_filters()).filter(letter)\
.group_by(text('books_tags_link.tag'))\ .group_by(text('books_tags_link.tag'))\
.order_by(db.Tags.name)\ .order_by(db.Tags.name)
.offset(off)\
.limit(config.config_books_per_page)
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.Tags).all())) entries.count())
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) return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination)
@@ -229,16 +318,40 @@ def feed_category(book_id):
@opds.route("/opds/series") @opds.route("/opds/series")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_seriesindex(): def feed_seriesindex():
shift = 0
off = int(request.args.get("offset") or 0)
entries = calibre_db.session.query(func.upper(func.substr(db.Series.sort, 1, 1)).label('id'))\
.join(db.books_series_link).join(db.Books).filter(calibre_db.common_filters())\
.group_by(func.upper(func.substr(db.Series.sort, 1, 1))).all()
elements = []
if off == 0:
elements.append({'id': "00", 'name':_("All")})
shift = 1
for entry in entries[
off + shift - 1:
int(off + int(config.config_books_per_page) - shift)]:
elements.append({'id': entry.id, 'name': entry.id})
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(entries) + 1)
return render_xml_template('feed.xml',
letterelements=elements,
folder='opds.feed_letter_series',
pagination=pagination)
@opds.route("/opds/series/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_series(book_id):
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
entries = calibre_db.session.query(db.Series)\ entries = calibre_db.session.query(db.Series)\
.join(db.books_series_link)\ .join(db.books_series_link)\
.join(db.Books)\ .join(db.Books)\
.filter(calibre_db.common_filters())\ .filter(calibre_db.common_filters()).filter(letter)\
.group_by(text('books_series_link.series'))\ .group_by(text('books_series_link.series'))\
.order_by(db.Series.sort)\ .order_by(db.Series.sort)
.offset(off).all()
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.Series).all())) entries.count())
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) return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination)
@@ -269,7 +382,7 @@ def feed_ratingindex():
len(entries)) len(entries))
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) return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination)
@@ -428,9 +541,14 @@ def check_auth(username, password):
username = username.encode('windows-1252') username = username.encode('windows-1252')
except UnicodeEncodeError: except UnicodeEncodeError:
username = username.encode('utf-8') username = username.encode('utf-8')
user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == user = ub.session.query(ub.User).filter(func.lower(ub.User.name) ==
username.decode('utf-8').lower()).first() username.decode('utf-8').lower()).first()
return bool(user and check_password_hash(str(user.password), password)) if bool(user and check_password_hash(str(user.password), password)):
return True
else:
ip_Address = request.headers.get('X-Forwarded-For', request.remote_addr)
log.warning('OPDS Login failed for user "%s" IP-address: %s', username.decode('utf-8'), ip_Address)
return False
def authenticate(): def authenticate():
+3 -3
View File
@@ -62,7 +62,7 @@ def remote_login():
ub.session_commit() ub.session_commit()
verify_url = url_for('remotelogin.verify_token', token=auth_token.auth_token, _external=true) verify_url = url_for('remotelogin.verify_token', token=auth_token.auth_token, _external=true)
log.debug(u"Remot Login request with token: %s", auth_token.auth_token) log.debug(u"Remot Login request with token: %s", auth_token.auth_token)
return render_title_template('remote_login.html', title=_(u"login"), token=auth_token.auth_token, return render_title_template('remote_login.html', title=_(u"Login"), token=auth_token.auth_token,
verify_url=verify_url, page="remotelogin") verify_url=verify_url, page="remotelogin")
@@ -126,11 +126,11 @@ def token_verified():
login_user(user) login_user(user)
ub.session.delete(auth_token) ub.session.delete(auth_token)
ub.session_commit("User {} logged in via remotelogin, token deleted".format(user.nickname)) ub.session_commit("User {} logged in via remotelogin, token deleted".format(user.name))
data['status'] = 'success' data['status'] = 'success'
log.debug(u"Remote Login for userid %s succeded", user.id) log.debug(u"Remote Login for userid %s succeded", user.id)
flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.name), category="success")
response = make_response(json.dumps(data, ensure_ascii=False)) response = make_response(json.dumps(data, ensure_ascii=False))
response.headers["Content-Type"] = "application/json; charset=utf-8" response.headers["Content-Type"] = "application/json; charset=utf-8"
+8 -2
View File
@@ -36,12 +36,18 @@ def get_sidebar_config(kwargs=None):
else: else:
content = 'conf' in kwargs content = 'conf' in kwargs
sidebar = list() sidebar = list()
sidebar.append({"glyph": "glyphicon-book", "text": _('Recently Added'), "link": 'web.index', "id": "new", sidebar.append({"glyph": "glyphicon-book", "text": _('Books'), "link": 'web.index', "id": "new",
"visibility": constants.SIDEBAR_RECENT, 'public': True, "page": "root", "visibility": constants.SIDEBAR_RECENT, 'public': True, "page": "root",
"show_text": _('Show recent books'), "config_show":False}) "show_text": _('Show recent books'), "config_show":False})
sidebar.append({"glyph": "glyphicon-fire", "text": _('Hot Books'), "link": 'web.books_list', "id": "hot", sidebar.append({"glyph": "glyphicon-fire", "text": _('Hot Books'), "link": 'web.books_list', "id": "hot",
"visibility": constants.SIDEBAR_HOT, 'public': True, "page": "hot", "visibility": constants.SIDEBAR_HOT, 'public': True, "page": "hot",
"show_text": _('Show Hot Books'), "config_show": True}) "show_text": _('Show Hot Books'), "config_show": True})
if current_user.role_admin():
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list',
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous),
"page": "download", "show_text": _('Show Downloaded Books'),
"config_show": content})
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',
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous), "id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous),
"page": "download", "show_text": _('Show Downloaded Books'), "page": "download", "show_text": _('Show Downloaded Books'),
@@ -59,7 +65,7 @@ def get_sidebar_config(kwargs=None):
"show_text": _('Show unread'), "config_show": False}) "show_text": _('Show unread'), "config_show": False})
sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand", sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand",
"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",
"show_text": _('Show category selection'), "config_show": True}) "show_text": _('Show category selection'), "config_show": True})
+64 -7
View File
@@ -22,6 +22,7 @@ import os
import errno import errno
import signal import signal
import socket import socket
import subprocess # nosec
try: try:
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
@@ -136,6 +137,64 @@ class WebServer(object):
return sock, _readable_listen_address(*address) return sock, _readable_listen_address(*address)
@staticmethod
def _get_args_for_reloading():
"""Determine how the script was executed, and return the args needed
to execute it again in a new process.
Code from https://github.com/pyload/pyload. Author GammaC0de, voulter
"""
rv = [sys.executable]
py_script = sys.argv[0]
args = sys.argv[1:]
# Need to look at main module to determine how it was executed.
__main__ = sys.modules["__main__"]
# The value of __package__ indicates how Python was called. It may
# not exist if a setuptools script is installed as an egg. It may be
# set incorrectly for entry points created with pip on Windows.
if getattr(__main__, "__package__", None) is None or (
os.name == "nt"
and __main__.__package__ == ""
and not os.path.exists(py_script)
and os.path.exists("{}.exe".format(py_script))
):
# Executed a file, like "python app.py".
py_script = os.path.abspath(py_script)
if os.name == "nt":
# Windows entry points have ".exe" extension and should be
# called directly.
if not os.path.exists(py_script) and os.path.exists("{}.exe".format(py_script)):
py_script += ".exe"
if (
os.path.splitext(sys.executable)[1] == ".exe"
and os.path.splitext(py_script)[1] == ".exe"
):
rv.pop(0)
rv.append(py_script)
else:
# Executed a module, like "python -m module".
if sys.argv[0] == "-m":
args = sys.argv
else:
if os.path.isfile(py_script):
# Rewritten by Python from "-m script" to "/path/to/script.py".
py_module = __main__.__package__
name = os.path.splitext(os.path.basename(py_script))[0]
if name != "__main__":
py_module += ".{}".format(name)
else:
# Incorrectly rewritten by pydevd debugger from "-m script" to "script".
py_module = py_script
rv.extend(("-m", py_module.lstrip(".")))
rv.extend(args)
return rv
def _start_gevent(self): def _start_gevent(self):
ssl_args = self.ssl_args or {} ssl_args = self.ssl_args or {}
@@ -192,18 +251,16 @@ class WebServer(object):
finally: finally:
self.wsgiserver = None self.wsgiserver = None
# prevent irritating log of pending tasks message from asyncio
logger.get('asyncio').setLevel(logger.logging.CRITICAL)
if not self.restart: if not self.restart:
log.info("Performing shutdown of Calibre-Web") log.info("Performing shutdown of Calibre-Web")
# prevent irritiating log of pending tasks message from asyncio
logger.get('asyncio').setLevel(logger.logging.CRITICAL)
return True return True
log.info("Performing restart of Calibre-Web") log.info("Performing restart of Calibre-Web")
arguments = list(sys.argv) args = self._get_args_for_reloading()
arguments.insert(0, sys.executable) subprocess.call(args, close_fds=True) # nosec
if os.name == 'nt':
arguments = ["\"%s\"" % a for a in arguments]
os.execv(sys.executable, arguments)
return True return True
def _killServer(self, __, ___): def _killServer(self, __, ___):
+4 -3
View File
@@ -22,6 +22,7 @@ from base64 import b64decode, b64encode
from jsonschema import validate, exceptions, __version__ from jsonschema import validate, exceptions, __version__
from datetime import datetime from datetime import datetime
try: try:
# pylint: disable=unused-import
from urllib import unquote from urllib import unquote
except ImportError: except ImportError:
from urllib.parse import unquote from urllib.parse import unquote
@@ -64,7 +65,7 @@ class SyncToken:
books_last_modified: Datetime representing the last modified book that the device knows about. books_last_modified: Datetime representing the last modified book that the device knows about.
""" """
SYNC_TOKEN_HEADER = "x-kobo-synctoken" SYNC_TOKEN_HEADER = "x-kobo-synctoken" # nosec
VERSION = "1-1-0" VERSION = "1-1-0"
LAST_MODIFIED_ADDED_VERSION = "1-1-0" LAST_MODIFIED_ADDED_VERSION = "1-1-0"
MIN_VERSION = "1-0-0" MIN_VERSION = "1-0-0"
@@ -98,7 +99,7 @@ class SyncToken:
reading_state_last_modified=datetime.min, reading_state_last_modified=datetime.min,
tags_last_modified=datetime.min, tags_last_modified=datetime.min,
books_last_id=-1 books_last_id=-1
): ): # nosec
self.raw_kobo_store_token = raw_kobo_store_token self.raw_kobo_store_token = raw_kobo_store_token
self.books_last_created = books_last_created self.books_last_created = books_last_created
self.books_last_modified = books_last_modified self.books_last_modified = books_last_modified
@@ -110,7 +111,7 @@ class SyncToken:
@staticmethod @staticmethod
def from_headers(headers): def from_headers(headers):
sync_token_header = headers.get(SyncToken.SYNC_TOKEN_HEADER, "") sync_token_header = headers.get(SyncToken.SYNC_TOKEN_HEADER, "")
if sync_token_header == "": if sync_token_header == "": # nosec
return SyncToken() return SyncToken()
# On the first sync from a Kobo device, we may receive the SyncToken # On the first sync from a Kobo device, we may receive the SyncToken
+6
View File
@@ -45,3 +45,9 @@ except ImportError as err:
log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err) log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err)
kobo = None kobo = None
SyncToken = None SyncToken = None
try:
from . import gmail
except ImportError as err:
log.debug("Cannot import gmail, sending books via Gmail Oauth2 Verification will not work: %s", err)
gmail = None
+83
View File
@@ -0,0 +1,83 @@
from __future__ import print_function
import os.path
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
from datetime import datetime
import base64
from flask_babel import gettext as _
from ..constants import BASE_DIR
from .. import logger
log = logger.create()
SCOPES = ['openid', 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/userinfo.email']
def setup_gmail(token):
# If there are no (valid) credentials available, let the user log in.
creds = None
if "token" in token:
creds = Credentials(
token=token['token'],
refresh_token=token['refresh_token'],
token_uri=token['token_uri'],
client_id=token['client_id'],
client_secret=token['client_secret'],
scopes=token['scopes'],
)
creds.expiry = datetime.fromisoformat(token['expiry'])
if not creds or not creds.valid:
# don't forget to dump one more time after the refresh
# also, some file-locking routines wouldn't be needless
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
cred_file = os.path.join(BASE_DIR, 'gmail.json')
if not os.path.exists(cred_file):
raise Exception(_("Found no valid gmail.json file with OAuth information"))
flow = InstalledAppFlow.from_client_secrets_file(
os.path.join(BASE_DIR, 'gmail.json'), SCOPES)
creds = flow.run_local_server(port=0)
user_info = get_user_info(creds)
return {
'token': creds.token,
'refresh_token': creds.refresh_token,
'token_uri': creds.token_uri,
'client_id': creds.client_id,
'client_secret': creds.client_secret,
'scopes': creds.scopes,
'expiry': creds.expiry.isoformat(),
'email': user_info
}
return {}
def get_user_info(credentials):
user_info_service = build(serviceName='oauth2', version='v2',credentials=credentials)
user_info = user_info_service.userinfo().get().execute()
return user_info.get('email', "")
def send_messsage(token, msg):
log.debug("Start sending email via Gmail")
creds = Credentials(
token=token['token'],
refresh_token=token['refresh_token'],
token_uri=token['token_uri'],
client_id=token['client_id'],
client_secret=token['client_secret'],
scopes=token['scopes'],
)
creds.expiry = datetime.fromisoformat(token['expiry'])
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
service = build('gmail', 'v1', credentials=creds)
message_as_bytes = msg.as_bytes() # the message should converted from string to bytes.
message_as_base64 = base64.urlsafe_b64encode(message_as_bytes) # encode in base64 (printable letters coding)
raw = message_as_base64.decode() # convert to something JSON serializable
body = {'raw': raw}
(service.users().messages().send(userId='me', body=body).execute())
log.debug("Email send successfully via Gmail")
+5 -4
View File
@@ -45,7 +45,7 @@ class ImprovedQueue(queue.Queue):
with self.mutex: with self.mutex:
return list(self.queue) return list(self.queue)
#Class for all worker tasks in the background # Class for all worker tasks in the background
class WorkerThread(threading.Thread): class WorkerThread(threading.Thread):
_instance = None _instance = None
@@ -69,6 +69,7 @@ class WorkerThread(threading.Thread):
def add(cls, user, task): def add(cls, user, task):
ins = cls.getInstance() ins = cls.getInstance()
ins.num += 1 ins.num += 1
log.debug("Add Task for user: {}: {}".format(user, task))
ins.queue.put(QueuedTask( ins.queue.put(QueuedTask(
num=ins.num, num=ins.num,
user=user, user=user,
@@ -159,9 +160,9 @@ class CalibreTask:
# catch any unhandled exceptions in a task and automatically fail it # catch any unhandled exceptions in a task and automatically fail it
try: try:
self.run(*args) self.run(*args)
except Exception as e: except Exception as ex:
self._handleError(str(e)) self._handleError(str(ex))
log.debug_or_exception(e) log.debug_or_exception(ex)
self.end_time = datetime.now() self.end_time = datetime.now()
+25 -12
View File
@@ -99,12 +99,14 @@ def add_to_shelf(shelf_id, book_id):
ub.session.commit() ub.session.commit()
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError):
ub.session.rollback() ub.session.rollback()
log.error("Settings DB is not Writeable")
flash(_(u"Settings DB is not Writeable"), category="error") flash(_(u"Settings DB is not Writeable"), category="error")
if "HTTP_REFERER" in request.environ: if "HTTP_REFERER" in request.environ:
return redirect(request.environ["HTTP_REFERER"]) return redirect(request.environ["HTTP_REFERER"])
else: else:
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
if not xhr: if not xhr:
log.debug("Book has been added to shelf: {}".format(shelf.name))
flash(_(u"Book has been added to shelf: %(sname)s", sname=shelf.name), category="success") flash(_(u"Book has been added to shelf: %(sname)s", sname=shelf.name), category="success")
if "HTTP_REFERER" in request.environ: if "HTTP_REFERER" in request.environ:
return redirect(request.environ["HTTP_REFERER"]) return redirect(request.environ["HTTP_REFERER"])
@@ -123,6 +125,7 @@ def search_to_shelf(shelf_id):
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
if not check_shelf_edit_permissions(shelf): if not check_shelf_edit_permissions(shelf):
log.warning("You are not allowed to add a book to the the shelf: {}".format(shelf.name))
flash(_(u"You are not allowed to add a book to the the shelf: %(name)s", name=shelf.name), category="error") flash(_(u"You are not allowed to add a book to the the shelf: %(name)s", name=shelf.name), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
@@ -140,7 +143,7 @@ def search_to_shelf(shelf_id):
books_for_shelf = ub.searched_ids[current_user.id] books_for_shelf = ub.searched_ids[current_user.id]
if not books_for_shelf: if not books_for_shelf:
log.error("Books are already part of %s", shelf.name) log.error("Books are already part of {}".format(shelf.name))
flash(_(u"Books are already part of the shelf: %(name)s", name=shelf.name), category="error") flash(_(u"Books are already part of the shelf: %(name)s", name=shelf.name), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
@@ -156,8 +159,10 @@ def search_to_shelf(shelf_id):
flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success") flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError):
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
else: else:
log.error("Could not add books to shelf: {}".format(shelf.name))
flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error") flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
@@ -168,7 +173,7 @@ def remove_from_shelf(shelf_id, book_id):
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest' xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
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 is None: if shelf is None:
log.error("Invalid shelf specified: %s", shelf_id) log.error("Invalid shelf specified: {}".format(shelf_id))
if not xhr: if not xhr:
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
return "Invalid shelf specified", 400 return "Invalid shelf specified", 400
@@ -197,7 +202,8 @@ def remove_from_shelf(shelf_id, book_id):
ub.session.commit() ub.session.commit()
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError):
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
if "HTTP_REFERER" in request.environ: if "HTTP_REFERER" in request.environ:
return redirect(request.environ["HTTP_REFERER"]) return redirect(request.environ["HTTP_REFERER"])
else: else:
@@ -211,6 +217,7 @@ def remove_from_shelf(shelf_id, book_id):
return "", 204 return "", 204
else: else:
if not xhr: if not xhr:
log.warning("You are not allowed to remove a book from shelf: {}".format(shelf.name))
flash(_(u"Sorry you are not allowed to remove a book from this shelf: %(sname)s", sname=shelf.name), flash(_(u"Sorry you are not allowed to remove a book from this shelf: %(sname)s", sname=shelf.name),
category="error") category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
@@ -255,13 +262,14 @@ def create_edit_shelf(shelf, title, page, shelf_id=False):
log.info(u"Shelf {} {}".format(to_save["title"], shelf_action)) log.info(u"Shelf {} {}".format(to_save["title"], shelf_action))
flash(flash_text, category="success") flash(flash_text, category="success")
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id)) return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as ex:
ub.session.rollback() ub.session.rollback()
log.debug_or_exception(e) log.debug_or_exception(ex)
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
except Exception as e: flash(_("Settings DB is not Writeable"), category="error")
except Exception as ex:
ub.session.rollback() ub.session.rollback()
log.debug_or_exception(e) log.debug_or_exception(ex)
flash(_(u"There was an error"), category="error") flash(_(u"There was an error"), category="error")
return render_title_template('shelf_edit.html', shelf=shelf, title=title, page=page) return render_title_template('shelf_edit.html', shelf=shelf, title=title, page=page)
@@ -278,6 +286,7 @@ def check_shelf_is_unique(shelf, to_save, shelf_id=False):
.first() is None .first() is None
if not is_shelf_name_unique: if not is_shelf_name_unique:
log.error("A public shelf with the name '{}' already exists.".format(to_save["title"]))
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]), flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]),
category="error") category="error")
else: else:
@@ -288,6 +297,7 @@ def check_shelf_is_unique(shelf, to_save, shelf_id=False):
.first() is None .first() is None
if not is_shelf_name_unique: if not is_shelf_name_unique:
log.error("A private shelf with the name '{}' already exists.".format(to_save["title"]))
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]), flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]),
category="error") category="error")
return is_shelf_name_unique return is_shelf_name_unique
@@ -311,7 +321,8 @@ def delete_shelf(shelf_id):
delete_shelf_helper(cur_shelf) delete_shelf_helper(cur_shelf)
except InvalidRequestError: except InvalidRequestError:
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
@@ -345,7 +356,8 @@ def order_shelf(shelf_id):
ub.session.commit() ub.session.commit()
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError):
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
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()
result = list() result = list()
@@ -415,7 +427,8 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
ub.session.commit() ub.session.commit()
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError):
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
return render_title_template(page, return render_title_template(page,
entries=result, entries=result,
+64 -88
View File
@@ -577,10 +577,6 @@ body.shelforder > div.container-fluid > div.row-fluid > div.col-sm-10:before {
color: hsla(0, 0%, 100%, .7) color: hsla(0, 0%, 100%, .7)
} }
div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] > .downloadBtn {
border-left: 2px solid rgba(0, 0, 0, .15)
}
div[aria-label="Edit/Delete book"] > .btn { div[aria-label="Edit/Delete book"] > .btn {
width: 50px; width: 50px;
height: 60px; height: 60px;
@@ -1502,7 +1498,7 @@ body > div.container-fluid > div.row-fluid > div.col-sm-10 > div.discover > form
} }
#books > .cover > a:hover, #books_rand > .cover > a:hover, .book.isotope-item > .cover > a:hover, body > div.container-fluid > div.row-fluid > div.col-sm-10 > div.discover > form > div.col-sm-12 > div.col-sm-12 > div.col-sm-2 > a:hover { #books > .cover > a:hover, #books_rand > .cover > a:hover, .book.isotope-item > .cover > a:hover, body > div.container-fluid > div.row-fluid > div.col-sm-10 > div.discover > form > div.col-sm-12 > div.col-sm-12 > div.col-sm-2 > a:hover {
outline: solid var(--color-secondary); /* outline: solid var(--color-secondary); */
font-size: 50px; font-size: 50px;
-o-transition: outline 0s; -o-transition: outline 0s;
transition: outline 0s; transition: outline 0s;
@@ -1783,6 +1779,12 @@ body > div.container-fluid > div > div.col-sm-10 > div.discover {
margin-top: 0 margin-top: 0
} }
.container-fluid .book .meta .series {
/* font-weight: 400; */
/* font-size: 12px; */
color: hsla(0, 0%, 100%, .45);
}
.container-fluid .book .meta > p { .container-fluid .book .meta > p {
-o-text-overflow: ellipsis; -o-text-overflow: ellipsis;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -2119,8 +2121,7 @@ body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container
transition: all 0s transition: all 0s
} }
.well > form > .btn {
.book-meta > .bookinfo > .tags .btn-info, .well > form > .btn {
vertical-align: middle; vertical-align: middle;
-o-transition: background-color .2s, color .2s -o-transition: background-color .2s, color .2s
} }
@@ -2931,8 +2932,9 @@ body > div.container-fluid > div > div.col-sm-10 > div > div > div.col-sm-3.col-
} }
#bookDetailsModal > .modal-dialog.modal-lg > .modal-content > .modal-body > div > div > div > div.col-sm-3.col-lg-3.col-xs-5 > div.cover, body > div.container-fluid > div > div.col-sm-10 > div > div > div.col-sm-3.col-lg-3.col-xs-5 > div.cover { #bookDetailsModal > .modal-dialog.modal-lg > .modal-content > .modal-body > div > div > div > div.col-sm-3.col-lg-3.col-xs-5 > div.cover, body > div.container-fluid > div > div.col-sm-10 > div > div > div.col-sm-3.col-lg-3.col-xs-5 > div.cover {
margin: 0; margin: auto;
width: 100%; width: 100%;
max-width: 200px;
} }
#bookDetailsModal > .modal-dialog.modal-lg > .modal-content > .modal-body > div > div > div > div.col-sm-3.col-lg-3.col-xs-5 > div.cover > img, body > div.container-fluid > div > div.col-sm-10 > div > div > div.col-sm-3.col-lg-3.col-xs-5 > div.cover > img { #bookDetailsModal > .modal-dialog.modal-lg > .modal-content > .modal-body > div > div > div > div.col-sm-3.col-lg-3.col-xs-5 > div.cover > img, body > div.container-fluid > div > div.col-sm-10 > div > div > div.col-sm-3.col-lg-3.col-xs-5 > div.cover > img {
@@ -2957,46 +2959,35 @@ body > div.container-fluid > div > div.col-sm-10 > div > div > div.col-sm-3.col-
margin-top: 24px margin-top: 24px
} }
.book-meta > .bookinfo > .publishers > span:first-of-type, .book-meta > .bookinfo > .publishing-date > span:first-of-type { .book-meta > .bookinfo > .languages > span:first-of-type,
.book-meta > .bookinfo > .publishers > span:first-of-type,
.book-meta > .bookinfo > .publishing-date > span:first-of-type,
.real_custom_columns > span:first-of-type {
color: hsla(0, 0%, 100%, .45); color: hsla(0, 0%, 100%, .45);
text-transform: uppercase; text-transform: uppercase;
font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif;
width: 200px;
display: inline-block
} }
.book-meta > .bookinfo > .publishers > span:last-of-type, .book-meta > .bookinfo > .publishing-date > span:last-of-type { .book-meta > .bookinfo > .languages > span:last-of-type,
.book-meta > .bookinfo > .publishers > span:last-of-type,
.book-meta > .bookinfo > .publishing-date > span:last-of-type,
.real_custom_columns > span:last-of-type {
font-family: Open Sans Semibold, Helvetica Neue, Helvetica, Arial, sans-serif; font-family: Open Sans Semibold, Helvetica Neue, Helvetica, Arial, sans-serif;
color: #fff; color: #fff;
font-size: 15px; font-size: 15px;
-webkit-font-smoothing: antialiased -webkit-font-smoothing: antialiased
} }
.book-meta > .bookinfo > .publishers > span:last-of-type { .book-meta > .bookinfo > .languages > span > a,
padding-left: 90px .book-meta > .bookinfo > .publishers > span > a,
.book-meta > .bookinfo > .publishing-date > span > a,
.real_custom_columns > span > a {
color: #fff
} }
.real_custom_columns > span:last-of-type { .book-meta > h2, body.book .author {
padding-left: 90px
}
.book-meta > .bookinfo > .publishing-date > span:last-of-type {
padding-left: 90px
}
.book-meta > .bookinfo > .languages > span:first-of-type {
color: hsla(0, 0%, 100%, .45);
text-transform: uppercase;
font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif
}
.book-meta > .bookinfo > .languages > span:last-of-type {
font-size: 15px;
font-family: Open Sans Semibold, Helvetica Neue, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
color: #fff;
padding-left: 85px
}
.book-meta > .bookinfo > .tags .btn-info, .book-meta > h2, body.book .author {
font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif
} }
@@ -3077,34 +3068,10 @@ body.book .author {
background-color: rgba(0, 0, 0, .3) background-color: rgba(0, 0, 0, .3)
} }
.book-meta > .bookinfo > .identifiers > p > .btn-success, .book-meta > .bookinfo > .tags .btn-info {
overflow: hidden;
text-align: center;
white-space: nowrap;
margin: 2px 3px 0 0;
padding: 0 10px
}
.book-meta > .bookinfo > .tags .btn-info {
background-color: rgba(0, 0, 0, .15);
color: hsla(0, 0%, 100%, .7);
font-size: 13px;
display: inline-block;
border-radius: 4px;
-webkit-transition: background-color .2s, color .2s;
transition: background-color .2s, color .2s;
text-transform: none
}
.dropdown-menu, .tooltip.in { .dropdown-menu, .tooltip.in {
-webkit-transition: opacity .15s ease-out, -webkit-transform .15s cubic-bezier(.6, .4, .2, 1.4) -webkit-transition: opacity .15s ease-out, -webkit-transform .15s cubic-bezier(.6, .4, .2, 1.4)
} }
.book-meta > .bookinfo > .tags .btn-info:hover {
color: #fff;
text-decoration: underline
}
.book-meta > .bookinfo > .identifiers, .book-meta > .bookinfo > .tags { .book-meta > .bookinfo > .identifiers, .book-meta > .bookinfo > .tags {
padding-left: 40px; padding-left: 40px;
margin: 10px 0 margin: 10px 0
@@ -3166,7 +3133,7 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] > div.
float: left float: left
} }
#add-to-shelf, #btnGroupDrop1, #read-in-browser, #sendbtn, .book-meta .btn-toolbar > .btn-group > .btn-group:nth-child(1) > a:first-of-type, .book-meta .btn-toolbar > .btn-group > .btn-warning, .btn-toolbar > .btn-group > #btnGroupDrop2, .btn-toolbar > .btn-group > .btn-group > #btnGroupDrop2 { #add-to-shelf, #btnGroupDrop1, #readbtn, #sendbtn, .book-meta .btn-toolbar > .btn-group > .btn-group:nth-child(1) > a:first-of-type, .book-meta .btn-toolbar > .btn-group > .btn-warning, .btn-toolbar > .btn-group > #btnGroupDrop2, .btn-toolbar > .btn-group > .btn-group > #btnGroupDrop2 {
background: 0 0; background: 0 0;
color: transparent; color: transparent;
width: 50px; width: 50px;
@@ -3180,11 +3147,11 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] > div.
padding-bottom: 5px padding-bottom: 5px
} }
#add-to-shelf > span, #btnGroupDrop1 > span, #read-in-browser > span, #sendbtn > span, .book-meta .btn-toolbar > .btn-group > .btn-group:nth-child(1) > a:first-of-type > span, .book-meta .btn-toolbar > .btn-group > .btn-warning > span, .btn-toolbar > .btn-group > #btnGroupDrop2 > span, .btn-toolbar > .btn-group > .btn-group > #btnGroupDrop2 > span { #add-to-shelf > span, #btnGroupDrop1 > span, #readbtn > span, #sendbtn > span, .book-meta .btn-toolbar > .btn-group > .btn-group:nth-child(1) > a:first-of-type > span, .book-meta .btn-toolbar > .btn-group > .btn-warning > span, .btn-toolbar > .btn-group > #btnGroupDrop2 > span, .btn-toolbar > .btn-group > .btn-group > #btnGroupDrop2 > span {
color: hsla(0, 0%, 100%, .7) color: hsla(0, 0%, 100%, .7)
} }
#add-to-shelf:hover span, #btnGroupDrop1:hover > span, #read-in-browser:hover > span, #sendbtn:hover > span, .book-meta .btn-toolbar > .btn-group > .btn-group:nth-child(1) > a:first-of-type:hover > span, .book-meta .btn-toolbar > .btn-group > .btn-warning:hover > span, .btn-toolbar > .btn-group > #btnGroupDrop2:hover > span, .btn-toolbar > .btn-group > .btn-group > #btnGroupDrop2:hover > span { #add-to-shelf:hover span, #btnGroupDrop1:hover > span, #readbtn:hover > span, #sendbtn:hover > span, .book-meta .btn-toolbar > .btn-group > .btn-group:nth-child(1) > a:first-of-type:hover > span, .book-meta .btn-toolbar > .btn-group > .btn-warning:hover > span, .btn-toolbar > .btn-group > #btnGroupDrop2:hover > span, .btn-toolbar > .btn-group > .btn-group > #btnGroupDrop2:hover > span {
color: #fff color: #fff
} }
@@ -3194,13 +3161,13 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] > div.
font-size: 18px font-size: 18px
} }
#sendbtn > span, .book-meta .btn-toolbar > .btn-group > .btn-group:nth-child(1) > a:first-of-type > span, .book-meta .btn-toolbar > .btn-group > .btn-warning > span.glyphicon-edit { #sendbtn > span, #readbtn > span, .book-meta .btn-toolbar > .btn-group > .btn-group:nth-child(1) > a:first-of-type > span, .book-meta .btn-toolbar > .btn-group > .btn-warning > span.glyphicon-edit {
font-size: 16px; font-size: 16px;
line-height: 54px; line-height: 54px;
width: 100% width: 100%
} }
#read-in-browser > span.glyphicon-eye-open:before, .btn-toolbar > .btn-group > .btn-group > #btnGroupDrop2 > span.glyphicon-eye-open:before { #readbtn > span.glyphicon-book:before, .btn-toolbar > .btn-group > .btn-group > #btnGroupDrop2 > span.glyphicon-book:before {
content: "\e352"; content: "\e352";
font-family: Glyphicons Regular, serif; font-family: Glyphicons Regular, serif;
font-size: 18px; font-size: 18px;
@@ -3323,7 +3290,9 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] .dropd
-ms-transform-origin: center top; -ms-transform-origin: center top;
transform-origin: center top; transform-origin: center top;
border: 0; border: 0;
left: 0 !important left: 0 !important;
max-height: 80%;
overflow-y: auto;
} }
.dropdown-menu > li > a { .dropdown-menu > li > a {
@@ -3345,7 +3314,8 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] .dropd
box-shadow: none box-shadow: none
} }
.book-meta > .bookinfo > .identifiers > p > .btn-success { .book-meta > .bookinfo .btn-info,
.book-meta > .bookinfo .btn-success {
background-color: rgba(0, 0, 0, .15); background-color: rgba(0, 0, 0, .15);
color: hsla(0, 0%, 100%, .7); color: hsla(0, 0%, 100%, .7);
font-size: 13px; font-size: 13px;
@@ -3359,11 +3329,21 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] .dropd
text-transform: none text-transform: none
} }
.book-meta > .bookinfo > .identifiers > p > .btn-success:hover { .book-meta > .bookinfo .btn-info:hover,
.book-meta > .bookinfo .btn-success:hover {
color: #fff; color: #fff;
text-decoration: underline text-decoration: underline
} }
.book-meta > .bookinfo .btn-info,
.book-meta > .bookinfo .btn-success {
overflow: hidden;
text-align: center;
white-space: nowrap;
margin: 2px 3px 0 0;
padding: 0 10px
}
#bookDetailsModal .book-meta { #bookDetailsModal .book-meta {
color: hsla(0, 0%, 100%, .7); color: hsla(0, 0%, 100%, .7);
height: calc(100% - 120px); height: calc(100% - 120px);
@@ -5181,11 +5161,11 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
right: 5px right: 5px
} }
#read-in-browser[aria-expanded=true], #shelf-actions > .btn-group.open, .downloadBtn.open, .profileDrop[aria-expanded=true] { #shelf-actions > .btn-group.open, .downloadBtn.open, .profileDrop[aria-expanded=true] {
pointer-events: none pointer-events: none
} }
#btnGroupDrop1[aria-expanded=true] > span, #read-in-browser[aria-expanded=true] > span, #shelf-actions > .btn-group.open > #add-to-shelf > span { #btnGroupDrop1[aria-expanded=true] > span, #shelf-actions > .btn-group.open > #add-to-shelf > span {
color: #fff color: #fff
} }
@@ -6970,16 +6950,12 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
margin: 45px margin: 45px
} }
.book-meta > .bookinfo > .publishing-date > span:last-of-type { .book-meta > .bookinfo > .languages > span:first-of-type,
padding-left: 25px .book-meta > .bookinfo > .publishers > span:first-of-type,
} .book-meta > .bookinfo > .publishing-date > span:first-of-type,
.real_custom_columns > span:first-of-type {
.book-meta > .bookinfo > .publishers > span:last-of-type { width: 50%;
padding-left: 70px max-width: 200px;
}
.book-meta > .bookinfo > .languages > span:last-of-type {
padding-left: 65px
} }
.book-meta > .bookinfo .publishers { .book-meta > .bookinfo .publishers {
@@ -7506,14 +7482,14 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
margin-left: 8px margin-left: 8px
} }
#read-in-browser > span.caret, .btn-toolbar > .btn-group > #btnGroupDrop2 > span.caret, .btn-toolbar > .btn-group > .btn-group > #btnGroupDrop2 > span.caret { .btn-toolbar > .btn-group > #btnGroupDrop2 > span.caret, .btn-toolbar > .btn-group > .btn-group > #btnGroupDrop2 > span.caret {
position: absolute; position: absolute;
margin-left: 0; margin-left: 0;
left: 33px; left: 33px;
top: 28px top: 28px
} }
#read-in-browser > span.glyphicon-eye-open:before, .btn-toolbar > .btn-group > .btn-group > #btnGroupDrop2 > span.glyphicon-eye-open:before { .btn-toolbar > .btn-group > .btn-group > #btnGroupDrop2 > span.glyphicon-eye-open:before {
margin-left: 8px margin-left: 8px
} }
@@ -7914,7 +7890,7 @@ body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover .glyphic
} }
} }
#sendbtn2 { #sendbtn2, #read-in-browser {
background: 0 0; background: 0 0;
color: transparent; color: transparent;
width: 50px; width: 50px;
@@ -7924,15 +7900,15 @@ body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover .glyphic
padding: 0 padding: 0
} }
#sendbtn2 > span { #sendbtn2 > span, #read-in-browser > span {
color: hsla(0, 0%, 100%, .7) color: hsla(0, 0%, 100%, .7)
} }
#sendbtn2 > span.glyphicon-send:before { #sendbtn2 > span.glyphicon-send:before, #read-in-browser > span.glyphicon-book:before {
margin-left: 8px margin-left: 8px
} }
#sendbtn2 > span.caret { #sendbtn2> span.caret, #read-in-browser > span.caret {
position: absolute; position: absolute;
margin-left: 0; margin-left: 0;
left: 33px; left: 33px;
@@ -7940,11 +7916,11 @@ body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover .glyphic
padding-bottom: 5px padding-bottom: 5px
} }
#sendbtn2:focus span, #sendbtn2:hover span { #sendbtn2:focus span, #sendbtn2:hover span, #read-in-browser:focus span, #read-in-browser:hover span {
color: #fff color: #fff
} }
#sendbtn2[aria-expanded=true] { #sendbtn2[aria-expanded=true], #read-in-browser[aria-expanded=true] {
pointer-events: none pointer-events: none
} }
+9 -7
View File
@@ -1,7 +1,8 @@
body.serieslist.grid-view div.container-fluid>div>div.col-sm-10:before{ body.serieslist.grid-view div.container-fluid > div > div.col-sm-10::before {
display: none; display: none;
} }
.cover .badge{
.cover .badge {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@@ -9,14 +10,15 @@ body.serieslist.grid-view div.container-fluid>div>div.col-sm-10:before{
background-color: #cc7b19; background-color: #cc7b19;
border-radius: 0; border-radius: 0;
padding: 0 8px; padding: 0 8px;
box-shadow: 0 0 4px rgba(0,0,0,.6); box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
line-height: 24px; line-height: 24px;
} }
.cover{
box-shadow: 0 0 4px rgba(0,0,0,.6); .cover {
box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
} }
.cover .read{ .cover .read {
padding: 0 0px; padding: 0 0;
line-height: 15px; line-height: 15px;
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 509 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+24 -5
View File
@@ -33,7 +33,6 @@ body {
position: relative; position: relative;
cursor: pointer; cursor: pointer;
padding: 4px; padding: 4px;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@@ -45,7 +44,7 @@ body {
#sidebar a.active, #sidebar a.active,
#sidebar a.active img + span { #sidebar a.active img + span {
background-color: #45B29D; background-color: #45b29d;
} }
#sidebar li img { #sidebar li img {
@@ -85,15 +84,24 @@ body {
#progress .bar-load, #progress .bar-load,
#progress .bar-read { #progress .bar-read {
display: flex; display: flex;
align-items: flex-end;
justify-content: flex-end;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0;
bottom: 0; bottom: 0;
transition: width 150ms ease-in-out; transition: width 150ms ease-in-out;
} }
#progress .from-left {
left: 0;
align-items: flex-end;
justify-content: flex-end;
}
#progress .from-right {
right: 0;
align-items: flex-start;
justify-content: flex-start;
}
#progress .bar-load { #progress .bar-load {
color: #000; color: #000;
background-color: #ccc; background-color: #ccc;
@@ -219,3 +227,14 @@ th {
.dark-theme .overlay { .dark-theme .overlay {
background-color: rgba(0, 0, 0, 0.8); background-color: rgba(0, 0, 0, 0.8);
} }
/* Hide scrollbar for Chrome, Safari and Opera */
.disabled-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.disabled-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
fill="rgba(255,255,255,1)"><path d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8
9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"></path></svg>

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 B

@@ -0,0 +1,4 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8 9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"></path></svg>

After

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 B

@@ -0,0 +1,5 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
fill="rgba(255,255,255,1)"><path d="M13 11a1 1 0 0 1-.707-.293L8 6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 11z"></path></svg>

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 B

@@ -0,0 +1,4 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M13 11a1 1 0 0 1-.707-.293L8 6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 11z"></path></svg>

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 B

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"
fill="rgba(255,255,255,1)" style="animation:spinLoadingIcon 1s steps(12,end)
infinite"><style>@keyframes
spinLoadingIcon{to{transform:rotate(360deg)}}</style><path
d="M7 3V1s0-1 1-1 1 1 1 1v2s0 1-1 1-1-1-1-1z"/><path d="M4.63
4.1l-1-1.73S3.13 1.5 4 1c.87-.5 1.37.37 1.37.37l1 1.73s.5.87-.37
1.37c-.87.57-1.37-.37-1.37-.37z" fill-opacity=".93"/><path
d="M3.1 6.37l-1.73-1S.5 4.87 1 4c.5-.87 1.37-.37 1.37-.37l1.73 1s.87.5.37
1.37c-.5.87-1.37.37-1.37.37z" fill-opacity=".86"/><path d="M3
9H1S0 9 0 8s1-1 1-1h2s1 0 1 1-1 1-1 1z" fill-opacity=".79"/><path d="M4.1 11.37l-1.73 1S1.5 12.87 1
12c-.5-.87.37-1.37.37-1.37l1.73-1s.87-.5 1.37.37c.5.87-.37 1.37-.37 1.37z"
fill-opacity=".72"/><path d="M3.63 13.56l1-1.73s.5-.87
1.37-.37c.87.5.37 1.37.37 1.37l-1 1.73s-.5.87-1.37.37c-.87-.5-.37-1.37-.37-1.37z"
fill-opacity=".65"/><path d="M7 15v-2s0-1 1-1 1 1 1 1v2s0 1-1
1-1-1-1-1z" fill-opacity=".58"/><path d="M10.63
14.56l-1-1.73s-.5-.87.37-1.37c.87-.5 1.37.37 1.37.37l1 1.73s.5.87-.37
1.37c-.87.5-1.37-.37-1.37-.37z" fill-opacity=".51"/><path
d="M13.56 12.37l-1.73-1s-.87-.5-.37-1.37c.5-.87 1.37-.37 1.37-.37l1.73 1s.87.5.37
1.37c-.5.87-1.37.37-1.37.37z" fill-opacity=".44"/><path d="M15
9h-2s-1 0-1-1 1-1 1-1h2s1 0 1 1-1 1-1 1z" fill-opacity=".37"/><path d="M14.56 5.37l-1.73
1s-.87.5-1.37-.37c-.5-.87.37-1.37.37-1.37l1.73-1s.87-.5 1.37.37c.5.87-.37 1.37-.37
1.37z" fill-opacity=".3"/><path d="M9.64 3.1l.98-1.66s.5-.874
1.37-.37c.87.5.37 1.37.37 1.37l-1 1.73s-.5.87-1.37.37c-.87-.5-.37-1.37-.37-1.37z"
fill-opacity=".23"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" style="animation:spinLoadingIcon 1s steps(12,end) infinite"><style>@keyframes spinLoadingIcon{to{transform:rotate(360deg)}}</style><path d="M7 3V1s0-1 1-1 1 1 1 1v2s0 1-1 1-1-1-1-1z"/><path d="M4.63 4.1l-1-1.73S3.13 1.5 4 1c.87-.5 1.37.37 1.37.37l1 1.73s.5.87-.37 1.37c-.87.57-1.37-.37-1.37-.37z" fill-opacity=".93"/><path d="M3.1 6.37l-1.73-1S.5 4.87 1 4c.5-.87 1.37-.37 1.37-.37l1.73 1s.87.5.37 1.37c-.5.87-1.37.37-1.37.37z" fill-opacity=".86"/><path d="M3 9H1S0 9 0 8s1-1 1-1h2s1 0 1 1-1 1-1 1z" fill-opacity=".79"/><path d="M4.1 11.37l-1.73 1S1.5 12.87 1 12c-.5-.87.37-1.37.37-1.37l1.73-1s.87-.5 1.37.37c.5.87-.37 1.37-.37 1.37z" fill-opacity=".72"/><path d="M3.63 13.56l1-1.73s.5-.87 1.37-.37c.87.5.37 1.37.37 1.37l-1 1.73s-.5.87-1.37.37c-.87-.5-.37-1.37-.37-1.37z" fill-opacity=".65"/><path d="M7 15v-2s0-1 1-1 1 1 1 1v2s0 1-1 1-1-1-1-1z" fill-opacity=".58"/><path d="M10.63 14.56l-1-1.73s-.5-.87.37-1.37c.87-.5 1.37.37 1.37.37l1 1.73s.5.87-.37 1.37c-.87.5-1.37-.37-1.37-.37z" fill-opacity=".51"/><path d="M13.56 12.37l-1.73-1s-.87-.5-.37-1.37c.5-.87 1.37-.37 1.37-.37l1.73 1s.87.5.37 1.37c-.5.87-1.37.37-1.37.37z" fill-opacity=".44"/><path d="M15 9h-2s-1 0-1-1 1-1 1-1h2s1 0 1 1-1 1-1 1z" fill-opacity=".37"/><path d="M14.56 5.37l-1.73 1s-.87.5-1.37-.37c-.5-.87.37-1.37.37-1.37l1.73-1s.87-.5 1.37.37c.5.87-.37 1.37-.37 1.37z" fill-opacity=".3"/><path d="M9.64 3.1l.98-1.66s.5-.874 1.37-.37c.87.5.37 1.37.37 1.37l-1 1.73s-.5.87-1.37.37c-.87-.5-.37-1.37-.37-1.37z" fill-opacity=".23"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,16 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16
16"
fill="rgba(255,255,255,1)">
<path
d="M8 16a8 8 0 1 1 8-8 8.009 8.009 0 0 1-8 8zM8 2a6 6 0 1 0 6 6 6.006 6.006 0 0 0-6-6z">
</path>
<path
d="M8 7a1 1 0 0 0-1 1v3a1 1 0 0 0 2 0V8a1 1 0 0 0-1-1z">
</path>
<circle
cx="8" cy="5" r="1.188">
</circle>
</svg>

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 B

@@ -0,0 +1,15 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16
16">
<path
d="M8 16a8 8 0 1 1 8-8 8.009 8.009 0 0 1-8 8zM8 2a6 6 0 1 0 6 6 6.006 6.006 0 0 0-6-6z">
</path>
<path
d="M8 7a1 1 0 0 0-1 1v3a1 1 0 0 0 2 0V8a1 1 0 0 0-1-1z">
</path>
<circle
cx="8" cy="5" r="1.188">
</circle>
</svg>

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 B

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
fill="rgba(255,255,255,1)"><path d="M13 13c-.3 0-.5-.1-.7-.3L8 8.4l-4.3 4.3c-.9.9-2.3-.5-1.4-1.4l5-5c.4-.4 1-.4 1.4 0l5 5c.6.6.2 1.7-.7 1.7zm0-11H3C1.7 2 1.7 4 3 4h10c1.3 0 1.3-2 0-2z"/></svg>

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M13 13c-.3 0-.5-.1-.7-.3L8 8.4l-4.3 4.3c-.9.9-2.3-.5-1.4-1.4l5-5c.4-.4 1-.4 1.4 0l5 5c.6.6.2 1.7-.7 1.7zm0-11H3C1.7 2 1.7 4 3 4h10c1.3 0 1.3-2 0-2z"/></svg>

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 B

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"
fill="rgba(255,255,255,1)"><path d="M15 3.7V13c0 1.5-1.53 3-3 3H7.13c-.72 0-1.63-.5-2.13-1l-5-5s.84-1 .87-1c.13-.1.33-.2.53-.2.1 0 .3.1.4.2L4 10.6V2.7c0-.6.4-1 1-1s1 .4 1 1v4.6h1V1c0-.6.4-1 1-1s1 .4 1 1v6.3h1V1.7c0-.6.4-1 1-1s1 .4 1 1v5.7h1V3.7c0-.6.4-1 1-1s1 .4 1 1z"/></svg>

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M15 3.7V13c0 1.5-1.53 3-3 3H7.13c-.72 0-1.63-.5-2.13-1l-5-5s.84-1 .87-1c.13-.1.33-.2.53-.2.1 0 .3.1.4.2L4 10.6V2.7c0-.6.4-1 1-1s1 .4 1 1v4.6h1V1c0-.6.4-1 1-1s1 .4 1 1v6.3h1V1.7c0-.6.4-1 1-1s1 .4 1 1v5.7h1V3.7c0-.6.4-1 1-1s1 .4 1 1z"/></svg>

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 B

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
fill="rgba(255,255,255,1)"><path d="M8 10c-.3 0-.5-.1-.7-.3l-5-5c-.9-.9.5-2.3 1.4-1.4L8 7.6l4.3-4.3c.9-.9 2.3.5 1.4 1.4l-5 5c-.2.2-.4.3-.7.3zm5 2H3c-1.3 0-1.3 2 0 2h10c1.3 0 1.3-2 0-2z"/></svg>

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 10c-.3 0-.5-.1-.7-.3l-5-5c-.9-.9.5-2.3 1.4-1.4L8 7.6l4.3-4.3c.9-.9 2.3.5 1.4 1.4l-5 5c-.2.2-.4.3-.7.3zm5 2H3c-1.3 0-1.3 2 0 2h10c1.3 0 1.3-2 0-2z"/></svg>

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 B

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"
fill="rgba(255,255,255,1)"><path d="M1 1a1 1 0 011 1v2.4A7 7 0 118 15a7 7 0 01-4.9-2 1 1 0 011.4-1.5 5 5 0 10-1-5.5H6a1 1 0 010 2H1a1 1 0 01-1-1V2a1 1 0 011-1z"/></svg>

After

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M1 1a1 1 0 011 1v2.4A7 7 0 118 15a7 7 0 01-4.9-2 1 1 0 011.4-1.5 5 5 0 10-1-5.5H6a1 1 0 010 2H1a1 1 0 01-1-1V2a1 1 0 011-1z"/></svg>

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 B

@@ -0,0 +1,5 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
fill="rgba(255,255,255,1)"><path d="M15 1a1 1 0 0 0-1 1v2.418A6.995 6.995 0 1 0 8 15a6.954 6.954 0 0 0 4.95-2.05 1 1 0 0 0-1.414-1.414A5.019 5.019 0 1 1 12.549 6H10a1 1 0 0 0 0 2h5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"></path></svg>

After

Width:  |  Height:  |  Size: 521 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 B

@@ -0,0 +1,4 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M15 1a1 1 0 0 0-1 1v2.418A6.995 6.995 0 1 0 8 15a6.954 6.954 0 0 0 4.95-2.05 1 1 0 0 0-1.414-1.414A5.019 5.019 0 1 1 12.549 6H10a1 1 0 0 0 0 2h5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"></path></svg>

After

Width:  |  Height:  |  Size: 494 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 714 B

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"
fill="rgba(255,255,255,1)"><path d="M0 4h1.5c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5H0zM9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM16 4h-1.5c-1 0-1.5.5-1.5 1.5v5c0 1 .5 1.5 1.5 1.5H16z"/></svg>

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M0 4h1.5c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5H0zM9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM16 4h-1.5c-1 0-1.5.5-1.5 1.5v5c0 1 .5 1.5 1.5 1.5H16z"/></svg>

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 B

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"
fill="rgba(255,255,255,1)"><path d="M9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM11 0v.5c0 1-.5 1.5-1.5 1.5h-3C5.5 2 5 1.5 5 .5V0h6zM11 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6z"/></svg>

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM11 0v.5c0 1-.5 1.5-1.5 1.5h-3C5.5 2 5 1.5 5 .5V0h6zM11 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6z"/></svg>

After

Width:  |  Height:  |  Size: 280 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 B

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
fill="rgba(255,255,255,1)"><path d="M5.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C1 4.5 1.5 4 2.5 4zM7 0v.5C7 1.5 6.5 2 5.5 2h-3C1.5 2 1 1.5 1 .5V0h6zM7 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6zM13.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5c0-1 .5-1.5 1.5-1.5zM15 0v.5c0 1-.5 1.5-1.5 1.5h-3C9.5 2 9 1.5 9 .5V0h6zM15 16v-.507c0-1-.5-1.5-1.5-1.5h-3C9.5 14 9 14.5 9 15.5v.5h6z"/></svg>

After

Width:  |  Height:  |  Size: 509 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M5.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C1 4.5 1.5 4 2.5 4zM7 0v.5C7 1.5 6.5 2 5.5 2h-3C1.5 2 1 1.5 1 .5V0h6zM7 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6zM13.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5c0-1 .5-1.5 1.5-1.5zM15 0v.5c0 1-.5 1.5-1.5 1.5h-3C9.5 2 9 1.5 9 .5V0h6zM15 16v-.507c0-1-.5-1.5-1.5-1.5h-3C9.5 14 9 14.5 9 15.5v.5h6z"/></svg>

After

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 B

@@ -0,0 +1,5 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
fill="rgba(255,255,255,1)"><path d="M12.408 8.217l-8.083-6.7A.2.2 0 0 0 4 1.672V12.3a.2.2 0 0 0 .333.146l2.56-2.372 1.857 3.9A1.125 1.125 0 1 0 10.782 13L8.913 9.075l3.4-.51a.2.2 0 0 0 .095-.348z"></path></svg>

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 461 B

@@ -0,0 +1,4 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M12.408 8.217l-8.083-6.7A.2.2 0 0 0 4 1.672V12.3a.2.2 0 0 0 .333.146l2.56-2.372 1.857 3.9A1.125 1.125 0 1 0 10.782 13L8.913 9.075l3.4-.51a.2.2 0 0 0 .095-.348z"></path></svg>

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
fill="rgba(255,255,255,1)"><path d="M1.5 3.5C.5 3.5 0 4 0 5v6.5c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm2 1.2c.8 0 1.4.2 1.8.6.5.4.7 1 .7 1.7 0 .5-.2 1-.5 1.4-.2.3-.5.7-1 1l-.6.4c-.4.3-.6.4-.75.56-.15.14-.25.24-.35.44H6v1.3H1c0-.6.1-1.1.3-1.5.3-.6.7-1 1.5-1.6.7-.4 1.1-.8 1.28-1 .32-.3.42-.6.42-1 0-.3-.1-.6-.23-.8-.17-.2-.37-.3-.77-.3s-.7.1-.9.5c-.04.2-.1.5-.1.9H1.1c0-.6.1-1.1.3-1.5.4-.7 1.1-1.1 2.1-1.1zM10.54 3.54C9.5 3.54 9 4 9 5v6.5c0 1 .5 1.5 1.54 1.5h4c.96 0 1.46-.5 1.46-1.5V5c0-1-.5-1.46-1.5-1.46zm1.9.95c.7 0 1.3.2 1.7.5.4.4.6.8.6 1.4 0 .4-.1.8-.4 1.1-.2.2-.3.3-.5.4.1 0 .3.1.6.3.4.3.5.8.5 1.4 0 .6-.2 1.2-.6 1.6-.4.5-1.1.7-1.9.7-1 0-1.8-.3-2.2-1-.14-.29-.24-.69-.24-1.29h1.4c0 .3 0 .5.1.7.2.4.5.5 1 .5.3 0 .5-.1.7-.3.2-.2.3-.5.3-.8 0-.5-.2-.8-.6-.95-.2-.05-.5-.15-1-.15v-1c.5 0 .8-.1 1-.14.3-.1.5-.4.5-.9 0-.3-.1-.5-.2-.7-.2-.2-.4-.3-.7-.3-.3 0-.6.1-.75.3-.2.2-.2.5-.2.86h-1.34c0-.4.1-.7.19-1.1 0-.12.2-.32.4-.62.2-.2.4-.3.7-.4.3-.1.6-.1 1-.1z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 347 B

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