Compare commits

..
669 Commits
Author SHA1 Message Date
Ozzieisaacs 0735fb1e92 Fix #1349 (Fix error on move cover with foreign file systems, e.g. samba shares)
Preparation for release
2020-05-05 20:28:10 +02:00
Ozzieisaacs 850a85915b Fix #1354 (Error on uploading single book, because of missing rarfile) 2020-05-05 18:48:40 +02:00
Ozzieisaacs fcbeeca305 Merge remote-tracking branch 'it/patch-11' 2020-05-04 20:21:05 +02:00
Ozzieisaacs fb16429867 Randomize flask secret_key 2020-05-04 19:02:03 +02:00
ElQuimm db38d7ee78 Updated version of italian.po
:-)
2020-05-03 21:30:06 +02:00
OzzieIsaacs 36a984ce3c Revert proxyfix 2020-05-02 18:17:52 +02:00
Ozzieisaacs 1ce432b136 Merge remote-tracking branch 'kobo/fix_covers_merge' 2020-05-02 09:34:24 +02:00
Michael Shavit e0fbfa44a4 Fix issue with cover images introduced during the merge of #1277. 2020-05-02 01:55:14 -04:00
Ozzieisaacs 547bbecef1 Merge branch 'master' into Develop 2020-05-01 17:16:28 +02:00
Ozzieisaacs 700cb3b553 Merge remote-tracking branch 'NL/master'
Updated all strings
2020-05-01 17:15:59 +02:00
Ozzieisaacs 8646f8f23a Merge branch 'Develop'
# Conflicts:
#	cps/__init__.py
2020-05-01 14:51:54 +02:00
OzzieIsaacs 99cc69c67d Update Teststatus 2020-05-01 14:38:54 +02:00
OzzieIsaacs 2c5d76908a Added missing upload format 2020-05-01 13:34:16 +02:00
Ozzieisaacs 832b34fc54 Improved errorhandling for resending password
Improved errorhandling for editing user
2020-05-01 12:00:45 +02:00
Ozzieisaacs 000b85ff81 Fixes for deleting books(error handling and user feedback) 2020-05-01 10:26:35 +02:00
Ozzieisaacs bb317d54f2 Fix reject reset password without configured email server 2020-05-01 08:37:54 +02:00
Ozzieisaacs d6f41d8dc0 Fix error 404 on reset password (Fix #1342) 2020-05-01 08:33:50 +02:00
Marcel 6dff5ed679 NL language update 2020-04-30 21:30:50 +02:00
OzzieIsaacs fb8b6310d5 Fix from tests 2020-04-30 20:58:01 +02:00
Ozzieisaacs 02aaf17ac5 Fix #1339 (Proxyfix import with old werkzeug versions causes traceback) 2020-04-30 19:21:08 +02:00
Ozzieisaacs b160a8de0b Merge branch 'master' into Develop
# Conflicts:
#	cps/__init__.py
#	cps/comic.py
#	cps/editbooks.py
#	cps/helper.py
#	cps/kobo.py
#	cps/translations/nl/LC_MESSAGES/messages.mo
#	cps/translations/nl/LC_MESSAGES/messages.po
#	cps/ub.py
#	cps/uploader.py
#	cps/web.py
2020-04-30 18:08:28 +02:00
Ozzieisaacs e3246fd751 Merge remote-tracking branch 'key' 2020-04-30 17:29:41 +02:00
Ozzieisaacs 91b1775f50 Fix #1225 (Unrar binary added, cover files from cbr files can be extracted) 2020-04-29 19:02:35 +02:00
Ozzieisaacs fb18ab1ca5 Fix #866 (Recent book sidebar element can't be removed anymore) 2020-04-29 18:57:39 +02:00
Ozzieisaacs 01ff55c84e Removed non working filters for search 2020-04-29 17:57:53 +02:00
jvoisin 523aab2e9e Don't use an hardcoded session key
This fixes a trivial authentication bypass,
according to https://flask.palletsprojects.com/en/1.1.x/quickstart/#sessions
2020-04-29 13:59:34 +02:00
Ozzieisaacs 9a7d9da654 Merge remote-tracking branch 'js_caliblur/xss' 2020-04-29 12:08:51 +02:00
Ozzieisaacs e9446556a1 Merge remote-tracking branch 'ru/master' 2020-04-29 12:06:35 +02:00
jvoisin 806a5f209f Fix two minor xss 2020-04-29 11:33:33 +02:00
ZIzA c864b368b0 Russian language update 2020-04-29 01:30:14 +04:00
Ozzieisaacs 27eb09fb19 Add unrar Support 2020-04-28 20:23:39 +02:00
Ozzieisaacs bea7223a0a Renabled cbrimage extraction (via unrar and rarfile) 2020-04-28 16:50:08 +02:00
Ozzieisaacs 0297823bda Merge remote-tracking branch 'codecosmetic/move_var' 2020-04-28 15:33:25 +02:00
Ozzieisaacs d1bce5c2d9 Update nl translation from development branch 2020-04-27 21:15:19 +02:00
Ozzieisaacs 46c0ae3ccc Revert accidently deleted nl translation strings 2020-04-27 21:11:43 +02:00
Ozzieisaacs ce6b689147 Code cosmetics 2020-04-27 20:22:55 +02:00
Ozzieisaacs 2d92818613 Code cosmetics 2020-04-27 20:01:13 +02:00
jvoisin 487878819e Move a few variables around
This should prevent a couple of crashes
due to undeclared variables.
2020-04-27 12:03:54 +02:00
Ozzieisaacs 6682b1ced1 Fix download of book covers with enabled pillow from url 2020-04-26 21:00:29 +02:00
Ozzieisaacs bc89b0658a Fix deletion of books with multiple custom column links
Fix delete of author-folder in case last book of author was deleted (#1269)
2020-04-26 20:44:37 +02:00
Ozzieisaacs d9dde36c74 Merge remote-tracking branch 'improve_read/sort-book-name' search results are ordered according to sort field in book title 2020-04-26 19:00:36 +02:00
Ozzieisaacs 44284ea5fb Merge remote-tracking branch 'opds/master'
# Conflicts:
#	cps/translations/cs/LC_MESSAGES/messages.po
#	cps/translations/de/LC_MESSAGES/messages.po
#	cps/translations/es/LC_MESSAGES/messages.po
#	cps/translations/fi/LC_MESSAGES/messages.po
#	cps/translations/fr/LC_MESSAGES/messages.po
#	cps/translations/hu/LC_MESSAGES/messages.po
#	cps/translations/it/LC_MESSAGES/messages.po
#	cps/translations/ja/LC_MESSAGES/messages.po
#	cps/translations/km/LC_MESSAGES/messages.po
#	cps/translations/nl/LC_MESSAGES/messages.po
#	cps/translations/pl/LC_MESSAGES/messages.po
#	cps/translations/ru/LC_MESSAGES/messages.po
#	cps/translations/sv/LC_MESSAGES/messages.po
#	cps/translations/uk/LC_MESSAGES/messages.po
#	cps/translations/zh_Hans_CN/LC_MESSAGES/messages.po
#	messages.pot
2020-04-26 18:55:23 +02:00
Ozzieisaacs 9f0c0b34af Merge remote-tracking branch 'order_category/master' 2020-04-26 18:50:28 +02:00
Ozzieisaacs 87ec44aed5 Merge remote-tracking branch 'improve_reaad/keep-focus-on-search-input' 2020-04-26 18:38:09 +02:00
Ozzieisaacs 898e6b4f80 Calibre-Web is rebooted everytime login type ic shanged (from and to standard authentication and between login methods) 2020-04-26 18:24:22 +02:00
Ozzieisaacs dea2600913 Merge remote-tracking branch 'oauth/patch-2' Fix oauth in combination with nginx reverse proxy 2020-04-26 18:23:16 +02:00
Ozzieisaacs dc46ad16ae Bugfix view shelfs on book detail page 2020-04-26 11:35:47 +02:00
Ozzieisaacs 456550a943 Bugfix view shelfs on detiled page
bugfix datetime in archive mode
2020-04-26 11:34:10 +02:00
Ozzieisaacs 7b789b3701 Fix opds with shelves
Fix errorhandler ldap without ldap
2020-04-26 08:40:53 +02:00
Ozzieisaacs 0480d493cf Fix opds login with ldap, if ldap server is down 2020-04-25 21:12:07 +02:00
Ozzieisaacs b4d7733e0a Merge branch 'master' into Develop
# Conflicts:
#	cps/templates/list.html
#	cps/translations/cs/LC_MESSAGES/messages.po
#	cps/translations/de/LC_MESSAGES/messages.po
#	cps/translations/es/LC_MESSAGES/messages.po
#	cps/translations/fi/LC_MESSAGES/messages.po
#	cps/translations/fr/LC_MESSAGES/messages.po
#	cps/translations/hu/LC_MESSAGES/messages.po
#	cps/translations/it/LC_MESSAGES/messages.po
#	cps/translations/ja/LC_MESSAGES/messages.po
#	cps/translations/km/LC_MESSAGES/messages.po
#	cps/translations/nl/LC_MESSAGES/messages.po
#	cps/translations/pl/LC_MESSAGES/messages.po
#	cps/translations/ru/LC_MESSAGES/messages.po
#	cps/translations/sv/LC_MESSAGES/messages.po
#	cps/translations/uk/LC_MESSAGES/messages.po
#	cps/translations/zh_Hans_CN/LC_MESSAGES/messages.po
#	messages.pot
2020-04-25 20:06:25 +02:00
Ozzieisaacs 8b8fe7a0ae bugfix on create shelv via kobo sync protocol
bugfix for totally wrong json requests
prevent empty shelf names on kobo create shelf
Removed errorhandler 404
2020-04-25 20:02:55 +02:00
Ozzie Isaacs 85ffc90f66 Update markup 2020-04-25 08:56:51 +02:00
Ozzieisaacs fa38798066 Added contributing file 2020-04-25 08:55:01 +02:00
Ozzieisaacs d657330584 Fix for removing admin role from last admin user (in addition to prevent delete of last admin user) #1326 2020-04-25 07:13:55 +02:00
Ozzieisaacs 36cb79de62 Merge remote-tracking branch 'turkish/master' 2020-04-24 22:14:57 +02:00
Ozzieisaacs 063ee5e855 Merge remote-tracking branch 'remove_usage_local_var_undeclared'
Code cosmetics
2020-04-24 21:05:56 +02:00
Ozzieisaacs 7393b69757 Fix for #1326 (typo) 2020-04-24 19:25:11 +02:00
Ozzieisaacs 8bd1903d98 Fix for #1326 (opds feed user login not working -> download was not possible) 2020-04-24 18:37:29 +02:00
jvoisin d8bf540db2 Remove an undeclared and useless local variable 2020-04-24 18:04:27 +02:00
Ozzieisaacs e29f17ac46 Merge remote-tracking branch 'kobo/shelves' into Develop
# Conflicts:
#	cps/kobo.py
#	cps/shelf.py
2020-04-24 16:56:08 +02:00
iz 1770f3fd0d Update turkish translation headers 2020-04-23 22:47:24 +03:00
iz 4239f2ed71 Add turkish translation 2020-04-23 22:37:44 +03:00
iz 3939ec28ba Add turkish language names 2020-04-23 22:37:35 +03:00
Michael Shavit 742ec2b38d Fix issue with the delete_book changes introduced in
41a3623
2020-04-22 00:19:33 -04:00
Michael Shavit 9296d35517 Fix bug with shelf deletions for Kobo.
We were incorrectly setting the user_id field in the ShelfArchive table
2020-04-21 23:57:20 -04:00
Michael Shavit 06c15a792e Minor fixes to the Kobo shelves implementation (mostly typos) 2020-04-21 23:36:47 -04:00
Ozzieisaacs 95ca1e6a9d Fix for #1319 2020-04-20 18:56:39 +02:00
Ozzieisaacs 1df82110d1 Update Italian language translation 2020-04-19 19:13:10 +02:00
Ozzieisaacs 24c743d23d Code cosmetics 2020-04-19 19:08:58 +02:00
ElQuimm f1b1ebe29e Updated italian version of messages.po 2020-04-19 16:38:30 +02:00
Jeff 6384cdc74d Fix https github oauth
while using https domian and nginx as proxy, a `ProxyFix` is required.
2020-04-19 19:53:41 +08:00
celogeek d093fbb069 Keep previous search value and focus 2020-04-19 13:12:02 +02:00
celogeek f9b4505843 Sort search result by Books sort
It is far easier to search for a
series or anything and see
books in sort order
2020-04-19 13:03:46 +02:00
Ozzieisaacs 902685a197 Fix text in user config section (random books in Detail View) 2020-04-18 21:30:28 +02:00
Ozzieisaacs c04b146486 Fix #1309 (fix duplicate shelf names in book detail view) 2020-04-18 21:15:02 +02:00
Ozzieisaacs 7bb5afa585 Merge remote-tracking branch 'kobo/shelves' into Develop 2020-04-18 21:12:21 +02:00
Ozzieisaacs 06fde4fcd0 Fix #1311 (Enable LDAP Anonymous Login) 2020-04-18 11:48:34 +02:00
Ozzieisaacs 195845ab0c Fix 2020-04-17 18:17:35 +02:00
Ozzieisaacs 81a329f1e7 Fix for #1087 ( Caliblur! Theme button alignment) 2020-04-17 18:16:28 +02:00
OzzieIsaacs cd6272a1c9 Update teststatus 2020-04-16 22:00:39 +02:00
Ozzieisaacs 53be752787 Merge remote-tracking branch 'it/patch-10' 2020-04-16 20:17:02 +02:00
Ozzieisaacs 42ac06c114 Merge remote-tracking branch 'nl/Develop' into Develop 2020-04-16 20:14:56 +02:00
Ozzieisaacs 9e159ed5ab Merge branch 'master' into Develop
# Conflicts:
#	cps/config_sql.py
#	cps/ub.py
#	cps/web.py
2020-04-16 20:12:27 +02:00
Ozzieisaacs 2c42972230 Fix for #1307 (Oauth login not working)
- Changed blueprint reference in Backend from name to id, users can now again be found
- Depending on version of flask-dance the resultcode of authorized is set different to prevent redirect loop with newer versions(>1.3)
- Redirect to login page in case no user is linked to current oauth account
- Flash messages upon successfull login, successsfull linked account
2020-04-16 17:58:42 +02:00
aribes f926e58891 Changing ordering logic when showing books from a category 2020-04-16 11:37:04 +10:00
Ozzieisaacs a784c6bd52 Fixes for oauth login after programming basic tests 2020-04-15 22:03:21 +02:00
ElQuimm 05d78f5cb5 Update italian version of message.po
Have a nice day!
2020-04-15 10:59:08 +02:00
Ozzieisaacs ce4f1258b5 Fix #1293 (error 500 on search without query) 2020-04-14 19:27:18 +02:00
Ozzieisaacs 3fbaba6693 Fix #1307 2020-04-14 18:28:16 +02:00
Ozzieisaacs a8b36aed92 Temporary fix for #1307 2020-04-14 14:13:54 +02:00
Ozzieisaacs 4749eccfa5 Added fix for python2 regex
Fix for python2 attributeError instead of TypeError on login with wrong openLDAP setting
Added default empty string on LDAPCertificate
Fix ldap as scheme for tls connection
Enabled add user on LDAP Authentication
LDAP config port is now number input
Added header for user import config
Added python ldap version to about section
Fix: It's no longer possible to login via fallback password as long as LDAP server is available
Fix: TypeError on bind is now catched and transformed to error message
Update Readme
Fixes for ldap
2020-04-14 10:58:32 +02:00
Michael Shavit 41a3623fcc [Kobo] Add Shelf/Collection support.
Implements the v1/library/tags set of endpoints to sync CalibreWeb
shelves with the Kobo device.
Drive-by: Refactors shelf.py to consolidate how user permissions are checked.
Drive-by: Fix issue with the sync endpoint that arrises when a book is
hard-deleted.
2020-04-12 22:29:20 -04:00
Marcel 296ac203d4 Update messages.po 2020-04-11 12:32:37 +02:00
xcffl 0c9436ca82 Regenerate translation files 2020-04-11 00:06:56 +08:00
xcffl 70c9dd1b95 Fix opds format errors & extend info 2020-04-11 00:06:50 +08:00
xcffl 80753b1115 Add vscode configs into .gitignore. 2020-04-08 23:01:57 +08:00
Ozzieisaacs a194216568 Merge remote-tracking branch 'origin/master' 2020-04-05 19:10:27 +02:00
Ozzieisaacs 8bee424cc0 Merge remote-tracking branch 'ldap/master'
# Conflicts:
#	cps/admin.py
#	cps/templates/admin.html
#	cps/templates/config_edit.html
#	cps/web.py
2020-04-05 17:31:41 +02:00
Ozzie Isaacs 25ab3cabfe Add question for logfile to Issue Template 2020-04-04 18:28:43 +02:00
Ozzieisaacs 587174b771 Fix #1272 2020-04-04 08:53:22 +02:00
Ozzieisaacs 3e1c34efe6 Merge remote-tracking branch 'fix/fix_covers' into Develop 2020-04-04 06:57:54 +02:00
Ozzieisaacs 5864637f1c Merge branch 'master' into Develop 2020-04-04 06:22:17 +02:00
Ozzieisaacs ec6b346ca1 Update Translation 2020-04-03 19:50:29 +02:00
Ozzieisaacs e99f5bcced Merge remote-tracking branch 'english/master' 2020-04-03 19:46:26 +02:00
Ozzieisaacs b89309ab82 Merge remote-tracking branch 'po/master' 2020-04-03 19:27:10 +02:00
OzzieIsaacs 2d230ec96a Updated Teststatus 2020-04-02 21:13:43 +02:00
Ozzieisaacs 4550333f1e Added redirect after create shelf 2020-04-02 20:19:05 +02:00
Ozzieisaacs 3ba610eb64 Revert accidentally committed changes in opds feed 2020-04-02 19:07:41 +02:00
Ozzieisaacs 2436c6a118 Merge remote-tracking branch 'caliblur/master' 2020-04-02 18:27:21 +02:00
Ozzieisaacs 4de80b26c1 Merge remote-tracking branch 'public_shelf/calibre-web_645'
# Conflicts:
#	cps/templates/feed.xml
#	cps/templates/layout.html
2020-04-02 18:24:32 +02:00
Ozzieisaacs bab14a1fbf Update shelfs handling, bugfix changed updater 2020-04-02 18:23:24 +02:00
Ozzieisaacs 0c27ff11b9 Update Updater 2020-04-01 18:45:16 +02:00
BeckyDTP 734e2ffbb2 Updated Polish translations 2020-03-31 21:53:13 +02:00
hexeth da42c51af2 Updated to exclude new modules 2020-03-31 08:42:08 -07:00
Michael Shavit 7cb6801241 Fix issue with cover images. 2020-03-30 02:17:08 -04:00
Ozzieisaacs f6c04b9b84 Merge branch 'master' into Develop 2020-03-29 16:48:56 +02:00
Ozzieisaacs 4eacb21259 Update updater
Update change logfile
code cosmetics js files
2020-03-29 16:44:24 +02:00
Ozzie Isaacs 6d1a3ccdcc Improve logger for windows 2020-03-28 07:13:51 +01:00
Josh O'Brien c870f6e87d English Language V2 2020-03-19 13:10:10 +11:00
Ozzieisaacs 6d907094d7 Update Readme 2020-03-14 11:22:27 +01:00
Ozzieisaacs 6643f0d1e0 Merge remote-tracking branch 'nl/master'
# Conflicts:
#	cps/translations/nl/LC_MESSAGES/messages.mo
#	cps/translations/nl/LC_MESSAGES/messages.po
2020-03-14 10:45:58 +01:00
Ozzieisaacs 20b07e0752 Merge remote-tracking branch 'sv/master'
# Conflicts:
#	cps/translations/sv/LC_MESSAGES/messages.mo
#	cps/translations/sv/LC_MESSAGES/messages.po
2020-03-14 10:39:27 +01:00
Ozzieisaacs 092423adc7 Merge remote-tracking branch 'sv/master'
# Conflicts:
#	cps/translations/sv/LC_MESSAGES/messages.mo
#	cps/translations/sv/LC_MESSAGES/messages.po
2020-03-14 10:38:50 +01:00
Ozzieisaacs a50ca1a85f Fix #1259 2020-03-14 10:35:58 +01:00
Jony 02199c8c1d Update messages.po (Swedish)
Update Swedish translation
2020-03-14 09:32:33 +01:00
Jony c166c92685 Merge pull request #3 from janeczku/master
Update fork
2020-03-14 09:31:29 +01:00
Ozzieisaacs f243515261 Fix #950 (sorting order in comics) 2020-03-14 07:15:15 +01:00
Ozzieisaacs 98181fe21c Merge remote-tracking branch 'fix/kobo_book_delete' into Develop 2020-03-13 17:00:38 +01:00
Unknown a26ce8d8b5 Updated & corrected Dutch translations
Un-wonky-fied some literal translations, corrected contexts an respected punctuations, whitespaces, etc.....

See https://github.com/janeczku/calibre-web/issues/606#issuecomment-595913808
2020-03-13 14:01:42 +01:00
Michael Shavit de0e27c512 Fix #1164 merge. 2020-03-12 20:09:26 -04:00
Ozzieisaacs 09e7d76c6f Merge remote-tracking branch 'kobo_book_delete' into Develop
# Conflicts:
#	cps/kobo.py
#	cps/services/SyncToken.py
#	cps/templates/book_edit.html
#	cps/ub.py
2020-03-12 20:43:39 +01:00
Ozzieisaacs d597e05fa9 Merge remote-tracking branch 'reading_state' into Develop
# Conflicts:
#	cps/kobo.py
2020-03-12 20:35:24 +01:00
Ozzieisaacs 98dc991339 Merge remote-tracking branch 'more_kobo_fixes' into Develop 2020-03-12 20:33:22 +01:00
Ozzieisaacs 1d40434d2b Merge remote-tracking branch 'it/patch-9' 2020-03-12 20:32:08 +01:00
Ozzieisaacs 46b87dc7eb Fix for testability of kobo 2020-03-09 20:25:59 +01:00
ElQuimm fe7c56d269 italian updated version of messages.po
09.03.2020
2020-03-09 09:11:41 +01:00
OzzieIsaacs 5ba4801a79 Updated test result 2020-03-08 08:02:15 +01:00
Michael Shavit ad564e25ca More fixes for Kobo Sync
- Fix incorrect maintenance of the Synctoken timestamps.
- Fix issue where the SyncToken isn't included in the response when
kobo_proxying is disabled.
2020-03-08 01:20:54 -05:00
Ozzieisaacs 404be948d4 Merge remote-tracking branch 'ldap/Develop' into Develop
# Conflicts:
#	cps/admin.py
#	cps/web.py
2020-03-07 13:39:47 +01:00
Ozzieisaacs 8cbc345f36 Merge remote-tracking branch 'identifier/develop/edit_identifiers' into Develop 2020-03-07 13:35:58 +01:00
Ozzieisaacs 89927fd7e9 Merge ComicVine API 2020-03-07 13:33:35 +01:00
Ozzieisaacs 6b4a024234 Merge branch 'master' into Develop 2020-03-07 12:47:38 +01:00
Ozzieisaacs 18794831e0 Chang link to issue template 2020-03-07 11:27:50 +01:00
Ozzieisaacs 3fb851304f Merge remote-tracking branch 'ru/master' 2020-03-07 11:21:10 +01:00
Ozzieisaacs d267338837 Fixes for Kobo sync
Better output on upload cover
Fix for download after access to opds/fileformat
Fix osd search link
Added ratings to opds feed
Change for kobo sync for testing
2020-03-07 11:07:43 +01:00
Michael Shavit 8e1641dac9 Add support for syncing Kobo reading state. 2020-03-01 22:01:05 -05:00
Michael Shavit 57d37ffba8 Add schema support for more reading states to the ReadBook table. 2020-03-01 20:26:42 -05:00
ZIzA 82afa81220 up 2020-02-29 20:41:51 +04:00
ZIzA d730eb8d31 fix 2020-02-29 20:26:52 +04:00
ZIzA 5a219b580f ru update 2020-02-29 19:10:01 +04:00
Ozzieisaacs fb83bfb363 Fix for #1223 (pdf-reader isn't working on chrome, IE, Edge) 2020-02-29 09:55:54 +01:00
Ozzie Isaacs df7d3d18b6 pdf reader 2020-02-29 08:33:09 +01:00
Wanoo a0535aa3db Add ComicVine Api
Fetching Metadata for comicBook with ComicVine API
2020-02-28 16:04:07 +01:00
Ozzieisaacs 202b6121ab Fix for #1227 (Download books not possible with gevent IPV6 connection)
Fix issue where books aren't correctly ordered by Date Added
2020-02-26 20:05:44 +01:00
Ozzieisaacs 4e8b814ec2 Fix download URL for IPV6 2020-02-26 20:04:15 +01:00
Ozzieisaacs 6becca17bf Merge remote-tracking branch 'fix/fix_time' into Develop 2020-02-26 18:09:01 +01:00
Ozzieisaacs c8b64d4162 Update chinese translation Merge remote-tracking branch 'cn/patch-1' 2020-02-26 18:03:26 +01:00
Jeff 0854303710 Update Chinese translation 2020-02-26 19:47:25 +08:00
Michael Shavit cba3e62e71 Fix issue where books weren't correctly ordered by Date added on the
Kobo device.
2020-02-26 01:06:03 -05:00
Ozzieisaacs dcba720e97 Merge remote-tracking branch 'restrict/patch-1' 2020-02-25 16:06:24 +01:00
Rewerson 6c614c06f6 restricted_tags -> denied_tags
Fix for the next error after update:
Traceback (most recent call last):
  File "cps.py", line 34, in <module>
    from cps import create_app
  File "/home/rewerson/lib/web/cps/__init__.py", line 68, in <module>
    config = config_sql.load_configuration(ub.session)
  File "/home/rewerson/lib/web/cps/config_sql.py", line 348, in load_configuration
    update({"restricted_tags": conf.config_mature_content_tags}, synchronize_session=False)
  File "/home/rewerson/.local/lib/python2.7/site-packages/sqlalchemy/orm/query.py", line 3862, in update
    update_op.exec_()
  File "/home/rewerson/.local/lib/python2.7/site-packages/sqlalchemy/orm/persistence.py", line 1693, in exec_
    self._do_exec()
  File "/home/rewerson/.local/lib/python2.7/site-packages/sqlalchemy/orm/persistence.py", line 1874, in _do_exec
    values = self._resolved_values
  File "/home/rewerson/.local/lib/python2.7/site-packages/sqlalchemy/orm/persistence.py", line 1840, in _resolved_values
    desc = _entity_descriptor(self.mapper, k)
  File "/home/rewerson/.local/lib/python2.7/site-packages/sqlalchemy/orm/base.py", line 402, in _entity_descriptor
    "Entity '%s' has no property '%s'" % (description, key)
sqlalchemy.exc.InvalidRequestError: Entity '<class 'cps.ub.User'>' has no property 'restricted_tags'
2020-02-25 16:30:51 +03:00
Ozzieisaacs 9a812f11e7 Merge remote-tracking branch 'it/patch-8'
# Conflicts:
#	cps/translations/it/LC_MESSAGES/messages.po
2020-02-24 19:40:10 +01:00
Ozzieisaacs 917132fe26 Update Translation (german and russian)
Merge remote-tracking branch 'it/patch-8'

# Conflicts:
#	cps/translations/it/LC_MESSAGES/messages.po
2020-02-24 19:38:12 +01:00
Ozzieisaacs 187ca5dc8f Fix #1221 2020-02-24 19:08:54 +01:00
Ozzieisaacs 7d795771d3 Fix #1221 2020-02-24 19:02:38 +01:00
ElQuimm e51bc4ea78 Update italian message.po
Thank you for your job :-)
2020-02-23 21:23:00 +01:00
Ozzieisaacs 2dc3235d4f Merge branch 'master' into Develop 2020-02-23 20:08:37 +01:00
Ozzieisaacs 040bb4a5a8 Merge remote-tracking branch 'origin/master' 2020-02-23 20:07:51 +01:00
Ozzieisaacs fc4436f091 Update Translation 2020-02-23 20:07:21 +01:00
Ozzie Isaacs 5dbdef25d3 Windows compatibility 2020-02-23 17:24:25 +01:00
Ozzie Isaacs b9f3ac2eea Windows compatability 2020-02-23 17:23:38 +01:00
Ozzieisaacs 9fc0c3b3de Merge remote-tracking branch 'English_texts/master'
# Conflicts:
#	cps/templates/config_edit.html
#	cps/templates/config_view_edit.html
#	cps/templates/layout.html
#	cps/templates/search_form.html
#	cps/templates/user_edit.html
2020-02-23 13:27:44 +01:00
Ozzieisaacs 4f81184da0 Update translation 2020-02-23 13:22:57 +01:00
Ozzieisaacs 8223561844 Merge branch 'master' into Develop 2020-02-23 13:21:11 +01:00
Ozzieisaacs 32a6beae65 Merge remote-tracking branch 'fix/kobo'
# Conflicts:
#	cps/kobo.py
2020-02-23 13:17:59 +01:00
Ozzieisaacs 146068c936 Merge branch 'Develop'
# Conflicts:
#	cps/db.py
#	cps/templates/user_edit.html
2020-02-23 13:15:30 +01:00
Ozzieisaacs 3b8c5ef21a Merge remote-tracking branch 'get_metadata/patch-1'
Fix #1210 Load multiple authors correct via get metadata
2020-02-23 13:14:12 +01:00
Ozzieisaacs e60ef8fc97 Calibre-web version visible in about section
Update Calibre-web Version info
2020-02-23 12:40:11 +01:00
Ozzieisaacs 24d755b123 Update translations
Merge remote-tracking branch 'it/patch-7'
2020-02-23 10:55:56 +01:00
Ozzieisaacs 7c89f0b5b9 Merge with branch develop
Release version
2020-02-23 09:08:01 +01:00
Ozzieisaacs 134a10f56c Merge remote-tracking branch 'origin/Develop' into Develop
# Conflicts:
#	cps/updater.py
2020-02-23 08:58:47 +01:00
Ozzieisaacs 3a70c86f49 Update updater to handle venv folders 2020-02-23 08:58:13 +01:00
Ozzieisaacs ac431bbc4a Limit requirements 2020-02-23 08:58:13 +01:00
OzzieIsaacs 371097eb4d Update Updater 2020-02-23 07:54:58 +01:00
OzzieIsaacs 6346059698 Changed optional-requirements 2020-02-23 06:53:17 +01:00
Ozzieisaacs 372c284ad4 Merge branch 'master' into Develop 2020-02-22 16:24:06 +01:00
Ozzie Isaacs 264ccdbf6d Update bug_report.md 2020-02-22 13:36:31 +01:00
Ozzie Isaacs b95150ff11 Update bug report 2020-02-22 13:33:21 +01:00
Ozzie Isaacs 81fc04f24d Update bug_report.md 2020-02-22 13:29:36 +01:00
Ozzie Isaacs 3d4ebddb04 Update issue templates 2020-02-22 13:27:56 +01:00
Ozzie Isaacs de299e0d6a Update issue templates 2020-02-22 13:25:31 +01:00
Johnny A. dos Santos 29cb8bfec4 Fix #1210 2020-02-21 18:25:02 -03:00
Ozzieisaacs b7f3e00fbf Update updater to handle venv folders 2020-02-21 18:04:26 +01:00
Ozzieisaacs 27a18d60a7 Merge remote-tracking branch 'fix/robust_proxy' into Develop
# Conflicts:
#	cps/kobo.py
2020-02-19 18:20:03 +01:00
Ozzieisaacs de8f6d3e8d Limit requirements 2020-02-19 18:15:40 +01:00
Ozzieisaacs 6893635251 Handle kobo auth request
Handle access from localhost for kobo
2020-02-18 20:36:32 +01:00
ElQuimm 94a38a3b47 udate message.po in italian
Hello, 
updated one more time the translation :-)
I think the string "Author list" is missing.
Thank you!
Quimm
2020-02-17 21:03:33 +01:00
Michael Shavit 7d99e21d0d Merge remote-tracking branch 'original/Develop' into kobo_book_delete 2020-02-17 14:49:42 -05:00
Michael Shavit df3eb40e3c Make KoboStore proxying more robust.
* Add a timeout to prevent hanging when the KoboStore isn't reachable.
* Add back a the dummy auth implementation for when proxying is
disabled.
* Return the dummy auth response as a fallback when failing to contact
the KoboStore.
* Don't contact the KoboStore during the /sync API call when proxying is
disabled.
2020-02-17 14:37:02 -05:00
Ozzieisaacs ba6b5f8fd1 Merge branch 'master' into Develop 2020-02-17 18:42:53 +01:00
Josh O'Brien 8f518993a4 Merge branch 'master' into master 2020-02-18 00:21:59 +11:00
Niktia Pchelin dac48a2610 #645 - changes create_shelf and edit_shelf to allow user create public and private shelves with the same name 2020-02-16 23:38:35 -05:00
Niktia Pchelin 7c0d10da79 #645 - displays '(public)' next to user's public shelves in OPDS feed 2020-02-16 22:47:32 -05:00
Niktia Pchelin d77b52af96 #645 - displays '(public)' next to public shelves created by the user 2020-02-16 22:24:00 -05:00
Ozzieisaacs 29f6463ed9 Fix #1149 (Prevent invalid file extensions on Kindle Paperwhite download) 2020-02-16 19:55:05 +01:00
Ozzieisaacs ed0bdbf31d Fix for #1199 (missing referrer) 2020-02-16 19:39:32 +01:00
Ozzieisaacs b152d3e06d Merge remote-tracking branch 'auth_token/fix/reverse-proxy-authentication-with-bearer-token' 2020-02-16 19:24:28 +01:00
Ozzieisaacs 16cd57fe55 Merge remote-tracking branch 'cover_layout/fix-pull-left-cover-layout' 2020-02-16 19:19:10 +01:00
Ozzieisaacs 3f578122a3 Merge remote-tracking branch 'it/patch-6' 2020-02-16 18:56:14 +01:00
Ozzieisaacs 51a27322be Merge remote-tracking branch 'cn/master' 2020-02-16 18:53:27 +01:00
OzzieIsaacs 050feed5dc Updated test result 2020-02-16 16:53:54 +01:00
Ozzieisaacs e3ddc16657 Fix opds login not working anymore (due to kobo sync protocol) 2020-02-16 14:25:15 +01:00
André Frimberger 33cdf20cd5 Remove trailing whitespaces 2020-02-16 10:25:01 +01:00
Ozzieisaacs 317e59df4b Bugfixes from tests 2020-02-15 16:02:31 +01:00
Ozzieisaacs a9a6f5b97e Renamed restrict -> deny 2020-02-15 10:21:45 +01:00
Ozzieisaacs 8b1444ebc2 Improved testability
Less content exposed with restricted content
Tornado restart and stop working with systemd
2020-02-15 09:08:39 +01:00
Kyos 509071949a Moved style to css file 2020-02-13 21:05:24 +01:00
ElQuimm 697d857549 message.po italian 2020-02-13 11:29:26 +01:00
Ozzieisaacs 2ea45b1fdc Removed shebang on non executable scripts 2020-02-10 18:37:47 +01:00
Ozzieisaacs 726595e117 Update for testability 2020-02-10 18:34:10 +01:00
Kyos 1666e32aaf Fixes cover layouts in user edit page 2020-02-09 22:31:08 +01:00
Kyos 6a69bbe4b5 Fixes cover layouts in user edit page 2020-02-09 22:20:52 +01:00
André Frimberger 7a608b4fb0 fix binascii.Error with reverse proxy bearer token
When an authenticating reverse proxy (e.g. Keycloak Gatekeeper) adds a Bearer token in the Authorization header, every request fails with HTTP status code 500.  The corresponding error in the logs is: binascii.Error: Incorrect padding. 
Despite "reverse_proxy_header_login" is enabled, calibre-web tries first to base64decode the bearer token and fails. This patch just reverses the order in which the authentication methods  are checked.
2020-02-09 17:21:22 +01:00
Ozzieisaacs 814ad87a42 Updated pdf viewer to 2.2.228 (#1184) 2020-02-09 17:02:47 +01:00
Ozzieisaacs 3e4b5e23fa Updated pdf viewer to 2.2.228 (#1184) 2020-02-09 17:02:28 +01:00
Ozzieisaacs ab24ed8088 Handle sorting of hidden books #1123
Prevent downloading of covers from hidden books
2020-02-09 14:31:42 +01:00
Ozzieisaacs 50ba2e329a Merge branch 'master' into Develop
# Conflicts:
#	cps/shelf.py
2020-02-09 07:00:04 +01:00
Ozzie Isaacs c1e2a98f46 Merge remote-tracking branch 'origin/master' 2020-02-08 14:40:28 +01:00
Ozzie Isaacs e04aa80fd6 Fix #1181 and Fix #1182 handle removed "is_xhr" on werkzeug version 1.0.0 2020-02-08 14:40:13 +01:00
Ozzie Isaacs 482e977af4 Clarified installation procedure 2020-02-08 13:27:51 +01:00
Ozzie Isaacs 2535bbbcf1 Fix #1180 working on windows with tornado and python <3.8 again 2020-02-08 13:24:01 +01:00
Ozzieisaacs 6698773d81 Improved testability 2020-02-08 11:33:31 +01:00
xcffl aefaf47f4c Update Simplified Chinese translation 2020-02-03 17:26:17 +08:00
Josh O'Brien 9b49125776 English Language Updates
Changes to provide consistency for English language.
2020-02-03 14:22:00 +11:00
Ozzieisaacs b33a2ac90d Fix #1170 Auth-digest Header no longer crashs calibre-web
Fix #1161 Shelfs are reordering again
Update Sortable.js to 2.10.1
Code cosmetics
2020-02-02 07:47:27 +01:00
Ozzieisaacs f67953c447 Update translation
Merge remote-tracking branch 'cz/patch-11'
2020-02-01 15:03:03 +01:00
Ozzieisaacs 981632f599 Merge remote-tracking branch 'it/patch-5' 2020-02-01 15:02:10 +01:00
Ozzieisaacs a6c453d826 Merge remote-tracking branch 'pl/pl_translate_update' 2020-02-01 15:01:58 +01:00
Ozzieisaacs 4087e685f4 Fix auto detection of locale 2020-02-01 13:40:29 +01:00
Ozzieisaacs 5255085de1 Fix auto detection of locale 2020-02-01 13:38:11 +01:00
ElQuimm 9247ded710 Update - italian version of message.po 2020-01-29 21:41:54 +01:00
Jerzy Piątek 0bb0cbaef0 Updated polish translations 2020-01-28 22:28:58 +01:00
Lukáš Heroudek 0f7d272e13 Update messages.po 2020-01-28 10:03:40 +00:00
Ozzieisaacs 00dafe3121 Fix restrict columns 2020-01-27 20:34:36 +01:00
Ozzieisaacs e44494aad0 Kobo sync enable/disable working 2020-01-27 20:32:37 +01:00
Ozzieisaacs 4ab3dc2599 Merge branch 'master' into Develop
# Conflicts:
#	cps/admin.py
2020-01-27 18:20:50 +01:00
Ozzieisaacs acfad7a982 Update Italian translation 2020-01-27 18:17:17 +01:00
Ozzieisaacs b29b5b7ac1 Merge remote-tracking branch 'github/fix-admin-view-log-file' 2020-01-27 18:14:16 +01:00
ElQuimm 7803ffb995 Update italian message.po
Thank you.
2020-01-27 15:07:00 +01:00
Ghighi Eftimie fc79cdfaa2 fix mismerge 2020-01-27 15:24:11 +02:00
Michael Shavit f9dbc6bc78 Clean-up book from ArchivedBook on hard-delete.
This change also adds a warning to the hard-delete prompt that deleted
books should first be archived if the Kobo Sync feature is enabled.
An alternative would be to keep a permanent record of hard-deleted
book.
2020-01-26 16:20:10 -05:00
Michael Shavit dc7aaae235 Now that CalibreWeb delete requests are respected, we can forward
them to the KoboStore for books that aren't in Calibre.

Note: There's still an edge case where a book is removed from Calibre
without first being archived, in which case the delete call will fail.
2020-01-26 16:01:27 -05:00
Michael Shavit 9804a98af8 Merge remote-tracking branch 'original/Develop' into kobo_book_delete 2020-01-26 15:58:57 -05:00
Ghighi Eftimie 647e954e8a fix admin view of error log 2020-01-26 20:53:03 +02:00
Ozzieisaacs 004d9118bc Merge branch 'master' into Develop 2020-01-26 19:37:14 +01:00
Ozzieisaacs 594c8aad91 Update Version Info 2020-01-26 19:36:15 +01:00
Ozzieisaacs 542a0008c9 Merge remote-tracking branch 'ru/master' 2020-01-26 19:33:37 +01:00
Ozzieisaacs 24f7918aa4 Merge remote-tracking branch 'it/patch-4' 2020-01-26 19:31:52 +01:00
Ozzieisaacs 2eec329bdf Merge branch 'master' into Develop
# Conflicts:
#	cps/templates/user_edit.html
#	optional-requirements.txt
2020-01-26 19:29:36 +01:00
Ozzieisaacs 0411d4a8c9 Added 2 new kobo settings: Enable Kobo Sync (currently not working) and proxy Requests to Kobo
Added fix for kobo reader generating requests without right port number, causing url_for not working correct
2020-01-26 16:52:40 +01:00
Ozzieisaacs a986faea56 Merge remote-tracking branch 'fix/fix_creation_time' into Develop 2020-01-26 16:50:38 +01:00
Ozzieisaacs ad71d0a03f Merge remote-tracking branch 'kobo_/develop/fix-new-user' into Develop 2020-01-26 14:43:44 +01:00
Ozzieisaacs 0955c6d6fb Update Kobo 2020-01-26 14:42:53 +01:00
Michael Shavit d30b44ee0f Minor formatting changes per codacy review. 2020-01-26 01:12:52 -05:00
Michael Shavit a6f4db0f25 Fix bug where last_created is incorectly set in the SyncToken. 2020-01-26 00:32:05 -05:00
Michael Shavit 4547c328bc Delete/Restore book from Kobo device upon (un)archiving of a book in the web UI. 2020-01-26 00:29:36 -05:00
Michael Shavit 5027aeb3a0 Fix bug where last_created is incorectly set in the SyncToken. 2020-01-26 00:29:36 -05:00
Michael Shavit c0239a659c Add UI support for archived books.
Archived books will no longer appear in any book lists or searches, and
may only be restored from the Archive view.
2020-01-26 00:27:54 -05:00
Michael Shavit e404da4192 Add support for book 'deletion' (i.e archiving) from a Kobo device. 2020-01-26 00:16:55 -05:00
Simon Latapie 69fa7d0091 new user: disable kobo token on new user page
kobo token generator function is related to a user id, which is not
present on new user page.
Disable the Kobo token part when creating a new user.
2020-01-25 22:41:55 +01:00
ZIzA e1d6aec682 quick fix
sorry
2020-01-21 23:06:22 +04:00
ZIzA 155795a18e Russian language update 2020-01-21 22:29:23 +04:00
ZIzA 8c4052e884 Update README.md 2020-01-21 22:22:10 +04:00
Ozzie Isaacs 3c63e2b7e4 Update version number 2020-01-21 07:04:18 +01:00
Simon Latapie 9b119fa724 edit book: manage identifiers 2020-01-20 22:26:43 +01:00
ElQuimm a17c1c063e Update messages.po
:-)
Thank you
2020-01-20 22:05:11 +01:00
Ozzieisaacs 6728f5da2d Update Czech translation 2020-01-20 20:32:41 +01:00
Lukáš Heroudek 16adeae5c3 Update messages.po 2020-01-20 15:23:54 +00:00
Lukáš Heroudek 485eba94cc Update messages.po 2020-01-20 14:41:51 +00:00
Lukáš Heroudek 5a074348ac Update messages.po 2020-01-20 14:02:23 +00:00
Ozzieisaacs cd9bb56db5 Cleanup Kobo integration 2020-01-20 06:14:53 +01:00
Сергей 8150f934fd Update README.md 2020-01-20 01:48:57 +04:00
Сергей 4c8f3f7bae Add files via upload 2020-01-20 01:46:41 +04:00
Сергей ab5f176f58 Update README.md 2020-01-20 01:45:37 +04:00
Ozzieisaacs b294ac00ed Update for updated dependencies on comicapi 2020-01-19 17:45:04 +01:00
Ozzie Isaacs 165cbad67b Delete tess.py 2020-01-19 15:04:49 +01:00
Ozzieisaacs b30da58eb9 Update german translation 2020-01-18 12:55:18 +01:00
Ozzieisaacs b0fb6b858d Merge remote-tracking branch 'swedish/patch-4' 2020-01-18 12:29:22 +01:00
Jony 53ce22ef5e Update Swedish translation
I translated the new strings.
2020-01-18 12:18:05 +01:00
Ozzieisaacs 8e7a52f44e Merge remote-tracking branch 'swedish/master' 2020-01-18 12:04:49 +01:00
OzzieIsaacs 05a35be019 Update test result 2020-01-18 11:53:23 +01:00
Jony 4406220f70 Update Swedish translation
Update file
2020-01-18 11:25:07 +01:00
Jony 51a6cff411 Merge pull request #2 from janeczku/master
Update respository
2020-01-18 11:24:03 +01:00
Ozzieisaacs 8f4253adbd Fix for limiting domains (now no restriction to domain only) 2020-01-18 10:32:09 +01:00
Ozzieisaacs 3e404101bd Merge remote-tracking branch 'cc/develop/custom_float' into Develop 2020-01-15 17:58:57 +01:00
Ozzieisaacs 65105d9dbe Merge remote-tracking branch 'cc/develop/custom_float' into Develop 2020-01-15 17:58:23 +01:00
Ozzieisaacs 3a4d351a57 Merge remote-tracking branch 'develop/calibur-fix-colon' into Develop 2020-01-15 17:57:00 +01:00
Ozzieisaacs ce66c752c4 Updated czech translation
removed language selection for guest user
2020-01-14 18:21:31 +01:00
Lukáš Heroudek 4e42a179fa Update messages.po 2020-01-14 16:45:45 +00:00
Ozzieisaacs 973f555544 Merge branch 'master' into Develop
(updated tinymce and bootstrap datepicker)
2020-01-13 18:39:25 +01:00
Ozzieisaacs 1d7e52c198 Merge with develop:
- No. of unread books correct calculated
- logviewer handles stream output correct
- Id for testablility in email server setup
2020-01-13 18:37:29 +01:00
Ozzieisaacs 1b42dd1043 Update czech and german translation 2020-01-13 18:24:47 +01:00
Lukáš Heroudek 77e0022252 Update messages.po
Added missing and clenup
2020-01-13 10:29:14 +00:00
Simon Latapie 56964a890b caliBlur theme: fix custom column display that contain colons 2020-01-12 14:26:07 +01:00
Simon Latapie cef41661dd custom columns: add Float type support
- remove float datatype from cc_exceptions
- handle float datatype like int or bool
- change edit template to modify input step (0.01 precision, like
  in calibre UI)
2020-01-12 14:16:08 +01:00
Ozzieisaacs 68ca0b86da Updated tiniymce + locale
updated datepicker + locale
Included cs, fi language, updated ru language
2020-01-12 14:11:53 +01:00
Ozzieisaacs 79a9ef4859 Make Kobo optional
move jsonschema dependency to optional-requirements.txt
Added version of jsonschema to about section
Added additional column to RemoteAuthToken table
Update configuration of Kobo sync protocol
2020-01-12 13:42:39 +01:00
Ozzieisaacs 2798dd5916 Merge remote-tracking branch 'fix/kobo' into Develop
# Conflicts:
#	cps/db.py
#	cps/web.py
2020-01-11 18:35:04 +01:00
Ozzieisaacs 8143c16c14 Added texts for allowed denied tags in configuration
Removed domain only restriction (now it's also possible to allow/deny name parts of users email)
Fix no. of displayed books in unread books section, with applied restrictions
2020-01-11 13:28:09 +01:00
Ozzieisaacs 42435ab34a Improved testablility 2020-01-08 19:52:05 +01:00
Lukáš Heroudek 434fb2e7cb Add files via upload 2020-01-08 12:06:13 +00:00
Lukáš Heroudek bce70bf17c Update messages.po 2020-01-08 12:05:36 +00:00
Lukáš Heroudek cde44178c4 Create messages.po 2020-01-08 12:03:52 +00:00
Ozzieisaacs 661ed17d23 Handle stream output in logviewer
Add migration of global matrue content settings to user based settings
Fix Caliblur Theme (Publisherslist)
Allow/deny list/edit/add/delete of tags and custom columns
Colored table background for tags restriction
2020-01-07 20:26:59 +01:00
Ozzieisaacs c659f28334 Fix #1128 2020-01-06 11:52:59 +01:00
Ozzieisaacs 218e35e3aa Merge remote-tracking branch 'origin/Develop' into Develop 2020-01-05 16:20:46 +01:00
Ozzieisaacs cabad83418 moved language selection back, default value for restriction columns
Merge branch 'master' into Develop

# Conflicts:
#	cps/editbooks.py
2020-01-05 16:20:36 +01:00
Ozzieisaacs 24ae7350f5 Fix # #1127 (user template show random books in detail view not saved) 2020-01-05 16:18:51 +01:00
Ozzieisaacs c60277f4d3 Capitalize language selection in User settings
Remove sql statement from log.debug statement upon creation of new column in settings database
2020-01-05 14:07:26 +01:00
Ozzieisaacs 6a07cfba65 Merge remote-tracking branch 'proxy_login/1105-reverse-proxy-login'
Fix for creation of bool config settings (before: new bool columns where always created with true as default)
2020-01-05 13:38:51 +01:00
Ozzieisaacs 87c415830f Merge branch 'master' into Develop
# Conflicts:
#	cps/editbooks.py
2020-01-05 13:37:45 +01:00
Ozzieisaacs c78c63e1d5 Merge remote-tracking branch 'fix_reconnect'
# Conflicts:
#	cps/web.py
2020-01-05 08:47:03 +01:00
Ozzieisaacs 56ee8c56ba Fix #1122
(Uploading books with applied language restriction leads no longer to error 500)
2020-01-05 08:40:57 +01:00
Ozzieisaacs 48495f0d66 Merge branch 'master' into Develop
# Conflicts:
#	cps/helper.py
#	cps/templates/user_edit.html
#	cps/web.py
2020-01-04 07:57:54 +01:00
Ozzieisaacs 8ad84a7ceb Fix for #1123 (mature content is visible in shelfs) 2020-01-02 17:11:30 +01:00
Ozzieisaacs 32e818af6a Fix #1124 2020-01-02 16:09:06 +01:00
Ozzieisaacs d9adb4fc94 Improvements for limit tags 2020-01-01 17:30:08 +01:00
Ozzieisaacs 513ac6cfb4 Fix for #768 (If email server is configured, admins can send user passwords, also domains can be denied from registration)
Fixes from tests
Fix response opds with read/unread
changed db_reconnect
Changed output for error 500 (now including error message)
Fix in task queue after 20 messages
2020-01-01 17:29:01 +01:00
Ozzieisaacs 1da4efec86 Merge branch 'master' into Develop 2019-12-31 10:52:45 +01:00
Ozzieisaacs 1c630eb604 fixes from tests 2019-12-30 15:16:09 +01:00
Ozzieisaacs 1c18a788f4 Merge remote-tracking branch 'tags_restrict/master' into Develop 2019-12-30 15:15:07 +01:00
Ozzieisaacs 5887f0fe6b Fix for #768 (If email server is configured, admins can send user passwords, also domains can be denied from registration) 2019-12-29 13:54:52 +01:00
ground7 b782489a8c ldap opds download bugged 2019-12-28 21:52:26 -07:00
Ozzieisaacs 01381488f4 Fixes from tests 2019-12-28 16:18:21 +01:00
ground7 6555d5869f attempt regular login if ldap login fails as fallback 2019-12-27 23:45:42 -07:00
ground7 54c4f40188 added LDAP import
update defaults
2019-12-27 23:12:18 -07:00
Ozzieisaacs 62e8bee2a8 Fix response opds with read/unread
changed db_reconnect
Changed output for error 500 (now including error message)
Fix in task queue after 20 messages
2019-12-24 13:02:53 +01:00
Michael Shavit 9ec3ddd492 Fix the HandleCoverImage endpoint so that it requires login, and doesn't
take unused parameters.
2019-12-22 16:59:00 -05:00
Michael Shavit d81dbb13e4 Support Epub downloads 2019-12-22 16:59:00 -05:00
Michael Shavit c238367b64 Reload database on every call to v1/library/sync. This fixes an issue where side-loaded books appear in the sync response with no download urls. 2019-12-22 16:59:00 -05:00
Michael Shavit cdcb8a50d1 Fix /reconnect endpoint, which was broken by https://github.com/janeczku/calibre-web/commit/006e596c7221729c9a37b79037fa72bb88506492 2019-12-22 16:58:55 -05:00
Michael Shavit 520c695401 Fix /reconnect endpoint, which was broken by https://github.com/janeczku/calibre-web/commit/006e596c7221729c9a37b79037fa72bb88506492 2019-12-22 16:54:16 -05:00
Michael Shavit b831b9d6b2 Integrate with the official Kobo store endpoint so that no
functionanility is lost by overriding the api_endpoint setting.

Requests are either:
 * Redirected to the Kobo Store
 * Proxied to the Kobo Store
 * Proxied to the Kobo Store and merged with results from CalibreWeb.
2019-12-22 13:40:49 -05:00
Ozzieisaacs bbe91f439a Merge from master 2019-12-22 15:28:43 +01:00
Ozzieisaacs b586a32843 Fix #1115 (comic reader not working under iOS, maybe invalid issue)
Improvement for #925 (Next/Prev buttons are bigger)
2019-12-22 15:24:22 +01:00
Ozzieisaacs 288944db2c Merge remote-tracking branch 'kobo_sync/kobo' into Develop
# Conflicts:
#	cps.py
#	cps/kobo.py
#	cps/kobo_auth.py
#	cps/ub.py
2019-12-20 19:24:31 +01:00
Ozzieisaacs f2c07d8f81 Update Kobo sync 2019-12-20 19:17:08 +01:00
Michael Shavit d6a9746824 Add a filter to the Sync request endpoint to ignore books that don't
have any formats supported by the device.
2019-12-20 01:28:53 -05:00
Michael Shavit f84274f1c5 git add missing generate_kobo_auth_url.html 2019-12-20 01:08:15 -05:00
Michael Shavit 2118d920f5 Formatter. 2019-12-20 01:04:12 -05:00
Michael Shavit 207004beff Remove config_server_url setting. 2019-12-20 01:02:49 -05:00
Michael Shavit 27d084ce39 Remove the KoboUserKey-based Authentication.
Instead, the user generates the api_endpoint url to set on their device
by visiting http://.../kobo_auth/generate_auth_token.
The generated url will contain a RemoteAuthorizationToken that will be
included on all subsequent requests from the device to the kobo/
endpoints. (In contrast, the device is authenticated using a session cookie on
requests to the download endpoint).

Also use Flask.url_for to generate download urls.
2019-12-20 00:55:53 -05:00
Ozzieisaacs f705889c23 Inital Kobo 2019-12-17 20:28:20 +01:00
Ozzieisaacs 7098d08888 Added option to convert AZW3 to mobi for sending to kindle 2019-12-15 18:44:02 +01:00
Ozzieisaacs eabc6e23be Test Email now send to user's email address (#834)
Added forgot/reset password routine (#1098, #1063)
2019-12-15 17:08:17 +01:00
Ozzieisaacs b6d7207ec3 Added platform information for better debugging 2019-12-15 13:33:38 +01:00
Ozzieisaacs c33623efee Unified wording for recently added books, series, categories, etc in opds and web UI ( #1045)
Added file formats and languages to opds feed
2019-12-15 13:32:34 +01:00
Ozzieisaacs 2215bf3d7f Implemented #1083 (Advanced search for extensions) 2019-12-15 11:35:07 +01:00
Ozzieisaacs 86fe970651 More fixes for googledrive 2019-12-14 22:22:27 +01:00
Andrew Roberts 3dc372c573 fixed typo 2019-12-12 21:38:45 -05:00
Andrew Roberts efcee0a7b7 added reverse proxy configuration form and handler 2019-12-12 21:31:21 -05:00
Andrew Roberts 39b6b100f9 whitespace 2019-12-12 21:31:19 -05:00
Andrew Roberts 9351ff032f whitespace 2019-12-12 21:31:17 -05:00
Andrew Roberts f0760c07d8 added admin display of reverse proxy settings 2019-12-12 21:31:12 -05:00
Andrew Roberts 77b0954c70 use a macro for the display of boolean settings 2019-12-12 21:28:50 -05:00
Andrew Roberts af7dbbf1e4 added logic for reverse proxy login 2019-12-12 21:27:40 -05:00
Andrew Roberts b661c2fa92 added config fields to settings table 2019-12-12 21:27:38 -05:00
Ozzieisaacs e308a74dc2 Fix (#1103) Internal server error with Goodreads and no result for author name on Goodreads 2019-12-12 20:08:16 +01:00
Michael Shavit 040d7d9ae3 Remove unused import and fix python3 compatibility, as per Ozielsaacs comments. 2019-12-11 00:12:35 -05:00
Michael Shavit f9b1e84704 Remove backblaze leftovers 2019-12-10 23:57:53 -05:00
Ozzieisaacs eede894e72 Merge remote-tracking branch 'merge-metadata' 2019-12-09 20:53:16 +01:00
Michael Shavit 55c0bb6d34 Set the "Size" attribute for Kobo download_urls, and refactor the code
to eventually allow formats other than KEPUB.
2019-12-08 17:28:25 -05:00
Michael Shavit 2b55b9b250 Use the login_user Cookie to authorize download requests instead of
passing the UserKey over url params.
2019-12-08 15:33:57 -05:00
Ozzieisaacs 22add37b64 Merge remote-tracking branch 'fix typo in config_edit.html #1097'
Updated translations
2019-12-08 10:10:32 +01:00
Ozzieisaacs 8a9695d48e Fix getting metadata from douban (#858) 2019-12-08 09:52:57 +01:00
Ozzieisaacs e0faad1e59 Handle no write permission to tmp folder (#1060) 2019-12-08 09:40:54 +01:00
Michael Shavit fffa2d5a1b Support passing the Auth token as a url param.
This is required to support ebook downloads which the Kobo device emits
without any auth headers.

* Also some other small bug fixes discovered during device testing.
2019-12-07 22:55:07 -05:00
Michael Shavit 0926ae530c Remove custom_column usages from an earlier commit. 2019-12-07 21:08:53 -05:00
Michael Shavit 0b709f7dfb Merge branch 'master' into kobo 2019-12-07 20:04:54 -05:00
Michael Shavit b5da2c4199 Clean-up: Fix import order in kobo.py, and other minor formatting
changes.
2019-12-07 19:54:49 -05:00
Michael Shavit 9ede01f130 * Add a UserKeyToken to the User table for Kobo authorization.
* Add proper authorization checks on the new Kobo endpoints.

Important Note: As a side-effect, all CalibreWeb API calls can be
authorized using this token (i.e without a username&password).
2019-12-07 19:54:44 -05:00
Christian Keil c61463447f Merge metadata of uploaded book versions. 2019-12-06 15:00:01 +01:00
Michael Shavit 55b54de6a0 Add simple get_download_url implementation to replace the backblaze-backed implementation 2019-12-05 19:06:39 -05:00
Michael Shavit 5357867103 Add initial support for Kobo device Sync endpoint.
- Supports /v1/library/sync call to get list of books
- Supports /v1/library/metadata call to get metadata for a given book
  + Assumes books are stored on Backblaze for metadata call
- Changes to helper.py so that we can return no cover instead of a blank
image.
2019-12-05 01:56:59 -05:00
zhiyue 222797e631 support douban book search using apikey 2019-12-04 11:59:06 +08:00
dalin 92841b46d7 update Simplified Chinese translations. 2019-12-03 16:32:03 +08:00
dalin 6fe60d5c5e Merge branch 'master' of https://github.com/idalin/calibre-web 2019-12-03 16:07:09 +08:00
dalin 4c2323fcc9 fix typo in config_edit.html 2019-12-03 16:06:25 +08:00
Ozzieisaacs fda0ab1e86 FIx for (#1092 listening to mp3 not working) 2019-12-01 12:36:55 +01:00
Ozzieisaacs 54079b36ae Fix #1095 (epub viewer not working if only viewer rule wa sapplied) 2019-12-01 12:21:21 +01:00
Ozzieisaacs f8a99c60d8 Fix for #1096 (exception on digest request) 2019-12-01 09:33:11 +01:00
Ozzieisaacs 2f27a7b0ce Merge remote-tracking branch 'd/master' 2019-11-26 10:50:55 +01:00
Ozzieisaacs 8af178c19c Fix for gdrive not working #1081 2019-11-26 10:46:06 +01:00
Ozzieisaacs 78f9ee86b1 Fix pdf cover
Fix massadding books
Add feature inform of duplicate books
2019-11-26 08:19:03 +01:00
Ozzieisaacs ab5873984e Merge remote-tracking branch 'translation/patch-3' 2019-11-17 06:59:22 +01:00
Ozzieisaacs 62ea8b8913 Logging to stdout, proposal form #1078 2019-11-16 10:09:34 +01:00
Ozzieisaacs a4416c202d Merge remote-tracking branch 'rename/develop' 2019-11-16 08:01:18 +01:00
Jony 1f5edffccf Fix typo and update translation 2019-11-16 07:49:07 +01:00
Ozzieisaacs 651260022c Merge remote-tracking branch 'search_title/hotfix-searchform-title' 2019-11-16 07:29:00 +01:00
Ozzieisaacs 2e4344f7ea Merge remote-tracking branch 'swedish/master' 2019-11-16 07:27:08 +01:00
Jony 3cb7e77b60 Update messages.po 2019-11-14 18:52:06 +01:00
Ghighi Eftimie f782dc1857 fix for search title 2019-11-14 12:08:19 +02:00
Jony 7179f133bb Merge pull request #1 from janeczku/master
Update from original.
2019-11-12 19:34:34 +01:00
Ozzieisaacs 88f31ddad1 Fix for #1034 2019-11-07 21:04:03 +01:00
Ozzieisaacs a7ab7fcf06 Fix #1068 PDF cover Images inverted or incorrect single image grabbed from cover page 2019-11-03 09:49:54 +01:00
Ozzieisaacs 6f61e80c97 Fix #1074, #1071 2019-10-31 15:46:38 +01:00
Ozzieisaacs d1afdb4aac Fix #1074, #1071 2019-10-31 15:44:36 +01:00
Ozzieisaacs 1112dc82c9 Merge remote-tracking branch 'origin/dependabot/pip/pyyaml-5.1' 2019-10-30 20:25:53 +01:00
dependabot[bot] e47b0c6433 Bump pyyaml from 3.12 to 5.1
Bumps [pyyaml](https://github.com/yaml/pyyaml) from 3.12 to 5.1.
- [Release notes](https://github.com/yaml/pyyaml/releases)
- [Changelog](https://github.com/yaml/pyyaml/blob/master/CHANGES)
- [Commits](https://github.com/yaml/pyyaml/compare/3.12...5.1)

Signed-off-by: dependabot[bot] <support@github.com>
2019-10-29 13:55:45 +00:00
Jan Guzej 94ae9937f0 Add flash show book template 2019-10-26 13:45:06 +02:00
Jan Guzej fadd085b57 Checking and warrying on possible duplicity 2019-10-26 11:54:27 +02:00
Ozzieisaacs 5167ee520e Solve cropped image pdf import (#1068) 2019-10-22 19:11:40 +02:00
Ozzieisaacs f758a1cc64 Solve inverted image pdf import (#1068) 2019-10-21 19:33:09 +02:00
Ozzieisaacs 2145be6db2 Improvement for #1062 2019-10-20 16:47:15 +02:00
Ozzieisaacs c740fe9124 Merge remote-tracking branch 'github_2/it-adds-info-to-shelf-order' 2019-10-20 15:50:52 +02:00
Ozzieisaacs a371e40c66 Merge remote-tracking branch 'github_3/hotfix/epub-import'
Improved cover extraction from epub files
2019-10-20 15:48:06 +02:00
Ozzieisaacs ccc6184342 Merge remote-tracking branch 'github_1/patch-1' 2019-10-20 15:03:37 +02:00
Ozzieisaacs c8c2d6659c Merge remote-tracking branch 'public_reconnect/master' 2019-10-20 15:03:02 +02:00
Ozzieisaacs 1413f26c85 Merge remote-tracking branch 'github_readme/master' 2019-10-20 15:02:07 +02:00
Jan Guzej c7d7a7597c remove language from array metadata 2019-10-19 21:49:18 +02:00
Jan Guzej fbb7663a2f epub metadata import fix 2019-10-19 14:04:15 -04:00
Kyos c93dd32179 Fixed typo and column size for LG screens 2019-10-19 12:02:14 +02:00
Kyos 7165826011 Adds Authors, Series and Book Cover to the shelf order view 2019-10-19 10:57:48 +02:00
Kyos ada727a570 Adds Authors, Series and Book Cover to the shelf order view 2019-10-19 10:52:03 +02:00
gwenhael 01b0f9534c fix issue #1064
Allow for finer steps in serie-index
2019-10-18 15:12:27 +02:00
DenysNahurnyi 0735283d45 Fix syntax errors in README.md 2019-10-08 20:48:26 +03:00
zelazna 3764c33a3a Add the posibility to change the username 2019-10-01 15:36:00 +02:00
Ozzieisaacs 9fc02f67c2 Debug output for lcase 2019-09-27 15:30:39 +02:00
Ozzieisaacs 0c40e40dc3 Fix logfile loading behind reverse proxy (#1047) 2019-09-27 08:15:10 +02:00
Ozzieisaacs e31df16309 Revert bug in updater.py preventing new files from being created 2019-09-24 19:00:47 +02:00
Ozzieisaacs d7ea5bb9d7 Fix for #1037
Update German translation
2019-09-17 18:25:17 +02:00
Ozzieisaacs 6cda5fee0d Fix language selection on python3 2019-09-06 20:56:17 +02:00
Ozzieisaacs 1fb45d769f Merge remote-tracking branch 'requirements/patch-1' 2019-09-06 19:07:47 +02:00
Ozzieisaacs ca5e285c5a Merge branch 'Develop' 2019-09-06 19:05:02 +02:00
Ozzieisaacs fb0eebfc52 Merge remote-tracking branch 'spanish/master' 2019-09-06 19:03:03 +02:00
Ozzieisaacs dd90fb003e Merge remote-tracking branch 'french/master' 2019-09-06 19:01:45 +02:00
Ozzieisaacs 61cd044255 Merge remote-tracking branch 'polish/master' 2019-09-06 19:01:17 +02:00
Ozzieisaacs 879d02081a Fix #1021 2019-09-06 18:57:41 +02:00
Ozzieisaacs 051bc53aa2 Fix for #1009 2019-09-02 19:27:34 +02:00
Ozzie Isaacs a7fdbad8b4 Changed minimum flask version
Minimum flask version to 1.0.2 due to blueprint issues
2019-09-01 16:35:30 +02:00
Angel Docampo 5515772903 updated spanish translations 2019-08-27 13:16:23 +02:00
Angel Docampo ff900fd9c0 updated spanish translations 2019-08-27 13:15:18 +02:00
YamakuniandJohan Bonneau eec4be7a29 Update cps/translations/fr/LC_MESSAGES/messages.po
Co-Authored-By: Johan Bonneau <lhaagounet@gmail.com>
2019-08-25 01:01:05 +01:00
Alex Viscreanu 6a821b8a75 Fix SSLHandshakeError error on gdrive callback
changes: Update google-api-python-client and httplib2 versions
details: https://github.com/googleapis/google-api-python-client/issues/453
2019-08-24 23:43:49 +02:00
YamakuniandJohan Bonneau 1385ecb383 Update cps/translations/fr/LC_MESSAGES/messages.po
Co-Authored-By: Johan Bonneau <lhaagounet@gmail.com>
2019-08-23 16:02:21 +01:00
YamakuniandJohan Bonneau 74418f3139 Update cps/translations/fr/LC_MESSAGES/messages.po
Co-Authored-By: Johan Bonneau <lhaagounet@gmail.com>
2019-08-23 16:02:13 +01:00
YamakuniandJohan Bonneau 72def4b97b Update cps/translations/fr/LC_MESSAGES/messages.po
Co-Authored-By: Johan Bonneau <lhaagounet@gmail.com>
2019-08-23 16:02:04 +01:00
YamakuniandJohan Bonneau 564c3b4778 Update cps/translations/fr/LC_MESSAGES/messages.po
Co-Authored-By: Johan Bonneau <lhaagounet@gmail.com>
2019-08-23 16:01:47 +01:00
YamakuniandJohan Bonneau c9eff4a70c Update cps/translations/fr/LC_MESSAGES/messages.po
Co-Authored-By: Johan Bonneau <lhaagounet@gmail.com>
2019-08-23 16:01:38 +01:00
YamakuniandJohan Bonneau 879f63d1c1 Update cps/translations/fr/LC_MESSAGES/messages.po
Co-Authored-By: Johan Bonneau <lhaagounet@gmail.com>
2019-08-23 16:01:01 +01:00
YamakuniandJohan Bonneau 3fb458dd19 Update cps/translations/fr/LC_MESSAGES/messages.po
Co-Authored-By: Johan Bonneau <lhaagounet@gmail.com>
2019-08-23 16:00:52 +01:00
Yamakuni d9a73b4fa3 French translation
Add and modify some little things
2019-08-21 15:21:17 +01:00
Ozzieisaacs 23b3bfd967 Additional fix #1016 2019-08-20 19:12:40 +02:00
Ozzieisaacs f543d7f486 Fix #1016 2019-08-20 18:32:04 +02:00
Radosław Kierznowski 6a058d2c52 Update polish translation 2019-08-18 22:08:08 +02:00
Radosław Kierznowski c73698e8fd Merge https://github.com/janeczku/calibre-web 2019-08-18 21:54:29 +02:00
Ozzieisaacs ff41775dbb Merge remote-tracking branch 'pwr/unpickle-iso-languages' into Develop 2019-08-17 17:27:06 +02:00
Ozzieisaacs 7e530618b7 Merge branch 'Develop' 2019-08-17 16:12:26 +02:00
Ozzieisaacs d04a78afe6 Merge remote-tracking branch 'public_shelf/Develop' into Develop 2019-08-17 16:03:29 +02:00
Ozzieisaacs f566237be0 Merge remote-tracking branch 'bootstrap/Develop' into Develop 2019-08-17 15:59:13 +02:00
Ozzieisaacs 87fa4a57b5 Merge branch 'master' into Develop 2019-08-17 15:57:39 +02:00
Dmitriy Istomin a65ad9483c update Bootstrap v3.4.0 -> 3.4.1 2019-08-16 11:18:39 -04:00
W1ndst0rm 4cbdccd39e Fix for https://github.com/janeczku/calibre-web/issues/1002 2019-08-13 23:07:15 -06:00
Ozzieisaacs 9356148e2d Added more information about dependencies 2019-08-06 19:53:33 +02:00
Ozzieisaacs 4be55285d8 Fix #1001 2019-08-06 18:38:17 +02:00
Ozzieisaacs 2caee35884 Merge branch 'master' into Develop 2019-08-03 20:29:28 +02:00
Ozzieisaacs 3eae2e9c2c Changed text for not found imports 2019-08-03 18:24:56 +02:00
Ozzieisaacs e9fb5d9f25 Bugfix for comicapi 2019-08-03 14:09:54 +02:00
Ozzieisaacs 6261981656 Fix for #991
Removed additional psace before Advanced search (Pull request #996)
Set startup message for not relevant imports to debug, to reduce confusion
2019-08-03 12:56:32 +02:00
Ozzieisaacs 82ca3f31f9 Merge remote-tracking branch 'pwr/master' 2019-07-31 17:40:50 +02:00
Ozzieisaacs 97f3aa8325 Update dutch language 2019-07-31 17:38:28 +02:00
Daniel Pavel 7c503b4a31 provide a default logging configuration (fixes #987) 2019-07-31 09:42:10 +03:00
Daniel Pavel 9f8cab99e3 Moved language names from iso639.pickle to a python file 2019-07-30 15:16:24 +03:00
Ozzieisaacs 5f25b81004 Final fix for #983 2019-07-29 21:43:00 +02:00
Ozzieisaacs 73bbffccaa Fix for login issue on MacOS 2019-07-28 21:29:54 +02:00
Radosław Kierznowski 43ad7d6e29 Add Flask-SimpleLDAP to requirements.txt 2019-07-27 20:26:58 +02:00
Radosław Kierznowski 2c27d631b4 Add goodreads to requirements.txt 2019-07-27 14:55:12 +02:00
Ozzieisaacs eb31b4b00b Merge branch 'master' into Develop 2019-07-27 07:23:22 +02:00
Ozzieisaacs f59d9d5aa8 Merge remote-tracking branch 'polish/master' 2019-07-27 07:19:30 +02:00
Radosław Kierznowski 746b7b1262 Update polish translation 2019-07-26 23:04:04 +02:00
Radosław Kierznowski 2d67d55b73 Merge pull request #1 from janeczku/master
Merge oryginalnego repo
2019-07-26 21:38:27 +02:00
Ozzieisaacs 5f228fbb40 Update german translation 2019-07-26 17:20:48 +02:00
Ozzieisaacs 12576393cf Merge remote-tracking branch 'spanish/patch-1'
Merge remote-tracking branch 'german/master'
2019-07-26 16:41:52 +02:00
Mainak 7f43a2e104 Update messages.po
update and revision of the Spanish translation file.
2019-07-26 11:45:45 +02:00
Ozzieisaacs 00f17bb697 Mature contents no longer displayed in/allowed to (#975):
- typeahead tags
- advanced search tags buttons
- read
- download
2019-07-25 21:42:46 +02:00
Ozzieisaacs cf00b4eebf Cover upload gives Error 500 fix #972 (leftover from refactoring)
OPDS cannot be downloaded Fix #973 (wrong blueprint selected with wrong authentication type)
Read books with link to calibre wern't detected correct Fix #971 (Refactoring error "is" is not equal to "==" for sqlalchemy)
2019-07-24 18:43:23 +02:00
Ozzieisaacs be5c67fddd Merge branch 'master' into Develop 2019-07-23 21:41:27 +02:00
Ozzieisaacs fc4dc36c65 Fix #969 (duplicate code "init" in epub reader)
Fix for #890
2019-07-23 19:26:01 +02:00
Ozzieisaacs 97a0dccdec Fix for display of format, series, category, ratings,
Fix for display of language (sorting not working yet)
2019-07-22 21:38:52 +02:00
Ozzieisaacs 9f64a96502 Fix for #890 2019-07-22 18:28:45 +02:00
Ozzieisaacs b9c3a3fcea Fix #652 2019-07-21 18:19:07 +02:00
Ozzieisaacs 6d43e0422a Fix for non found flask_login version (#968) 2019-07-21 17:45:21 +02:00
Ozzieisaacs 0d7e58ce79 Fix for #968 (database conversion fails for default strings under python2.7) 2019-07-21 16:23:31 +02:00
Ozzieisaacs 3e008ef29b Fix update dates shown in local time format again
Fix "to update" history is shown again
2019-07-21 13:28:55 +02:00
Ozzieisaacs 5c6be5d6d0 Fix for python 3 and existing unrar entry in db
deleted unnecessary lines in config html template
2019-07-21 12:58:48 +02:00
Ozzieisaacs 2bd4eff56f Merge remote-tracking branch 'pwr/config_sql' into Develop 2019-07-21 09:50:18 +02:00
Ozzieisaacs 38f3c2d5b9 Reenabled multiple oauth provider
deleted duplicate download counting function
2019-07-21 09:45:36 +02:00
Ozzieisaacs c6542fdec6 New Oauth code 2019-07-20 20:01:05 +02:00
Ozzieisaacs 26a7d9ef30 Merge branch 'Develop'
# Conflicts:
#	cps/__init__.py
#	cps/about.py
#	cps/admin.py
#	cps/cli.py
#	cps/config_sql.py
#	cps/constants.py
#	cps/converter.py
#	cps/db.py
#	cps/editbooks.py
#	cps/gdriveutils.py
#	cps/helper.py
#	cps/logger.py
#	cps/oauth.py
#	cps/server.py
#	cps/services/simpleldap.py
#	cps/ub.py
#	cps/web.py
#	cps/worker.py
#	optional-requirements.txt
#	setup.cfg
#	setup.py
2019-07-17 19:07:05 +02:00
Daniel Pavel 847dbfc021 fixed custom columns handling on dispose 2019-07-17 11:42:19 +03:00
Ozzieisaacs 929f32335f Merge remote-tracking branch 'pwr/config_sql' into Develop 2019-07-16 21:15:06 +02:00
Ozzieisaacs 61a8eccf18 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	setup.cfg
2019-07-16 21:13:51 +02:00
Ozzieisaacs d01a0c6617 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	optional-requirements.txt
2019-07-16 21:09:24 +02:00
Ozzieisaacs d168e3bfdb Add licence to oauth
fix comicapi dependency
2019-07-16 21:08:37 +02:00
Ozzie Isaacs aba88ae53a Update setup.cfg 2019-07-15 21:04:08 +02:00
Ozzie Isaacs 67d0ddb180 Update optional requirements 2019-07-15 21:03:35 +02:00
Ozzie Isaacs b1bb1cfdfa Changed optional dependencies 2019-07-15 20:21:59 +02:00
Daniel Pavel 99c6247baf use the standard socket library to validate the ip address argument 2019-07-14 22:06:40 +03:00
Daniel Pavel a334ef28e7 about page: build the versions dictionary only once 2019-07-14 21:15:46 +03:00
Daniel Pavel 63634961d4 cleaner worker api
the worker thread now stops on its own
2019-07-14 20:28:32 +03:00
Ozzieisaacs d82289e303 Deactivate ldap
Fix setup for pypi
2019-07-14 18:37:44 +02:00
Daniel Pavel a836df9a5a more robust disposing of database session
avoid spamming the log with debug messages from libraries
2019-07-14 14:44:48 +03:00
Ozzieisaacs 8bfcdffeb6 Fix feature support 2019-07-14 13:20:40 +02:00
Ozzieisaacs e411c0fded Fix logging in debug mode 2019-07-14 09:18:37 +02:00
Ozzieisaacs 4708347c16 Merge branch 'Develop'
# Conflicts:
#	MANIFEST.in
#	README.md
#	cps/helper.py
#	cps/static/js/archive/archive.js
#	cps/translations/nl/LC_MESSAGES/messages.mo
#	cps/translations/nl/LC_MESSAGES/messages.po
#	cps/ub.py
#	cps/updater.py
#	cps/web.py
#	cps/worker.py
#	optional-requirements.txt
2019-07-13 20:54:21 +02:00
Ozzieisaacs 37736e11d5 Updated dependencies 2019-07-13 20:32:39 +02:00
Ozzieisaacs 792367e35e Version update 2019-07-13 20:27:32 +02:00
Ozzieisaacs be64961de5 Code cosmetics comic reader 2019-07-13 19:55:51 +02:00
Ozzieisaacs 405a3909b0 Fix for opds with LDAP authentication 2019-07-13 17:51:25 +02:00
Ozzieisaacs b1cb7123a3 Fix for #959 2019-07-11 20:37:03 +02:00
Ozzieisaacs e734bb120a Merge remote-tracking branch 'github/config_sql' into Develop 2019-07-09 19:29:58 +02:00
Daniel Pavel 006e596c72 Moved config class into separate file.
Moved Goodreads and LDAP services into separate package.
2019-07-07 16:05:51 +03:00
Ozzieisaacs 499a66dfb0 Additional glyphicons for music on search and author page
Fix duplicate user and email (now case insensitive)
Output of calibre on stderr is now logged (full traceback in debug-log, otherwise, only errormessage)
Natural sorting for comic reader
Fix for long running tasks
2019-07-06 14:46:25 +02:00
Ozzieisaacs f79d549910 Fix duplicate user and email (now case insensitive #948)
Fix sorting in comics (#950)
Fix log error on Calibre converter error (#953)
Fix long running tasks (#954)
2019-07-06 14:35:12 +02:00
Krakinou 11c8b47c39 Merge remote-tracking branch 'upstream/Develop' into Develop
Conflicts:
	cps/web.py
2019-07-01 22:58:11 +02:00
Krakinou 00a29f3d88 Check for change before encoding 2019-07-01 21:45:35 +02:00
Krakinou e5b9da5201 Error management 2019-07-01 21:44:58 +02:00
Ozzieisaacs ad44e58c7a Sorting for comics
Audiobook support
2019-06-29 14:23:39 +02:00
Ozzieisaacs 572b5427c7 Fix Encoding issues for python2 2019-06-23 21:11:45 +02:00
Ozzieisaacs 32af660f86 Improvements for logfile viewer
Fix for tornado-server with deactivated accesslog doesn't log to normal log anymore
Merge from master for unique user ids, get_metadata,
fix Goodreads integration
Update Translation (merge NL, update DE)
2019-06-22 19:55:59 +02:00
Ozzieisaacs 66283c542f Merge remote-tracking branch 'dutch/master' 2019-06-22 19:29:39 +02:00
Ozzieisaacs cc8a431532 Merge remote-tracking branch 'socket/unix-socket' into Develop 2019-06-21 09:46:03 +02:00
Ozzieisaacs 5c7aeb2f2c Change GDrive 2019-06-20 20:12:01 +02:00
Ozzie Isaacs c1d5f77fe8 Wiki link fixed 2019-06-20 10:38:32 +02:00
Daniel Pavel e254565901 support binding the http server to a unix socket file instead of TCP socket 2019-06-18 08:57:37 +03:00
Krakinou 3d0beba261 Base64 2019-06-17 23:47:35 +02:00
Krakinou 147947662c Base64 2019-06-17 23:46:38 +02:00
Heimen Stoffels d9f69ca264 Updated Dutch translation 2019-06-17 22:38:32 +02:00
Ozzieisaacs cd546eb6d4 Update comic reader js 2019-06-17 19:48:17 +02:00
Ozzieisaacs f40fc5aa75 Update LDAP 2019-06-16 08:59:25 +02:00
Ozzieisaacs 9b74d51f21 Merge remote-tracking branch 'ldap/master' into Develop
# Conflicts:
#	cps/server.py
#	cps/templates/config_edit.html
#	cps/ub.py
#	cps/updater.py
#	cps/web.py
#	optional-requirements-ldap.txt
#	setup.cfg
2019-06-16 08:20:01 +02:00
Ozzieisaacs d45b1b8ea5 Fix #937
logviewerintegrated
Code cosmetics
2019-06-15 21:18:13 +02:00
Ozzieisaacs e67d707867 Fix for #588
Merge remote-tracking branch 'linuxserver/master'
2019-06-15 11:58:55 +02:00
Krakinou 4437d7376d Merge pull request #2 from Krakinou/flaskldap
Solve typo
2019-06-14 22:56:21 +02:00
Krakinou 304db0d20e Solve typo 2019-06-14 22:55:17 +02:00
Ozzieisaacs 3f5c6c1fa5 Fix #937 2019-06-13 20:01:37 +02:00
Ozzieisaacs 26949970d8 Revert logging functions
Fix access logger for tornado
2019-06-11 20:07:51 +02:00
Ozzieisaacs f5e3ed26b9 Fix for #935 2019-06-10 19:31:13 +02:00
Ozzieisaacs 8e4539cf8e Prevent delete of last admin user 2019-06-10 19:26:01 +02:00
Ozzieisaacs c81d4edb7d Fix for #923 2019-06-10 16:38:29 +02:00
Ozzieisaacs 546ed65e1d Update logging
Fix sort order author
Fixes sorting view
Moved version info
added feature limit listening to single ipaddress
2019-06-10 14:15:21 +02:00
Ozzieisaacs 14b6202eec Code cosmetics
Fixes func in helper,web
Fixes for pdf reader
fixes for calling from another folder
renamed to calibreweb for importing in python caller script
2019-06-08 13:45:27 +02:00
Ozzieisaacs 50973ffb72 Merge remote-tracking branch 'constants/Develop-logging-cleanup' into Develop 2019-06-08 06:52:41 +02:00
Krakinou 9a5ab97d78 Merge branch 'master' into master 2019-06-08 01:41:43 +02:00
Krakinou 6190e64956 Merge pull request #1 from Krakinou/flaskldap
Flaskldap integration
2019-06-08 01:03:59 +02:00
Krakinou 79286c9384 encode password 2019-06-08 00:54:45 +02:00
yjouanique c4e3f3670f Added public /reconnect endpoint 2019-06-07 12:15:31 +07:00
Daniel Pavel b89ab9ff10 logging clean-up
- moved most constants to separate file
- sorted and cleaned-up imports
- moved logging setup to separate file
2019-06-06 18:10:22 +03:00
Krakinou 97d12b94f6 Correct settings update 2019-06-04 00:47:49 +02:00
Krakinou e4d801bbaf initial flask_simpleldap implementation 2019-06-02 22:07:22 +02:00
Ozzieisaacs f736a15c12 Fix for restart of newer versions of tornado during update
Fix for updater with Beta Versions
2019-06-02 18:47:25 +02:00
Ozzieisaacs a02f949d23 Fix for updater with beta releases 2019-06-02 18:09:44 +02:00
Ozzieisaacs b4e0d039ef Revert setup.cfg file 2019-06-02 09:35:42 +02:00
Ozzieisaacs bb0d5c5538 Working for pip and "normal" start for python3 and python2 2019-06-02 09:33:45 +02:00
Ozzieisaacs f5b335e8e9 Update setup 2019-06-01 09:51:20 +02:00
Ozzieisaacs 67bd5d41aa Update Version info 2019-05-31 20:06:07 +02:00
Ozzieisaacs 1879dcb24a Merge remote-tracking branch 'setup/feature/setuptools-integration' into Develop 2019-05-31 19:48:09 +02:00
Ozzieisaacs 87ca05f129 Merge remote-tracking branch 'setup/feature/setuptools-integration' 2019-05-31 16:24:13 +02:00
Ozzieisaacs 6662a58cb0 Update translationfiles 2019-05-31 15:48:19 +02:00
Luke Murphy b3a286c0b5 Add initial setuptools integration 2019-05-31 11:54:05 +02:00
Ozzieisaacs 4fecce0a0d Merge branch 'master' into Develop
# Conflicts:
#	cps/helper.py
#	cps/static/js/archive/unrar.js
#	cps/templates/readcbr.html
#	cps/templates/readpdf.html
#	cps/translations/de/LC_MESSAGES/messages.mo
#	cps/translations/de/LC_MESSAGES/messages.po
#	cps/translations/es/LC_MESSAGES/messages.mo
#	cps/translations/es/LC_MESSAGES/messages.po
#	cps/translations/fr/LC_MESSAGES/messages.mo
#	cps/translations/fr/LC_MESSAGES/messages.po
#	cps/translations/it/LC_MESSAGES/messages.mo
#	cps/translations/it/LC_MESSAGES/messages.po
#	cps/translations/ja/LC_MESSAGES/messages.mo
#	cps/translations/ja/LC_MESSAGES/messages.po
#	cps/translations/km/LC_MESSAGES/messages.mo
#	cps/translations/km/LC_MESSAGES/messages.po
#	cps/translations/nl/LC_MESSAGES/messages.mo
#	cps/translations/nl/LC_MESSAGES/messages.po
#	cps/translations/pl/LC_MESSAGES/messages.mo
#	cps/translations/pl/LC_MESSAGES/messages.po
#	cps/translations/ru/LC_MESSAGES/messages.mo
#	cps/translations/ru/LC_MESSAGES/messages.po
#	cps/translations/sv/LC_MESSAGES/messages.mo
#	cps/translations/sv/LC_MESSAGES/messages.po
#	cps/translations/uk/LC_MESSAGES/messages.mo
#	cps/translations/zh_Hans_CN/LC_MESSAGES/messages.mo
#	cps/translations/zh_Hans_CN/LC_MESSAGES/messages.po
#	cps/web.py
#	messages.pot
#	optional-requirements.txt
2019-05-31 11:17:47 +02:00
Ozzieisaacs 26e45f1f57 Merge remote-tracking branch 'comic-reader/master' 2019-05-31 08:43:51 +02:00
Ozzieisaacs df5d15d1a2 Merge remote-tracking branch 'comicapi/master' 2019-05-30 10:43:28 +02:00
Ozzieisaacs ecedf92783 Update version
Make custom columns visible (#904)
2019-05-30 10:21:11 +02:00
Ozzieisaacs f70c839014 Added additional permission viewer 2019-05-19 18:39:34 +02:00
subdiox 867aa2f0bd Beautify bitjs 2019-05-14 04:28:06 +09:00
subdiox 7982ed877c Downgrade bitjs to es5 branch 2019-05-13 14:53:25 +09:00
Ozzieisaacs 0c80f5c63a Update Teststatus 2019-05-12 19:44:52 +02:00
subdiox c0d136ccd8 Fix slow loading 2019-05-10 08:16:03 +09:00
Ozzieisaacs 3b03aa30a6 Fix for advanced search 2019-05-06 20:51:44 +02:00
Ozzieisaacs 9b3b2acb49 Sort button is correctly visible on list pages
Publisher list is working
2019-04-22 19:17:23 +02:00
Ozzieisaacs 406d1c76c9 Sorting and filtering of lists working (except file formats)
Refactored and bugfixing show_cover
Refactored import of helper in web.py
Fix for displaying /me (gettext) throwing error 500
Fix get search results throwing error 500
Fix routing books_list for python2.7
Fix for "me" and "settings" pages
Update sidebarview and list view
2019-04-22 19:11:35 +02:00
Ozzieisaacs bfd0e87a17 Merge branch 'master' into Develop
# Conflicts:
#	cps/helper.py
#	cps/reverseproxy.py
2019-04-21 08:50:31 +02:00
Ozzieisaacs 2de4bfdcf2 Merge branch 'master' into Develop
# Conflicts:
#	cps/book_formats.py
#	cps/helper.py
#	cps/web.py
2019-04-20 18:32:46 +02:00
Ozzieisaacs f1a65a2aeb Clarified licences for isolanguages.py and reverseproxy.py 2019-04-20 17:57:06 +02:00
Ozzieisaacs 05da2ae3c7 Update search for cyrillic letters 2019-04-14 18:20:45 +02:00
Ozzieisaacs 4ae9d4a749 Merge branch 'master' into Develop
# Conflicts:
#	cps/web.py
#	cps/worker.py
2019-04-14 18:18:39 +02:00
Ozzieisaacs 2253708da7 Merge remote-tracking branch 'proxy/patch-1' into Develop 2019-04-14 10:54:09 +02:00
Ozzieisaacs 51e591bd25 Added updated translation 2019-04-13 16:46:06 +02:00
Ozzieisaacs 2a5f2ff7b3 Merge branch 'master' into Develop
# Conflicts:
#	cps/templates/readpdf.html
#	cps/translations/uk/LC_MESSAGES/messages.po
#	cps/web.py
2019-04-13 16:42:09 +02:00
Iris W 029d299067 updated comicapi version to fix installation error 2019-04-09 15:22:03 -04:00
Iris W b7e30644ab updated comicapi requirement—libunrar no longer necessary (CBR parsing support was always disabled anyway) 2019-04-03 15:36:05 -04:00
Iris Wildthyme cbdc9876b2 comicapi dependency now pippable 2019-04-02 20:58:23 -04:00
Iris Wildthyme 05d0f12608 redid comic importing support to handle metadata properly using comicapi from comictagger. needs work to automate installation 2019-04-02 18:52:45 -04:00
Ozzieisaacs 1c9ff6421d Fix uploader 2019-03-26 18:23:30 +01:00
Ozzieisaacs dc93222579 Update 2 column author list view 2019-03-26 17:54:12 +01:00
Ethan Lin 8143bc7873 Merge pull request #20 from janeczku/master
merge janeczku/master
2019-03-26 11:27:11 +08:00
Ozzieisaacs 772f978b45 Improved code quality 2019-03-24 16:24:43 +01:00
Ozzieisaacs 0f1db18eae Simplified typeahead 2019-03-24 16:14:42 +01:00
Ozzieisaacs 6940bb9b88 Code improvement 2019-03-24 15:58:43 +01:00
Ozzieisaacs 07649d04a3 Fix updater
Update translations
Code cleaning updater
2019-03-24 15:27:23 +01:00
Ozzieisaacs 39a3f70084 Fix for slow updater 2019-03-24 15:22:41 +01:00
tomjmul 8e8486497f Restirct a user to a set of tags 2019-03-19 13:53:41 +00:00
Ozzieisaacs e5593d9a7f Integrated author in sorting functions 2019-03-16 21:14:01 +01:00
Ozzieisaacs 3f2a9c8bae Code improvement 2019-03-16 17:33:15 +01:00
Ozzieisaacs 7c69589c5b Code cosmetics unrar.js 2019-03-16 17:30:24 +01:00
Ozzieisaacs 149e9b4bd4 Code cosmetis unrar 2019-03-16 17:28:21 +01:00
Ozzieisaacs a360b1759a Code cosmetics
bugfix import HttpError from pydrive
2019-03-16 17:20:14 +01:00
Ozzieisaacs 765b817384 Refactored shelf.py and editbooks.py 2019-03-16 16:53:22 +01:00
Ozzieisaacs baf83b2f5a Refactored generating download links 2019-03-16 16:23:40 +01:00
Ozzieisaacs 9c1b3f136f Improved sorting for rated,random, hot books, read/unread book 2019-03-16 15:48:09 +01:00
Ozzieisaacs a66873a9e2 Added file formats view 2019-03-11 18:42:30 +01:00
Ozzieisaacs 4a33278596 Update ratings 2019-03-10 19:22:35 +01:00
Ozzieisaacs 6d2270d931 Fixes for Oauth 2019-03-10 12:54:32 +01:00
Ozzieisaacs 1db1c2e7df Enabling LDAP working again
LDAP/Github oauth and Google oauth no credentials/parameters are rejected now
2019-03-10 11:12:44 +01:00
Ozzieisaacs da3fcb9a72 Merge branch 'master' into Develop
# Conflicts:
#	cps/book_formats.py
#	cps/static/css/style.css
#	cps/static/js/uploadprogress.js
#	cps/templates/author.html
#	cps/templates/detail.html
#	cps/templates/discover.html
#	cps/templates/index.html
#	cps/templates/layout.html
#	cps/templates/osd.xml
#	cps/templates/search.html
#	cps/templates/shelf.html
#	cps/translations/de/LC_MESSAGES/messages.mo
#	cps/translations/de/LC_MESSAGES/messages.po
#	cps/web.py
#	messages.pot
2019-03-10 08:31:10 +01:00
Ozzieisaacs 9144a7ceb9 Fix for sqlalchemy 1.3 2019-03-08 16:29:12 +01:00
Ozzieisaacs feb6a71f95 Fix uncompressed cbz files
merge from master -> file extension limitation
2019-03-05 20:59:30 +01:00
Ozzieisaacs 1561a4abdf Comic improvments 2019-03-05 20:19:02 +01:00
Ozzieisaacs f483ca3214 Code cosmetics
Bugfix uploadprogress
Bugfix mature content
Reenable read and unread feature
2019-03-05 17:34:51 +01:00
Ozzieisaacs 0224d45961 Code cosmetics 2019-03-04 20:03:09 +01:00
Ozzieisaacs 0be17ed157 Refactored sidebar (some parts are missing) 2019-03-04 19:34:29 +01:00
Ozzieisaacs f0de822ce7 Start refactor sidebar and view settings sidebar 2019-03-03 19:42:17 +01:00
Ozzieisaacs fb23db57b4 Added filtering of authors, series, categories, publishers 2019-03-03 15:37:53 +01:00
Ozzieisaacs fda977b155 Fix for users with umlauts 2019-03-03 08:38:36 +01:00
Ozzieisaacs 68a36597ab Fix opds login on python3 2019-03-03 07:37:15 +01:00
Ozzieisaacs de58d0a4d8 Merge remote-tracking branch 'metadata/Develop' into Develop
Update logger for updater
2019-03-01 19:09:37 +01:00
jianyun.zhao 54a006a420 [fix] Add a scroll bar to a long list of meta information 2019-03-01 10:57:27 +08:00
Ozzieisaacs 246d7673a9 Corrected file header with copyright information 2019-02-27 18:20:50 +01:00
Ozzieisaacs eef4787b79 Merge remote-tracking branch 'github/patch-1' into Develop
Oauth is optional again
2019-02-24 19:34:57 +01:00
Ozzieisaacs 1de3929988 Unicode 2019-02-24 19:08:24 +01:00
Ozzieisaacs cc3088c52f Merge branch 'master' into Develop
# Conflicts:
#	cps/book_formats.py
#	cps/static/js/libs/bootstrap.min.js
#	cps/templates/detail.html
#	cps/templates/layout.html
#	cps/web.py

Improvement updater (up to 100 releases are loaded at once)
Fix oauth pathes
Fix oauth without registering
2019-02-24 10:39:37 +01:00
Ozzieisaacs 0facb8fffa Fix #766 2019-02-23 18:28:25 +01:00
Ozzieisaacs 1a7052b287 Update pdf upload
Translation of uploadprogress dialog
2019-02-23 14:26:02 +01:00
AngelByDay 38307ececb Force namespace resolution of imports
The current import for some local modules causes errors when those already exist in site-packages on the target system.
Namely "import db" is ambiguous and may cause errors due to loading of the 'db' PyPI package instead of the module.
2019-02-22 12:24:51 -05:00
Ozzieisaacs e92497b34e Merge branch 'master' into Develop
# Conflicts:
#	cps/gdriveutils.py
#	cps/helper.py
2019-02-20 18:09:42 +01:00
Ozzieisaacs 3d5d95904a Oauth working somehow (?) 2019-02-17 19:10:16 +01:00
Ozzieisaacs a0be02e687 Ldap working 2019-02-17 09:09:20 +01:00
Ozzieisaacs 37007dafee Merge branch 'master' into Develop
# Conflicts:
#	cps/templates/layout.html
#	cps/web.py
#	readme.md
2019-02-16 10:17:46 +01:00
Ozzieisaacs 4230226716 Link fixes
Fixes reader button visible in detail view
Fix formats to convert (added htmlz)
Fix logger in updater
Added request "v3" of github api on update
Fix quotes parameter on external calls
E-Mail logger working more stable (also on python3)
Routing fixes
Change import in ub
2019-02-16 07:23:36 +01:00
Ozzieisaacs 1dc6f44828 Merge branch 'master' into Develop 2019-02-10 07:56:18 +01:00
Ozzieisaacs c1ef1bcd19 User and admin pages are working again 2019-02-09 21:26:17 +01:00
Ozzieisaacs 1fc4bc5204 Fix routes
Fix error page
2019-02-09 19:01:57 +01:00
Ozzieisaacs f5235b1d4c Uploader progress bar
Fixes imports and routes
Merge branch 'master' into Develop
Finished routing
2019-02-09 18:46:41 +01:00
Ozzieisaacs d6ee8f75e9 More refactoring 2019-02-08 20:12:16 +01:00
Ozzieisaacs a00d93a2d9 Working again (basically) 2019-02-06 21:52:32 +01:00
Ozzieisaacs 561d40f8ff Merge branch 'master' into Develop 2019-02-03 20:30:37 +01:00
Ozzieisaacs 36229076f7 Refactor subprocess calls 2019-02-03 18:32:27 +01:00
Ozzieisaacs 7f34073955 Merge branch 'master' into Develop
# Conflicts:
#	cps/web.py
#	readme.md
2019-02-03 17:33:54 +01:00
Ozzieisaacs b75b91606c Fix config oauth-UI 2019-01-27 11:32:16 +01:00
Ozzieisaacs 80582573f5 Merge remote-tracking branch 'oauth/oauth' into Develop
# Conflicts:
#	cps/templates/config_edit.html
#	cps/ub.py
#	cps/web.py
2019-01-27 11:22:22 +01:00
Ozzieisaacs 3683e4e7eb Merge remote-tracking branch 'ldap/master' into Develop 2019-01-27 11:18:08 +01:00
Ozzieisaacs 8ababf9f77 Merge remote-tracking branch 'simple_shelf/shelf-down' into Develop 2019-01-27 11:16:11 +01:00
Ozzieisaacs 5971194678 Merge remote-tracking branch 'audiobook/Branch_c27805b'
# Conflicts:
#	cps/templates/detail.html
#	cps/web.py
#	readme.md
2019-01-27 11:14:38 +01:00
Krakinou d48cdcc789 Correct authentication in case LDAP not activated 2019-01-13 11:21:11 +01:00
Krakinou d763168dec Merge branch 'master' of https://github.com/Krakinou/calibre-web 2019-01-13 11:04:32 +01:00
Krakinou 7ccc40cf5b Moving import LDAP
Correct optional-requirements-ldap.txt spelling
2019-01-13 11:02:03 +01:00
Krakinou aafb267787 Merge branch 'master' into master 2019-01-12 23:46:59 +01:00
Krakinou 82e4f11334 Forgot requirements :/ 2019-01-12 19:24:21 +01:00
Krakinou 2e37c14d94 Clean some comment 2019-01-12 18:40:32 +01:00
Krakinou 91f0908059 insert into db
connect via LDAP config
2019-01-12 18:07:03 +01:00
Krakinou 8d284b151d Edit html config 2019-01-12 12:52:27 +01:00
Krakinou 30954cc27f Initial LDAP support 2019-01-10 23:51:01 +01:00
Jim Ma 4b76b8400d Add OAuth link&unlink in user profile 2018-10-14 16:51:00 +08:00
Jim Ma 1abbcfa3c6 Add OAuth support: GitHub & Google 2018-10-14 16:51:00 +08:00
otapi 9b4ca22254 Update shelf.html 2018-10-11 18:39:31 +02:00
otapi c6d3613e57 Add UI link button to shelves 2018-10-11 18:20:38 +02:00
otapi e0229c917c Download only shelf
Show only titles and download button for specific shelf. Currently only direct link works, e.g: calibre-web/shelfdown/6
2018-10-10 23:01:05 +02:00
342 changed files with 79564 additions and 65637 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
updater.py ident export-subst constants.py ident export-subst
/test export-ignore /test export-ignore
cps/static/css/libs/* linguist-vendored cps/static/css/libs/* linguist-vendored
cps/static/js/libs/* linguist-vendored cps/static/js/libs/* linguist-vendored
+38
View File
@@ -0,0 +1,38 @@
---
name: Bug/Problem report
about: Create a report to help us improve Calibre-Web
title: ''
labels: ''
assignees: ''
---
**Describe the bug/problem**
A clear and concise description of what the bug is. If you are asking for support, please check our [Wiki](https://github.com/janeczku/calibre-web/wiki) if your question is already answered there.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Logfile**
Add content of calibre-web.log file or the relevant error, try to reproduce your problem with "debug" log-level to get more output.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. Windows 10/raspian]
- Python version [e.g. python2.7]
- Calibre-Web version [e.g. 0.6.5 or master@16.02.20, 19:55 ]:
- Docker container [ None/Technosoft2000/Linuxuser]:
- Special Hardware [e.g. Rasperry Pi Zero]
- Browser [e.g. chrome, safari]
**Additional context**
Add any other context about the problem here. [e.g. access via reverse proxy]
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for Calibre-Web
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+6 -5
View File
@@ -7,25 +7,26 @@ __pycache__/
# Distribution / packaging # Distribution / packaging
.Python .Python
env/ env/
venv/
eggs/ eggs/
dist/
build/
vendor/
.eggs/ .eggs/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
.pylint.d
# calibre-web # calibre-web
*.db *.db
*.log *.log
config.ini
cps/static/[0-9]*
.idea/ .idea/
*.bak *.bak
*.log.* *.log.*
tags
settings.yaml settings.yaml
gdrive_credentials gdrive_credentials
vendor
client_secrets.json client_secrets.json
+49
View File
@@ -0,0 +1,49 @@
## How to contribute to Calibre-Web
First of all, we would like to thank you for reading this text. we are happy you are willing to contribute to Calibre-Web
### **General**
**Communication language** is english. Google translated texts are not as bad as you might think, they are usually understandable, so don't worry if you generate your post that way.
**Calibre-Web** is not **Calibre**. If you are having a question regarding Calibre please post this at their [repository](https://github.com/kovidgoyal/calibre).
**Docker-Containers** of Calibre-Web are maintained by different persons than the people who drive Calibre-Web. If we come to the conclusion during our analysis that the problem is related to Docker, we might ask you to open a new issue at the reprository of the Docker Container.
If you are having **Basic Installation Problems** with python or it's dependencies, please consider using your favorite search engine to find a solution. In case you can't find a solution, we are happy to help you.
We can offer only very limited support regarding configuration of **Reverse-Proxy Installations**, **OPDS-Reader** or other programs in combination with Calibre-Web.
### **Translation**
Some of the user languages in Calibre-Web having missing translations. We are happy to add the missing texts if you are translate them. Create a Pull Request, create an issue with the .po file attached, or write an email to "ozzie.fernandez.isaacs@googlemail.com" with attached translation file. To display all book languages in your native language an additional file is used (iso_language_names.py). The content of this file is autogenerated with the corresponding translations of Calibre, please do not edit this file on your own.
### **Documentation**
The Calibre-Web documentation is hosted in the Github [Wiki](https://github.com/janeczku/calibre-web/wiki). The Wiki is open to everybody, if you find a problem, feel free to correct it. If information is missing, you are welcome to add it. The content will be reviewed time by time. Please try to be consitent with the form with the other Wiki pages (e.g. the project name is Calibre-Web with 2 capital letters and a dash in between).
### **Reporting a bug**
Do not open up a GitHub issue if the bug is a **security vulnerability** in Calibre-Web. Please write intead an email to "ozzie.fernandez.isaacs@googlemail.com".
Ensure the ***bug was not already reported** by searching on GitHub under [Issues](https://github.com/janeczku/calibre-web/issues). Please also check if a solution for your problem can be found in the [wiki](https://github.com/janeczku/calibre-web/wiki).
If you're unable to find an **open issue** addressing the problem, open a [new one](https://github.com/janeczku/calibre-web/issues/new?assignees=&labels=&template=bug_report.md&title=). Be sure to include a **title** and **clear description**, as much relevant information as possible, the **issue form** helps you providing the right information. Deleting the form and just pasting the stack trace doesn't speed up fixing the problem. If your issue could be resolved, consider closing the issue.
### **Feature Request**
If there is afeature missing in Calibre-Web and you can't find a feature request in the [Issues](https://github.com/janeczku/calibre-web/issues) section, you could create a [feature request](https://github.com/janeczku/calibre-web/issues/new?assignees=&labels=&template=feature_request.md&title=).
We will not extend Calibre-Web with any more login abilitys or add further files storages, or file syncing ability. Furthermore Calibre-Web is made for home usage for company inhouse usage, so requests regarding any sorts of social interaction capability, payment routines, search engine or web site analytics integration will not be implemeted.
### **Contributing code to Calibre-Web**
Open a new GitHub pull request with the patch. Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
In case your code enhances features of Calibre-Web: Create your pull request for the development branch if your enhancement consits of more than some lines of code in a local section of Calibre-Webs code. This makes it easier to test it and check all implication before it's made public.
Please check if your code runs on Python 2.7 (still necessary in 2020) and mainly on python 3. If possible and the feature is related to operating system functions, try to check it on Windows and Linux.
Calibre-Web is automatically tested on Linux in combination with python 3.7. The code for testing is in a [seperate repo](https://github.com/OzzieIsaacs/calibre-web-test) on Github. It uses unittest and performs real system tests with selenium, would be great if you could consider also writing some tests.
A static code analysis is done by Codacy, but it's partly broken and doesn't run automatically. You could check your code with ESLint before contributing, a configuration file can be found in the projects root folder.
+1
View File
@@ -0,0 +1 @@
graft src/calibreweb
+87
View File
@@ -0,0 +1,87 @@
# About
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.
*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)
## Features
- Bootstrap 3 HTML5 interface
- full graphical setup
- User management with fine-grained per-user permissions
- Admin interface
- User Interface in czech, dutch, english, finnish, french, german, hungarian, italian, japanese, khmer, polish, russian, simplified chinese, spanish, swedish, turkish, ukrainian
- OPDS feed for eBook reader apps
- Filter and search by titles, authors, tags, series and language
- Create a custom book collection (shelves)
- Support for editing eBook metadata and deleting eBooks from Calibre library
- Support for converting eBooks through Calibre binaries
- Restrict eBook download to logged-in users
- Support for public user registration
- Send eBooks to Kindle devices with the click of a button
- 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)
- Upload new books in many formats, including audio formats (.mp3, .m4a, .m4b)
- Support for Calibre Custom Columns
- Ability to hide content based on categories and Custom Column content per user
- Self-update capability
- "Magic Link" login to make it easy to log on eReaders
- Login via LDAP, google/github oauth and via proxy authentication
## Quick start
1. Install dependencies by running `pip3 install --target vendor -r requirements.txt` (python3.x) or `pip install --target vendor -r requirements.txt` (python2.7).
2. Execute the command: `python cps.py` (or `nohup python cps.py` - recommended if you want to exit the terminal window)
3. Point your browser to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
4. Set `Location of Calibre database` to the path of the folder where your Calibre library (metadata.db) lives, push "submit" button\
Optionally a Google Drive can be used to host the calibre library [-> Using Google Drive integration](https://github.com/janeczku/calibre-web/wiki/Configuration#using-google-drive-integration)
5. Go to Login page
**Default admin login:**\
*Username:* admin\
*Password:* admin123
**Issues with Ubuntu:**
Please note that running the above install command can fail on some versions of Ubuntu, saying `"can't combine user with prefix"`. This is a [known bug](https://github.com/pypa/pip/issues/3826) and can be remedied by using the command `pip install --system --target vendor -r requirements.txt` instead.
## Requirements
python 3.x+, (Python 2.7+)
Optionally, to enable on-the-fly conversion from one ebook format to another when using the send-to-kindle feature, or during editing of ebooks metadata:
[Download and install](https://calibre-ebook.com/download) the Calibre desktop program for your platform and enter the folder including program name (normally /opt/calibre/ebook-convert, or C:\Program Files\calibre\ebook-convert.exe) in the field "calibre's converter tool" on the setup page.
\*** DEPRECATED \*** Support will be removed in future releases
[Download](http://www.amazon.com/gp/feature.html?docId=1000765211) Amazon's KindleGen tool for your platform and place the binary named `kindlegen` in the `vendor` folder.
## Docker Images
Pre-built Docker images are available in these Docker Hub repositories:
#### **Technosoft2000 - x64**
+ Docker Hub - [https://hub.docker.com/r/technosoft2000/calibre-web/](https://hub.docker.com/r/technosoft2000/calibre-web/)
+ Github - [https://github.com/Technosoft2000/docker-calibre-web](https://github.com/Technosoft2000/docker-calibre-web)
Includes the Calibre `ebook-convert` binary.
+ The "path to convertertool" should be set to `/opt/calibre/ebook-convert`
#### **LinuxServer - x64, armhf, aarch64**
+ Docker Hub - [https://hub.docker.com/r/linuxserver/calibre-web/](https://hub.docker.com/r/linuxserver/calibre-web/)
+ Github - [https://github.com/linuxserver/docker-calibre-web](https://github.com/linuxserver/docker-calibre-web)
+ Github - (Optional Calibre layer) - [https://github.com/linuxserver/docker-calibre-web/tree/calibre](https://github.com/linuxserver/docker-calibre-web/tree/calibre)
This image has the option to pull in an extra docker manifest layer to include the Calibre `ebook-convert` binary. Just include the environmental variable `DOCKER_MODS=linuxserver/calibre-web:calibre` in your docker run/docker compose file. **(x64 only)**
If you do not need this functionality then this can be omitted, keeping the image as lightweight as possible.
Both the Calibre-Web and Calibre-Mod images are rebuilt automatically on new releases of Calibre-Web and Calibre respectively, and on updates to any included base image packages on a weekly basis if required.
+ The "path to convertertool" should be set to `/usr/bin/ebook-convert`
+ The "path to unrar" should be set to `/usr/bin/unrar`
# Wiki
For further information, How To's and FAQ please check the [Wiki](https://github.com/janeczku/calibre-web/wiki)
+70 -12
View File
@@ -1,21 +1,79 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2012-2019 OzzieIsaacs
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import absolute_import, division, print_function, unicode_literals
import sys import sys
import os
base_path = os.path.dirname(os.path.abspath(__file__))
# Insert local directories into path # Insert local directories into path
sys.path.append(base_path) if sys.version_info < (3, 0):
sys.path.append(os.path.join(base_path, 'cps')) sys.path.append(os.path.dirname(os.path.abspath(__file__.decode('utf-8'))))
sys.path.append(os.path.join(base_path, 'vendor')) sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__.decode('utf-8'))), 'vendor'))
else:
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor'))
from cps import create_app
from cps import web_server
from cps.opds import opds
from cps.web import web
from cps.jinjia import jinjia
from cps.about import about
from cps.shelf import shelf
from cps.admin import admi
from cps.gdrive import gdrive
from cps.editbooks import editbook
try:
from cps.kobo import kobo, get_kobo_activated
from cps.kobo_auth import kobo_auth
kobo_available = get_kobo_activated()
except ImportError:
kobo_available = False
try:
from cps.oauth_bb import oauth
oauth_available = True
except ImportError:
oauth_available = False
def main():
app = create_app()
app.register_blueprint(web)
app.register_blueprint(opds)
app.register_blueprint(jinjia)
app.register_blueprint(about)
app.register_blueprint(shelf)
app.register_blueprint(admi)
app.register_blueprint(gdrive)
app.register_blueprint(editbook)
if kobo_available:
app.register_blueprint(kobo)
app.register_blueprint(kobo_auth)
if oauth_available:
app.register_blueprint(oauth)
success = web_server.start()
sys.exit(0 if success else 1)
from cps.server import Server
if __name__ == '__main__': if __name__ == '__main__':
Server.startServer() main()
Executable → Regular
+135 -1
View File
@@ -1,2 +1,136 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
# apetresc, nanu-c, mutschler
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import sys
import os
import mimetypes
from babel import Locale as LC
from babel import negotiate_locale
from babel.core import UnknownLocaleError
from flask import Flask, request, g
from flask_login import LoginManager
from flask_babel import Babel
from flask_principal import Principal
from . import config_sql, logger, cache_buster, cli, ub, db
from .reverseproxy import ReverseProxied
from .server import WebServer
mimetypes.init()
mimetypes.add_type('application/xhtml+xml', '.xhtml')
mimetypes.add_type('application/epub+zip', '.epub')
mimetypes.add_type('application/fb2+zip', '.fb2')
mimetypes.add_type('application/x-mobipocket-ebook', '.mobi')
mimetypes.add_type('application/x-mobipocket-ebook', '.prc')
mimetypes.add_type('application/vnd.amazon.ebook', '.azw')
mimetypes.add_type('application/x-cbr', '.cbr')
mimetypes.add_type('application/x-cbz', '.cbz')
mimetypes.add_type('application/x-cbt', '.cbt')
mimetypes.add_type('image/vnd.djvu', '.djvu')
mimetypes.add_type('application/mpeg', '.mpeg')
mimetypes.add_type('application/mpeg', '.mp3')
mimetypes.add_type('application/mp4', '.m4a')
mimetypes.add_type('application/mp4', '.m4b')
mimetypes.add_type('application/ogg', '.ogg')
mimetypes.add_type('application/ogg', '.oga')
app = Flask(__name__)
lm = LoginManager()
lm.login_view = 'web.login'
lm.anonymous_user = ub.Anonymous
ub.init_db(cli.settingspath)
# pylint: disable=no-member
config = config_sql.load_configuration(ub.session)
searched_ids = {}
web_server = WebServer()
babel = Babel()
_BABEL_TRANSLATIONS = set()
log = logger.create()
from . import services
def create_app():
app.wsgi_app = ReverseProxied(app.wsgi_app)
# For python2 convert path to unicode
if sys.version_info < (3, 0):
app.static_folder = app.static_folder.decode('utf-8')
app.root_path = app.root_path.decode('utf-8')
app.instance_path = app.instance_path .decode('utf-8')
cache_buster.init_cache_busting(app)
log.info('Starting Calibre Web...')
Principal(app)
lm.init_app(app)
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
web_server.init_app(app, config)
db.setup_db(config)
babel.init_app(app)
_BABEL_TRANSLATIONS.update(str(item) for item in babel.list_translations())
_BABEL_TRANSLATIONS.add('en')
if services.ldap:
services.ldap.init_app(app, config)
if services.goodreads_support:
services.goodreads_support.connect(config.config_goodreads_api_key,
config.config_goodreads_api_secret,
config.config_use_goodreads)
return app
@babel.localeselector
def get_locale():
# if a user is logged in, use the locale from the user settings
user = getattr(g, 'user', None)
# user = None
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
return user.locale
preferred = list()
if request.accept_languages:
for x in request.accept_languages.values():
try:
preferred.append(str(LC.parse(x.replace('-', '_'))))
except (UnknownLocaleError, ValueError) as e:
log.debug('Could not parse locale "%s": %s', x, e)
return negotiate_locale(preferred or ['en'], _BABEL_TRANSLATIONS)
@babel.timezoneselector
def get_timezone():
user = getattr(g, 'user', None)
return user.timezone if user else None
from .updater import Updater
updater_thread = Updater()
updater_thread.start()
+94
View File
@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
# apetresc, nanu-c, mutschler
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import sys
import platform
import sqlite3
from collections import OrderedDict
import babel, pytz, requests, sqlalchemy
import werkzeug, flask, flask_login, flask_principal, jinja2
from flask_babel import gettext as _
from . import db, converter, uploader, server, isoLanguages, constants
from .web import render_title_template
try:
from flask_login import __version__ as flask_loginVersion
except ImportError:
from flask_login.__about__ import __version__ as flask_loginVersion
try:
import unidecode
# _() necessary to make babel aware of string for translation
unidecode_version = _(u'installed')
except ImportError:
unidecode_version = _(u'not installed')
try:
from flask_dance import __version__ as flask_danceVersion
except ImportError:
flask_danceVersion = None
from . import services
about = flask.Blueprint('about', __name__)
_VERSIONS = OrderedDict(
Platform = '{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()),
Python=sys.version,
Calibre_Web=constants.STABLE_VERSION['version'] + ' - '
+ constants.NIGHTLY_VERSION[0].replace('%','%%') + ' - '
+ constants.NIGHTLY_VERSION[1].replace('%','%%'),
WebServer=server.VERSION,
Flask=flask.__version__,
Flask_Login=flask_loginVersion,
Flask_Principal=flask_principal.__version__,
Werkzeug=werkzeug.__version__,
Babel=babel.__version__,
Jinja2=jinja2.__version__,
Requests=requests.__version__,
SqlAlchemy=sqlalchemy.__version__,
pySqlite=sqlite3.version,
SQLite=sqlite3.sqlite_version,
iso639=isoLanguages.__version__,
pytz=pytz.__version__,
Unidecode = unidecode_version,
Flask_SimpleLDAP = u'installed' if bool(services.ldap) else None,
python_LDAP = services.ldapVersion if bool(services.ldapVersion) else None,
Goodreads = u'installed' if bool(services.goodreads_support) else None,
jsonschema = services.SyncToken.__version__ if bool(services.SyncToken) else None,
flask_dance = flask_danceVersion
)
_VERSIONS.update(uploader.get_versions())
@about.route("/stats")
@flask_login.login_required
def stats():
counter = db.session.query(db.Books).count()
authors = db.session.query(db.Authors).count()
categorys = db.session.query(db.Tags).count()
series = db.session.query(db.Series).count()
_VERSIONS['ebook converter'] = _(converter.get_version())
return render_title_template('stats.html', bookcounter=counter, authorcounter=authors, versions=_VERSIONS,
categorycounter=categorys, seriecounter=series, title=_(u"Statistics"), page="stat")
+1024
View File
File diff suppressed because it is too large Load Diff
-217
View File
@@ -1,217 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2016-2019 lemmsh cervinko Kennyl matthazinski OzzieIsaacs
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
import uploader
import os
from flask_babel import gettext as _
import comic
try:
from lxml.etree import LXML_VERSION as lxmlversion
except ImportError:
lxmlversion = None
__author__ = 'lemmsh'
logger = logging.getLogger("book_formats")
try:
from wand.image import Image
from wand import version as ImageVersion
from wand.exceptions import PolicyError
use_generic_pdf_cover = False
except (ImportError, RuntimeError) as e:
logger.warning('cannot import Image, generating pdf covers for pdf uploads will not work: %s', e)
use_generic_pdf_cover = True
try:
from PyPDF2 import PdfFileReader
from PyPDF2 import __version__ as PyPdfVersion
use_pdf_meta = True
except ImportError as e:
logger.warning('cannot import PyPDF2, extracting pdf metadata will not work: %s', e)
use_pdf_meta = False
try:
import epub
use_epub_meta = True
except ImportError as e:
logger.warning('cannot import epub, extracting epub metadata will not work: %s', e)
use_epub_meta = False
try:
import fb2
use_fb2_meta = True
except ImportError as e:
logger.warning('cannot import fb2, extracting fb2 metadata will not work: %s', e)
use_fb2_meta = False
try:
from PIL import Image
from PIL import __version__ as PILversion
use_PIL = True
except ImportError:
use_PIL = False
def process(tmp_file_path, original_file_name, original_file_extension):
meta = None
try:
if ".PDF" == original_file_extension.upper():
meta = pdf_meta(tmp_file_path, original_file_name, original_file_extension)
if ".EPUB" == original_file_extension.upper() and use_epub_meta is True:
meta = epub.get_epub_info(tmp_file_path, original_file_name, original_file_extension)
if ".FB2" == original_file_extension.upper() and use_fb2_meta is True:
meta = fb2.get_fb2_info(tmp_file_path, original_file_extension)
if original_file_extension.upper() in ['.CBZ', '.CBT']:
meta = comic.get_comic_info(tmp_file_path, original_file_name, original_file_extension)
except Exception as ex:
logger.warning('cannot parse metadata, using default: %s', ex)
if meta and meta.title.strip() and meta.author.strip():
return meta
else:
return default_meta(tmp_file_path, original_file_name, original_file_extension)
def default_meta(tmp_file_path, original_file_name, original_file_extension):
return uploader.BookMeta(
file_path=tmp_file_path,
extension=original_file_extension,
title=original_file_name,
author=u"Unknown",
cover=None,
description="",
tags="",
series="",
series_id="",
languages="")
def pdf_meta(tmp_file_path, original_file_name, original_file_extension):
if use_pdf_meta:
pdf = PdfFileReader(open(tmp_file_path, 'rb'), strict=False)
doc_info = pdf.getDocumentInfo()
else:
doc_info = None
if doc_info is not None:
author = doc_info.author if doc_info.author else u"Unknown"
title = doc_info.title if doc_info.title else original_file_name
subject = doc_info.subject
else:
author = u"Unknown"
title = original_file_name
subject = ""
return uploader.BookMeta(
file_path=tmp_file_path,
extension=original_file_extension,
title=title,
author=author,
cover=pdf_preview(tmp_file_path, original_file_name),
description=subject,
tags="",
series="",
series_id="",
languages="")
def pdf_preview(tmp_file_path, tmp_dir):
if use_generic_pdf_cover:
return None
else:
if use_PIL:
try:
input1 = PdfFileReader(open(tmp_file_path, 'rb'), strict=False)
page0 = input1.getPage(0)
xObject = page0['/Resources']['/XObject'].getObject()
for obj in xObject:
if xObject[obj]['/Subtype'] == '/Image':
size = (xObject[obj]['/Width'], xObject[obj]['/Height'])
data = xObject[obj]._data # xObject[obj].getData()
if xObject[obj]['/ColorSpace'] == '/DeviceRGB':
mode = "RGB"
else:
mode = "P"
if '/Filter' in xObject[obj]:
if xObject[obj]['/Filter'] == '/FlateDecode':
img = Image.frombytes(mode, size, data)
cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.png"
img.save(filename=os.path.join(tmp_dir, cover_file_name))
return cover_file_name
# img.save(obj[1:] + ".png")
elif xObject[obj]['/Filter'] == '/DCTDecode':
cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.jpg"
img = open(cover_file_name, "wb")
img.write(data)
img.close()
return cover_file_name
elif xObject[obj]['/Filter'] == '/JPXDecode':
cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.jp2"
img = open(cover_file_name, "wb")
img.write(data)
img.close()
return cover_file_name
else:
img = Image.frombytes(mode, size, data)
cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.png"
img.save(filename=os.path.join(tmp_dir, cover_file_name))
return cover_file_name
except Exception as ex:
print(ex)
try:
cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.jpg"
with Image(filename=tmp_file_path + "[0]", resolution=150) as img:
img.compression_quality = 88
img.save(filename=os.path.join(tmp_dir, cover_file_name))
return cover_file_name
except PolicyError as ex:
logger.warning('Pdf extraction forbidden by Imagemagick policy: %s', ex)
return None
except Exception as ex:
logger.warning('Cannot extract cover image, using default: %s', ex)
return None
def get_versions():
if not use_generic_pdf_cover:
IVersion = ImageVersion.MAGICK_VERSION
WVersion = ImageVersion.VERSION
else:
IVersion = _(u'not installed')
WVersion = _(u'not installed')
if use_pdf_meta:
PVersion='v'+PyPdfVersion
else:
PVersion=_(u'not installed')
if lxmlversion:
XVersion = 'v'+'.'.join(map(str, lxmlversion))
else:
XVersion = _(u'not installed')
if use_PIL:
PILVersion = 'v' + PILversion
else:
PILVersion = _(u'not installed')
return {'Image Magick': IVersion,
'PyPdf': PVersion,
'lxml':XVersion,
'Wand': WVersion,
'Pillow': PILVersion}
+11 -3
View File
@@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2016-2019 jkrehm andy29485 OzzieIsaacs # Copyright (C) 2016-2019 jkrehm andy29485 OzzieIsaacs
# #
@@ -17,8 +19,14 @@
# Inspired by https://github.com/ChrisTM/Flask-CacheBust # Inspired by https://github.com/ChrisTM/Flask-CacheBust
# Uses query strings so CSS font files are found without having to resort to absolute URLs # Uses query strings so CSS font files are found without having to resort to absolute URLs
import hashlib from __future__ import division, print_function, unicode_literals
import os import os
import hashlib
from . import logger
log = logger.create()
def init_cache_busting(app): def init_cache_busting(app):
@@ -34,7 +42,7 @@ def init_cache_busting(app):
hash_table = {} # map of file hashes hash_table = {} # map of file hashes
app.logger.debug('Computing cache-busting values...') log.debug('Computing cache-busting values...')
# compute file hashes # compute file hashes
for dirpath, __, filenames in os.walk(static_folder): for dirpath, __, filenames in os.walk(static_folder):
for filename in filenames: for filename in filenames:
@@ -47,7 +55,7 @@ def init_cache_busting(app):
file_path = rooted_filename.replace(static_folder, "") file_path = rooted_filename.replace(static_folder, "")
file_path = file_path.replace("\\", "/") # Convert Windows path to web path file_path = file_path.replace("\\", "/") # Convert Windows path to web path
hash_table[file_path] = file_hash hash_table[file_path] = file_hash
app.logger.debug('Finished computing cache-busting values') log.debug('Finished computing cache-busting values')
def bust_filename(filename): def bust_filename(filename):
return hash_table.get(filename, "") return hash_table.get(filename, "")
+62 -19
View File
@@ -1,7 +1,5 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018 OzzieIsaacs # Copyright (C) 2018 OzzieIsaacs
# #
@@ -18,30 +16,54 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import argparse from __future__ import division, print_function, unicode_literals
import os
import sys import sys
import os
import argparse
import socket
from .constants import CONFIG_DIR as _CONFIG_DIR
from .constants import STABLE_VERSION as _STABLE_VERSION
from .constants import NIGHTLY_VERSION as _NIGHTLY_VERSION
def version_info():
if _NIGHTLY_VERSION[1].startswith('$Format'):
return "Calibre-Web version: %s - unkown git-clone" % _STABLE_VERSION['version']
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'], _NIGHTLY_VERSION[1])
parser = argparse.ArgumentParser(description='Calibre Web is a web app' parser = argparse.ArgumentParser(description='Calibre Web is a web app'
' providing a interface for browsing, reading and downloading eBooks\n', prog='cps.py') ' providing a interface for browsing, reading and downloading eBooks\n', prog='cps.py')
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db') parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db') parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db')
parser.add_argument('-c', metavar='path', help='path and name to SSL certfile, e.g. /opt/test.cert, works only in combination with keyfile') parser.add_argument('-c', metavar='path',
parser.add_argument('-k', metavar='path', help='path and name to SSL keyfile, e.g. /opt/test.key, works only in combination with certfile') help='path and name to SSL certfile, e.g. /opt/test.cert, works only in combination with keyfile')
parser.add_argument('-k', metavar='path',
help='path and name to SSL keyfile, e.g. /opt/test.key, works only in combination with certfile')
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-web',
version=version_info())
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
parser.add_argument('-s', metavar='user:pass', help='Sets specific username to new password')
args = parser.parse_args() args = parser.parse_args()
generalPath = os.path.normpath(os.getenv("CALIBRE_DBPATH", if sys.version_info < (3, 0):
os.path.dirname(os.path.realpath(__file__)) + os.sep + ".." + os.sep)) if args.p:
if args.p: args.p = args.p.decode('utf-8')
settingspath = args.p if args.g:
else: args.g = args.g.decode('utf-8')
settingspath = os.path.join(generalPath, "app.db") if args.k:
args.k = args.k.decode('utf-8')
if args.c:
args.c = args.c.decode('utf-8')
if args.s:
args.s = args.s.decode('utf-8')
if args.g:
gdpath = args.g
else:
gdpath = os.path.join(generalPath, "gdrive.db")
settingspath = args.p or os.path.join(_CONFIG_DIR, "app.db")
gdpath = args.g or os.path.join(_CONFIG_DIR, "gdrive.db")
# handle and check parameter for ssl encryption
certfilepath = None certfilepath = None
keyfilepath = None keyfilepath = None
if args.c: if args.c:
@@ -51,7 +73,7 @@ if args.c:
print("Certfilepath is invalid. Exiting...") print("Certfilepath is invalid. Exiting...")
sys.exit(1) sys.exit(1)
if args.c is "": if args.c == "":
certfilepath = "" certfilepath = ""
if args.k: if args.k:
@@ -65,5 +87,26 @@ if (args.k and not args.c) or (not args.k and args.c):
print("Certfile and Keyfile have to be used together. Exiting...") print("Certfile and Keyfile have to be used together. Exiting...")
sys.exit(1) sys.exit(1)
if args.k is "": if args.k == "":
keyfilepath = "" keyfilepath = ""
# handle and check ipadress argument
ipadress = args.i or None
if ipadress:
try:
# try to parse the given ip address with socket
if hasattr(socket, 'inet_pton'):
if ':' in ipadress:
socket.inet_pton(socket.AF_INET6, ipadress)
else:
socket.inet_pton(socket.AF_INET, ipadress)
else:
# on windows python < 3.4, inet_pton is not available
# inet_atom only handles IPv4 addresses
socket.inet_aton(ipadress)
except socket.error as err:
print(ipadress, ':', err)
sys.exit(1)
# handle and check user password argument
user_password = args.s or None
+106 -33
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
@@ -17,32 +16,75 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import zipfile from __future__ import division, print_function, unicode_literals
import tarfile
import os import os
import uploader
from . import logger, isoLanguages
from .constants import BookMeta
def extractCover(tmp_file_name, original_file_extension): log = logger.create()
cover_data = None
if original_file_extension.upper() == '.CBZ':
cf = zipfile.ZipFile(tmp_file_name) try:
for name in cf.namelist(): from comicapi.comicarchive import ComicArchive, MetaDataStyle
use_comic_meta = True
except ImportError as e:
log.debug('cannot import comicapi, extracting comic metadata will not work: %s', e)
import zipfile
import tarfile
try:
import rarfile
use_rarfile = True
except ImportError as e:
log.debug('cannot import rarfile, extracting cover files from rar files will not work: %s', e)
use_rarfile = False
use_comic_meta = False
def _extractCover(tmp_file_name, original_file_extension, rarExceutable):
cover_data = extension = None
if use_comic_meta:
archive = ComicArchive(tmp_file_name)
for index, name in enumerate(archive.getPageNameList()):
ext = os.path.splitext(name) ext = os.path.splitext(name)
if len(ext) > 1: if len(ext) > 1:
extension = ext[1].lower() extension = ext[1].lower()
if extension == '.jpg': if extension == '.jpg' or extension == '.jpeg':
cover_data = cf.read(name) cover_data = archive.getPage(index)
break
elif original_file_extension.upper() == '.CBT':
cf = tarfile.TarFile(tmp_file_name)
for name in cf.getnames():
ext = os.path.splitext(name)
if len(ext) > 1:
extension = ext[1].lower()
if extension == '.jpg':
cover_data = cf.extractfile(name).read()
break break
else:
if original_file_extension.upper() == '.CBZ':
cf = zipfile.ZipFile(tmp_file_name)
for name in cf.namelist():
ext = os.path.splitext(name)
if len(ext) > 1:
extension = ext[1].lower()
if extension == '.jpg' or extension == '.jpeg':
cover_data = cf.read(name)
break
elif original_file_extension.upper() == '.CBT':
cf = tarfile.TarFile(tmp_file_name)
for name in cf.getnames():
ext = os.path.splitext(name)
if len(ext) > 1:
extension = ext[1].lower()
if extension == '.jpg' or extension == '.jpeg':
cover_data = cf.extractfile(name).read()
break
elif original_file_extension.upper() == '.CBR' and use_rarfile:
try:
rarfile.UNRAR_TOOL = rarExceutable
cf = rarfile.RarFile(tmp_file_name)
for name in cf.getnames():
ext = os.path.splitext(name)
if len(ext) > 1:
extension = ext[1].lower()
if extension == '.jpg' or extension == '.jpeg':
cover_data = cf.read(name)
break
except Exception as e:
log.debug('Rarfile failed with error: %s', e)
prefix = os.path.dirname(tmp_file_name) prefix = os.path.dirname(tmp_file_name)
if cover_data: if cover_data:
@@ -55,18 +97,49 @@ def extractCover(tmp_file_name, original_file_extension):
return tmp_cover_name return tmp_cover_name
def get_comic_info(tmp_file_path, original_file_name, original_file_extension): def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rarExceutable):
if use_comic_meta:
archive = ComicArchive(tmp_file_path, rar_exe_path=rarExceutable)
if archive.seemsToBeAComicArchive():
if archive.hasMetadata(MetaDataStyle.CIX):
style = MetaDataStyle.CIX
elif archive.hasMetadata(MetaDataStyle.CBI):
style = MetaDataStyle.CBI
else:
style = None
coverfile = extractCover(tmp_file_path, original_file_extension) # if style is not None:
loadedMetadata = archive.readMetadata(style)
return uploader.BookMeta( lang = loadedMetadata.language
file_path=tmp_file_path, if lang:
extension=original_file_extension, if len(lang) == 2:
title=original_file_name, loadedMetadata.language = isoLanguages.get(part1=lang).name
author=u"Unknown", elif len(lang) == 3:
cover=coverfile, loadedMetadata.language = isoLanguages.get(part3=lang).name
description="", else:
tags="", loadedMetadata.language = ""
series="",
series_id="", return BookMeta(
languages="") file_path=tmp_file_path,
extension=original_file_extension,
title=loadedMetadata.title or original_file_name,
author=" & ".join([credit["person"] for credit in loadedMetadata.credits if credit["role"] == "Writer"]) or u'Unknown',
cover=_extractCover(tmp_file_path, original_file_extension, rarExceutable),
description=loadedMetadata.comments or "",
tags="",
series=loadedMetadata.series or "",
series_id=loadedMetadata.issue or "",
languages=loadedMetadata.language)
return BookMeta(
file_path=tmp_file_path,
extension=original_file_extension,
title=original_file_name,
author=u'Unknown',
cover=_extractCover(tmp_file_path, original_file_extension, rarExceutable),
description="",
tags="",
series="",
series_id="",
languages="")
+374
View File
@@ -0,0 +1,374 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2019 OzzieIsaacs, pwr
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import os
import json
import sys
from sqlalchemy import exc, Column, String, Integer, SmallInteger, Boolean, BLOB
from sqlalchemy.ext.declarative import declarative_base
from . import constants, cli, logger, ub
log = logger.create()
_Base = declarative_base()
class _Flask_Settings(_Base):
__tablename__ = 'flask_settings'
id = Column(Integer, primary_key=True)
flask_session_key = Column(BLOB, default="")
def __init__(self, key):
self.flask_session_key = key
# Baseclass for representing settings in app.db with email server settings and Calibre database settings
# (application settings)
class _Settings(_Base):
__tablename__ = 'settings'
id = Column(Integer, primary_key=True)
mail_server = Column(String, default=constants.DEFAULT_MAIL_SERVER)
mail_port = Column(Integer, default=25)
mail_use_ssl = Column(SmallInteger, default=0)
mail_login = Column(String, default='mail@example.com')
mail_password = Column(String, default='mypassword')
mail_from = Column(String, default='automailer <mail@example.com>')
config_calibre_dir = Column(String)
config_port = Column(Integer, default=constants.DEFAULT_PORT)
config_certfile = Column(String)
config_keyfile = Column(String)
config_calibre_web_title = Column(String, default=u'Calibre-Web')
config_books_per_page = Column(Integer, default=60)
config_random_books = Column(Integer, default=4)
config_authors_max = 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_mature_content_tags = Column(String, default='')
config_theme = Column(Integer, default=0)
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
config_logfile = Column(String)
config_access_log = Column(SmallInteger, default=0)
config_access_logfile = Column(String)
config_uploading = Column(SmallInteger, default=0)
config_anonbrowse = Column(SmallInteger, default=0)
config_public_reg = Column(SmallInteger, default=0)
config_remote_login = Column(Boolean, default=False)
config_kobo_sync = Column(Boolean, default=False)
config_default_role = Column(SmallInteger, default=0)
config_default_show = Column(SmallInteger, default=constants.ADMIN_USER_SIDEBAR)
config_columns_to_ignore = Column(String)
config_denied_tags = Column(String, default="")
config_allowed_tags = Column(String, default="")
config_restricted_column = Column(SmallInteger, default=0)
config_denied_column_value = Column(String, default="")
config_allowed_column_value = Column(String, default="")
config_use_google_drive = Column(Boolean, default=False)
config_google_drive_folder = Column(String)
config_google_drive_watch_changes_response = Column(String)
config_use_goodreads = Column(Boolean, default=False)
config_goodreads_api_key = Column(String)
config_goodreads_api_secret = Column(String)
config_login_type = Column(Integer, default=0)
config_kobo_proxy = Column(Boolean, default=False)
config_ldap_provider_url = Column(String, default='example.org')
config_ldap_port = Column(SmallInteger, default=389)
config_ldap_authentication = Column(SmallInteger, default=constants.LDAP_AUTH_SIMPLE)
config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org')
config_ldap_serv_password = Column(String, default="")
config_ldap_encryption = Column(SmallInteger, default=0)
config_ldap_cert_path = Column(String, default="")
config_ldap_dn = Column(String, default='dc=example,dc=org')
config_ldap_user_object = Column(String, default='uid=%s')
config_ldap_openldap = Column(Boolean, default=True)
config_ldap_group_object_filter = Column(String, default='(&(objectclass=posixGroup)(cn=%s))')
config_ldap_group_members_field = Column(String, default='memberUid')
config_ldap_group_name = Column(String, default='calibreweb')
config_ebookconverter = Column(Integer, default=0)
config_converterpath = Column(String)
config_calibre = Column(String)
config_rarfile_location = Column(String)
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
config_reverse_proxy_login_header_name = Column(String)
config_allow_reverse_proxy_header_login = Column(Boolean, default=False)
def __repr__(self):
return self.__class__.__name__
# Class holds all application specific settings in calibre-web
class _ConfigSQL(object):
# pylint: disable=no-member
def __init__(self, session):
self._session = session
self._settings = None
self.db_configured = None
self.config_calibre_dir = None
self.load()
def _read_from_storage(self):
if self._settings is None:
log.debug("_ConfigSQL._read_from_storage")
self._settings = self._session.query(_Settings).first()
return self._settings
def get_config_certfile(self):
if cli.certfilepath:
return cli.certfilepath
if cli.certfilepath == "":
return None
return self.config_certfile
def get_config_keyfile(self):
if cli.keyfilepath:
return cli.keyfilepath
if cli.certfilepath == "":
return None
return self.config_keyfile
def get_config_ipaddress(self):
return cli.ipadress or ""
def _has_role(self, role_flag):
return constants.has_flag(self.config_default_role, role_flag)
def role_admin(self):
return self._has_role(constants.ROLE_ADMIN)
def role_download(self):
return self._has_role(constants.ROLE_DOWNLOAD)
def role_viewer(self):
return self._has_role(constants.ROLE_VIEWER)
def role_upload(self):
return self._has_role(constants.ROLE_UPLOAD)
def role_edit(self):
return self._has_role(constants.ROLE_EDIT)
def role_passwd(self):
return self._has_role(constants.ROLE_PASSWD)
def role_edit_shelfs(self):
return self._has_role(constants.ROLE_EDIT_SHELFS)
def role_delete_books(self):
return self._has_role(constants.ROLE_DELETE_BOOKS)
def show_element_new_user(self, value):
return constants.has_flag(self.config_default_show, value)
def show_detail_random(self):
return self.show_element_new_user(constants.DETAIL_RANDOM)
def list_denied_tags(self):
mct = self.config_denied_tags.split(",")
return [t.strip() for t in mct]
def list_allowed_tags(self):
mct = self.config_allowed_tags.split(",")
return [t.strip() for t in mct]
def list_denied_column_values(self):
mct = self.config_denied_column_value.split(",")
return [t.strip() for t in mct]
def list_allowed_column_values(self):
mct = self.config_allowed_column_value.split(",")
return [t.strip() for t in mct]
def get_log_level(self):
return logger.get_level_name(self.config_log_level)
def get_mail_settings(self):
return {k:v for k, v in self.__dict__.items() if k.startswith('mail_')}
def get_mail_server_configured(self):
return not bool(self.mail_server == constants.DEFAULT_MAIL_SERVER)
def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None):
'''Possibly updates a field of this object.
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
'''
new_value = dictionary.get(field, default)
if new_value is None:
# log.debug("_ConfigSQL set_from_dictionary field '%s' not found", field)
return False
if field not in self.__dict__:
log.warning("_ConfigSQL trying to set unknown field '%s' = %r", field, new_value)
return False
if convertor is not None:
if encode:
new_value = convertor(new_value.encode(encode))
else:
new_value = convertor(new_value)
current_value = self.__dict__.get(field)
if current_value == new_value:
return False
# log.debug("_ConfigSQL set_from_dictionary '%s' = %r (was %r)", field, new_value, current_value)
setattr(self, field, new_value)
return True
def load(self):
'''Load all configuration values from the underlying storage.'''
s = self._read_from_storage() # type: _Settings
for k, v in s.__dict__.items():
if k[0] != '_':
if v is None:
# if the storage column has no value, apply the (possible) default
column = s.__class__.__dict__.get(k)
if column.default is not None:
v = column.default.arg
setattr(self, k, v)
if self.config_google_drive_watch_changes_response:
self.config_google_drive_watch_changes_response = json.loads(self.config_google_drive_watch_changes_response)
have_metadata_db = bool(self.config_calibre_dir)
if have_metadata_db:
if not self.config_use_google_drive:
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
have_metadata_db = os.path.isfile(db_file)
self.db_configured = have_metadata_db
logger.setup(self.config_logfile, self.config_log_level)
def save(self):
'''Apply all configuration values to the underlying storage.'''
s = self._read_from_storage() # type: _Settings
for k, v in self.__dict__.items():
if k[0] == '_':
continue
if hasattr(s, k):
setattr(s, k, v)
log.debug("_ConfigSQL updating storage")
self._session.merge(s)
self._session.commit()
self.load()
def invalidate(self, error=None):
if error:
log.error(error)
log.warning("invalidating configuration")
self.db_configured = False
self.config_calibre_dir = None
self.save()
def _migrate_table(session, orm_class):
changed = False
for column_name, column in orm_class.__dict__.items():
if column_name[0] != '_':
try:
session.query(column).first()
except exc.OperationalError as err:
log.debug("%s: %s", column_name, err.args[0])
if column.default is not None:
if sys.version_info < (3, 0):
if isinstance(column.default.arg, unicode):
column.default.arg = column.default.arg.encode('utf-8')
if column.default is None:
column_default = ""
else:
if isinstance(column.default.arg, bool):
column_default = ("DEFAULT %r" % int(column.default.arg))
else:
column_default = ("DEFAULT %r" % column.default.arg)
alter_table = "ALTER TABLE %s ADD COLUMN `%s` %s %s" % (orm_class.__tablename__,
column_name,
column.type,
column_default)
log.debug(alter_table)
session.execute(alter_table)
changed = True
if changed:
session.commit()
def autodetect_calibre_binary():
if sys.platform == "win32":
calibre_path = ["C:\\program files\calibre\calibre-convert.exe",
"C:\\program files(x86)\calibre\calibre-convert.exe"]
else:
calibre_path = ["/opt/calibre/ebook-convert"]
for element in calibre_path:
if os.path.isfile(element) and os.access(element, os.X_OK):
return element
return None
def _migrate_database(session):
# make sure the table is created, if it does not exist
_Base.metadata.create_all(session.bind)
_migrate_table(session, _Settings)
_migrate_table(session, _Flask_Settings)
def load_configuration(session):
_migrate_database(session)
if not session.query(_Settings).count():
session.add(_Settings())
session.commit()
conf = _ConfigSQL(session)
# Migrate from global restrictions to user based restrictions
if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "":
conf.config_denied_tags = conf.config_mature_content_tags
conf.save()
session.query(ub.User).filter(ub.User.mature_content != True). \
update({"denied_tags": conf.config_mature_content_tags}, synchronize_session=False)
session.commit()
return conf
def get_flask_session_key(session):
flask_settings = session.query(_Flask_Settings).one_or_none()
if flask_settings == None:
flask_settings = _Flask_Settings(os.urandom(32))
session.add(flask_settings)
session.commit()
return flask_settings.flask_session_key
+142
View File
@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2019 OzzieIsaacs, pwr
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import sys
import os
from collections import namedtuple
HOME_CONFIG = False
# Base dir is parent of current file, necessary if called from different folder
if sys.version_info < (3, 0):
BASE_DIR = os.path.abspath(os.path.join(
os.path.dirname(os.path.abspath(__file__)),os.pardir)).decode('utf-8')
else:
BASE_DIR = os.path.abspath(os.path.join(
os.path.dirname(os.path.abspath(__file__)),os.pardir))
STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static')
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
if HOME_CONFIG:
home_dir = os.path.join(os.path.expanduser("~"),".calibre-web")
if not os.path.exists(home_dir):
os.makedirs(home_dir)
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', home_dir)
else:
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', BASE_DIR)
ROLE_USER = 0 << 0
ROLE_ADMIN = 1 << 0
ROLE_DOWNLOAD = 1 << 1
ROLE_UPLOAD = 1 << 2
ROLE_EDIT = 1 << 3
ROLE_PASSWD = 1 << 4
ROLE_ANONYMOUS = 1 << 5
ROLE_EDIT_SHELFS = 1 << 6
ROLE_DELETE_BOOKS = 1 << 7
ROLE_VIEWER = 1 << 8
ALL_ROLES = {
"admin_role": ROLE_ADMIN,
"download_role": ROLE_DOWNLOAD,
"upload_role": ROLE_UPLOAD,
"edit_role": ROLE_EDIT,
"passwd_role": ROLE_PASSWD,
"edit_shelf_role": ROLE_EDIT_SHELFS,
"delete_role": ROLE_DELETE_BOOKS,
"viewer_role": ROLE_VIEWER,
}
DETAIL_RANDOM = 1 << 0
SIDEBAR_LANGUAGE = 1 << 1
SIDEBAR_SERIES = 1 << 2
SIDEBAR_CATEGORY = 1 << 3
SIDEBAR_HOT = 1 << 4
SIDEBAR_RANDOM = 1 << 5
SIDEBAR_AUTHOR = 1 << 6
SIDEBAR_BEST_RATED = 1 << 7
SIDEBAR_READ_AND_UNREAD = 1 << 8
SIDEBAR_RECENT = 1 << 9
SIDEBAR_SORTED = 1 << 10
MATURE_CONTENT = 1 << 11
SIDEBAR_PUBLISHER = 1 << 12
SIDEBAR_RATING = 1 << 13
SIDEBAR_FORMAT = 1 << 14
SIDEBAR_ARCHIVED = 1 << 15
ADMIN_USER_ROLES = sum(r for r in ALL_ROLES.values()) & ~ROLE_ANONYMOUS
ADMIN_USER_SIDEBAR = (SIDEBAR_ARCHIVED << 1) - 1
UPDATE_STABLE = 0 << 0
AUTO_UPDATE_STABLE = 1 << 0
UPDATE_NIGHTLY = 1 << 1
AUTO_UPDATE_NIGHTLY = 1 << 2
LOGIN_STANDARD = 0
LOGIN_LDAP = 1
LOGIN_OAUTH = 2
LDAP_AUTH_ANONYMOUS = 0
LDAP_AUTH_UNAUTHENTICATE = 1
LDAP_AUTH_SIMPLE = 0
DEFAULT_MAIL_SERVER = "mail.example.org"
DEFAULT_PASSWORD = "admin123"
DEFAULT_PORT = 8083
env_CALIBRE_PORT = os.environ.get("CALIBRE_PORT", DEFAULT_PORT)
try:
DEFAULT_PORT = int(env_CALIBRE_PORT)
except ValueError:
print('Environment variable CALIBRE_PORT has invalid value (%s), faling back to default (8083)' % env_CALIBRE_PORT)
del env_CALIBRE_PORT
EXTENSIONS_AUDIO = {'mp3', 'm4a', 'm4b'}
EXTENSIONS_CONVERT = {'pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt'}
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu', 'prc', 'doc', 'docx',
'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'm4a', 'm4b'}
# EXTENSIONS_READER = set(['txt', 'pdf', 'epub', 'zip', 'cbz', 'tar', 'cbt'] +
# (['rar','cbr'] if feature_support['rar'] else []))
def has_flag(value, bit_flag):
return bit_flag == (bit_flag & (value or 0))
def selected_roles(dictionary):
return sum(v for k, v in ALL_ROLES.items() if k in dictionary)
# :rtype: BookMeta
BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, '
'series_id, languages')
STABLE_VERSION = {'version': '0.6.7'}
NIGHTLY_VERSION = {}
NIGHTLY_VERSION[0] = '$Format:%H$'
NIGHTLY_VERSION[1] = '$Format:%cI$'
# NIGHTLY_VERSION[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57'
# NIGHTLY_VERSION[1] = '2018-09-09T10:13:08+02:00'
# clean-up the module namespace
del sys, os, namedtuple
+31 -41
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
@@ -17,51 +16,42 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import os import os
import subprocess
import ub
import re import re
from flask_babel import gettext as _ from flask_babel import gettext as _
from . import config, logger
from .subproc_wrapper import process_wait
def versionKindle():
versions = _(u'not installed') log = logger.create()
if os.path.exists(ub.config.config_converterpath):
# _() necessary to make babel aware of string for translation
_NOT_CONFIGURED = _('not configured')
_NOT_INSTALLED = 'not installed'
_EXECUTION_ERROR = 'Execution permissions missing'
def _get_command_version(path, pattern, argument=None):
if os.path.exists(path):
command = [path]
if argument:
command.append(argument)
try: try:
p = subprocess.Popen(ub.config.config_converterpath, stdout=subprocess.PIPE, stderr=subprocess.PIPE) for line in process_wait(command):
p.wait() if re.search(pattern, line):
for lines in p.stdout.readlines(): return line
if isinstance(lines, bytes): except Exception as ex:
lines = lines.decode('utf-8') log.warning("%s: %s", path, ex)
if re.search('Amazon kindlegen\(', lines): return _EXECUTION_ERROR
versions = lines return _NOT_INSTALLED
except Exception:
versions = _(u'Excecution permissions missing')
return {'kindlegen' : versions}
def versionCalibre(): def get_version():
versions = _(u'not installed') version = None
if os.path.exists(ub.config.config_converterpath): if config.config_ebookconverter == 1:
try: version = _get_command_version(config.config_converterpath, r'Amazon kindlegen\(')
p = subprocess.Popen([ub.config.config_converterpath, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) elif config.config_ebookconverter == 2:
p.wait() version = _get_command_version(config.config_converterpath, r'ebook-convert.*\(calibre', '--version')
for lines in p.stdout.readlines(): return version or _NOT_CONFIGURED
if isinstance(lines, bytes):
lines = lines.decode('utf-8')
if re.search('ebook-convert.*\(calibre', lines):
versions = lines
except Exception:
versions = _(u'Excecution permissions missing')
return {'Calibre converter' : versions}
def versioncheck():
if ub.config.config_ebookconverter == 1:
return versionKindle()
elif ub.config.config_ebookconverter == 2:
return versionCalibre()
else:
return {'ebook_converter':_(u'not configured')}
+90 -64
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
@@ -18,42 +17,24 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from sqlalchemy import * from __future__ import division, print_function, unicode_literals
from sqlalchemy.ext.declarative import declarative_base import sys
from sqlalchemy.orm import *
import os import os
import re import re
import ast import ast
from ub import config
import ub from sqlalchemy import create_engine
import sys from sqlalchemy import Table, Column, ForeignKey
import unidecode from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float, DateTime
from sqlalchemy.orm import relationship, sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base
session = None session = None
cc_exceptions = ['datetime', 'comments', 'float', 'composite', 'series'] cc_exceptions = ['datetime', 'comments', 'composite', 'series']
cc_classes = None cc_classes = {}
engine = None engine = None
# user defined sort function for calibre databases (Series, etc.)
def title_sort(title):
# calibre sort stuff
title_pat = re.compile(config.config_title_regex, re.IGNORECASE)
match = title_pat.search(title)
if match:
prep = match.group(1)
title = title.replace(prep, '') + ', ' + prep
return title.strip()
def lcase(s):
return unidecode.unidecode(s.lower())
def ucase(s):
return s.upper()
Base = declarative_base() Base = declarative_base()
books_authors_link = Table('books_authors_link', Base.metadata, books_authors_link = Table('books_authors_link', Base.metadata,
@@ -270,10 +251,10 @@ class Books(Base):
title = Column(String) title = Column(String)
sort = Column(String) sort = Column(String)
author_sort = Column(String) author_sort = Column(String)
timestamp = Column(String) timestamp = Column(TIMESTAMP)
pubdate = Column(String) pubdate = Column(String)
series_index = Column(String) series_index = Column(String)
last_modified = Column(String) last_modified = Column(TIMESTAMP)
path = Column(String) path = Column(String)
has_cover = Column(Integer) has_cover = Column(Integer)
uuid = Column(String) uuid = Column(String)
@@ -307,7 +288,7 @@ class Books(Base):
@property @property
def atom_timestamp(self): def atom_timestamp(self):
return (self.timestamp or '').replace(' ', 'T') return (self.timestamp.strftime('%Y-%m-%dT%H:%M:%S+00:00') or '')
class Custom_Columns(Base): class Custom_Columns(Base):
__tablename__ = 'custom_columns' __tablename__ = 'custom_columns'
@@ -329,46 +310,53 @@ class Custom_Columns(Base):
return display_dict return display_dict
def setup_db(): def update_title_sort(config, conn=None):
global engine # user defined sort function for calibre databases (Series, etc.)
global session def _title_sort(title):
global cc_classes # calibre sort stuff
title_pat = re.compile(config.config_title_regex, re.IGNORECASE)
match = title_pat.search(title)
if match:
prep = match.group(1)
title = title.replace(prep, '') + ', ' + prep
return title.strip()
if config.config_calibre_dir is None or config.config_calibre_dir == u'': conn = conn or session.connection().connection.connection
content = ub.session.query(ub.Settings).first() conn.create_function("title_sort", 1, _title_sort)
content.config_calibre_dir = None
content.db_configured = False
ub.session.commit() def setup_db(config):
config.loadSettings() dispose()
global engine
if not config.config_calibre_dir:
config.invalidate()
return False return False
dbpath = os.path.join(config.config_calibre_dir, "metadata.db") dbpath = os.path.join(config.config_calibre_dir, "metadata.db")
try: if not os.path.exists(dbpath):
if not os.path.exists(dbpath): config.invalidate()
raise
engine = create_engine('sqlite:///' + dbpath, echo=False, isolation_level="SERIALIZABLE", connect_args={'check_same_thread': False})
conn = engine.connect()
except Exception:
content = ub.session.query(ub.Settings).first()
content.config_calibre_dir = None
content.db_configured = False
ub.session.commit()
config.loadSettings()
return False return False
content = ub.session.query(ub.Settings).first()
content.db_configured = True try:
ub.session.commit() engine = create_engine('sqlite:///{0}'.format(dbpath),
config.loadSettings() echo=False,
conn.connection.create_function('title_sort', 1, title_sort) isolation_level="SERIALIZABLE",
# conn.connection.create_function('lower', 1, lcase) connect_args={'check_same_thread': False})
# conn.connection.create_function('upper', 1, ucase) conn = engine.connect()
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
except Exception as e:
config.invalidate(e)
return False
config.db_configured = True
update_title_sort(config, conn.connection)
if not cc_classes: if not cc_classes:
cc = conn.execute("SELECT id, datatype FROM custom_columns") cc = conn.execute("SELECT id, datatype FROM custom_columns")
cc_ids = [] cc_ids = []
books_custom_column_links = {} books_custom_column_links = {}
cc_classes = {}
for row in cc: for row in cc:
if row.datatype not in cc_exceptions: if row.datatype not in cc_exceptions:
books_custom_column_links[row.id] = Table('books_custom_column_' + str(row.id) + '_link', Base.metadata, books_custom_column_links[row.id] = Table('books_custom_column_' + str(row.id) + '_link', Base.metadata,
@@ -389,14 +377,19 @@ def setup_db():
'id': Column(Integer, primary_key=True), 'id': Column(Integer, primary_key=True),
'book': Column(Integer, ForeignKey('books.id')), 'book': Column(Integer, ForeignKey('books.id')),
'value': Column(Integer)} 'value': Column(Integer)}
elif row.datatype == 'float':
ccdict = {'__tablename__': 'custom_column_' + str(row.id),
'id': Column(Integer, primary_key=True),
'book': Column(Integer, ForeignKey('books.id')),
'value': Column(Float)}
else: else:
ccdict = {'__tablename__': 'custom_column_' + str(row.id), ccdict = {'__tablename__': 'custom_column_' + str(row.id),
'id': Column(Integer, primary_key=True), 'id': Column(Integer, primary_key=True),
'value': Column(String)} 'value': Column(String)}
cc_classes[row.id] = type('Custom_Column_' + str(row.id), (Base,), ccdict) cc_classes[row.id] = type(str('Custom_Column_' + str(row.id)), (Base,), ccdict)
for cc_id in cc_ids: for cc_id in cc_ids:
if (cc_id[1] == 'bool') or (cc_id[1] == 'int'): 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]], setattr(Books, 'custom_column_' + str(cc_id[0]), relationship(cc_classes[cc_id[0]],
primaryjoin=( primaryjoin=(
Books.id == cc_classes[cc_id[0]].book), Books.id == cc_classes[cc_id[0]].book),
@@ -407,8 +400,41 @@ def setup_db():
backref='books')) backref='books'))
global session
Session = scoped_session(sessionmaker(autocommit=False, Session = scoped_session(sessionmaker(autocommit=False,
autoflush=False, autoflush=False,
bind=engine)) bind=engine))
session = Session() session = Session()
return True return True
def dispose():
global session
old_session = session
session = None
if old_session:
try: old_session.close()
except: pass
if old_session.bind:
try: old_session.bind.dispose()
except Exception: pass
for attr in list(Books.__dict__.keys()):
if attr.startswith("custom_column_"):
setattr(Books, attr, None)
for db_class in cc_classes.values():
Base.metadata.remove(db_class.__table__)
cc_classes.clear()
for table in reversed(Base.metadata.sorted_tables):
name = table.key
if name.startswith("custom_column_") or name.startswith("books_custom_column_"):
if table is not None:
Base.metadata.remove(table)
def reconnect_db(config):
session.close()
engine.dispose()
setup_db(config)
+849
View File
@@ -0,0 +1,849 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
# apetresc, nanu-c, mutschler
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import os
from datetime import datetime
import json
from shutil import move, copyfile
from uuid import uuid4
from flask import Blueprint, request, flash, redirect, url_for, abort, Markup, Response
from flask_babel import gettext as _
from flask_login import current_user, login_required
from . import constants, logger, isoLanguages, gdriveutils, uploader, helper
from . import config, get_locale, db, ub, worker
from .helper import order_authors, common_filters
from .web import login_required_if_no_ano, render_title_template, edit_required, upload_required
editbook = Blueprint('editbook', __name__)
log = logger.create()
# 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 = []
for c_elements in db_book_object:
found = False
if db_type == 'languages':
type_elements = c_elements.lang_code
elif db_type == 'custom':
type_elements = c_elements.value
else:
type_elements = c_elements.name
for inp_element in input_elements:
if inp_element.lower() == type_elements.lower():
# if inp_element == type_elements:
found = True
break
# if the element was not found in the new list, add it to remove list
if not found:
del_elements.append(c_elements)
# 2. search for elements that need to be added
add_elements = []
for inp_element in input_elements:
found = False
for c_elements in db_book_object:
if db_type == 'languages':
type_elements = c_elements.lang_code
elif db_type == 'custom':
type_elements = c_elements.value
else:
type_elements = c_elements.name
if inp_element == type_elements:
found = True
break
if not found:
add_elements.append(inp_element)
# if there are elements to remove, we remove them now
if len(del_elements) > 0:
for del_element in del_elements:
db_book_object.remove(del_element)
changed = True
if len(del_element.books) == 0:
db_session.delete(del_element)
# if there are elements to add, we add them now!
if len(add_elements) > 0:
if db_type == 'languages':
db_filter = db_object.lang_code
elif db_type == 'custom':
db_filter = db_object.value
else:
db_filter = db_object.name
for add_element in add_elements:
# check if a element with that name exists
db_element = db_session.query(db_object).filter(db_filter == add_element).first()
# if no element is found add it
# if new_element is None:
if db_type == 'author':
new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')), "")
elif db_type == 'series':
new_element = db_object(add_element, add_element)
elif db_type == 'custom':
new_element = db_object(value=add_element)
elif db_type == 'publisher':
new_element = db_object(add_element, None)
else: # db_type should be tag or language
new_element = db_object(add_element)
if db_element is None:
changed = True
db_session.add(new_element)
db_book_object.append(new_element)
else:
if db_type == 'custom':
if db_element.value != add_element:
new_element.value = add_element
elif db_type == 'languages':
if db_element.lang_code != add_element:
db_element.lang_code = add_element
elif db_type == 'series':
if db_element.name != add_element:
db_element.name = add_element
db_element.sort = add_element
elif db_type == 'author':
if db_element.name != add_element:
db_element.name = add_element
db_element.sort = add_element.replace('|', ',')
elif db_type == 'publisher':
if db_element.name != add_element:
db_element.name = add_element
db_element.sort = None
elif db_element.name != add_element:
db_element.name = add_element
# add element to book
changed = True
db_book_object.append(db_element)
return changed
def modify_identifiers(input_identifiers, db_identifiers, db_session):
"""Modify Identifiers to match input information.
input_identifiers is a list of read-to-persist Identifiers objects.
db_identifiers is a list of already persisted list of Identifiers objects."""
changed = False
input_dict = dict([ (identifier.type.lower(), identifier) for identifier in input_identifiers ])
db_dict = dict([ (identifier.type.lower(), identifier) for identifier in db_identifiers ])
# delete db identifiers not present in input or modify them with input val
for identifier_type, identifier in db_dict.items():
if identifier_type not in input_dict.keys():
db_session.delete(identifier)
changed = True
else:
input_identifier = input_dict[identifier_type]
identifier.type = input_identifier.type
identifier.val = input_identifier.val
# add input identifiers not present in db
for identifier_type, identifier in input_dict.items():
if identifier_type not in db_dict.keys():
db_session.add(identifier)
changed = True
return changed
@editbook.route("/delete/<int:book_id>/", defaults={'book_format': ""})
@editbook.route("/delete/<int:book_id>/<string:book_format>/")
@login_required
def delete_book(book_id, book_format):
if current_user.role_delete_books():
book = db.session.query(db.Books).filter(db.Books.id == book_id).first()
if book:
try:
result, error = helper.delete_book(book, config.config_calibre_dir, book_format=book_format.upper())
if not result:
flash(error, category="error")
return redirect(url_for('editbook.edit_book', book_id=book_id))
if not book_format:
# delete book from Shelfs, Downloads, Read list
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.delete_download(book_id)
ub.session.commit()
# check if only this book links to:
# author, language, series, tags, custom columns
modify_database_object([u''], book.authors, db.Authors, db.session, 'author')
modify_database_object([u''], book.tags, db.Tags, db.session, 'tags')
modify_database_object([u''], book.series, db.Series, db.session, 'series')
modify_database_object([u''], book.languages, db.Languages, db.session, 'languages')
modify_database_object([u''], book.publishers, db.Publishers, db.session, 'publishers')
cc = 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:
if c.datatype == 'bool' or c.datatype == 'integer' or c.datatype == 'float':
del_cc = getattr(book, cc_string)[0]
getattr(book, cc_string).remove(del_cc)
log.debug('remove ' + str(c.id))
db.session.delete(del_cc)
db.session.commit()
elif c.datatype == 'rating':
del_cc = getattr(book, cc_string)[0]
getattr(book, cc_string).remove(del_cc)
if len(del_cc.books) == 0:
log.debug('remove ' + str(c.id))
db.session.delete(del_cc)
db.session.commit()
else:
del_cc = getattr(book, cc_string)[0]
getattr(book, cc_string).remove(del_cc)
log.debug('remove ' + str(c.id))
db.session.delete(del_cc)
db.session.commit()
else:
modify_database_object([u''], getattr(book, cc_string), db.cc_classes[c.id],
db.session, 'custom')
db.session.query(db.Books).filter(db.Books.id == book_id).delete()
else:
db.session.query(db.Data).filter(db.Data.book == book.id).\
filter(db.Data.format == book_format).delete()
db.session.commit()
except Exception as e:
log.debug(e)
db.session.rollback()
else:
# book not found
log.error('Book with id "%s" could not be deleted: not found', book_id)
if book_format:
flash(_('Book Format Successfully Deleted'), category="success")
return redirect(url_for('editbook.edit_book', book_id=book_id))
else:
flash(_('Book Successfully Deleted'), category="success")
return redirect(url_for('web.index'))
def render_edit_book(book_id):
db.update_title_sort(config)
cc = db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all()
book = db.session.query(db.Books)\
.filter(db.Books.id == book_id).filter(common_filters()).first()
if not book:
flash(_(u"Error opening eBook. File does not exist or file is not accessible"), category="error")
return redirect(url_for("web.index"))
for lang in book.languages:
lang.language_name = isoLanguages.get_language_name(get_locale(), lang.lang_code)
book = order_authors(book)
author_names = []
for authr in book.authors:
author_names.append(authr.name.replace('|', ','))
# Option for showing convertbook button
valid_source_formats=list()
if config.config_ebookconverter == 2:
for file in book.data:
if file.format.lower() in constants.EXTENSIONS_CONVERT:
valid_source_formats.append(file.format.lower())
# Determine what formats don't already exist
allowed_conversion_formats = constants.EXTENSIONS_CONVERT.copy()
for file in book.data:
try:
allowed_conversion_formats.remove(file.format.lower())
except Exception:
log.warning('%s already removed from list.', file.format.lower())
return render_title_template('book_edit.html', book=book, authors=author_names, cc=cc,
title=_(u"edit metadata"), page="editbook",
conversion_formats=allowed_conversion_formats,
config=config,
source_formats=valid_source_formats)
def edit_book_ratings(to_save, book):
changed = False
if to_save["rating"].strip():
old_rating = False
if len(book.ratings) > 0:
old_rating = book.ratings[0].rating
ratingx2 = int(float(to_save["rating"]) * 2)
if ratingx2 != old_rating:
changed = True
is_rating = db.session.query(db.Ratings).filter(db.Ratings.rating == ratingx2).first()
if is_rating:
book.ratings.append(is_rating)
else:
new_rating = db.Ratings(rating=ratingx2)
book.ratings.append(new_rating)
if old_rating:
book.ratings.remove(book.ratings[0])
else:
if len(book.ratings) > 0:
book.ratings.remove(book.ratings[0])
changed = True
return changed
def edit_book_languages(to_save, book):
input_languages = to_save["languages"].split(',')
unknown_languages = []
input_l = isoLanguages.get_language_codes(get_locale(), input_languages, unknown_languages)
for l in unknown_languages:
log.error('%s is not a valid language', l)
flash(_(u"%(langname)s is not a valid language", langname=l), category="error")
return modify_database_object(list(input_l), book.languages, db.Languages, db.session, 'languages')
def edit_book_publisher(to_save, book):
changed = False
if to_save["publisher"]:
publisher = to_save["publisher"].rstrip().strip()
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, db.session, 'publisher')
elif len(book.publishers):
changed |= modify_database_object([], book.publishers, db.Publishers, db.session, 'publisher')
return changed
def edit_cc_data(book_id, book, to_save):
changed = False
cc = 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':
to_save[cc_string] = None
elif c.datatype == 'bool':
to_save[cc_string] = 1 if to_save[cc_string] == 'True' else 0
if to_save[cc_string] != cc_db_value:
if cc_db_value is not None:
if to_save[cc_string] is not None:
setattr(getattr(book, cc_string)[0], 'value', to_save[cc_string])
changed = True
else:
del_cc = getattr(book, cc_string)[0]
getattr(book, cc_string).remove(del_cc)
db.session.delete(del_cc)
changed = True
else:
cc_class = db.cc_classes[c.id]
new_cc = cc_class(value=to_save[cc_string], book=book_id)
db.session.add(new_cc)
changed = True
else:
if c.datatype == 'rating':
to_save[cc_string] = str(int(float(to_save[cc_string]) * 2))
if to_save[cc_string].strip() != cc_db_value:
if cc_db_value is not None:
# remove old cc_val
del_cc = getattr(book, cc_string)[0]
getattr(book, cc_string).remove(del_cc)
if len(del_cc.books) == 0:
db.session.delete(del_cc)
changed = True
cc_class = db.cc_classes[c.id]
new_cc = db.session.query(cc_class).filter(
cc_class.value == to_save[cc_string].strip()).first()
# if no cc val is found add it
if new_cc is None:
new_cc = cc_class(value=to_save[cc_string].strip())
db.session.add(new_cc)
changed = True
db.session.flush()
new_cc = db.session.query(cc_class).filter(
cc_class.value == to_save[cc_string].strip()).first()
# add cc value to book
getattr(book, cc_string).append(new_cc)
else:
if cc_db_value is not None:
# remove old cc_val
del_cc = getattr(book, cc_string)[0]
getattr(book, cc_string).remove(del_cc)
if not del_cc.books or len(del_cc.books) == 0:
db.session.delete(del_cc)
changed = True
else:
input_tags = to_save[cc_string].split(',')
input_tags = list(map(lambda it: it.strip(), input_tags))
changed |= modify_database_object(input_tags, getattr(book, cc_string), db.cc_classes[c.id], db.session,
'custom')
return changed
def upload_single_file(request, book, book_id):
# Check and handle Uploaded file
if 'btn-upload-format' in request.files:
requested_file = request.files['btn-upload-format']
# check for empty request
if requested_file.filename != '':
if '.' in requested_file.filename:
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
if file_ext 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 redirect(url_for('web.show_book', book_id=book.id))
else:
flash(_('File to be uploaded must have an extension'), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
file_name = book.path.rsplit('/', 1)[-1]
filepath = os.path.normpath(os.path.join(config.config_calibre_dir, book.path))
saved_filename = os.path.join(filepath, file_name + '.' + file_ext)
# check if file path exists, otherwise create it, copy file to calibre path and delete temp file
if not os.path.exists(filepath):
try:
os.makedirs(filepath)
except OSError:
flash(_(u"Failed to create path %(path)s (Permission denied).", path=filepath), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
try:
requested_file.save(saved_filename)
except OSError:
flash(_(u"Failed to store file %(file)s.", file=saved_filename), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
file_size = os.path.getsize(saved_filename)
is_format = db.session.query(db.Data).filter(db.Data.book == book_id).\
filter(db.Data.format == file_ext.upper()).first()
# Format entry already exists, no need to update the database
if is_format:
log.warning('Book format %s already existing', file_ext.upper())
else:
db_format = db.Data(book_id, file_ext.upper(), file_size, file_name)
db.session.add(db_format)
db.session.commit()
db.update_title_sort(config)
# Queue uploader info
uploadText=_(u"File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=book.title)
worker.add_upload(current_user.nickname,
"<a href=\"" + url_for('web.show_book', book_id=book.id) + "\">" + uploadText + "</a>")
return uploader.process(
saved_filename, *os.path.splitext(requested_file.filename), config.config_rarfile_location)
def upload_cover(request, book):
if 'btn-upload-cover' in request.files:
requested_file = request.files['btn-upload-cover']
# check for empty request
if requested_file.filename != '':
ret, message = helper.save_cover(requested_file, book.path)
if ret is True:
return True
else:
flash(message, category="error")
return False
return None
@editbook.route("/admin/book/<int:book_id>", methods=['GET', 'POST'])
@login_required_if_no_ano
@edit_required
def edit_book(book_id):
modif_date = False
# Show form
if request.method != 'POST':
return render_edit_book(book_id)
# create the function for sorting...
db.update_title_sort(config)
book = db.session.query(db.Books)\
.filter(db.Books.id == book_id).filter(common_filters()).first()
# Book not found
if not book:
flash(_(u"Error opening eBook. File does not exist or file is not accessible"), category="error")
return redirect(url_for("web.index"))
meta = upload_single_file(request, book, book_id)
if upload_cover(request, book) is True:
book.has_cover = 1
modif_date = True
try:
to_save = request.form.to_dict()
merge_metadata(to_save, meta)
# Update book
edited_books_id = None
#handle book title
if book.title != to_save["book_title"].rstrip().strip():
if to_save["book_title"] == '':
to_save["book_title"] = _(u'Unknown')
book.title = to_save["book_title"].rstrip().strip()
edited_books_id = book.id
modif_date = True
# handle author(s)
input_authors = to_save["author_name"].split('&')
input_authors = list(map(lambda it: it.strip().replace(',', '|'), 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, 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 = 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:
gdriveutils.updateGdriveCalibreFromLocal()
error = False
if edited_books_id:
error = helper.update_dir_stucture(edited_books_id, config.config_calibre_dir, input_authors[0])
if not error:
if to_save["cover_url"]:
result, error = helper.save_cover_from_url(to_save["cover_url"], book.path)
if result is True:
book.has_cover = 1
modif_date = True
else:
flash(error, category="error")
if book.series_index != to_save["series_index"]:
book.series_index = to_save["series_index"]
modif_date = True
# Handle book comments/description
if len(book.comments):
if book.comments[0].text != to_save["description"]:
book.comments[0].text = to_save["description"]
modif_date = True
else:
if to_save["description"]:
book.comments.append(db.Comments(text=to_save["description"], book=book.id))
modif_date = True
# Handle identifiers
input_identifiers = identifier_list(to_save, book)
modif_date |= modify_identifiers(input_identifiers, book.identifiers, db.session)
# Handle book tags
input_tags = to_save["tags"].split(',')
input_tags = list(map(lambda it: it.strip(), input_tags))
modif_date |= modify_database_object(input_tags, book.tags, db.Tags, db.session, 'tags')
# Handle book series
input_series = [to_save["series"].strip()]
input_series = [x for x in input_series if x != '']
modif_date |= modify_database_object(input_series, book.series, db.Series, db.session, 'series')
if to_save["pubdate"]:
try:
book.pubdate = datetime.strptime(to_save["pubdate"], "%Y-%m-%d")
except ValueError:
book.pubdate = db.Books.DEFAULT_PUBDATE
else:
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, 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:
book.last_modified = datetime.utcnow()
db.session.commit()
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if "detail_view" in to_save:
return redirect(url_for('web.show_book', book_id=book.id))
else:
flash(_("Metadata successfully updated"), category="success")
return render_edit_book(book_id)
else:
db.session.rollback()
flash(error, category="error")
return render_edit_book(book_id)
except Exception as e:
log.exception(e)
db.session.rollback()
flash(_("Error editing book, please check logfile for details"), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
def merge_metadata(to_save, meta):
if to_save['author_name'] == _(u'Unknown'):
to_save['author_name'] = ''
if to_save['book_title'] == _(u'Unknown'):
to_save['book_title'] = ''
for s_field, m_field in [
('tags', 'tags'), ('author_name', 'author'), ('series', 'series'),
('series_index', 'series_id'), ('languages', 'languages'),
('book_title', 'title')]:
to_save[s_field] = to_save[s_field] or getattr(meta, m_field, '')
to_save["description"] = to_save["description"] or Markup(
getattr(meta, 'description', '')).unescape()
def identifier_list(to_save, book):
"""Generate a list of Identifiers from form information"""
id_type_prefix = 'identifier-type-'
id_val_prefix = 'identifier-val-'
result = []
for type_key, type_value in to_save.items():
if not type_key.startswith(id_type_prefix):
continue
val_key = id_val_prefix + type_key[len(id_type_prefix):]
if val_key not in to_save.keys():
continue
result.append( db.Identifiers(to_save[val_key], type_value, book.id) )
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"):
# create the function for sorting...
db.update_title_sort(config)
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:
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
tags = meta.tags
series = meta.series
series_index = meta.series_id
title_dir = helper.get_valid_filename(title)
author_dir = helper.get_valid_filename(authr)
filepath = os.path.join(config.config_calibre_dir, author_dir, title_dir)
saved_filename = os.path.join(filepath, title_dir + meta.extension.lower())
if title != _(u'Unknown') and authr != _(u'Unknown'):
entry = helper.check_exists_book(authr, title)
if entry:
log.info("Uploaded book probably exists in library")
flash(_(u"Uploaded book probably exists in the library, consider to change before upload new: ")
+ Markup(render_title_template('book_exists_flash.html', entry=entry)), category="warning")
# check if file path exists, otherwise create it, copy file to calibre path and delete temp file
if not os.path.exists(filepath):
try:
os.makedirs(filepath)
except OSError:
log.error("Failed to create path %s (Permission denied)", filepath)
flash(_(u"Failed to create path %(path)s (Permission denied).", path=filepath), category="error")
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
try:
copyfile(meta.file_path, saved_filename)
os.unlink(meta.file_path)
except OSError as e:
log.error("Failed to move file %s: %s", saved_filename, e)
flash(_(u"Failed to Move File %(file)s: %(error)s", file=saved_filename, error=e), category="error")
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
if meta.cover is None:
has_cover = 0
copyfile(os.path.join(constants.STATIC_DIR, 'generic_cover.jpg'),
os.path.join(filepath, "cover.jpg"))
else:
has_cover = 1
try:
copyfile(meta.cover, os.path.join(filepath, "cover.jpg"))
os.unlink(meta.cover)
except OSError as e:
log.error("Failed to move cover file %s: %s", os.path.join(filepath, "cover.jpg"), e)
flash(_(u"Failed to Move Cover File %(file)s: %(error)s", file=os.path.join(filepath, "cover.jpg"),
error=e),
category="error")
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
# handle authors
is_author = db.session.query(db.Authors).filter(db.Authors.name == authr).first()
if is_author:
db_author = is_author
else:
db_author = db.Authors(authr, helper.get_sorted_author(authr), "")
db.session.add(db_author)
# handle series
db_series = None
is_series = db.session.query(db.Series).filter(db.Series.name == series).first()
if is_series:
db_series = is_series
elif series != '':
db_series = db.Series(series, "")
db.session.add(db_series)
# add language actually one value in list
input_language = meta.languages
db_language = None
if input_language != "":
input_language = isoLanguages.get(name=input_language).part3
hasLanguage = db.session.query(db.Languages).filter(db.Languages.lang_code == input_language).first()
if hasLanguage:
db_language = hasLanguage
else:
db_language = db.Languages(input_language)
db.session.add(db_language)
# If the language of the file is excluded from the users view, it's not imported, to allow the user to view
# the book it's language is set to the filter language
if db_language != current_user.filter_language() and current_user.filter_language() != "all":
db_language = db.session.query(db.Languages).\
filter(db.Languages.lang_code == current_user.filter_language()).first()
# combine path and normalize path from windows systems
path = os.path.join(author_dir, title_dir).replace('\\', '/')
# Calibre adds books with utc as timezone
db_book = db.Books(title, "", db_author.sort, datetime.utcnow(), datetime(101, 1, 1),
series_index, datetime.utcnow(), path, has_cover, db_author, [], db_language)
db_book.authors.append(db_author)
if db_series:
db_book.series.append(db_series)
if db_language is not None:
db_book.languages.append(db_language)
file_size = os.path.getsize(saved_filename)
db_data = db.Data(db_book, meta.extension.upper()[1:], file_size, title_dir)
# handle tags
input_tags = tags.split(',')
input_tags = list(map(lambda it: it.strip(), input_tags))
if input_tags[0] !="":
modify_database_object(input_tags, db_book.tags, db.Tags, db.session, 'tags')
# flush content, get db_book.id available
db_book.data.append(db_data)
db.session.add(db_book)
db.session.flush()
# add comment
book_id = db_book.id
upload_comment = Markup(meta.description).unescape()
if upload_comment != "":
db.session.add(db.Comments(upload_comment, book_id))
# save data to database, reread data
db.session.commit()
db.update_title_sort(config)
# Reread book. It's important not to filter the result, as it could have language which hide it from
# current users view (tags are not stored/extracted from metadata and could also be limited)
book = db.session.query(db.Books).filter(db.Books.id == book_id).first()
# upload book to gdrive if nesseccary and add "(bookid)" to folder name
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
error = helper.update_dir_stucture(book.id, config.config_calibre_dir)
db.session.commit()
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if error:
flash(error, category="error")
uploadText=_(u"File %(file)s uploaded", file=book.title)
worker.add_upload(current_user.nickname,
"<a href=\"" + url_for('web.show_book', book_id=book.id) + "\">" + uploadText + "</a>")
# create data for displaying display Full language name instead of iso639.part3language
if db_language is not None:
book.languages[0].language_name = _(meta.languages)
author_names = []
for author in db_book.authors:
author_names.append(author.name)
if len(request.files.getlist("btn-upload")) < 2:
if current_user.role_edit() or current_user.role_admin():
resp = {"location": url_for('editbook.edit_book', book_id=db_book.id)}
return Response(json.dumps(resp), mimetype='application/json')
else:
resp = {"location": url_for('web.show_book', book_id=db_book.id)}
return Response(json.dumps(resp), mimetype='application/json')
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
@editbook.route("/admin/book/convert/<int:book_id>", methods=['POST'])
@login_required_if_no_ano
@edit_required
def convert_bookformat(book_id):
# check to see if we have form fields to work with - if not send user back
book_format_from = request.form.get('book_format_from', None)
book_format_to = request.form.get('book_format_to', None)
if (book_format_from is None) or (book_format_to is None):
flash(_(u"Source or destination format for conversion missing"), category="error")
return redirect(url_for('editbook.edit_book', book_id=book_id))
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(),
book_format_to.upper(), current_user.nickname)
if rtn is None:
flash(_(u"Book successfully queued for converting to %(book_format)s",
book_format=book_format_to),
category="success")
else:
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))
+30 -22
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
@@ -17,11 +16,13 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import os
import zipfile import zipfile
from lxml import etree from lxml import etree
import os
import uploader from . import isoLanguages
import isoLanguages from .constants import BookMeta
def extractCover(zipFile, coverFile, coverpath, tmp_file_name): def extractCover(zipFile, coverFile, coverpath, tmp_file_name):
@@ -62,21 +63,26 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
for s in ['title', 'description', 'creator', 'language', 'subject']: for s in ['title', 'description', 'creator', 'language', 'subject']:
tmp = p.xpath('dc:%s/text()' % s, namespaces=ns) tmp = p.xpath('dc:%s/text()' % s, namespaces=ns)
if len(tmp) > 0: if len(tmp) > 0:
epub_metadata[s] = p.xpath('dc:%s/text()' % s, namespaces=ns)[0] if s == 'creator':
epub_metadata[s] = ' & '.join(p.xpath('dc:%s/text()' % s, namespaces=ns))
elif s == 'subject':
epub_metadata[s] = ', '.join(p.xpath('dc:%s/text()' % s, namespaces=ns))
else:
epub_metadata[s] = p.xpath('dc:%s/text()' % s, namespaces=ns)[0]
else: else:
epub_metadata[s] = "Unknown" epub_metadata[s] = u'Unknown'
if epub_metadata['subject'] == "Unknown": if epub_metadata['subject'] == u'Unknown':
epub_metadata['subject'] = '' epub_metadata['subject'] = ''
if epub_metadata['description'] == "Unknown": if epub_metadata['description'] == u'Unknown':
description = tree.xpath("//*[local-name() = 'description']/text()") description = tree.xpath("//*[local-name() = 'description']/text()")
if len(description) > 0: if len(description) > 0:
epub_metadata['description'] = description epub_metadata['description'] = description
else: else:
epub_metadata['description'] = "" epub_metadata['description'] = ""
if epub_metadata['language'] == "Unknown": if epub_metadata['language'] == u'Unknown':
epub_metadata['language'] = "" epub_metadata['language'] = ""
else: else:
lang = epub_metadata['language'].split('-', 1)[0].lower() lang = epub_metadata['language'].split('-', 1)[0].lower()
@@ -107,25 +113,27 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
meta_cover = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='cover']/@content", namespaces=ns) meta_cover = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='cover']/@content", namespaces=ns)
if len(meta_cover) > 0: if len(meta_cover) > 0:
coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='"+meta_cover[0]+"']/@href", namespaces=ns) coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='"+meta_cover[0]+"']/@href", namespaces=ns)
if len(coversection) > 0: else:
filetype = coversection[0].rsplit('.', 1)[-1] coversection = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@href", namespaces=ns)
if filetype == "xhtml" or filetype == "html": # if cover is (x)html format if len(coversection) > 0:
markup = epubZip.read(os.path.join(coverpath, coversection[0])) filetype = coversection[0].rsplit('.', 1)[-1]
markupTree = etree.fromstring(markup) if filetype == "xhtml" or filetype == "html": # if cover is (x)html format
# no matter xhtml or html with no namespace markup = epubZip.read(os.path.join(coverpath, coversection[0]))
imgsrc = markupTree.xpath("//*[local-name() = 'img']/@src") markupTree = etree.fromstring(markup)
# imgsrc maybe startwith "../"" so fullpath join then relpath to cwd # no matter xhtml or html with no namespace
filename = os.path.relpath(os.path.join(os.path.dirname(os.path.join(coverpath, coversection[0])), imgsrc[0])) imgsrc = markupTree.xpath("//*[local-name() = 'img']/@src")
coverfile = extractCover(epubZip, filename, "", tmp_file_path) # imgsrc maybe startwith "../"" so fullpath join then relpath to cwd
else: filename = os.path.relpath(os.path.join(os.path.dirname(os.path.join(coverpath, coversection[0])), imgsrc[0]))
coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path) coverfile = extractCover(epubZip, filename, "", tmp_file_path)
else:
coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path)
if not epub_metadata['title']: if not epub_metadata['title']:
title = original_file_name title = original_file_name
else: else:
title = epub_metadata['title'] title = epub_metadata['title']
return uploader.BookMeta( return BookMeta(
file_path=tmp_file_path, file_path=tmp_file_path,
extension=original_file_extension, extension=original_file_extension,
title=title.encode('utf-8').decode('utf-8'), title=title.encode('utf-8').decode('utf-8'),
+4 -3
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
@@ -17,8 +16,10 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
from lxml import etree from lxml import etree
import uploader
from .constants import BookMeta
def get_fb2_info(tmp_file_path, original_file_extension): def get_fb2_info(tmp_file_path, original_file_extension):
@@ -66,7 +67,7 @@ def get_fb2_info(tmp_file_path, original_file_extension):
else: else:
description = u'' description = u''
return uploader.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.decode('utf-8'),
+161
View File
@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
# apetresc, nanu-c, mutschler
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import os
import sys
import hashlib
import json
import tempfile
from uuid import uuid4
from time import time
from shutil import move, copyfile
from flask import Blueprint, flash, request, redirect, url_for, abort
from flask_babel import gettext as _
from flask_login import login_required
try:
from googleapiclient.errors import HttpError
except ImportError:
pass
from . import logger, gdriveutils, config, db
from .web import admin_required
gdrive = Blueprint('gdrive', __name__)
log = logger.create()
current_milli_time = lambda: int(round(time() * 1000))
gdrive_watch_callback_token = 'target=calibreweb-watch_files'
@gdrive.route("/gdrive/authenticate")
@login_required
@admin_required
def authenticate_google_drive():
try:
authUrl = gdriveutils.Gauth.Instance().auth.GetAuthUrl()
except gdriveutils.InvalidConfigError:
flash(_(u'Google Drive setup not completed, try to deactivate and activate Google Drive again'),
category="error")
return redirect(url_for('web.index'))
return redirect(authUrl)
@gdrive.route("/gdrive/callback")
def google_drive_callback():
auth_code = request.args.get('code')
if not auth_code:
abort(403)
try:
credentials = gdriveutils.Gauth.Instance().auth.flow.step2_exchange(auth_code)
with open(gdriveutils.CREDENTIALS, 'w') as f:
f.write(credentials.to_json())
except ValueError as error:
log.error(error)
return redirect(url_for('admin.configuration'))
@gdrive.route("/gdrive/watch/subscribe")
@login_required
@admin_required
def watch_gdrive():
if not config.config_google_drive_watch_changes_response:
with open(gdriveutils.CLIENT_SECRETS, 'r') as settings:
filedata = json.load(settings)
if filedata['web']['redirect_uris'][0].endswith('/'):
filedata['web']['redirect_uris'][0] = filedata['web']['redirect_uris'][0][:-((len('/gdrive/callback')+1))]
else:
filedata['web']['redirect_uris'][0] = filedata['web']['redirect_uris'][0][:-(len('/gdrive/callback'))]
address = '%s/gdrive/watch/callback' % filedata['web']['redirect_uris'][0]
notification_id = str(uuid4())
try:
result = gdriveutils.watchChange(gdriveutils.Gdrive.Instance().drive, notification_id,
'web_hook', address, gdrive_watch_callback_token, current_milli_time() + 604800*1000)
config.config_google_drive_watch_changes_response = json.dumps(result)
# after save(), config_google_drive_watch_changes_response will be a json object, not string
config.save()
except HttpError as e:
reason=json.loads(e.content)['error']['errors'][0]
if reason['reason'] == u'push.webhookUrlUnauthorized':
flash(_(u'Callback domain is not verified, please follow steps to verify domain in google developer console'), category="error")
else:
flash(reason['message'], category="error")
return redirect(url_for('admin.configuration'))
@gdrive.route("/gdrive/watch/revoke")
@login_required
@admin_required
def revoke_watch_gdrive():
last_watch_response = config.config_google_drive_watch_changes_response
if last_watch_response:
try:
gdriveutils.stopChannel(gdriveutils.Gdrive.Instance().drive, last_watch_response['id'],
last_watch_response['resourceId'])
except HttpError:
pass
config.config_google_drive_watch_changes_response = None
config.save()
return redirect(url_for('admin.configuration'))
@gdrive.route("/gdrive/watch/callback", methods=['GET', 'POST'])
def on_received_watch_confirmation():
log.debug('%r', request.headers)
if request.headers.get('X-Goog-Channel-Token') == gdrive_watch_callback_token \
and request.headers.get('X-Goog-Resource-State') == 'change' \
and request.data:
data = request.data
def updateMetaData():
log.info('Change received from gdrive')
log.debug('%r', data)
try:
j = json.loads(data)
log.info('Getting change details')
response = gdriveutils.getChangeById(gdriveutils.Gdrive.Instance().drive, j['id'])
log.debug('%r', response)
if response:
if sys.version_info < (3, 0):
dbpath = os.path.join(config.config_calibre_dir, "metadata.db")
else:
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
if not response['deleted'] and response['file']['title'] == 'metadata.db' and response['file']['md5Checksum'] != hashlib.md5(dbpath):
tmpDir = tempfile.gettempdir()
log.info('Database file updated')
copyfile(dbpath, os.path.join(tmpDir, "metadata.db_" + str(current_milli_time())))
log.info('Backing up existing and downloading updated metadata.db')
gdriveutils.downloadFile(None, "metadata.db", os.path.join(tmpDir, "tmp_metadata.db"))
log.info('Setting up new DB')
# prevent error on windows, as os.rename does on exisiting files
move(os.path.join(tmpDir, "tmp_metadata.db"), dbpath)
db.setup_db(config)
except Exception as e:
log.exception(e)
updateMetaData()
return ''
+93 -21
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
@@ -17,24 +16,41 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import os
import json
import shutil
from flask import Response, stream_with_context
from sqlalchemy import create_engine
from sqlalchemy import Column, UniqueConstraint
from sqlalchemy import String, Integer
from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base
try: try:
from pydrive.auth import GoogleAuth from pydrive.auth import GoogleAuth
from pydrive.drive import GoogleDrive from pydrive.drive import GoogleDrive
from pydrive.auth import RefreshError, InvalidConfigError from pydrive.auth import RefreshError
from apiclient import errors from apiclient import errors
gdrive_support = True gdrive_support = True
except ImportError: except ImportError:
gdrive_support = False gdrive_support = False
import os from . import logger, cli, config
from ub import config from .constants import CONFIG_DIR as _CONFIG_DIR
import cli
import shutil
from flask import Response, stream_with_context SETTINGS_YAML = os.path.join(_CONFIG_DIR, 'settings.yaml')
from sqlalchemy import * CREDENTIALS = os.path.join(_CONFIG_DIR, 'gdrive_credentials')
from sqlalchemy.ext.declarative import declarative_base CLIENT_SECRETS = os.path.join(_CONFIG_DIR, 'client_secrets.json')
from sqlalchemy.orm import *
import web log = logger.create()
if gdrive_support:
logger.get('googleapiclient.discovery_cache').setLevel(logger.logging.ERROR)
if not logger.is_debug_enabled():
logger.get('googleapiclient.discovery').setLevel(logger.logging.ERROR)
class Singleton: class Singleton:
""" """
@@ -67,6 +83,9 @@ class Singleton:
except AttributeError: except AttributeError:
self._instance = self._decorated() self._instance = self._decorated()
return self._instance return self._instance
except ImportError as e:
log.debug(e)
return None
def __call__(self): def __call__(self):
raise TypeError('Singletons must be accessed through `Instance()`.') raise TypeError('Singletons must be accessed through `Instance()`.')
@@ -78,7 +97,7 @@ class Singleton:
@Singleton @Singleton
class Gauth: class Gauth:
def __init__(self): def __init__(self):
self.auth = GoogleAuth(settings_file=os.path.join(config.get_main_dir,'settings.yaml')) self.auth = GoogleAuth(settings_file=SETTINGS_YAML)
@Singleton @Singleton
@@ -86,6 +105,9 @@ class Gdrive:
def __init__(self): def __init__(self):
self.drive = getDrive(gauth=Gauth.Instance().auth) self.drive = getDrive(gauth=Gauth.Instance().auth)
def is_gdrive_ready():
return os.path.exists(SETTINGS_YAML) and os.path.exists(CREDENTIALS)
engine = create_engine('sqlite:///{0}'.format(cli.gdpath), echo=False) engine = create_engine('sqlite:///{0}'.format(cli.gdpath), echo=False)
Base = declarative_base() Base = declarative_base()
@@ -146,17 +168,17 @@ migrate()
def getDrive(drive=None, gauth=None): def getDrive(drive=None, gauth=None):
if not drive: if not drive:
if not gauth: if not gauth:
gauth = GoogleAuth(settings_file=os.path.join(config.get_main_dir,'settings.yaml')) gauth = GoogleAuth(settings_file=SETTINGS_YAML)
# Try to load saved client credentials # Try to load saved client credentials
gauth.LoadCredentialsFile(os.path.join(config.get_main_dir,'gdrive_credentials')) gauth.LoadCredentialsFile(CREDENTIALS)
if gauth.access_token_expired: if gauth.access_token_expired:
# Refresh them if expired # Refresh them if expired
try: try:
gauth.Refresh() gauth.Refresh()
except RefreshError as e: except RefreshError as e:
web.app.logger.error("Google Drive error: " + e.message) log.error("Google Drive error: %s", e)
except Exception as e: except Exception as e:
web.app.logger.exception(e) log.exception(e)
else: else:
# Initialize the saved creds # Initialize the saved creds
gauth.Authorize() gauth.Authorize()
@@ -166,7 +188,7 @@ def getDrive(drive=None, gauth=None):
try: try:
drive.auth.Refresh() drive.auth.Refresh()
except RefreshError as e: except RefreshError as e:
web.app.logger.error("Google Drive error: " + e.message) log.error("Google Drive error: %s", e)
return drive return drive
def listRootFolders(): def listRootFolders():
@@ -203,7 +225,7 @@ def getEbooksFolderId(drive=None):
try: try:
gDriveId.gdrive_id = getEbooksFolder(drive)['id'] gDriveId.gdrive_id = getEbooksFolder(drive)['id']
except Exception: except Exception:
web.app.logger.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)
session.commit() session.commit()
@@ -443,10 +465,10 @@ def getChangeById (drive, change_id):
change = drive.auth.service.changes().get(changeId=change_id).execute() change = drive.auth.service.changes().get(changeId=change_id).execute()
return change return change
except (errors.HttpError) as error: except (errors.HttpError) as error:
web.app.logger.info(error.message) log.error(error)
return None return None
except Exception as e: except Exception as e:
web.app.logger.info(e) log.error(e)
return None return None
@@ -516,6 +538,56 @@ def do_gdrive_download(df, headers):
if resp.status == 206: if resp.status == 206:
yield content yield content
else: else:
web.app.logger.info('An error occurred: %s' % resp) log.warning('An error occurred: %s', resp)
return return
return Response(stream_with_context(stream()), headers=headers) return Response(stream_with_context(stream()), headers=headers)
_SETTINGS_YAML_TEMPLATE = """
client_config_backend: settings
client_config_file: %(client_file)s
client_config:
client_id: %(client_id)s
client_secret: %(client_secret)s
redirect_uri: %(redirect_uri)s
save_credentials: True
save_credentials_backend: file
save_credentials_file: %(credential)s
get_refresh_token: True
oauth_scope:
- https://www.googleapis.com/auth/drive
"""
def update_settings(client_id, client_secret, redirect_uri):
if redirect_uri.endswith('/'):
redirect_uri = redirect_uri[:-1]
config_params = {
'client_file': CLIENT_SECRETS,
'client_id': client_id,
'client_secret': client_secret,
'redirect_uri': redirect_uri,
'credential': CREDENTIALS
}
with open(SETTINGS_YAML, 'w') as f:
f.write(_SETTINGS_YAML_TEMPLATE % config_params)
def get_error_text(client_secrets=None):
if not gdrive_support:
return 'Import of optional Google Drive requirements missing'
if not os.path.isfile(CLIENT_SECRETS):
return 'client_secrets.json is missing or not readable'
with open(CLIENT_SECRETS, 'r') as settings:
filedata = json.load(settings)
if 'web' not in filedata:
return 'client_secrets.json is not configured for web application'
if 'redirect_uris' not in filedata['web']:
return 'Callback url (redirect url) is missing in client_secrets.json'
if client_secrets:
client_secrets.update(filedata['web'])
+496 -185
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
@@ -18,32 +17,36 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import db
import ub
from flask import current_app as app
from tempfile import gettempdir
import sys import sys
import io
import os import os
import io
import json
import mimetypes
import random
import re import re
import unicodedata import shutil
import worker
import time import time
import unicodedata
from datetime import datetime, timedelta
from tempfile import gettempdir
import requests
from babel import Locale as LC
from babel.core import UnknownLocaleError
from babel.dates import format_datetime
from babel.units import format_unit
from flask import send_from_directory, make_response, redirect, abort from flask import send_from_directory, make_response, redirect, abort
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 babel.dates import format_datetime from sqlalchemy.sql.expression import true, false, and_, or_, text, func
from datetime import datetime from werkzeug.datastructures import Headers
import shutil from werkzeug.security import generate_password_hash
import requests
try: try:
import gdriveutils as gd from urllib.parse import quote
except ImportError: except ImportError:
pass from urllib import quote
import web
import random
import subprocess
try: try:
import unidecode import unidecode
@@ -52,24 +55,22 @@ except ImportError:
use_unidecode = False use_unidecode = False
try: try:
from PIL import Image from PIL import Image as PILImage
use_PIL = True use_PIL = True
except ImportError: except ImportError:
use_PIL = False use_PIL = False
# Global variables from . import logger, config, get_locale, db, ub, isoLanguages, worker
# updater_thread = None from . import gdriveutils as gd
global_WorkerThread = worker.WorkerThread() from .constants import STATIC_DIR as _STATIC_DIR
global_WorkerThread.start() from .pagination import Pagination
from .subproc_wrapper import process_wait
from .worker import STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS
from .worker import TASK_EMAIL, TASK_CONVERT, TASK_UPLOAD, TASK_CONVERT_ANY
def update_download(book_id, user_id): log = logger.create()
check = ub.session.query(ub.Downloads).filter(ub.Downloads.user_id == user_id).filter(ub.Downloads.book_id ==
book_id).first()
if not check:
new_download = ub.Downloads(user_id=user_id, book_id=book_id)
ub.session.add(new_download)
ub.session.commit()
# Convert existing book entry to new format # Convert existing book entry to new format
def convert_book_format(book_id, calibrepath, old_book_format, new_book_format, user_id, kindle_mail=None): def convert_book_format(book_id, calibrepath, old_book_format, new_book_format, user_id, kindle_mail=None):
@@ -77,9 +78,9 @@ def convert_book_format(book_id, calibrepath, old_book_format, new_book_format,
data = db.session.query(db.Data).filter(db.Data.book == book.id).filter(db.Data.format == old_book_format).first() data = db.session.query(db.Data).filter(db.Data.book == book.id).filter(db.Data.format == old_book_format).first()
if not data: if not data:
error_message = _(u"%(format)s format not found for book id: %(book)d", format=old_book_format, book=book_id) error_message = _(u"%(format)s format not found for book id: %(book)d", format=old_book_format, book=book_id)
app.logger.error("convert_book_format: " + error_message) log.error("convert_book_format: %s", error_message)
return error_message return error_message
if ub.config.config_use_google_drive: if config.config_use_google_drive:
df = gd.getFileFromEbooksFolder(book.path, data.name + "." + old_book_format.lower()) df = gd.getFileFromEbooksFolder(book.path, data.name + "." + old_book_format.lower())
if df: if df:
datafile = os.path.join(calibrepath, book.path, data.name + u"." + old_book_format.lower()) datafile = os.path.join(calibrepath, book.path, data.name + u"." + old_book_format.lower())
@@ -94,8 +95,8 @@ def convert_book_format(book_id, calibrepath, old_book_format, new_book_format,
if os.path.exists(file_path + "." + old_book_format.lower()): if os.path.exists(file_path + "." + old_book_format.lower()):
# read settings and append converter task to queue # read settings and append converter task to queue
if kindle_mail: if kindle_mail:
settings = ub.get_mail_settings() settings = config.get_mail_settings()
settings['subject'] = _('Send to Kindle') # pretranslate Subject for e-mail settings['subject'] = _('Send to Kindle') # pretranslate Subject for e-mail
settings['body'] = _(u'This e-mail has been sent via Calibre-Web.') settings['body'] = _(u'This e-mail has been sent via Calibre-Web.')
# text = _(u"%(format)s: %(book)s", format=new_book_format, book=book.title) # text = _(u"%(format)s: %(book)s", format=new_book_format, book=book.title)
else: else:
@@ -103,18 +104,18 @@ def convert_book_format(book_id, calibrepath, old_book_format, new_book_format,
text = (u"%s -> %s: %s" % (old_book_format, new_book_format, book.title)) text = (u"%s -> %s: %s" % (old_book_format, new_book_format, book.title))
settings['old_book_format'] = old_book_format settings['old_book_format'] = old_book_format
settings['new_book_format'] = new_book_format settings['new_book_format'] = new_book_format
global_WorkerThread.add_convert(file_path, book.id, user_id, text, settings, kindle_mail) worker.add_convert(file_path, book.id, user_id, text, settings, kindle_mail)
return None return None
else: else:
error_message = _(u"%(format)s not found: %(fn)s", error_message = _(u"%(format)s not found: %(fn)s",
format=old_book_format, fn=data.name + "." + old_book_format.lower()) format=old_book_format, fn=data.name + "." + old_book_format.lower())
return error_message return error_message
def send_test_mail(kindle_mail, user_name): def send_test_mail(kindle_mail, user_name):
global_WorkerThread.add_email(_(u'Calibre-Web test e-mail'),None, None, ub.get_mail_settings(), worker.add_email(_(u'Calibre-Web test e-mail'), None, None,
kindle_mail, user_name, _(u"Test e-mail"), config.get_mail_settings(), kindle_mail, user_name,
_(u'This e-mail has been sent via Calibre-Web.')) _(u"Test e-mail"), _(u'This e-mail has been sent via Calibre-Web.'))
return return
@@ -124,53 +125,71 @@ def send_registration_mail(e_mail, user_name, default_password, resend=False):
if not resend: if not resend:
text += "Your new account at Calibre-Web has been created. Thanks for joining us!\r\n" text += "Your new account at Calibre-Web has been created. Thanks for joining us!\r\n"
text += "Please log in to your account using the following informations:\r\n" text += "Please log in to your account using the following informations:\r\n"
text += "User name: %s\n" % user_name text += "User name: %s\r\n" % user_name
text += "Password: %s\r\n" % default_password text += "Password: %s\r\n" % default_password
text += "Don't forget to change your password after first login.\r\n" text += "Don't forget to change your password after first login.\r\n"
text += "Sincerely\r\n\r\n" text += "Sincerely\r\n\r\n"
text += "Your Calibre-Web team" text += "Your Calibre-Web team"
global_WorkerThread.add_email(_(u'Get Started with Calibre-Web'),None, None, ub.get_mail_settings(), worker.add_email(_(u'Get Started with Calibre-Web'), None, None,
e_mail, None, _(u"Registration e-mail for user: %(name)s", name=user_name), text) config.get_mail_settings(), e_mail, None,
_(u"Registration e-mail for user: %(name)s", name=user_name), text)
return return
def check_send_to_kindle(entry): def check_send_to_kindle(entry):
""" """
returns all available book formats for sending to Kindle returns all available book formats for sending to Kindle
""" """
if len(entry.data): if len(entry.data):
bookformats=list() bookformats = list()
if ub.config.config_ebookconverter == 0: if config.config_ebookconverter == 0:
# no converter - only for mobi and pdf formats # no converter - only for mobi and pdf formats
for ele in iter(entry.data): for ele in iter(entry.data):
if 'MOBI' in ele.format: if 'MOBI' in ele.format:
bookformats.append({'format':'Mobi','convert':0,'text':_('Send %(format)s to Kindle',format='Mobi')}) bookformats.append({'format': 'Mobi',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Mobi')})
if 'PDF' in ele.format: if 'PDF' in ele.format:
bookformats.append({'format':'Pdf','convert':0,'text':_('Send %(format)s to Kindle',format='Pdf')}) bookformats.append({'format': 'Pdf',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Pdf')})
if 'AZW' in ele.format: if 'AZW' in ele.format:
bookformats.append({'format':'Azw','convert':0,'text':_('Send %(format)s to Kindle',format='Azw')}) bookformats.append({'format': 'Azw',
'''if 'AZW3' in ele.format: 'convert': 0,
bookformats.append({'format':'Azw3','convert':0,'text':_('Send %(format)s to Kindle',format='Azw3')})''' 'text': _('Send %(format)s to Kindle', format='Azw')})
else: else:
formats = list() formats = list()
for ele in iter(entry.data): for ele in iter(entry.data):
formats.append(ele.format) formats.append(ele.format)
if 'MOBI' in formats: if 'MOBI' in formats:
bookformats.append({'format': 'Mobi','convert':0,'text':_('Send %(format)s to Kindle',format='Mobi')}) bookformats.append({'format': 'Mobi',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Mobi')})
if 'AZW' in formats: if 'AZW' in formats:
bookformats.append({'format': 'Azw','convert':0,'text':_('Send %(format)s to Kindle',format='Azw')}) bookformats.append({'format': 'Azw',
'convert': 0,
'text': _('Send %(format)s to Kindle', format='Azw')})
if 'PDF' in formats: if 'PDF' in formats:
bookformats.append({'format': 'Pdf','convert':0,'text':_('Send %(format)s to Kindle',format='Pdf')}) bookformats.append({'format': 'Pdf',
if ub.config.config_ebookconverter >= 1: 'convert': 0,
'text': _('Send %(format)s to Kindle', format='Pdf')})
if config.config_ebookconverter >= 1:
if 'EPUB' in formats and not 'MOBI' in formats: if 'EPUB' in formats and not 'MOBI' in formats:
bookformats.append({'format': 'Mobi','convert':1, bookformats.append({'format': 'Mobi',
'text':_('Convert %(orig)s to %(format)s and send to Kindle',orig='Epub',format='Mobi')}) 'convert':1,
'''if ub.config.config_ebookconverter == 2: 'text': _('Convert %(orig)s to %(format)s and send to Kindle',
if 'EPUB' in formats and not 'AZW3' in formats: orig='Epub',
bookformats.append({'format': 'Azw3','convert':1, format='Mobi')})
'text':_('Convert %(orig)s to %(format)s and send to Kindle',orig='Epub',format='Azw3')})''' if config.config_ebookconverter == 2:
if 'AZW3' in formats and not 'MOBI' in formats:
bookformats.append({'format': 'Mobi',
'convert': 2,
'text': _('Convert %(orig)s to %(format)s and send to Kindle',
orig='Azw3',
format='Mobi')})
return bookformats return bookformats
else: else:
app.logger.error(u'Cannot find book entry %d', entry.id) log.error(u'Cannot find book entry %d', entry.id)
return None return None
@@ -181,7 +200,7 @@ def check_read_formats(entry):
bookformats = list() bookformats = list()
if len(entry.data): if len(entry.data):
for ele in iter(entry.data): for ele in iter(entry.data):
if ele.format in EXTENSIONS_READER: if ele.format.upper() in EXTENSIONS_READER:
bookformats.append(ele.format.lower()) bookformats.append(ele.format.lower())
return bookformats return bookformats
@@ -194,18 +213,21 @@ def send_mail(book_id, book_format, convert, kindle_mail, calibrepath, user_id):
"""Send email with attachments""" """Send email with attachments"""
book = db.session.query(db.Books).filter(db.Books.id == book_id).first() book = db.session.query(db.Books).filter(db.Books.id == book_id).first()
if convert: if convert == 1:
# returns None if success, otherwise errormessage # returns None if success, otherwise errormessage
return convert_book_format(book_id, calibrepath, u'epub', book_format.lower(), user_id, kindle_mail) return convert_book_format(book_id, calibrepath, u'epub', book_format.lower(), user_id, kindle_mail)
else: if convert == 2:
for entry in iter(book.data): # returns None if success, otherwise errormessage
if entry.format.upper() == book_format.upper(): return convert_book_format(book_id, calibrepath, u'azw3', book_format.lower(), user_id, kindle_mail)
result = entry.name + '.' + book_format.lower()
global_WorkerThread.add_email(_(u"Send to Kindle"), book.path, result, ub.get_mail_settings(), for entry in iter(book.data):
kindle_mail, user_id, _(u"E-mail: %(book)s", book=book.title), if entry.format.upper() == book_format.upper():
_(u'This e-mail has been sent via Calibre-Web.')) converted_file_name = entry.name + '.' + book_format.lower()
return worker.add_email(_(u"Send to Kindle"), book.path, converted_file_name,
return _(u"The requested file could not be read. Maybe wrong permissions?") config.get_mail_settings(), kindle_mail, user_id,
_(u"E-mail: %(book)s", book=book.title), _(u'This e-mail has been sent via Calibre-Web.'))
return
return _(u"The requested file could not be read. Maybe wrong permissions?")
def get_valid_filename(value, replace_whitespace=True): def get_valid_filename(value, replace_whitespace=True):
@@ -222,7 +244,7 @@ def get_valid_filename(value, replace_whitespace=True):
value = value.replace(u'§', u'SS') value = value.replace(u'§', u'SS')
value = value.replace(u'ß', u'ss') value = value.replace(u'ß', u'ss')
value = unicodedata.normalize('NFKD', value) value = unicodedata.normalize('NFKD', value)
re_slugify = re.compile('[\W\s-]', re.UNICODE) re_slugify = re.compile(r'[\W\s-]', re.UNICODE)
if isinstance(value, str): # Python3 str, Python2 unicode if isinstance(value, str): # Python3 str, Python2 unicode
value = re_slugify.sub('', value).strip() value = re_slugify.sub('', value).strip()
else: else:
@@ -244,7 +266,7 @@ def get_valid_filename(value, replace_whitespace=True):
def get_sorted_author(value): def get_sorted_author(value):
try: try:
if ',' not in value: if ',' not in value:
regexes = ["^(JR|SR)\.?$", "^I{1,3}\.?$", "^IV\.?$"] regexes = [r"^(JR|SR)\.?$", r"^I{1,3}\.?$", r"^IV\.?$"]
combined = "(" + ")|(".join(regexes) + ")" combined = "(" + ")|(".join(regexes) + ")"
value = value.split(" ") value = value.split(" ")
if re.match(combined, value[-1].upper()): if re.match(combined, value[-1].upper()):
@@ -255,8 +277,8 @@ def get_sorted_author(value):
value2 = value[-1] + ", " + " ".join(value[:-1]) value2 = value[-1] + ", " + " ".join(value[:-1])
else: else:
value2 = value value2 = value
except Exception: except Exception as ex:
web.app.logger.error("Sorting author " + str(value) + "failed") log.error("Sorting author %s failed: %s", value, ex)
value2 = value value2 = value
return value2 return value2
@@ -270,17 +292,34 @@ def delete_book_file(book, calibrepath, book_format=None):
for file in os.listdir(path): for file in os.listdir(path):
if file.upper().endswith("."+book_format): if file.upper().endswith("."+book_format):
os.remove(os.path.join(path, file)) os.remove(os.path.join(path, file))
return True, None
else: else:
if os.path.isdir(path): if os.path.isdir(path):
if len(next(os.walk(path))[1]): if len(next(os.walk(path))[1]):
web.app.logger.error( log.error("Deleting book %s failed, path has subfolders: %s", book.id, book.path)
"Deleting book " + str(book.id) + " failed, path has subfolders: " + book.path) return False , _("Deleting book %(id)s failed, path has subfolders: %(path)s",
return False id=book.id,
shutil.rmtree(path, ignore_errors=True) path=book.path)
return True try:
for root, __, files in os.walk(path):
for f in files:
os.unlink(os.path.join(root, f))
shutil.rmtree(path)
except (IOError, OSError) as e:
log.error("Deleting book %s failed: %s", book.id, e)
return False, _("Deleting book %(id)s failed: %(message)s", id=book.id, message=e)
authorpath = os.path.join(calibrepath, os.path.split(book.path)[0])
if not os.listdir(authorpath):
try:
shutil.rmtree(authorpath)
except (IOError, OSError) as e:
log.error("Deleting authorpath for book %s failed: %s", book.id, e)
return True, None
else: else:
web.app.logger.error("Deleting book " + str(book.id) + " failed, book path not valid: " + book.path) log.error("Deleting book %s failed, book path not valid: %s", book.id, book.path)
return False return False, _("Deleting book %(id)s failed, book path not valid: %(path)s",
id=book.id,
path=book.path)
def update_dir_structure_file(book_id, calibrepath, first_author): def update_dir_structure_file(book_id, calibrepath, first_author):
@@ -297,31 +336,31 @@ def update_dir_structure_file(book_id, calibrepath, first_author):
new_titledir = get_valid_filename(localbook.title) + " (" + str(book_id) + ")" new_titledir = get_valid_filename(localbook.title) + " (" + str(book_id) + ")"
if titledir != new_titledir: if titledir != new_titledir:
new_title_path = os.path.join(os.path.dirname(path), new_titledir)
try: try:
new_title_path = os.path.join(os.path.dirname(path), new_titledir)
if not os.path.exists(new_title_path): if not os.path.exists(new_title_path):
os.renames(path, new_title_path) os.renames(path, new_title_path)
else: else:
web.app.logger.info("Copying title: " + path + " into existing: " + new_title_path) log.info("Copying title: %s into existing: %s", path, new_title_path)
for dir_name, subdir_list, file_list in os.walk(path): for dir_name, __, file_list in os.walk(path):
for file in file_list: for file in file_list:
os.renames(os.path.join(dir_name, file), os.renames(os.path.join(dir_name, file),
os.path.join(new_title_path + dir_name[len(path):], file)) os.path.join(new_title_path + dir_name[len(path):], file))
path = new_title_path path = new_title_path
localbook.path = localbook.path.split('/')[0] + '/' + new_titledir localbook.path = localbook.path.split('/')[0] + '/' + new_titledir
except OSError as ex: except OSError as ex:
web.app.logger.error("Rename title from: " + path + " to " + new_title_path + ": " + str(ex)) log.error("Rename title from: %s to %s: %s", path, new_title_path, ex)
web.app.logger.debug(ex, exc_info=True) log.debug(ex, exc_info=True)
return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s", return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
src=path, dest=new_title_path, error=str(ex)) src=path, dest=new_title_path, error=str(ex))
if authordir != new_authordir: if authordir != new_authordir:
new_author_path = os.path.join(calibrepath, new_authordir, os.path.basename(path))
try: try:
new_author_path = os.path.join(calibrepath, new_authordir, os.path.basename(path))
os.renames(path, new_author_path) os.renames(path, new_author_path)
localbook.path = new_authordir + '/' + localbook.path.split('/')[1] localbook.path = new_authordir + '/' + localbook.path.split('/')[1]
except OSError as ex: except OSError as ex:
web.app.logger.error("Rename author from: " + path + " to " + new_author_path + ": " + str(ex)) log.error("Rename author from: %s to %s: %s", path, new_author_path, ex)
web.app.logger.debug(ex, exc_info=True) log.debug(ex, exc_info=True)
return _("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s", return _("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
src=path, dest=new_author_path, error=str(ex)) src=path, dest=new_author_path, error=str(ex))
# Rename all files from old names to new names # Rename all files from old names to new names
@@ -334,8 +373,8 @@ def update_dir_structure_file(book_id, calibrepath, first_author):
os.path.join(path_name, new_name + '.' + file_format.format.lower())) os.path.join(path_name, new_name + '.' + file_format.format.lower()))
file_format.name = new_name file_format.name = new_name
except OSError as ex: except OSError as ex:
web.app.logger.error("Rename file in path " + path + " to " + new_name + ": " + str(ex)) log.error("Rename file in path %s to %s: %s", path, new_name, ex)
web.app.logger.debug(ex, exc_info=True) log.debug(ex, exc_info=True)
return _("Rename file in path '%(src)s' to '%(dest)s' failed with error: %(error)s", return _("Rename file in path '%(src)s' to '%(dest)s' failed with error: %(error)s",
src=path, dest=new_name, error=str(ex)) src=path, dest=new_name, error=str(ex))
return False return False
@@ -363,7 +402,7 @@ def update_dir_structure_gdrive(book_id, first_author):
path = book.path path = book.path
gd.updateDatabaseOnEdit(gFile['id'], book.path) # only child folder affected gd.updateDatabaseOnEdit(gFile['id'], book.path) # only child folder affected
else: else:
error = _(u'File %(file)s not found on Google Drive', file=book.path) # file not found error = _(u'File %(file)s not found on Google Drive', file=book.path) # file not found
if authordir != new_authordir: if authordir != new_authordir:
gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path), new_titledir) gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path), new_titledir)
@@ -373,7 +412,7 @@ def update_dir_structure_gdrive(book_id, first_author):
path = book.path path = book.path
gd.updateDatabaseOnEdit(gFile['id'], book.path) gd.updateDatabaseOnEdit(gFile['id'], book.path)
else: else:
error = _(u'File %(file)s not found on Google Drive', file=authordir) # file not found error = _(u'File %(file)s not found on Google Drive', file=authordir) # file not found
# Rename all files from old names to new names # Rename all files from old names to new names
if authordir != new_authordir or titledir != new_titledir: if authordir != new_authordir or titledir != new_titledir:
@@ -389,7 +428,7 @@ def update_dir_structure_gdrive(book_id, first_author):
def delete_book_gdrive(book, book_format): def delete_book_gdrive(book, book_format):
error= False error = None
if book_format: if book_format:
name = '' name = ''
for entry in book.data: for entry in book.data:
@@ -397,64 +436,108 @@ def delete_book_gdrive(book, book_format):
name = entry.name + '.' + book_format name = entry.name + '.' + book_format
gFile = gd.getFileFromEbooksFolder(book.path, name) gFile = gd.getFileFromEbooksFolder(book.path, name)
else: else:
gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path),book.path.split('/')[1]) gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path), book.path.split('/')[1])
if gFile: if gFile:
gd.deleteDatabaseEntry(gFile['id']) gd.deleteDatabaseEntry(gFile['id'])
gFile.Trash() gFile.Trash()
else: else:
error =_(u'Book path %(path)s not found on Google Drive', path=book.path) # file not found error = _(u'Book path %(path)s not found on Google Drive', path=book.path) # file not found
return error
return error is None, error
def reset_password(user_id):
existing_user = ub.session.query(ub.User).filter(ub.User.id == user_id).first()
if not existing_user:
return 0, None
password = generate_random_password()
existing_user.password = generate_password_hash(password)
if not config.get_mail_server_configured():
return 2, None
try:
ub.session.commit()
send_registration_mail(existing_user.email, existing_user.nickname, password, True)
return 1, existing_user.nickname
except Exception:
ub.session.rollback()
return 0, None
def generate_random_password(): def generate_random_password():
s = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?" s = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
passlen = 8 passlen = 8
return "".join(random.sample(s,passlen )) return "".join(random.sample(s, passlen))
################################## External interface ################################## External interface
def update_dir_stucture(book_id, calibrepath, first_author = None):
if ub.config.config_use_google_drive: def update_dir_stucture(book_id, calibrepath, first_author=None):
if config.config_use_google_drive:
return update_dir_structure_gdrive(book_id, first_author) return update_dir_structure_gdrive(book_id, first_author)
else: else:
return update_dir_structure_file(book_id, calibrepath, first_author) return update_dir_structure_file(book_id, calibrepath, first_author)
def delete_book(book, calibrepath, book_format): def delete_book(book, calibrepath, book_format):
if ub.config.config_use_google_drive: if config.config_use_google_drive:
return delete_book_gdrive(book, book_format) return delete_book_gdrive(book, book_format)
else: else:
return delete_book_file(book, calibrepath, book_format) return delete_book_file(book, calibrepath, book_format)
def get_book_cover(cover_path): def get_cover_on_failure(use_generic_cover):
if ub.config.config_use_google_drive: if use_generic_cover:
try: return send_from_directory(_STATIC_DIR, "generic_cover.jpg")
if not web.is_gdrive_ready():
return send_from_directory(os.path.join(os.path.dirname(__file__), "static"), "generic_cover.jpg")
path=gd.get_cover_via_gdrive(cover_path)
if path:
return redirect(path)
else:
web.app.logger.error(cover_path + '/cover.jpg not found on Google Drive')
return send_from_directory(os.path.join(os.path.dirname(__file__), "static"), "generic_cover.jpg")
except Exception as e:
web.app.logger.error("Error Message: " + e.message)
web.app.logger.exception(e)
# traceback.print_exc()
return send_from_directory(os.path.join(os.path.dirname(__file__), "static"),"generic_cover.jpg")
else: else:
return send_from_directory(os.path.join(ub.config.config_calibre_dir, cover_path), "cover.jpg") return None
def get_book_cover(book_id):
book = db.session.query(db.Books).filter(db.Books.id == book_id).filter(common_filters()).first()
return get_book_cover_internal(book, use_generic_cover_on_failure=True)
def get_book_cover_with_uuid(book_uuid,
use_generic_cover_on_failure=True):
book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first()
return get_book_cover_internal(book, use_generic_cover_on_failure)
def get_book_cover_internal(book,
use_generic_cover_on_failure):
if book and book.has_cover:
if config.config_use_google_drive:
try:
if not gd.is_gdrive_ready():
return get_cover_on_failure(use_generic_cover_on_failure)
path = gd.get_cover_via_gdrive(book.path)
if path:
return redirect(path)
else:
log.error('%s/cover.jpg not found on Google Drive', book.path)
return get_cover_on_failure(use_generic_cover_on_failure)
except Exception as e:
log.exception(e)
# traceback.print_exc()
return get_cover_on_failure(use_generic_cover_on_failure)
else:
cover_file_path = os.path.join(config.config_calibre_dir, book.path)
if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")):
return send_from_directory(cover_file_path, "cover.jpg")
else:
return get_cover_on_failure(use_generic_cover_on_failure)
else:
return get_cover_on_failure(use_generic_cover_on_failure)
# saves book cover from url # saves book cover from url
def save_cover_from_url(url, book_path): def save_cover_from_url(url, book_path):
img = requests.get(url) img = requests.get(url, timeout=10) # ToDo: Error Handling
return save_cover(img, book_path) return save_cover(img, book_path)
def save_cover_from_filestorage(filepath, saved_filename, img): def save_cover_from_filestorage(filepath, saved_filename, img):
if hasattr(img,'_content'): if hasattr(img, '_content'):
f = open(os.path.join(filepath, saved_filename), "wb") f = open(os.path.join(filepath, saved_filename), "wb")
f.write(img._content) f.write(img._content)
f.close() f.close()
@@ -464,17 +547,14 @@ def save_cover_from_filestorage(filepath, saved_filename, img):
try: try:
os.makedirs(filepath) os.makedirs(filepath)
except OSError: except OSError:
web.app.logger.error(u"Failed to create path for cover") log.error(u"Failed to create path for cover")
return False return False, _(u"Failed to create path for cover")
try: try:
img.save(os.path.join(filepath, saved_filename)) img.save(os.path.join(filepath, saved_filename))
except OSError: except (IOError, OSError):
web.app.logger.error(u"Failed to store cover-file") log.error(u"Cover-file is not a valid image file, or could not be stored")
return False return False, _(u"Cover-file is not a valid image file, or could not be stored")
except IOError: return True, None
web.app.logger.error(u"Cover-file is not a valid image file")
return False
return True
# saves book cover to gdrive or locally # saves book cover to gdrive or locally
@@ -483,51 +563,51 @@ def save_cover(img, book_path):
if use_PIL: if use_PIL:
if content_type not in ('image/jpeg', 'image/png', 'image/webp'): if content_type not in ('image/jpeg', 'image/png', 'image/webp'):
web.app.logger.error("Only jpg/jpeg/png/webp files are supported as coverfile") log.error("Only jpg/jpeg/png/webp files are supported as coverfile")
return False return False, _("Only jpg/jpeg/png/webp files are supported as coverfile")
# convert to jpg because calibre only supports jpg # convert to jpg because calibre only supports jpg
if content_type in ('image/png', 'image/webp'): if content_type in ('image/png', 'image/webp'):
if hasattr(img,'stream'): if hasattr(img, 'stream'):
imgc = Image.open(img.stream) imgc = PILImage.open(img.stream)
else: else:
imgc = Image.open(io.BytesIO(img.content)) imgc = PILImage.open(io.BytesIO(img.content))
im = imgc.convert('RGB') im = imgc.convert('RGB')
tmp_bytesio = io.BytesIO() tmp_bytesio = io.BytesIO()
im.save(tmp_bytesio, format='JPEG') im.save(tmp_bytesio, format='JPEG')
img._content = tmp_bytesio.getvalue() img._content = tmp_bytesio.getvalue()
else: else:
if content_type not in ('image/jpeg'): if content_type not in 'image/jpeg':
web.app.logger.error("Only jpg/jpeg files are supported as coverfile") log.error("Only jpg/jpeg files are supported as coverfile")
return False return False, _("Only jpg/jpeg files are supported as coverfile")
if ub.config.config_use_google_drive: if config.config_use_google_drive:
tmpDir = gettempdir() tmpDir = gettempdir()
if save_cover_from_filestorage(tmpDir, "uploaded_cover.jpg", img) is True: ret, message = save_cover_from_filestorage(tmpDir, "uploaded_cover.jpg", img)
if ret is True:
gd.uploadFileToEbooksFolder(os.path.join(book_path, 'cover.jpg'), gd.uploadFileToEbooksFolder(os.path.join(book_path, 'cover.jpg'),
os.path.join(tmpDir, "uploaded_cover.jpg")) os.path.join(tmpDir, "uploaded_cover.jpg"))
web.app.logger.info("Cover is saved on Google Drive") log.info("Cover is saved on Google Drive")
return True return True, None
else: else:
return False return False, message
else: else:
return save_cover_from_filestorage(os.path.join(ub.config.config_calibre_dir, book_path), "cover.jpg", img) return save_cover_from_filestorage(os.path.join(config.config_calibre_dir, book_path), "cover.jpg", img)
def do_download_file(book, book_format, data, headers): def do_download_file(book, book_format, data, headers):
if ub.config.config_use_google_drive: if config.config_use_google_drive:
startTime = time.time() startTime = time.time()
df = gd.getFileFromEbooksFolder(book.path, data.name + "." + book_format) df = gd.getFileFromEbooksFolder(book.path, data.name + "." + book_format)
web.app.logger.debug(time.time() - startTime) log.debug('%s', time.time() - startTime)
if df: if df:
return gd.do_gdrive_download(df, headers) return gd.do_gdrive_download(df, headers)
else: else:
abort(404) abort(404)
else: else:
filename = os.path.join(ub.config.config_calibre_dir, book.path) filename = os.path.join(config.config_calibre_dir, book.path)
if not os.path.isfile(os.path.join(filename, data.name + "." + book_format)): if not os.path.isfile(os.path.join(filename, data.name + "." + book_format)):
# ToDo: improve error handling # ToDo: improve error handling
web.app.logger.error('File not found: %s' % os.path.join(filename, data.name + "." + book_format)) log.error('File not found: %s', os.path.join(filename, data.name + "." + book_format))
response = make_response(send_from_directory(filename, data.name + "." + book_format)) response = make_response(send_from_directory(filename, data.name + "." + book_format))
response.headers = headers response.headers = headers
return response return response
@@ -535,72 +615,99 @@ def do_download_file(book, book_format, data, headers):
################################## ##################################
def check_unrar(unrarLocation): def check_unrar(unrarLocation):
error = False if not unrarLocation:
if os.path.exists(unrarLocation): return
try:
if sys.version_info < (3, 0):
unrarLocation = unrarLocation.encode(sys.getfilesystemencoding())
p = subprocess.Popen(unrarLocation, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
p.wait()
for lines in p.stdout.readlines():
if isinstance(lines, bytes):
lines = lines.decode('utf-8')
value=re.search('UNRAR (.*) freeware', lines)
if value:
version = value.group(1)
except OSError as e:
error = True
web.app.logger.exception(e)
version =_(u'Error excecuting UnRar')
else:
version = _(u'Unrar binary file not found')
error=True
return (error, version)
if not os.path.exists(unrarLocation):
return _('Unrar binary file not found')
try:
if sys.version_info < (3, 0):
unrarLocation = unrarLocation.encode(sys.getfilesystemencoding())
unrarLocation = [unrarLocation]
for lines in process_wait(unrarLocation):
value = re.search('UNRAR (.*) freeware', lines)
if value:
version = value.group(1)
log.debug("unrar version %s", version)
except OSError as err:
log.exception(err)
return _('Error excecuting UnRar')
def json_serial(obj): def json_serial(obj):
"""JSON serializer for objects not serializable by default json code""" """JSON serializer for objects not serializable by default json code"""
if isinstance(obj, (datetime)): if isinstance(obj, datetime):
return obj.isoformat() return obj.isoformat()
raise TypeError ("Type %s not serializable" % type(obj)) if isinstance(obj, timedelta):
return {
'__type__': 'timedelta',
'days': obj.days,
'seconds': obj.seconds,
'microseconds': obj.microseconds,
}
# return obj.isoformat()
raise TypeError("Type %s not serializable" % type(obj))
# helper function for displaying the runtime of tasks
def format_runtime(runtime):
retVal = ""
if runtime.days:
retVal = format_unit(runtime.days, 'duration-day', length="long", locale=get_locale()) + ', '
mins, seconds = divmod(runtime.seconds, 60)
hours, minutes = divmod(mins, 60)
# ToDo: locale.number_symbols._data['timeSeparator'] -> localize time separator ?
if hours:
retVal += '{:d}:{:02d}:{:02d}s'.format(hours, minutes, seconds)
elif minutes:
retVal += '{:2d}:{:02d}s'.format(minutes, seconds)
else:
retVal += '{:2d}s'.format(seconds)
return retVal
# 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 task in tasklist: for task in tasklist:
if task['user'] == current_user.nickname or current_user.role_admin(): if task['user'] == current_user.nickname or current_user.role_admin():
if task['formStarttime']: if task['formStarttime']:
task['starttime'] = format_datetime(task['formStarttime'], format='short', locale=web.get_locale()) task['starttime'] = format_datetime(task['formStarttime'], format='short', locale=get_locale())
# task2['formStarttime'] = ""
else: else:
if 'starttime' not in task: if 'starttime' not in task:
task['starttime'] = "" task['starttime'] = ""
if 'formRuntime' not in task:
task['runtime'] = ""
else:
task['runtime'] = format_runtime(task['formRuntime'])
# localize the task status # localize the task status
if isinstance( task['stat'], int ): if isinstance( task['stat'], int):
if task['stat'] == worker.STAT_WAITING: if task['stat'] == STAT_WAITING:
task['status'] = _(u'Waiting') task['status'] = _(u'Waiting')
elif task['stat'] == worker.STAT_FAIL: elif task['stat'] == STAT_FAIL:
task['status'] = _(u'Failed') task['status'] = _(u'Failed')
elif task['stat'] == worker.STAT_STARTED: elif task['stat'] == STAT_STARTED:
task['status'] = _(u'Started') task['status'] = _(u'Started')
elif task['stat'] == worker.STAT_FINISH_SUCCESS: elif task['stat'] == STAT_FINISH_SUCCESS:
task['status'] = _(u'Finished') task['status'] = _(u'Finished')
else: else:
task['status'] = _(u'Unknown Status') task['status'] = _(u'Unknown Status')
# localize the task type # localize the task type
if isinstance( task['taskType'], int ): if isinstance( task['taskType'], int):
if task['taskType'] == worker.TASK_EMAIL: if task['taskType'] == TASK_EMAIL:
task['taskMessage'] = _(u'E-mail: ') + task['taskMess'] task['taskMessage'] = _(u'E-mail: ') + task['taskMess']
elif task['taskType'] == worker.TASK_CONVERT: elif task['taskType'] == TASK_CONVERT:
task['taskMessage'] = _(u'Convert: ') + task['taskMess'] task['taskMessage'] = _(u'Convert: ') + task['taskMess']
elif task['taskType'] == worker.TASK_UPLOAD: elif task['taskType'] == TASK_UPLOAD:
task['taskMessage'] = _(u'Upload: ') + task['taskMess'] task['taskMessage'] = _(u'Upload: ') + task['taskMess']
elif task['taskType'] == worker.TASK_CONVERT_ANY: elif task['taskType'] == TASK_CONVERT_ANY:
task['taskMessage'] = _(u'Convert: ') + task['taskMess'] task['taskMessage'] = _(u'Convert: ') + task['taskMess']
else: else:
task['taskMessage'] = _(u'Unknown Task: ') + task['taskMess'] task['taskMessage'] = _(u'Unknown Task: ') + task['taskMess']
@@ -608,3 +715,207 @@ def render_task_status(tasklist):
renderedtasklist.append(task) renderedtasklist.append(task)
return renderedtasklist return renderedtasklist
# Language and content filters for displaying in the UI
def common_filters(allow_show_archived=False):
if not allow_show_archived:
archived_books = (
ub.session.query(ub.ArchivedBook)
.filter(ub.ArchivedBook.user_id == int(current_user.id))
.filter(ub.ArchivedBook.is_archived == True)
.all()
)
archived_book_ids = [archived_book.book_id for archived_book in archived_books]
archived_filter = db.Books.id.notin_(archived_book_ids)
else:
archived_filter = true()
if current_user.filter_language() != "all":
lang_filter = db.Books.languages.any(db.Languages.lang_code == current_user.filter_language())
else:
lang_filter = true()
negtags_list = current_user.list_denied_tags()
postags_list = current_user.list_allowed_tags()
neg_content_tags_filter = false() if negtags_list == [''] else db.Books.tags.any(db.Tags.name.in_(negtags_list))
pos_content_tags_filter = true() if postags_list == [''] else db.Books.tags.any(db.Tags.name.in_(postags_list))
if config.config_restricted_column:
pos_cc_list = current_user.allowed_column_value.split(',')
pos_content_cc_filter = true() if pos_cc_list == [''] else \
getattr(db.Books, 'custom_column_' + str(config.config_restricted_column)).\
any(db.cc_classes[config.config_restricted_column].value.in_(pos_cc_list))
neg_cc_list = current_user.denied_column_value.split(',')
neg_content_cc_filter = false() if neg_cc_list == [''] else \
getattr(db.Books, 'custom_column_' + str(config.config_restricted_column)).\
any(db.cc_classes[config.config_restricted_column].value.in_(neg_cc_list))
else:
pos_content_cc_filter = true()
neg_content_cc_filter = false()
return and_(lang_filter, pos_content_tags_filter, ~neg_content_tags_filter,
pos_content_cc_filter, ~neg_content_cc_filter, archived_filter)
def tags_filters():
negtags_list = current_user.list_denied_tags()
postags_list = current_user.list_allowed_tags()
neg_content_tags_filter = false() if negtags_list == [''] else db.Tags.name.in_(negtags_list)
pos_content_tags_filter = true() if postags_list == [''] else db.Tags.name.in_(postags_list)
return and_(pos_content_tags_filter, ~neg_content_tags_filter)
# return ~(false()) if postags_list == [''] else db.Tags.in_(postags_list)
# Creates for all stored languages a translated speaking name in the array for the UI
def speaking_language(languages=None):
if not languages:
languages = db.session.query(db.Languages).join(db.books_languages_link).join(db.Books)\
.filter(common_filters())\
.group_by(text('books_languages_link.lang_code')).all()
for lang in languages:
try:
cur_l = LC.parse(lang.lang_code)
lang.name = cur_l.get_language_name(get_locale())
except UnknownLocaleError:
lang.name = _(isoLanguages.get(part3=lang.lang_code).name)
return languages
# checks if domain is in database (including wildcards)
# example SELECT * FROM @TABLE WHERE 'abcdefg' LIKE Name;
# from https://code.luasoftware.com/tutorials/flask/execute-raw-sql-in-flask-sqlalchemy/
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);"
result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all()
if not len(result):
return False
sql = "SELECT * FROM registration WHERE (:domain LIKE domain and allow = 0);"
result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all()
return not len(result)
# Orders all Authors in the list according to authors sort
def order_authors(entry):
sort_authors = entry.author_sort.split('&')
authors_ordered = list()
error = False
for auth in sort_authors:
# ToDo: How to handle not found authorname
result = db.session.query(db.Authors).filter(db.Authors.sort == auth.lstrip().strip()).first()
if not result:
error = True
break
authors_ordered.append(result)
if not error:
entry.authors = authors_ordered
return entry
# Fill indexpage with all requested data from database
def fill_indexpage(page, database, db_filter, order, *join):
return fill_indexpage_with_archived_books(page, database, db_filter, order, False, *join)
def fill_indexpage_with_archived_books(page, database, db_filter, order, allow_show_archived, *join):
if current_user.show_detail_random():
randm = db.session.query(db.Books).filter(common_filters(allow_show_archived))\
.order_by(func.random()).limit(config.config_random_books)
else:
randm = false()
off = int(int(config.config_books_per_page) * (page - 1))
pagination = Pagination(page, config.config_books_per_page,
len(db.session.query(database).filter(db_filter)
.filter(common_filters(allow_show_archived)).all()))
entries = db.session.query(database).join(*join, isouter=True).filter(db_filter)\
.filter(common_filters(allow_show_archived))\
.order_by(*order).offset(off).limit(config.config_books_per_page).all()
for book in entries:
book = order_authors(book)
return entries, randm, pagination
def get_typeahead(database, query, replace=('', ''), tag_filter=true()):
query = query or ''
db.session.connection().connection.connection.create_function("lower", 1, lcase)
entries = db.session.query(database).filter(tag_filter).\
filter(func.lower(database.name).ilike("%" + query + "%")).all()
json_dumps = json.dumps([dict(name=r.name.replace(*replace)) for r in entries])
return json_dumps
# read search results from calibre-database and return it (function is used for feed and simple search
def get_search_results(term):
db.session.connection().connection.connection.create_function("lower", 1, lcase)
q = list()
authorterms = re.split("[, ]+", term)
for authorterm in authorterms:
q.append(db.Books.authors.any(func.lower(db.Authors.name).ilike("%" + authorterm + "%")))
db.Books.authors.any(func.lower(db.Authors.name).ilike("%" + term + "%"))
return db.session.query(db.Books).filter(common_filters()).filter(
or_(db.Books.tags.any(func.lower(db.Tags.name).ilike("%" + term + "%")),
db.Books.series.any(func.lower(db.Series.name).ilike("%" + term + "%")),
db.Books.authors.any(and_(*q)),
db.Books.publishers.any(func.lower(db.Publishers.name).ilike("%" + term + "%")),
func.lower(db.Books.title).ilike("%" + term + "%")
)).order_by(db.Books.sort).all()
def get_cc_columns():
tmpcc = db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all()
if config.config_columns_to_ignore:
cc = []
for col in tmpcc:
r = re.compile(config.config_columns_to_ignore)
if not r.match(col.name):
cc.append(col)
else:
cc = tmpcc
return cc
def get_download_link(book_id, book_format):
book_format = book_format.split(".")[0]
book = db.session.query(db.Books).filter(db.Books.id == book_id).filter(common_filters()).first()
if book:
data1 = db.session.query(db.Data).filter(db.Data.book == book.id)\
.filter(db.Data.format == book_format.upper()).first()
else:
abort(404)
if data1:
# collect downloaded books only for registered user and not for anonymous user
if current_user.is_authenticated:
ub.update_download(book_id, int(current_user.id))
file_name = book.title
if len(book.authors) > 0:
file_name = book.authors[0].name + '_' + file_name
file_name = get_valid_filename(file_name)
headers = Headers()
headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream")
headers["Content-Disposition"] = "attachment; filename=%s.%s; filename*=UTF-8''%s.%s" % (
quote(file_name.encode('utf-8')), book_format, quote(file_name.encode('utf-8')), book_format)
return do_download_file(book, book_format, data1, headers)
else:
abort(404)
def check_exists_book(authr, title):
db.session.connection().connection.connection.create_function("lower", 1, lcase)
q = list()
authorterms = re.split(r'\s*&\s*', authr)
for authorterm in authorterms:
q.append(db.Books.authors.any(func.lower(db.Authors.name).ilike("%" + authorterm + "%")))
return db.session.query(db.Books).filter(
and_(db.Books.authors.any(and_(*q)),
func.lower(db.Books.title).ilike("%" + title + "%")
)).first()
############### Database Helper functions
def lcase(s):
try:
return unidecode.unidecode(s.lower())
except Exception as e:
log.exception(e)
+31 -6
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
@@ -17,6 +16,11 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
from .iso_language_names import LANGUAGE_NAMES as _LANGUAGE_NAMES
try: try:
from iso639 import languages, __version__ from iso639 import languages, __version__
get = languages.get get = languages.get
@@ -30,14 +34,35 @@ except ImportError:
__version__ = "? (PyCountry)" __version__ = "? (PyCountry)"
def _copy_fields(l): def _copy_fields(l):
l.part1 = l.alpha_2 l.part1 = getattr(l, 'alpha_2', None)
l.part3 = l.alpha_3 l.part3 = getattr(l, 'alpha_3', None)
return l return l
def get(name=None, part1=None, part3=None): def get(name=None, part1=None, part3=None):
if (part3 is not None): if part3 is not None:
return _copy_fields(pyc_languages.get(alpha_3=part3)) return _copy_fields(pyc_languages.get(alpha_3=part3))
if (part1 is not None): if part1 is not None:
return _copy_fields(pyc_languages.get(alpha_2=part1)) return _copy_fields(pyc_languages.get(alpha_2=part1))
if (name is not None): if name is not None:
return _copy_fields(pyc_languages.get(name=name)) return _copy_fields(pyc_languages.get(name=name))
def get_language_names(locale):
return _LANGUAGE_NAMES.get(locale)
def get_language_name(locale, lang_code):
return get_language_names(locale)[lang_code]
def get_language_codes(locale, language_names, remainder=None):
language_names = set(x.strip().lower() for x in language_names if x)
for k, v in get_language_names(locale).items():
v = v.lower()
if v in language_names:
language_names.remove(v)
yield k
if remainder is not None:
remainder.extend(language_names)
File diff suppressed because it is too large Load Diff
+120
View File
@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
# apetresc, nanu-c, mutschler
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# custom jinja filters
from __future__ import division, print_function, unicode_literals
import datetime
import mimetypes
import re
from babel.dates import format_date
from flask import Blueprint, request, url_for
from flask_babel import get_locale
from flask_login import current_user
from . import logger
jinjia = Blueprint('jinjia', __name__)
log = logger.create()
# pagination links in jinja
@jinjia.app_template_filter('url_for_other_page')
def url_for_other_page(page):
args = request.view_args.copy()
args['page'] = page
return url_for(request.endpoint, **args)
# shortentitles to at longest nchar, shorten longer words if necessary
@jinjia.app_template_filter('shortentitle')
def shortentitle_filter(s, nchar=20):
text = s.split()
res = "" # result
suml = 0 # overall length
for line in text:
if suml >= 60:
res += '...'
break
# if word longer than 20 chars truncate line and append '...', otherwise add whole word to result
# string, and summarize total length to stop at chars given by nchar
if len(line) > nchar:
res += line[:(nchar-3)] + '[..] '
suml += nchar+3
else:
res += line + ' '
suml += len(line) + 1
return res.strip()
@jinjia.app_template_filter('mimetype')
def mimetype_filter(val):
return mimetypes.types_map.get('.' + val, 'application/octet-stream')
@jinjia.app_template_filter('formatdate')
def formatdate_filter(val):
try:
conformed_timestamp = re.sub(r"[:]|([-](?!((\d{2}[:]\d{2})|(\d{4}))$))", '', val)
formatdate = datetime.datetime.strptime(conformed_timestamp[:15], "%Y%m%d %H%M%S")
return format_date(formatdate, format='medium', locale=get_locale())
except AttributeError as e:
log.error('Babel error: %s, Current user locale: %s, Current User: %s', e,
current_user.locale,
current_user.nickname
)
return formatdate
@jinjia.app_template_filter('formatdateinput')
def format_date_input(val):
conformed_timestamp = re.sub(r"[:]|([-](?!((\d{2}[:]\d{2})|(\d{4}))$))", '', val)
date_obj = datetime.datetime.strptime(conformed_timestamp[:15], "%Y%m%d %H%M%S")
input_date = date_obj.isoformat().split('T', 1)[0] # Hack to support dates <1900
return '' if input_date == "0101-01-01" else input_date
@jinjia.app_template_filter('strftime')
def timestamptodate(date, fmt=None):
date = datetime.datetime.fromtimestamp(
int(date)/1000
)
native = date.replace(tzinfo=None)
if fmt:
time_format = fmt
else:
time_format = '%d %m %Y - %H:%S'
return native.strftime(time_format)
@jinjia.app_template_filter('yesno')
def yesno(value, yes, no):
return yes if value else no
'''@jinjia.app_template_filter('canread')
def canread(ext):
if isinstance(ext, db.Data):
ext = ext.format
return ext.lower() in EXTENSIONS_READER'''
+1092
View File
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 shavitmichael, OzzieIsaacs
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""This module is used to control authentication/authorization of Kobo sync requests.
This module also includes research notes into the auth protocol used by Kobo devices.
Log-in:
When first booting a Kobo device the user must sign into a Kobo (or affiliate) account.
Upon successful sign-in, the user is redirected to
https://auth.kobobooks.com/CrossDomainSignIn?id=<some id>
which serves the following response:
<script type='text/javascript'>
location.href='kobo://UserAuthenticated?userId=<redacted>&userKey<redacted>&email=<redacted>&returnUrl=https%3a%2f%2fwww.kobo.com';
</script>
And triggers the insertion of a userKey into the device's User table.
Together, the device's DeviceId and UserKey act as an *irrevocable* authentication
token to most (if not all) Kobo APIs. In fact, in most cases only the UserKey is
required to authorize the API call.
Changing Kobo password *does not* invalidate user keys! This is apparently a known
issue for a few years now https://www.mobileread.com/forums/showpost.php?p=3476851&postcount=13
(although this poster hypothesised that Kobo could blacklist a DeviceId, many endpoints
will still grant access given the userkey.)
Official Kobo Store Api authorization:
* For most of the endpoints we care about (sync, metadata, tags, etc), the userKey is
passed in the x-kobo-userkey header, and is sufficient to authorize the API call.
* Some endpoints (e.g: AnnotationService) instead make use of Bearer tokens pass through
an authorization header. To get a BearerToken, the device makes a POST request to the
v1/auth/device endpoint with the secret UserKey and the device's DeviceId.
* The book download endpoint passes an auth token as a URL param instead of a header.
Our implementation:
We pretty much ignore all of the above. To authenticate the user, we generate a random
and unique token that they append to the CalibreWeb Url when setting up the api_store
setting on the device.
Thus, every request from the device to the api_store will hit CalibreWeb with the
auth_token in the url (e.g: https://mylibrary.com/<auth_token>/v1/library/sync).
In addition, once authenticated we also set the login cookie on the response that will
be sent back for the duration of the session to authorize subsequent API calls (in
particular calls to non-Kobo specific endpoints such as the CalibreWeb book download).
"""
from binascii import hexlify
from datetime import datetime
from os import urandom
from flask import g, Blueprint, url_for, abort, request
from flask_login import login_user, login_required
from flask_babel import gettext as _
from . import logger, ub, lm
from .web import render_title_template
try:
from functools import wraps
except ImportError:
pass # We're not using Python 3
log = logger.create()
def register_url_value_preprocessor(kobo):
@kobo.url_value_preprocessor
def pop_auth_token(__, values):
g.auth_token = values.pop("auth_token")
def disable_failed_auth_redirect_for_blueprint(bp):
lm.blueprint_login_views[bp.name] = None
def get_auth_token():
if "auth_token" in g:
return g.get("auth_token")
else:
return None
def requires_kobo_auth(f):
@wraps(f)
def inner(*args, **kwargs):
auth_token = get_auth_token()
if auth_token is not None:
user = (
ub.session.query(ub.User)
.join(ub.RemoteAuthToken)
.filter(ub.RemoteAuthToken.auth_token == auth_token).filter(ub.RemoteAuthToken.token_type==1)
.first()
)
if user is not None:
login_user(user)
return f(*args, **kwargs)
log.debug("Received Kobo request without a recognizable auth token.")
return abort(401)
return inner
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
@kobo_auth.route("/generate_auth_token/<int:user_id>")
@login_required
def generate_auth_token(user_id):
host = ':'.join(request.host.rsplit(':')[0:-1])
if host.startswith('127.') or host.lower() == 'localhost' or host.startswith('[::ffff:7f'):
warning = _('PLease access calibre-web from non localhost to get valid api_endpoint for kobo device')
return render_title_template(
"generate_kobo_auth_url.html",
title=_(u"Kobo Setup"),
warning = warning
)
else:
# Invalidate any prevously generated Kobo Auth token for this user.
auth_token = ub.session.query(ub.RemoteAuthToken).filter(
ub.RemoteAuthToken.user_id == user_id
).filter(ub.RemoteAuthToken.token_type==1).first()
if not auth_token:
auth_token = ub.RemoteAuthToken()
auth_token.user_id = user_id
auth_token.expiration = datetime.max
auth_token.auth_token = (hexlify(urandom(16))).decode("utf-8")
auth_token.token_type = 1
ub.session.add(auth_token)
ub.session.commit()
return render_title_template(
"generate_kobo_auth_url.html",
title=_(u"Kobo Setup"),
kobo_auth_url=url_for(
"kobo.TopLevelEndpoint", auth_token=auth_token.auth_token, _external=True
),
warning = False
)
@kobo_auth.route("/deleteauthtoken/<int:user_id>")
@login_required
def delete_auth_token(user_id):
# Invalidate any prevously generated Kobo Auth token for this user.
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
.filter(ub.RemoteAuthToken.token_type==1).delete()
ub.session.commit()
return ""
+178
View File
@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2019 pwr
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import os
import sys
import inspect
import logging
from logging import Formatter, StreamHandler
from logging.handlers import RotatingFileHandler
from .constants import CONFIG_DIR as _CONFIG_DIR
ACCESS_FORMATTER_GEVENT = Formatter("%(message)s")
ACCESS_FORMATTER_TORNADO = Formatter("[%(asctime)s] %(message)s")
FORMATTER = Formatter("[%(asctime)s] %(levelname)5s {%(name)s:%(lineno)d} %(message)s")
DEFAULT_LOG_LEVEL = logging.INFO
DEFAULT_LOG_FILE = os.path.join(_CONFIG_DIR, "calibre-web.log")
DEFAULT_ACCESS_LOG = os.path.join(_CONFIG_DIR, "access.log")
LOG_TO_STDERR = '/dev/stderr'
LOG_TO_STDOUT = '/dev/stdout'
logging.addLevelName(logging.WARNING, "WARN")
logging.addLevelName(logging.CRITICAL, "CRIT")
def get(name=None):
return logging.getLogger(name)
def create():
parent_frame = inspect.stack(0)[1]
if hasattr(parent_frame, 'frame'):
parent_frame = parent_frame.frame
else:
parent_frame = parent_frame[0]
parent_module = inspect.getmodule(parent_frame)
return get(parent_module.__name__)
def is_debug_enabled():
return logging.root.level <= logging.DEBUG
def is_info_enabled(logger):
return logging.getLogger(logger).level <= logging.INFO
def get_level_name(level):
return logging.getLevelName(level)
def is_valid_logfile(file_path):
if file_path == LOG_TO_STDERR or file_path == LOG_TO_STDOUT:
return True
if not file_path:
return True
if os.path.isdir(file_path):
return False
log_dir = os.path.dirname(file_path)
return (not log_dir) or os.path.isdir(log_dir)
def _absolute_log_file(log_file, default_log_file):
if log_file:
if not os.path.dirname(log_file):
log_file = os.path.join(_CONFIG_DIR, log_file)
return os.path.abspath(log_file)
return default_log_file
def get_logfile(log_file):
return _absolute_log_file(log_file, DEFAULT_LOG_FILE)
def get_accesslogfile(log_file):
return _absolute_log_file(log_file, DEFAULT_ACCESS_LOG)
def setup(log_file, log_level=None):
'''
Configure the logging output.
May be called multiple times.
'''
log_level = log_level or DEFAULT_LOG_LEVEL
logging.getLogger(__package__).setLevel(log_level)
r = logging.root
if log_level >= logging.INFO or os.environ.get('FLASK_DEBUG'):
# avoid spamming the log with debug messages from libraries
r.setLevel(log_level)
# Otherwise name get's destroyed on windows
if log_file != LOG_TO_STDERR and log_file != LOG_TO_STDOUT:
log_file = _absolute_log_file(log_file, DEFAULT_LOG_FILE)
previous_handler = r.handlers[0] if r.handlers else None
if previous_handler:
# if the log_file has not changed, don't create a new handler
if getattr(previous_handler, 'baseFilename', None) == log_file:
return
logging.debug("logging to %s level %s", log_file, r.level)
if log_file == LOG_TO_STDERR or log_file == LOG_TO_STDOUT:
if log_file == LOG_TO_STDOUT:
file_handler = StreamHandler(sys.stdout)
file_handler.baseFilename = log_file
else:
file_handler = StreamHandler(sys.stderr)
file_handler.baseFilename = log_file
else:
try:
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2)
except IOError:
if log_file == DEFAULT_LOG_FILE:
raise
file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=50000, backupCount=2)
file_handler.setFormatter(FORMATTER)
for h in r.handlers:
r.removeHandler(h)
h.close()
r.addHandler(file_handler)
def create_access_log(log_file, log_name, formatter):
'''
One-time configuration for the web server's access log.
'''
log_file = _absolute_log_file(log_file, DEFAULT_ACCESS_LOG)
logging.debug("access log: %s", log_file)
access_log = logging.getLogger(log_name)
access_log.propagate = False
access_log.setLevel(logging.INFO)
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2)
file_handler.setFormatter(formatter)
access_log.addHandler(file_handler)
return access_log
# Enable logging of smtp lib debug output
class StderrLogger(object):
def __init__(self, name=None):
self.log = get(name or self.__class__.__name__)
self.buffer = ''
def write(self, message):
try:
if message == '\n':
self.log.debug(self.buffer.replace('\n', '\\n'))
self.buffer = ''
else:
self.buffer += message
except Exception:
self.log.debug("Logging Error")
# default configuration, before application settings are applied
setup(LOG_TO_STDERR, logging.DEBUG if os.environ.get('FLASK_DEBUG') else DEFAULT_LOG_LEVEL)
+167
View File
@@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 jim3ma
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
from __future__ import division, print_function, unicode_literals
from flask import session
try:
from flask_dance.consumer.backend.sqla import SQLAlchemyBackend, first, _get_real_user
from sqlalchemy.orm.exc import NoResultFound
backend_resultcode = False # prevent storing values with this resultcode
except ImportError:
# fails on flask-dance >1.3, due to renaming
try:
from flask_dance.consumer.storage.sqla import SQLAlchemyStorage as SQLAlchemyBackend
from flask_dance.consumer.storage.sqla import first, _get_real_user
from sqlalchemy.orm.exc import NoResultFound
backend_resultcode = True # prevent storing values with this resultcode
except ImportError:
pass
try:
class OAuthBackend(SQLAlchemyBackend):
"""
Stores and retrieves OAuth tokens using a relational database through
the `SQLAlchemy`_ ORM.
.. _SQLAlchemy: http://www.sqlalchemy.org/
"""
def __init__(self, model, session, provider_id,
user=None, user_id=None, user_required=None, anon_user=None,
cache=None):
self.provider_id = provider_id
super(OAuthBackend, self).__init__(model, session, user, user_id, user_required, anon_user, cache)
def get(self, blueprint, user=None, user_id=None):
if self.provider_id + '_oauth_token' in session and session[self.provider_id + '_oauth_token'] != '':
return session[self.provider_id + '_oauth_token']
# check cache
cache_key = self.make_cache_key(blueprint=blueprint, user=user, user_id=user_id)
token = self.cache.get(cache_key)
if token:
return token
# if not cached, make database queries
query = (
self.session.query(self.model)
.filter_by(provider=self.provider_id)
)
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
u = first(_get_real_user(ref, self.anon_user)
for ref in (user, self.user, blueprint.config.get("user")))
use_provider_user_id = False
if self.provider_id + '_oauth_user_id' in session and session[self.provider_id + '_oauth_user_id'] != '':
query = query.filter_by(provider_user_id=session[self.provider_id + '_oauth_user_id'])
use_provider_user_id = True
if self.user_required and not u and not uid and not use_provider_user_id:
# raise ValueError("Cannot get OAuth token without an associated user")
return None
# check for user ID
if hasattr(self.model, "user_id") and uid:
query = query.filter_by(user_id=uid)
# check for user (relationship property)
elif hasattr(self.model, "user") and u:
query = query.filter_by(user=u)
# if we have the property, but not value, filter by None
elif hasattr(self.model, "user_id"):
query = query.filter_by(user_id=None)
# run query
try:
token = query.one().token
except NoResultFound:
token = None
# cache the result
self.cache.set(cache_key, token)
return token
def set(self, blueprint, token, user=None, user_id=None):
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
u = first(_get_real_user(ref, self.anon_user)
for ref in (user, self.user, blueprint.config.get("user")))
if self.user_required and not u and not uid:
raise ValueError("Cannot set OAuth token without an associated user")
# if there was an existing model, delete it
existing_query = (
self.session.query(self.model)
.filter_by(provider=self.provider_id)
)
# check for user ID
has_user_id = hasattr(self.model, "user_id")
if has_user_id and uid:
existing_query = existing_query.filter_by(user_id=uid)
# check for user (relationship property)
has_user = hasattr(self.model, "user")
if has_user and u:
existing_query = existing_query.filter_by(user=u)
# queue up delete query -- won't be run until commit()
existing_query.delete()
# create a new model for this token
kwargs = {
"provider": self.provider_id,
"token": token,
}
if has_user_id and uid:
kwargs["user_id"] = uid
if has_user and u:
kwargs["user"] = u
self.session.add(self.model(**kwargs))
# commit to delete and add simultaneously
self.session.commit()
# invalidate cache
self.cache.delete(self.make_cache_key(
blueprint=blueprint, user=user, user_id=user_id
))
def delete(self, blueprint, user=None, user_id=None):
query = (
self.session.query(self.model)
.filter_by(provider=self.provider_id)
)
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
u = first(_get_real_user(ref, self.anon_user)
for ref in (user, self.user, blueprint.config.get("user")))
if self.user_required and not u and not uid:
raise ValueError("Cannot delete OAuth token without an associated user")
# check for user ID
if hasattr(self.model, "user_id") and uid:
query = query.filter_by(user_id=uid)
# check for user (relationship property)
elif hasattr(self.model, "user") and u:
query = query.filter_by(user=u)
# if we have the property, but not value, filter by None
elif hasattr(self.model, "user_id"):
query = query.filter_by(user_id=None)
# run query
query.delete()
self.session.commit()
# invalidate cache
self.cache.delete(self.make_cache_key(
blueprint=blueprint, user=user, user_id=user_id,
))
except Exception:
pass
+358
View File
@@ -0,0 +1,358 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
# apetresc, nanu-c, mutschler
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
from __future__ import division, print_function, unicode_literals
import json
from functools import wraps
from flask import session, request, make_response, abort
from flask import Blueprint, flash, redirect, url_for
from flask_babel import gettext as _
from flask_dance.consumer import oauth_authorized, oauth_error
from flask_dance.contrib.github import make_github_blueprint, github
from flask_dance.contrib.google import make_google_blueprint, google
from flask_login import login_user, current_user
from sqlalchemy.orm.exc import NoResultFound
from . import constants, logger, config, app, ub
from .web import login_required
from .oauth import OAuthBackend, backend_resultcode
oauth_check = {}
oauth = Blueprint('oauth', __name__)
log = logger.create()
def oauth_required(f):
@wraps(f)
def inner(*args, **kwargs):
if config.config_login_type == constants.LOGIN_OAUTH:
return f(*args, **kwargs)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
data = {'status': 'error', 'message': 'Not Found'}
response = make_response(json.dumps(data, ensure_ascii=False))
response.headers["Content-Type"] = "application/json; charset=utf-8"
return response, 404
abort(404)
return inner
def register_oauth_blueprint(cid, show_name):
oauth_check[cid] = show_name
def register_user_with_oauth(user=None):
all_oauth = {}
for oauth_key in oauth_check.keys():
if str(oauth_key) + '_oauth_user_id' in session and session[str(oauth_key) + '_oauth_user_id'] != '':
all_oauth[oauth_key] = oauth_check[oauth_key]
if len(all_oauth.keys()) == 0:
return
if user is None:
flash(_(u"Register with %(provider)s", provider=", ".join(list(all_oauth.values()))), category="success")
else:
for oauth_key in all_oauth.keys():
# Find this OAuth token in the database, or create it
query = ub.session.query(ub.OAuth).filter_by(
provider=oauth_key,
provider_user_id=session[str(oauth_key) + "_oauth_user_id"],
)
try:
oauth_key = query.one()
oauth_key.user_id = user.id
except NoResultFound:
# no found, return error
return
try:
ub.session.commit()
except Exception as e:
log.exception(e)
ub.session.rollback()
def logout_oauth_user():
for oauth_key in oauth_check.keys():
if str(oauth_key) + '_oauth_user_id' in session:
session.pop(str(oauth_key) + '_oauth_user_id')
if ub.oauth_support:
oauthblueprints = []
if not ub.session.query(ub.OAuthProvider).count():
oauthProvider = ub.OAuthProvider()
oauthProvider.provider_name = "github"
oauthProvider.active = False
ub.session.add(oauthProvider)
ub.session.commit()
oauthProvider = ub.OAuthProvider()
oauthProvider.provider_name = "google"
oauthProvider.active = False
ub.session.add(oauthProvider)
ub.session.commit()
oauth_ids = ub.session.query(ub.OAuthProvider).all()
ele1 = dict(provider_name='github',
id=oauth_ids[0].id,
active=oauth_ids[0].active,
oauth_client_id=oauth_ids[0].oauth_client_id,
scope=None,
oauth_client_secret=oauth_ids[0].oauth_client_secret,
obtain_link='https://github.com/settings/developers')
ele2 = dict(provider_name='google',
id=oauth_ids[1].id,
active=oauth_ids[1].active,
scope=["https://www.googleapis.com/auth/plus.me", "https://www.googleapis.com/auth/userinfo.email"],
oauth_client_id=oauth_ids[1].oauth_client_id,
oauth_client_secret=oauth_ids[1].oauth_client_secret,
obtain_link='https://github.com/settings/developers')
oauthblueprints.append(ele1)
oauthblueprints.append(ele2)
for element in oauthblueprints:
if element['provider_name'] == 'github':
blueprint_func = make_github_blueprint
else:
blueprint_func = make_google_blueprint
blueprint = blueprint_func(
client_id=element['oauth_client_id'],
client_secret=element['oauth_client_secret'],
redirect_to="oauth."+element['provider_name']+"_login",
scope=element['scope']
)
element['blueprint'] = blueprint
element['blueprint'].backend = OAuthBackend(ub.OAuth, ub.session, str(element['id']),
user=current_user, user_required=True)
app.register_blueprint(blueprint, url_prefix="/login")
if element['active']:
register_oauth_blueprint(element['id'], element['provider_name'])
@oauth_authorized.connect_via(oauthblueprints[0]['blueprint'])
def github_logged_in(blueprint, token):
if not token:
flash(_(u"Failed to log in with GitHub."), category="error")
return False
resp = blueprint.session.get("/user")
if not resp.ok:
flash(_(u"Failed to fetch user info from GitHub."), category="error")
return False
github_info = resp.json()
github_user_id = str(github_info["id"])
return oauth_update_token(str(oauthblueprints[0]['id']), token, github_user_id)
@oauth_authorized.connect_via(oauthblueprints[1]['blueprint'])
def google_logged_in(blueprint, token):
if not token:
flash(_(u"Failed to log in with Google."), category="error")
return False
resp = blueprint.session.get("/oauth2/v2/userinfo")
if not resp.ok:
flash(_(u"Failed to fetch user info from Google."), category="error")
return False
google_info = resp.json()
google_user_id = str(google_info["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,
)
try:
ub.session.add(oauth_entry)
ub.session.commit()
except Exception as e:
log.exception(e)
ub.session.rollback()
# 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.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.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=oauth_check[provider]), category="error")
return redirect(url_for('web.profile'))
# notify on OAuth provider error
@oauth_error.connect_via(oauthblueprints[0]['blueprint'])
def github_error(blueprint, error, error_description=None, error_uri=None):
msg = (
u"OAuth error from {name}! "
u"error={error} description={description} uri={uri}"
).format(
name=blueprint.name,
error=error,
description=error_description,
uri=error_uri,
) # ToDo: Translate
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'])
def google_error(blueprint, error, error_description=None, error_uri=None):
msg = (
u"OAuth error from {name}! "
u"error={error} description={description} uri={uri}"
).format(
name=blueprint.name,
error=error,
description=error_description,
uri=error_uri,
) # ToDo: Translate
flash(msg, category="error")
@oauth.route('/unlink/google', methods=["GET"])
@login_required
def google_login_unlink():
return unlink_oauth(oauthblueprints[1]['blueprint'].name)
+444
View File
@@ -0,0 +1,444 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
# apetresc, nanu-c, mutschler
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import sys
import datetime
from functools import wraps
from flask import Blueprint, request, render_template, Response, g, make_response
from flask_login import current_user
from sqlalchemy.sql.expression import func, text, or_, and_
from werkzeug.security import check_password_hash
from . import constants, logger, config, db, ub, services, get_locale, isoLanguages
from .helper import fill_indexpage, get_download_link, get_book_cover, speaking_language
from .pagination import Pagination
from .web import common_filters, get_search_results, render_read_books, download_required
from flask_babel import gettext as _
from babel import Locale as LC
from babel.core import UnknownLocaleError
opds = Blueprint('opds', __name__)
log = logger.create()
def requires_basic_auth_if_no_ano(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if config.config_anonbrowse != 1:
if not auth or auth.type != 'basic' or not check_auth(auth.username, auth.password):
return authenticate()
return f(*args, **kwargs)
if config.config_login_type == constants.LOGIN_LDAP and services.ldap:
return services.ldap.basic_auth_required(f)
return decorated
class FeedObject:
def __init__(self, rating_id, rating_name):
self.rating_id = rating_id
self.rating_name = rating_name
@property
def id(self):
return self.rating_id
@property
def name(self):
return self.rating_name
@opds.route("/opds/")
@opds.route("/opds")
@requires_basic_auth_if_no_ano
def feed_index():
return render_xml_template('index.xml')
@opds.route("/opds/osd")
@requires_basic_auth_if_no_ano
def feed_osd():
return render_xml_template('osd.xml', lang='en-EN')
@opds.route("/opds/search", defaults={'query': ""})
@opds.route("/opds/search/<query>")
@requires_basic_auth_if_no_ano
def feed_cc_search(query):
return feed_search(query.strip())
@opds.route("/opds/search", methods=["GET"])
@requires_basic_auth_if_no_ano
def feed_normal_search():
return feed_search(request.args.get("query").strip())
@opds.route("/opds/new")
@requires_basic_auth_if_no_ano
def feed_new():
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
db.Books, True, [db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/discover")
@requires_basic_auth_if_no_ano
def feed_discover():
entries = db.session.query(db.Books).filter(common_filters()).order_by(func.random())\
.limit(config.config_books_per_page)
pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page))
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/rated")
@requires_basic_auth_if_no_ano
def feed_best_rated():
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
db.Books, db.Books.ratings.any(db.Ratings.rating > 9),
[db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/hot")
@requires_basic_auth_if_no_ano
def feed_hot():
off = request.args.get("offset") or 0
all_books = ub.session.query(ub.Downloads, func.count(ub.Downloads.book_id)).order_by(
func.count(ub.Downloads.book_id).desc()).group_by(ub.Downloads.book_id)
hot_books = all_books.offset(off).limit(config.config_books_per_page)
entries = list()
for book in hot_books:
downloadBook = db.session.query(db.Books).filter(db.Books.id == book.Downloads.book_id).first()
if downloadBook:
entries.append(
db.session.query(db.Books).filter(common_filters())
.filter(db.Books.id == book.Downloads.book_id).first()
)
else:
ub.delete_download(book.Downloads.book_id)
# ub.session.query(ub.Downloads).filter(book.Downloads.book_id == ub.Downloads.book_id).delete()
# ub.session.commit()
numBooks = entries.__len__()
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1),
config.config_books_per_page, numBooks)
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/author")
@requires_basic_auth_if_no_ano
def feed_authorindex():
off = request.args.get("offset") or 0
entries = db.session.query(db.Authors).join(db.books_authors_link).join(db.Books).filter(common_filters())\
.group_by(text('books_authors_link.author')).order_by(db.Authors.sort).limit(config.config_books_per_page)\
.offset(off)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(db.session.query(db.Authors).all()))
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination)
@opds.route("/opds/author/<int:book_id>")
@requires_basic_auth_if_no_ano
def feed_author(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
db.Books,
db.Books.authors.any(db.Authors.id == book_id),
[db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/publisher")
@requires_basic_auth_if_no_ano
def feed_publisherindex():
off = request.args.get("offset") or 0
entries = db.session.query(db.Publishers).join(db.books_publishers_link).join(db.Books).filter(common_filters())\
.group_by(text('books_publishers_link.publisher')).order_by(db.Publishers.sort)\
.limit(config.config_books_per_page).offset(off)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(db.session.query(db.Publishers).all()))
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_publisher', pagination=pagination)
@opds.route("/opds/publisher/<int:book_id>")
@requires_basic_auth_if_no_ano
def feed_publisher(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
db.Books,
db.Books.publishers.any(db.Publishers.id == book_id),
[db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/category")
@requires_basic_auth_if_no_ano
def feed_categoryindex():
off = request.args.get("offset") or 0
entries = db.session.query(db.Tags).join(db.books_tags_link).join(db.Books).filter(common_filters())\
.group_by(text('books_tags_link.tag')).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,
len(db.session.query(db.Tags).all()))
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination)
@opds.route("/opds/category/<int:book_id>")
@requires_basic_auth_if_no_ano
def feed_category(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
db.Books,
db.Books.tags.any(db.Tags.id == book_id),
[db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/series")
@requires_basic_auth_if_no_ano
def feed_seriesindex():
off = request.args.get("offset") or 0
entries = db.session.query(db.Series).join(db.books_series_link).join(db.Books).filter(common_filters())\
.group_by(text('books_series_link.series')).order_by(db.Series.sort).offset(off).all()
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(db.session.query(db.Series).all()))
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination)
@opds.route("/opds/series/<int:book_id>")
@requires_basic_auth_if_no_ano
def feed_series(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
db.Books,
db.Books.series.any(db.Series.id == book_id),
[db.Books.series_index])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/ratings")
@requires_basic_auth_if_no_ano
def feed_ratingindex():
off = request.args.get("offset") or 0
entries = db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
(db.Ratings.rating / 2).label('name')) \
.join(db.books_ratings_link).join(db.Books).filter(common_filters()) \
.group_by(text('books_ratings_link.rating')).order_by(db.Ratings.rating).all()
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(entries))
element = list()
for entry in entries:
element.append(FeedObject(entry[0].id, "{} Stars".format(entry.name)))
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination)
@opds.route("/opds/ratings/<book_id>")
@requires_basic_auth_if_no_ano
def feed_ratings(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
db.Books,
db.Books.ratings.any(db.Ratings.id == book_id),
[db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/formats")
@requires_basic_auth_if_no_ano
def feed_formatindex():
off = request.args.get("offset") or 0
entries = db.session.query(db.Data).join(db.Books).filter(common_filters()) \
.group_by(db.Data.format).order_by(db.Data.format).all()
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(entries))
element = list()
for entry in entries:
element.append(FeedObject(entry.format, entry.format))
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_format', pagination=pagination)
@opds.route("/opds/formats/<book_id>")
@requires_basic_auth_if_no_ano
def feed_format(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
db.Books,
db.Books.data.any(db.Data.format == book_id.upper()),
[db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/language")
@opds.route("/opds/language/")
@requires_basic_auth_if_no_ano
def feed_languagesindex():
off = request.args.get("offset") or 0
if current_user.filter_language() == u"all":
languages = speaking_language()
else:
try:
cur_l = LC.parse(current_user.filter_language())
except UnknownLocaleError:
cur_l = None
languages = db.session.query(db.Languages).filter(
db.Languages.lang_code == current_user.filter_language()).all()
if cur_l:
languages[0].name = cur_l.get_language_name(get_locale())
else:
languages[0].name = _(isoLanguages.get(part3=languages[0].lang_code).name)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(languages))
return render_xml_template('feed.xml', listelements=languages, folder='opds.feed_languages', pagination=pagination)
@opds.route("/opds/language/<int:book_id>")
@requires_basic_auth_if_no_ano
def feed_languages(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
db.Books,
db.Books.languages.any(db.Languages.id == book_id),
[db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/shelfindex")
@requires_basic_auth_if_no_ano
def feed_shelfindex():
off = request.args.get("offset") or 0
shelf = g.shelves_access
number = len(shelf)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
number)
return render_xml_template('feed.xml', listelements=shelf, folder='opds.feed_shelf', pagination=pagination)
@opds.route("/opds/shelf/<int:book_id>")
@requires_basic_auth_if_no_ano
def feed_shelf(book_id):
off = request.args.get("offset") or 0
if current_user.is_anonymous:
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1,
ub.Shelf.id == book_id).first()
else:
shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id),
ub.Shelf.id == book_id),
and_(ub.Shelf.is_public == 1,
ub.Shelf.id == book_id))).first()
result = list()
# user is allowed to access shelf
if shelf:
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == book_id).order_by(
ub.BookShelf.order.asc()).all()
for book in books_in_shelf:
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
result.append(cur_book)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(result))
return render_xml_template('feed.xml', entries=result, pagination=pagination)
@opds.route("/opds/download/<book_id>/<book_format>/")
@requires_basic_auth_if_no_ano
@download_required
def opds_download_link(book_id, book_format):
return get_download_link(book_id, book_format.lower())
@opds.route("/ajax/book/<string:uuid>/<library>")
@opds.route("/ajax/book/<string:uuid>", defaults={'library': ""})
@requires_basic_auth_if_no_ano
def get_metadata_calibre_companion(uuid, library):
entry = db.session.query(db.Books).filter(db.Books.uuid.like("%" + uuid + "%")).first()
if entry is not None:
js = render_template('json.txt', entry=entry)
response = make_response(js)
response.headers["Content-Type"] = "application/json; charset=utf-8"
return response
else:
return ""
def feed_search(term):
if term:
term = term.strip().lower()
entries = get_search_results(term)
entriescount = len(entries) if len(entries) > 0 else 1
pagination = Pagination(1, entriescount, entriescount)
return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination)
else:
return render_xml_template('feed.xml', searchterm="")
def check_auth(username, password):
if sys.version_info.major == 3:
username = username.encode('windows-1252')
user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) ==
username.decode('utf-8').lower()).first()
return bool(user and check_password_hash(str(user.password), password))
def authenticate():
return Response(
'Could not verify your access level for that URL.\n'
'You have to login with proper credentials', 401,
{'WWW-Authenticate': 'Basic realm="Login Required"'})
def render_xml_template(*args, **kwargs):
# ToDo: return time in current timezone similar to %z
currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00")
xml = render_template(current_time=currtime, instance=config.config_calibre_web_title, *args, **kwargs)
response = make_response(xml)
response.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
return response
@opds.route("/opds/thumb_240_240/<book_id>")
@opds.route("/opds/cover_240_240/<book_id>")
@opds.route("/opds/cover_90_90/<book_id>")
@opds.route("/opds/cover/<book_id>")
@requires_basic_auth_if_no_ano
def feed_get_cover(book_id):
return get_book_cover(book_id)
@opds.route("/opds/readbooks")
@requires_basic_auth_if_no_ano
def feed_read_books():
off = request.args.get("offset") or 0
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
return render_xml_template('feed.xml', entries=result, pagination=pagination)
@opds.route("/opds/unreadbooks")
@requires_basic_auth_if_no_ano
def feed_unread_books():
off = request.args.get("offset") or 0
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)
return render_xml_template('feed.xml', entries=result, pagination=pagination)
+76
View File
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
# apetresc, nanu-c, mutschler
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
from math import ceil
# simple pagination for the feed
class Pagination(object):
def __init__(self, page, per_page, total_count):
self.page = int(page)
self.per_page = int(per_page)
self.total_count = int(total_count)
@property
def next_offset(self):
return int(self.page * self.per_page)
@property
def previous_offset(self):
return int((self.page - 2) * self.per_page)
@property
def last_offset(self):
last = int(self.total_count) - int(self.per_page)
if last < 0:
last = 0
return int(last)
@property
def pages(self):
return int(ceil(self.total_count / float(self.per_page)))
@property
def has_prev(self):
return self.page > 1
@property
def has_next(self):
return self.page < self.pages
# right_edge: last right_edges count of all pages are shown as number, means, if 10 pages are paginated -> 9,10 shwn
# left_edge: first left_edges count of all pages are shown as number -> 1,2 shwn
# left_current: left_current count below current page are shown as number, means if current page 5 -> 3,4 shwn
# left_current: right_current count above current page are shown as number, means if current page 5 -> 6,7 shwn
def iter_pages(self, left_edge=2, left_current=2,
right_current=4, right_edge=2):
last = 0
left_current = self.page - left_current - 1
right_current = self.page + right_current + 1
right_edge = self.pages - right_edge
for num in range(1, (self.pages + 1)):
if num <= left_edge or (left_current < num < right_current) or num > right_edge:
if last + 1 != num:
yield None
yield num
last = num
+2 -1
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Flask License # Flask License
@@ -28,10 +27,12 @@
# http://flask.pocoo.org/snippets/62/ # http://flask.pocoo.org/snippets/62/
from __future__ import division, print_function, unicode_literals
try: try:
from urllib.parse import urlparse, urljoin from urllib.parse import urlparse, urljoin
except ImportError: except ImportError:
from urlparse import urlparse, urljoin from urlparse import urlparse, urljoin
from flask import request, url_for, redirect from flask import request, url_for, redirect
+10 -1
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Flask License # Flask License
@@ -37,6 +36,9 @@
# #
# Inspired by http://flask.pocoo.org/snippets/35/ # Inspired by http://flask.pocoo.org/snippets/35/
from __future__ import division, print_function, unicode_literals
class ReverseProxied(object): class ReverseProxied(object):
"""Wrap the application in this middleware and configure the """Wrap the application in this middleware and configure the
front-end server to add these headers, to let you quietly bind front-end server to add these headers, to let you quietly bind
@@ -57,10 +59,13 @@ class ReverseProxied(object):
def __init__(self, application): def __init__(self, application):
self.app = application self.app = application
self.proxied = False
def __call__(self, environ, start_response): def __call__(self, environ, start_response):
self.proxied = False
script_name = environ.get('HTTP_X_SCRIPT_NAME', '') script_name = environ.get('HTTP_X_SCRIPT_NAME', '')
if script_name: if script_name:
self.proxied = True
environ['SCRIPT_NAME'] = script_name environ['SCRIPT_NAME'] = script_name
path_info = environ.get('PATH_INFO', '') path_info = environ.get('PATH_INFO', '')
if path_info and path_info.startswith(script_name): if path_info and path_info.startswith(script_name):
@@ -73,3 +78,7 @@ class ReverseProxied(object):
if servr: if servr:
environ['HTTP_HOST'] = servr environ['HTTP_HOST'] = servr
return self.app(environ, start_response) return self.app(environ, start_response)
@property
def is_proxied(self):
return self.proxied
+158 -116
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
@@ -17,148 +16,191 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
from socket import error as SocketError
import sys import sys
import os import os
import errno
import signal import signal
import web import socket
try: try:
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
from gevent.pool import Pool from gevent.pool import Pool
from gevent import __version__ as geventVersion from gevent import __version__ as _version
gevent_present = True VERSION = 'Gevent ' + _version
_GEVENT = True
except ImportError: except ImportError:
from tornado.wsgi import WSGIContainer from tornado.wsgi import WSGIContainer
from tornado.httpserver import HTTPServer from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop from tornado.ioloop import IOLoop
from tornado import version as tornadoVersion from tornado import version as _version
gevent_present = False VERSION = 'Tornado ' + _version
_GEVENT = False
from . import logger
log = logger.create()
class server:
wsgiserver = None def _readable_listen_address(address, port):
restart= False if ':' in address:
address = "[" + address + "]"
return '%s:%s' % (address, port)
class WebServer(object):
def __init__(self): def __init__(self):
signal.signal(signal.SIGINT, self.killServer) signal.signal(signal.SIGINT, self._killServer)
signal.signal(signal.SIGTERM, self.killServer) signal.signal(signal.SIGTERM, self._killServer)
self.wsgiserver = None
self.access_logger = None
self.restart = False
self.app = None
self.listen_address = None
self.listen_port = None
self.unix_socket_file = None
self.ssl_args = None
def init_app(self, application, config):
self.app = application
self.listen_address = config.get_config_ipaddress()
self.listen_port = config.config_port
if config.config_access_log:
log_name = "gevent.access" if _GEVENT else "tornado.access"
formatter = logger.ACCESS_FORMATTER_GEVENT if _GEVENT else logger.ACCESS_FORMATTER_TORNADO
self.access_logger = logger.create_access_log(config.config_access_logfile, log_name, formatter)
else:
if not _GEVENT:
logger.get('tornado.access').disabled = True
certfile_path = config.get_config_certfile()
keyfile_path = config.get_config_keyfile()
if certfile_path and keyfile_path:
if os.path.isfile(certfile_path) and os.path.isfile(keyfile_path):
self.ssl_args = dict(certfile=certfile_path, keyfile=keyfile_path)
else:
log.warning('The specified paths for the ssl certificate file and/or key file seem to be broken. '
'Ignoring ssl.')
log.warning('Cert path: %s', certfile_path)
log.warning('Key path: %s', keyfile_path)
def _make_gevent_unix_socket(self, socket_file):
# the socket file must not exist prior to bind()
if os.path.exists(socket_file):
# avoid nuking regular files and symbolic links (could be a mistype or security issue)
if os.path.isfile(socket_file) or os.path.islink(socket_file):
raise OSError(errno.EEXIST, os.strerror(errno.EEXIST), socket_file)
os.remove(socket_file)
unix_sock = WSGIServer.get_listener(socket_file, family=socket.AF_UNIX)
self.unix_socket_file = socket_file
# ensure current user and group have r/w permissions, no permissions for other users
# this way the socket can be shared in a semi-secure manner
# between the user running calibre-web and the user running the fronting webserver
os.chmod(socket_file, 0o660)
return unix_sock
def _make_gevent_socket(self):
if os.name != 'nt':
unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET")
if unix_socket_file:
return self._make_gevent_unix_socket(unix_socket_file), "unix:" + unix_socket_file
if self.listen_address:
return (self.listen_address, self.listen_port), None
if os.name == 'nt':
self.listen_address = '0.0.0.0'
return (self.listen_address, self.listen_port), None
def start_gevent(self):
try: try:
ssl_args = dict() address = ('::', self.listen_port)
certfile_path = web.ub.config.get_config_certfile() sock = WSGIServer.get_listener(address, family=socket.AF_INET6)
keyfile_path = web.ub.config.get_config_keyfile() except socket.error as ex:
if certfile_path and keyfile_path: log.error('%s', ex)
if os.path.isfile(certfile_path) and os.path.isfile(keyfile_path): log.warning('Unable to listen on "", trying on IPv4 only...')
ssl_args = {"certfile": certfile_path, address = ('', self.listen_port)
"keyfile": keyfile_path} sock = WSGIServer.get_listener(address, family=socket.AF_INET)
else:
web.app.logger.info('The specified paths for the ssl certificate file and/or key file seem to be broken. Ignoring ssl. Cert path: %s | Key path: %s' % (certfile_path, keyfile_path)) return sock, _readable_listen_address(*address)
if os.name == 'nt':
self.wsgiserver= WSGIServer(('0.0.0.0', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args) def _start_gevent(self):
else: ssl_args = self.ssl_args or {}
self.wsgiserver = WSGIServer(('', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args)
web.py3_gevent_link = self.wsgiserver try:
sock, output = self._make_gevent_socket()
if output is None:
output = _readable_listen_address(self.listen_address, self.listen_port)
log.info('Starting Gevent server on %s', output)
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, spawn=Pool(), **ssl_args)
self.wsgiserver.serve_forever() self.wsgiserver.serve_forever()
finally:
if self.unix_socket_file:
os.remove(self.unix_socket_file)
self.unix_socket_file = None
except SocketError: def _start_tornado(self):
try: if os.name == 'nt' and sys.version_info > (3, 7):
web.app.logger.info('Unable to listen on \'\', trying on IPv4 only...') import asyncio
self.wsgiserver = WSGIServer(('0.0.0.0', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args) asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
web.py3_gevent_link = self.wsgiserver log.info('Starting Tornado server on %s', _readable_listen_address(self.listen_address, self.listen_port))
self.wsgiserver.serve_forever()
except (OSError, SocketError) as e:
web.app.logger.info("Error starting server: %s" % e.strerror)
print("Error starting server: %s" % e.strerror)
web.helper.global_WorkerThread.stop()
sys.exit(1)
except Exception:
web.app.logger.info("Unknown error while starting gevent")
def startServer(self): # Max Buffersize set to 200MB )
if gevent_present: http_server = HTTPServer(WSGIContainer(self.app),
web.app.logger.info('Starting Gevent server') max_buffer_size=209700000,
# leave subprocess out to allow forking for fetchers and processors ssl_options=self.ssl_args)
self.start_gevent() http_server.listen(self.listen_port, self.listen_address)
else: self.wsgiserver = IOLoop.current()
try: self.wsgiserver.start()
ssl = None # wait for stop signal
web.app.logger.info('Starting Tornado server') self.wsgiserver.close(True)
certfile_path = web.ub.config.get_config_certfile()
keyfile_path = web.ub.config.get_config_keyfile()
if certfile_path and keyfile_path:
if os.path.isfile(certfile_path) and os.path.isfile(keyfile_path):
ssl = {"certfile": certfile_path,
"keyfile": keyfile_path}
else:
web.app.logger.info('The specified paths for the ssl certificate file and/or key file seem to be broken. Ignoring ssl. Cert path: %s | Key path: %s' % (certfile_path, keyfile_path))
# Max Buffersize set to 200MB def start(self):
http_server = HTTPServer(WSGIContainer(web.app), try:
max_buffer_size = 209700000, if _GEVENT:
ssl_options=ssl) # leave subprocess out to allow forking for fetchers and processors
http_server.listen(web.ub.config.config_port) self._start_gevent()
self.wsgiserver=IOLoop.instance()
self.wsgiserver.start()
# wait for stop signal
self.wsgiserver.close(True)
except SocketError as e:
web.app.logger.info("Error starting server: %s" % e.strerror)
print("Error starting server: %s" % e.strerror)
web.helper.global_WorkerThread.stop()
sys.exit(1)
# ToDo: Somehow caused by circular import under python3 refactor
if sys.version_info > (3, 0):
self.restart = web.py3_restart_Typ
if self.restart == True:
web.app.logger.info("Performing restart of Calibre-Web")
web.helper.global_WorkerThread.stop()
if os.name == 'nt':
arguments = ["\"" + sys.executable + "\""]
for e in sys.argv:
arguments.append("\"" + e + "\"")
os.execv(sys.executable, arguments)
else: else:
os.execl(sys.executable, sys.executable, *sys.argv) self._start_tornado()
else: except Exception as ex:
web.app.logger.info("Performing shutdown of Calibre-Web") log.error("Error starting server: %s", ex)
web.helper.global_WorkerThread.stop() print("Error starting server: %s" % ex)
sys.exit(0) self.stop()
return False
finally:
self.wsgiserver = None
def setRestartTyp(self,starttyp): if not self.restart:
self.restart = starttyp log.info("Performing shutdown of Calibre-Web")
# ToDo: Somehow caused by circular import under python3 refactor # prevent irritiating log of pending tasks message from asyncio
web.py3_restart_Typ = starttyp logger.get('asyncio').setLevel(logger.logging.CRITICAL)
return True
def killServer(self, signum, frame): log.info("Performing restart of Calibre-Web")
self.stopServer() arguments = list(sys.argv)
arguments.insert(0, sys.executable)
if os.name == 'nt':
arguments = ["\"%s\"" % a for a in arguments]
os.execv(sys.executable, arguments)
return True
def stopServer(self): def _killServer(self, ignored_signum, ignored_frame):
# ToDo: Somehow caused by circular import under python3 refactor self.stop()
if sys.version_info > (3, 0):
if not self.wsgiserver: def stop(self, restart=False):
if gevent_present: from . import updater_thread
self.wsgiserver = web.py3_gevent_link updater_thread.stop()
else:
self.wsgiserver = IOLoop.instance() log.info("webserver stop (restart=%s)", restart)
self.restart = restart
if self.wsgiserver: if self.wsgiserver:
if gevent_present: if _GEVENT:
self.wsgiserver.close() self.wsgiserver.close()
else: else:
self.wsgiserver.add_callback(self.wsgiserver.stop) self.wsgiserver.add_callback_from_signal(self.wsgiserver.stop)
@staticmethod
def getNameVersion():
if gevent_present:
return {'Gevent':'v'+geventVersion}
else:
return {'Tornado':'v'+tornadoVersion}
# Start Instance of Server
Server=server()
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 shavitmichael, OzzieIsaacs
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
from base64 import b64decode, b64encode
from jsonschema import validate, exceptions, __version__
from datetime import datetime
try:
from urllib import unquote
except ImportError:
from urllib.parse import unquote
from flask import json
from .. import logger as log
def b64encode_json(json_data):
if sys.version_info < (3, 0):
return b64encode(json.dumps(json_data))
else:
return b64encode(json.dumps(json_data).encode())
# Python3 has a timestamp() method we could be calling, however it's not avaiable in python2.
def to_epoch_timestamp(datetime_object):
return (datetime_object - datetime(1970, 1, 1)).total_seconds()
def get_datetime_from_json(json_object, field_name):
try:
return datetime.utcfromtimestamp(json_object[field_name])
except KeyError:
return datetime.min
class SyncToken:
""" The SyncToken is used to persist state accross requests.
When serialized over the response headers, the Kobo device will propagate the token onto following
requests to the service. As an example use-case, the SyncToken is used to detect books that have been added
to the library since the last time the device synced to the server.
Attributes:
books_last_created: Datetime representing the newest 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"
VERSION = "1-1-0"
LAST_MODIFIED_ADDED_VERSION = "1-1-0"
MIN_VERSION = "1-0-0"
token_schema = {
"type": "object",
"properties": {"version": {"type": "string"}, "data": {"type": "object"}, },
}
# This Schema doesn't contain enough information to detect and propagate book deletions from Calibre to the device.
# A potential solution might be to keep a list of all known book uuids in the token, and look for any missing
# from the db.
data_schema_v1 = {
"type": "object",
"properties": {
"raw_kobo_store_token": {"type": "string"},
"books_last_modified": {"type": "string"},
"books_last_created": {"type": "string"},
"archive_last_modified": {"type": "string"},
"reading_state_last_modified": {"type": "string"},
"tags_last_modified": {"type": "string"},
},
}
def __init__(
self,
raw_kobo_store_token="",
books_last_created=datetime.min,
books_last_modified=datetime.min,
archive_last_modified=datetime.min,
reading_state_last_modified=datetime.min,
tags_last_modified=datetime.min,
):
self.raw_kobo_store_token = raw_kobo_store_token
self.books_last_created = books_last_created
self.books_last_modified = books_last_modified
self.archive_last_modified = archive_last_modified
self.reading_state_last_modified = reading_state_last_modified
self.tags_last_modified = tags_last_modified
@staticmethod
def from_headers(headers):
sync_token_header = headers.get(SyncToken.SYNC_TOKEN_HEADER, "")
if sync_token_header == "":
return SyncToken()
# On the first sync from a Kobo device, we may receive the SyncToken
# from the official Kobo store. Without digging too deep into it, that
# token is of the form [b64encoded blob].[b64encoded blob 2]
if "." in sync_token_header:
return SyncToken(raw_kobo_store_token=sync_token_header)
try:
sync_token_json = json.loads(
b64decode(sync_token_header + "=" * (-len(sync_token_header) % 4))
)
validate(sync_token_json, SyncToken.token_schema)
if sync_token_json["version"] < SyncToken.MIN_VERSION:
raise ValueError
data_json = sync_token_json["data"]
validate(sync_token_json, SyncToken.data_schema_v1)
except (exceptions.ValidationError, ValueError):
log.error("Sync token contents do not follow the expected json schema.")
return SyncToken()
raw_kobo_store_token = data_json["raw_kobo_store_token"]
try:
books_last_modified = get_datetime_from_json(data_json, "books_last_modified")
books_last_created = get_datetime_from_json(data_json, "books_last_created")
archive_last_modified = get_datetime_from_json(data_json, "archive_last_modified")
reading_state_last_modified = get_datetime_from_json(data_json, "reading_state_last_modified")
tags_last_modified = get_datetime_from_json(data_json, "tags_last_modified")
except TypeError:
log.error("SyncToken timestamps don't parse to a datetime.")
return SyncToken(raw_kobo_store_token=raw_kobo_store_token)
return SyncToken(
raw_kobo_store_token=raw_kobo_store_token,
books_last_created=books_last_created,
books_last_modified=books_last_modified,
archive_last_modified=archive_last_modified,
reading_state_last_modified=reading_state_last_modified,
tags_last_modified=tags_last_modified
)
def set_kobo_store_header(self, store_headers):
store_headers.set(SyncToken.SYNC_TOKEN_HEADER, self.raw_kobo_store_token)
def merge_from_store_response(self, store_response):
self.raw_kobo_store_token = store_response.headers.get(
SyncToken.SYNC_TOKEN_HEADER, ""
)
def to_headers(self, headers):
headers[SyncToken.SYNC_TOKEN_HEADER] = self.build_sync_token()
def build_sync_token(self):
token = {
"version": SyncToken.VERSION,
"data": {
"raw_kobo_store_token": self.raw_kobo_store_token,
"books_last_modified": to_epoch_timestamp(self.books_last_modified),
"books_last_created": to_epoch_timestamp(self.books_last_created),
"archive_last_modified": to_epoch_timestamp(self.archive_last_modified),
"reading_state_last_modified": to_epoch_timestamp(self.reading_state_last_modified),
"tags_last_modified": to_epoch_timestamp(self.tags_last_modified)
},
}
return b64encode_json(token)
+47
View File
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2019 pwr
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
from .. import logger
log = logger.create()
try: from . import goodreads_support
except ImportError as err:
log.debug("Cannot import goodreads, showing authors-metadata will not work: %s", err)
goodreads_support = None
try:
from . import simpleldap as ldap
from .simpleldap import ldapVersion
except ImportError as err:
log.debug("Cannot import simpleldap, logging in with ldap will not work: %s", err)
ldap = None
ldapVersion = None
try:
from . import SyncToken as SyncToken
kobo = True
except ImportError as err:
log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err)
kobo = None
SyncToken = None
+106
View File
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs, pwr
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import time
from functools import reduce
from goodreads.client import GoodreadsClient
try: import Levenshtein
except ImportError: Levenshtein = False
from .. import logger
log = logger.create()
_client = None # type: GoodreadsClient
# GoodReads TOS allows for 24h caching of data
_CACHE_TIMEOUT = 23 * 60 * 60 # 23 hours (in seconds)
_AUTHORS_CACHE = {}
def connect(key=None, secret=None, enabled=True):
global _client
if not enabled or not key or not secret:
_client = None
return
if _client:
# make sure the configuration has not changed since last we used the client
if _client.client_key != key or _client.client_secret != secret:
_client = None
if not _client:
_client = GoodreadsClient(key, secret)
def get_author_info(author_name):
now = time.time()
author_info = _AUTHORS_CACHE.get(author_name, None)
if author_info:
if now < author_info._timestamp + _CACHE_TIMEOUT:
return author_info
# clear expired entries
del _AUTHORS_CACHE[author_name]
if not _client:
log.warning("failed to get a Goodreads client")
return
try:
author_info = _client.find_author(author_name=author_name)
except Exception as ex:
# Skip goodreads, if site is down/inaccessible
log.warning('Goodreads website is down/inaccessible? %s', ex)
return
if author_info:
author_info._timestamp = now
_AUTHORS_CACHE[author_name] = author_info
return author_info
def get_other_books(author_info, library_books=None):
# Get all identifiers (ISBN, Goodreads, etc) and filter author's books by that list so we show fewer duplicates
# Note: Not all images will be shown, even though they're available on Goodreads.com.
# See https://www.goodreads.com/topic/show/18213769-goodreads-book-images
if not author_info:
return
identifiers = []
library_titles = []
if library_books:
identifiers = list(reduce(lambda acc, book: acc + [i.val for i in book.identifiers if i.val], library_books, []))
library_titles = [book.title for book in library_books]
for book in author_info.books:
if book.isbn in identifiers:
continue
if book.gid["#text"] in identifiers:
continue
if Levenshtein and library_titles:
goodreads_title = book._book_dict['title_without_series']
if any(Levenshtein.ratio(goodreads_title, title) > 0.7 for title in library_titles):
continue
yield book
+112
View File
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs, pwr
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
import base64
from flask_simpleldap import LDAP, LDAPException
from .. import constants, logger
try:
from ldap.pkginfo import __version__ as ldapVersion
except ImportError:
pass
log = logger.create()
_ldap = LDAP()
def init_app(app, config):
if config.config_login_type != constants.LOGIN_LDAP:
return
app.config['LDAP_HOST'] = config.config_ldap_provider_url
app.config['LDAP_PORT'] = config.config_ldap_port
if config.config_ldap_encryption == 2:
app.config['LDAP_SCHEMA'] = 'ldaps'
else:
app.config['LDAP_SCHEMA'] = 'ldap'
if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS:
if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE:
if config.config_ldap_serv_password is None:
config.config_ldap_serv_password = ''
app.config['LDAP_PASSWORD'] = base64.b64decode(config.config_ldap_serv_password)
else:
app.config['LDAP_PASSWORD'] = base64.b64decode("")
app.config['LDAP_USERNAME'] = config.config_ldap_serv_username
else:
app.config['LDAP_USERNAME'] = ""
app.config['LDAP_PASSWORD'] = base64.b64decode("")
if bool(config.config_ldap_cert_path):
app.config['LDAP_REQUIRE_CERT'] = True
app.config['LDAP_CERT_PATH'] = config.config_ldap_cert_path
app.config['LDAP_BASE_DN'] = config.config_ldap_dn
app.config['LDAP_USER_OBJECT_FILTER'] = config.config_ldap_user_object
app.config['LDAP_USE_TLS'] = bool(config.config_ldap_encryption == 1)
app.config['LDAP_USE_SSL'] = bool(config.config_ldap_encryption == 2)
app.config['LDAP_OPENLDAP'] = bool(config.config_ldap_openldap)
app.config['LDAP_GROUP_OBJECT_FILTER'] = config.config_ldap_group_object_filter
app.config['LDAP_GROUP_MEMBERS_FIELD'] = config.config_ldap_group_members_field
# app.config['LDAP_CUSTOM_OPTIONS'] = {'OPT_NETWORK_TIMEOUT': 10}
_ldap.init_app(app)
def get_object_details(user=None, group=None, query_filter=None, dn_only=False):
return _ldap.get_object_details(user, group, query_filter, dn_only)
def bind():
return _ldap.bind()
def get_group_members(group):
return _ldap.get_group_members(group)
def basic_auth_required(func):
return _ldap.basic_auth_required(func)
def bind_user(username, password):
'''Attempts a LDAP login.
:returns: True if login succeeded, False if login failed, None if server unavailable.
'''
try:
if _ldap.get_object_details(username):
result = _ldap.bind_user(username, password)
log.debug("LDAP login '%s': %r", username, result)
return result is not None, None
return None, None # User not found
except (TypeError, AttributeError, KeyError) as ex:
error = ("LDAP bind_user: %s" % ex)
return None, error
except LDAPException as ex:
if ex.message == 'Invalid credentials':
error = ("LDAP admin login failed")
return None, error
if ex.message == "Can't contact LDAP server":
# log.warning('LDAP Server down: %s', ex)
error = ('LDAP Server down: %s' % ex)
return None, error
else:
error = ('LDAP Server error: %s' % ex.message)
return None, error
+374
View File
@@ -0,0 +1,374 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
# apetresc, nanu-c, mutschler
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import division, print_function, unicode_literals
from datetime import datetime
from flask import Blueprint, request, flash, redirect, url_for
from flask_babel import gettext as _
from flask_login import login_required, current_user
from sqlalchemy.sql.expression import func
from . import logger, ub, searched_ids, db
from .web import render_title_template
from .helper import common_filters
shelf = Blueprint('shelf', __name__)
log = logger.create()
def check_shelf_edit_permissions(cur_shelf):
if not cur_shelf.is_public and not cur_shelf.user_id == int(current_user.id):
log.error("User %s not allowed to edit shelf %s", current_user, cur_shelf)
return False
if cur_shelf.is_public and not current_user.role_edit_shelfs():
log.info("User %s not allowed to edit public shelves", current_user)
return False
return True
def check_shelf_view_permissions(cur_shelf):
if cur_shelf.is_public:
return True
if current_user.is_anonymous or cur_shelf.user_id != current_user.id:
log.error("User is unauthorized to view non-public shelf: %s", cur_shelf)
return False
return True
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>")
@login_required
def add_to_shelf(shelf_id, book_id):
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf is None:
log.error("Invalid shelf specified: %s", shelf_id)
if not xhr:
flash(_(u"Invalid shelf specified"), category="error")
return redirect(url_for('web.index'))
return "Invalid shelf specified", 400
if not check_shelf_edit_permissions(shelf):
if not xhr:
flash(_(u"Sorry you are not allowed to add a book to the the shelf: %(shelfname)s", shelfname=shelf.name),
category="error")
return redirect(url_for('web.index'))
return "Sorry you are not allowed to add a book to the the shelf: %s" % shelf.name, 403
book_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id,
ub.BookShelf.book_id == book_id).first()
if book_in_shelf:
log.error("Book %s is already part of %s", book_id, shelf)
if not xhr:
flash(_(u"Book is already part of the shelf: %(shelfname)s", shelfname=shelf.name), category="error")
return redirect(url_for('web.index'))
return "Book is already part of the shelf: %s" % shelf.name, 400
maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()
if maxOrder[0] is None:
maxOrder = 0
else:
maxOrder = maxOrder[0]
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1))
shelf.last_modified = datetime.utcnow()
ub.session.merge(shelf)
ub.session.commit()
if not xhr:
flash(_(u"Book has been added to shelf: %(sname)s", sname=shelf.name), category="success")
if "HTTP_REFERER" in request.environ:
return redirect(request.environ["HTTP_REFERER"])
else:
return redirect(url_for('web.index'))
return "", 204
@shelf.route("/shelf/massadd/<int:shelf_id>")
@login_required
def search_to_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf is None:
log.error("Invalid shelf specified: %s", shelf_id)
flash(_(u"Invalid shelf specified"), category="error")
return redirect(url_for('web.index'))
if not check_shelf_edit_permissions(shelf):
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'))
if current_user.id in searched_ids and searched_ids[current_user.id]:
books_for_shelf = list()
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all()
if books_in_shelf:
book_ids = list()
for book_id in books_in_shelf:
book_ids.append(book_id.book_id)
for searchid in searched_ids[current_user.id]:
if searchid not in book_ids:
books_for_shelf.append(searchid)
else:
books_for_shelf = searched_ids[current_user.id]
if not books_for_shelf:
log.error("Books are already part of %s", shelf)
flash(_(u"Books are already part of the shelf: %(name)s", name=shelf.name), category="error")
return redirect(url_for('web.index'))
maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()
if maxOrder[0] is None:
maxOrder = 0
else:
maxOrder = maxOrder[0]
for book in books_for_shelf:
maxOrder = maxOrder + 1
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder))
shelf.last_modified = datetime.utcnow()
ub.session.merge(shelf)
ub.session.commit()
flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
else:
flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error")
return redirect(url_for('web.index'))
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>")
@login_required
def remove_from_shelf(shelf_id, book_id):
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf is None:
log.error("Invalid shelf specified: %s", shelf_id)
if not xhr:
return redirect(url_for('web.index'))
return "Invalid shelf specified", 400
# if shelf is public and use is allowed to edit shelfs, or if shelf is private and user is owner
# allow editing shelfs
# result shelf public user allowed user owner
# false 1 0 x
# true 1 1 x
# true 0 x 1
# false 0 x 0
if check_shelf_edit_permissions(shelf):
book_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id,
ub.BookShelf.book_id == book_id).first()
if book_shelf is None:
log.error("Book %s already removed from %s", book_id, shelf)
if not xhr:
return redirect(url_for('web.index'))
return "Book already removed from shelf", 410
ub.session.delete(book_shelf)
shelf.last_modified = datetime.utcnow()
ub.session.commit()
if not xhr:
flash(_(u"Book has been removed from shelf: %(sname)s", sname=shelf.name), category="success")
if "HTTP_REFERER" in request.environ:
return redirect(request.environ["HTTP_REFERER"])
else:
return redirect(url_for('web.index'))
return "", 204
else:
if not xhr:
flash(_(u"Sorry you are not allowed to remove a book from this shelf: %(sname)s", sname=shelf.name),
category="error")
return redirect(url_for('web.index'))
return "Sorry you are not allowed to remove a book from this shelf: %s" % shelf.name, 403
@shelf.route("/shelf/create", methods=["GET", "POST"])
@login_required
def create_shelf():
shelf = ub.Shelf()
if request.method == "POST":
to_save = request.form.to_dict()
if "is_public" in to_save:
shelf.is_public = 1
shelf.name = to_save["title"]
shelf.user_id = int(current_user.id)
is_shelf_name_unique = False
if shelf.is_public == 1:
is_shelf_name_unique = ub.session.query(ub.Shelf) \
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 1)) \
.first() is None
if not is_shelf_name_unique:
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]),
category="error")
else:
is_shelf_name_unique = ub.session.query(ub.Shelf) \
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 0) &
(ub.Shelf.user_id == int(current_user.id)))\
.first() is None
if not is_shelf_name_unique:
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]),
category="error")
if is_shelf_name_unique:
try:
ub.session.add(shelf)
ub.session.commit()
flash(_(u"Shelf %(title)s created", title=to_save["title"]), category="success")
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
except Exception:
flash(_(u"There was an error"), category="error")
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Create a Shelf"), page="shelfcreate")
else:
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Create a Shelf"), page="shelfcreate")
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
@login_required
def edit_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if request.method == "POST":
to_save = request.form.to_dict()
is_shelf_name_unique = False
if shelf.is_public == 1:
is_shelf_name_unique = ub.session.query(ub.Shelf) \
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 1)) \
.filter(ub.Shelf.id != shelf_id) \
.first() is None
if not is_shelf_name_unique:
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]),
category="error")
else:
is_shelf_name_unique = ub.session.query(ub.Shelf) \
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 0) &
(ub.Shelf.user_id == int(current_user.id)))\
.filter(ub.Shelf.id != shelf_id)\
.first() is None
if not is_shelf_name_unique:
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]),
category="error")
if is_shelf_name_unique:
shelf.name = to_save["title"]
shelf.last_modified = datetime.utcnow()
if "is_public" in to_save:
shelf.is_public = 1
else:
shelf.is_public = 0
try:
ub.session.commit()
flash(_(u"Shelf %(title)s changed", title=to_save["title"]), category="success")
except Exception:
flash(_(u"There was an error"), category="error")
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Edit a shelf"), page="shelfedit")
else:
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Edit a shelf"), page="shelfedit")
def delete_shelf_helper(cur_shelf):
if not cur_shelf or not check_shelf_edit_permissions(cur_shelf):
return
shelf_id = cur_shelf.id
ub.session.delete(cur_shelf)
ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete()
ub.session.add(ub.ShelfArchive(uuid=cur_shelf.uuid, user_id=cur_shelf.user_id))
ub.session.commit()
log.info("successfully deleted %s", cur_shelf)
@shelf.route("/shelf/delete/<int:shelf_id>")
@login_required
def delete_shelf(shelf_id):
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
delete_shelf_helper(cur_shelf)
return redirect(url_for('web.index'))
@shelf.route("/shelf/<int:shelf_id>", defaults={'shelf_type': 1})
@shelf.route("/shelf/<int:shelf_id>/<int:shelf_type>")
def show_shelf(shelf_type, shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
result = list()
# user is allowed to access shelf
if shelf and check_shelf_view_permissions(shelf):
page = "shelf.html" if shelf_type == 1 else 'shelfdown.html'
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id)\
.order_by(ub.BookShelf.order.asc()).all()
for book in books_in_shelf:
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).filter(common_filters()).first()
if cur_book:
result.append(cur_book)
else:
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
if not cur_book:
log.info('Not existing book %s in %s deleted', book.book_id, shelf)
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book.book_id).delete()
ub.session.commit()
return render_title_template(page, entries=result, title=_(u"Shelf: '%(name)s'", name=shelf.name),
shelf=shelf, page="shelf")
else:
flash(_(u"Error opening shelf. Shelf does not exist or is not accessible"), category="error")
return redirect(url_for("web.index"))
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
@login_required
def order_shelf(shelf_id):
if request.method == "POST":
to_save = request.form.to_dict()
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by(
ub.BookShelf.order.asc()).all()
counter = 0
for book in books_in_shelf:
setattr(book, 'order', to_save[str(book.book_id)])
counter += 1
# if order diffrent from before -> shelf.last_modified = datetime.utcnow()
ub.session.commit()
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
result = list()
if shelf and check_shelf_view_permissions(shelf):
books_in_shelf2 = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id) \
.order_by(ub.BookShelf.order.asc()).all()
for book in books_in_shelf2:
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).filter(common_filters()).first()
if cur_book:
result.append({'title': cur_book.title,
'id': cur_book.id,
'author': cur_book.authors,
'series': cur_book.series,
'series_index': cur_book.series_index})
else:
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
result.append({'title': _('Hidden Book'),
'id': cur_book.id,
'author': [],
'series': []})
return render_title_template('shelf_order.html', entries=result,
title=_(u"Change order of Shelf: '%(name)s'", name=shelf.name),
shelf=shelf, page="shelforder")
+1 -1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 B

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

@@ -0,0 +1,6 @@
SVG icons via Icomoon
https://icomoon.io/app
Icons used from the following sets:
* Entypo - Creative Commons BY-SA 3.0 http://creativecommons.org/licenses/by-sa/3.0/us/
* IcoMoon - Free (GPL) http://www.gnu.org/licenses/gpl.html
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M20.002 7.75h-13.5v-2.75l-5.25 4.5 5.25 4.5v-2.75h12.5v5.25h-16.25v3.5h17.25c1.38 0 2.5-1.12 2.5-2.5v-7.25c0-1.381-1.121-2.5-2.5-2.5z" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 491 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M5 21.25c0 1.38 1.12 2.5 2.5 2.5 1.381 0 2.5-1.12 2.5-2.5s-1.119-2.5-2.5-2.5c-1.38 0-2.5 1.12-2.5 2.5zM17.5 21.25c0 1.38 1.12 2.5 2.5 2.5 1.381 0 2.5-1.12 2.5-2.5s-1.119-2.5-2.5-2.5c-1.38 0-2.5 1.12-2.5 2.5zM9.434 15.34l13.836-3.952c0.264-0.076 0.48-0.363 0.48-0.638v-6.875h-17.625v-2.125c0-0.275-0.224-0.5-0.5-0.5h-3.875c-0.275 0-0.5 0.225-0.5 0.5v2h2.429l2.46 11.321 0.238 1.179v1.875c0 0.275 0.225 0.5 0.499 0.5h16.376c0.274 0 0.499-0.225 0.499-0.5v-1.875h-14.060c-1.436 0-1.466-0.562-0.256-0.91z" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 857 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M9.356 12.5c0 0.323 0.327 0.552 0.327 0.552l9.094 5.711c0.674 0.441 1.223 0.116 1.223-0.719v-11.090c0-0.836-0.549-1.161-1.223-0.72l-9.094 5.713c0 0.001-0.327 0.23-0.327 0.552zM5 6.696v11.607c0 1.11 0.765 1.45 1.875 1.45s1.875-0.34 1.875-1.449v-11.609c0-1.111-0.765-1.451-1.875-1.451s-1.875 0.341-1.875 1.451z" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 666 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M15.316 11.948l-9.094-5.713c-0.673-0.44-1.223-0.116-1.223 0.721v11.089c0 0.837 0.55 1.16 1.223 0.72l9.094-5.711c0 0 0.329-0.231 0.329-0.552s-0.329-0.554-0.329-0.554zM18.125 5.245c-1.11 0-1.875 0.341-1.875 1.451v11.607c0 1.111 0.765 1.452 1.875 1.452s1.875-0.341 1.875-1.452v-11.607c0-1.11-0.765-1.451-1.875-1.451z" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 671 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M6.25 16.25h-1.25c-0.69 0-1.25 0.559-1.25 1.25s0.56 1.25 1.25 1.25h1.25c0.69 0 1.25-0.559 1.25-1.25s-0.56-1.25-1.25-1.25zM6.25 11.25h-1.25c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25h1.25c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25zM6.25 6.25h-1.25c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25h1.25c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25zM11.25 8.75h8.75c0.691 0 1.25-0.56 1.25-1.25s-0.559-1.25-1.25-1.25h-8.75c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25zM20 11.25h-8.75c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25h8.75c0.691 0 1.25-0.56 1.25-1.25s-0.559-1.25-1.25-1.25zM20 16.25h-8.75c-0.69 0-1.25 0.559-1.25 1.25s0.56 1.25 1.25 1.25h8.75c0.691 0 1.25-0.559 1.25-1.25s-0.559-1.25-1.25-1.25z" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M20 11.25h-15c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25h15c0.691 0 1.25-0.56 1.25-1.25s-0.559-1.25-1.25-1.25zM5 8.75h15c0.691 0 1.25-0.56 1.25-1.25s-0.559-1.25-1.25-1.25h-15c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25zM20 16.25h-15c-0.69 0-1.25 0.559-1.25 1.25s0.56 1.25 1.25 1.25h15c0.691 0 1.25-0.559 1.25-1.25s-0.559-1.25-1.25-1.25z" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 703 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M21.25 7.75h-4.75v3.5h3.75v5.25h-15.5v-5.25h5.25v2.75l5-4.5-5-4.5v2.75h-6.25c-1.38 0-2.5 1.119-2.5 2.5v7.25c0 1.38 1.12 2.5 2.5 2.5h17.5c1.381 0 2.5-1.12 2.5-2.5v-7.25c0-1.381-1.119-2.5-2.5-2.5z" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 552 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M18.75 1.25h-12.5c-1.375 0-2.5 1.125-2.5 2.5v17.5c0 1.375 1.125 2.5 2.5 2.5h12.5c1.376 0 2.5-1.125 2.5-2.5v-17.5c0-1.375-1.124-2.5-2.5-2.5zM15.995 13.151c-0.296 0.463-0.454 0.265-0.365 0 0.235-0.698 0.226-2.809-1.74-3.118v6.516c0 1.215-0.7 1.977-2.188 2.455-1.445 0.462-3.063-0.019-3.441-1.057-0.377-1.037 0.473-2.3 1.899-2.82 0.796-0.289 1.604-0.286 2.226-0.045v-9.193h1.504c0 1.579 4.226 3.961 2.105 7.261z" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 766 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M16.875 3.75c-1.243 0-2.25 0.382-2.25 1.625v14.25c0 1.243 1.007 1.625 2.25 1.625s2.25-0.382 2.25-1.625v-14.25c0-1.243-1.007-1.625-2.25-1.625zM8.125 3.75c-1.243 0-2.25 0.382-2.25 1.625v14.25c0 1.243 1.007 1.625 2.25 1.625s2.25-0.382 2.25-1.625v-14.25c0-1.243-1.007-1.625-2.25-1.625z" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 639 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M20.873 11.859l-10.701-6.63c-0.78-0.511-1.418-0.134-1.418 0.838v12.87c0 0.971 0.639 1.348 1.418 0.836l10.702-6.63c0 0 0.38-0.268 0.38-0.643-0.001-0.374-0.381-0.641-0.381-0.641z" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 534 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M18.866 8.368h0.884v2.516l5.25-4.5-5.25-4.5v2.984h-0.884c-4.616 0-7.22 3.421-9.518 6.44-2.066 2.715-3.85 5.060-6.732 5.060h-2.616v3.501h2.616c4.618 0 7.22-3.423 9.519-6.441 2.065-2.716 3.849-5.060 6.731-5.060zM6.764 10.573c0.196-0.255 0.395-0.515 0.596-0.779 0.489-0.643 1.005-1.32 1.564-1.995-1.651-1.543-3.644-2.681-6.307-2.681h-2.616v3.5h2.616c1.659 0 2.954 0.779 4.148 1.955zM19.75 16.616h-0.884c-1.759 0-3.107-0.874-4.363-2.173-0.126 0.164-0.251 0.33-0.379 0.498-0.551 0.723-1.145 1.501-1.799 2.27 1.696 1.655 3.751 2.905 6.54 2.905h0.884v3l5.25-4.5-5.25-4.502v2.502z" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 930 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M23.709 16.798c0 1.101-0.901 2-2.001 2h-19.5c-1.1 0-1.229-0.461-0.285-1.027l20.070-11.318c0.944-0.566 1.716-0.129 1.716 0.971v9.374z" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M20.002 7.75h-13.5v-2.75l-5.25 4.5 5.25 4.5v-2.75h12.5v5.25h-16.25v3.5h17.25c1.38 0 2.5-1.12 2.5-2.5v-7.25c0-1.381-1.121-2.5-2.5-2.5z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 491 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M5 21.25c0 1.38 1.12 2.5 2.5 2.5 1.381 0 2.5-1.12 2.5-2.5s-1.119-2.5-2.5-2.5c-1.38 0-2.5 1.12-2.5 2.5zM17.5 21.25c0 1.38 1.12 2.5 2.5 2.5 1.381 0 2.5-1.12 2.5-2.5s-1.119-2.5-2.5-2.5c-1.38 0-2.5 1.12-2.5 2.5zM9.434 15.34l13.836-3.952c0.264-0.076 0.48-0.363 0.48-0.638v-6.875h-17.625v-2.125c0-0.275-0.224-0.5-0.5-0.5h-3.875c-0.275 0-0.5 0.225-0.5 0.5v2h2.429l2.46 11.321 0.238 1.179v1.875c0 0.275 0.225 0.5 0.499 0.5h16.376c0.274 0 0.499-0.225 0.499-0.5v-1.875h-14.060c-1.436 0-1.466-0.562-0.256-0.91z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 857 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M9.356 12.5c0 0.323 0.327 0.552 0.327 0.552l9.094 5.711c0.674 0.441 1.223 0.116 1.223-0.719v-11.090c0-0.836-0.549-1.161-1.223-0.72l-9.094 5.713c0 0.001-0.327 0.23-0.327 0.552zM5 6.696v11.607c0 1.11 0.765 1.45 1.875 1.45s1.875-0.34 1.875-1.449v-11.609c0-1.111-0.765-1.451-1.875-1.451s-1.875 0.341-1.875 1.451z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 666 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M15.316 11.948l-9.094-5.713c-0.673-0.44-1.223-0.116-1.223 0.721v11.089c0 0.837 0.55 1.16 1.223 0.72l9.094-5.711c0 0 0.329-0.231 0.329-0.552s-0.329-0.554-0.329-0.554zM18.125 5.245c-1.11 0-1.875 0.341-1.875 1.451v11.607c0 1.111 0.765 1.452 1.875 1.452s1.875-0.341 1.875-1.452v-11.607c0-1.11-0.765-1.451-1.875-1.451z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 671 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M6.25 16.25h-1.25c-0.69 0-1.25 0.559-1.25 1.25s0.56 1.25 1.25 1.25h1.25c0.69 0 1.25-0.559 1.25-1.25s-0.56-1.25-1.25-1.25zM6.25 11.25h-1.25c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25h1.25c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25zM6.25 6.25h-1.25c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25h1.25c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25zM11.25 8.75h8.75c0.691 0 1.25-0.56 1.25-1.25s-0.559-1.25-1.25-1.25h-8.75c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25zM20 11.25h-8.75c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25h8.75c0.691 0 1.25-0.56 1.25-1.25s-0.559-1.25-1.25-1.25zM20 16.25h-8.75c-0.69 0-1.25 0.559-1.25 1.25s0.56 1.25 1.25 1.25h8.75c0.691 0 1.25-0.559 1.25-1.25s-0.559-1.25-1.25-1.25z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M20 11.25h-15c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25h15c0.691 0 1.25-0.56 1.25-1.25s-0.559-1.25-1.25-1.25zM5 8.75h15c0.691 0 1.25-0.56 1.25-1.25s-0.559-1.25-1.25-1.25h-15c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25zM20 16.25h-15c-0.69 0-1.25 0.559-1.25 1.25s0.56 1.25 1.25 1.25h15c0.691 0 1.25-0.559 1.25-1.25s-0.559-1.25-1.25-1.25z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 703 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M21.25 7.75h-4.75v3.5h3.75v5.25h-15.5v-5.25h5.25v2.75l5-4.5-5-4.5v2.75h-6.25c-1.38 0-2.5 1.119-2.5 2.5v7.25c0 1.38 1.12 2.5 2.5 2.5h17.5c1.381 0 2.5-1.12 2.5-2.5v-7.25c0-1.381-1.119-2.5-2.5-2.5z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 552 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M18.75 1.25h-12.5c-1.375 0-2.5 1.125-2.5 2.5v17.5c0 1.375 1.125 2.5 2.5 2.5h12.5c1.376 0 2.5-1.125 2.5-2.5v-17.5c0-1.375-1.124-2.5-2.5-2.5zM15.995 13.151c-0.296 0.463-0.454 0.265-0.365 0 0.235-0.698 0.226-2.809-1.74-3.118v6.516c0 1.215-0.7 1.977-2.188 2.455-1.445 0.462-3.063-0.019-3.441-1.057-0.377-1.037 0.473-2.3 1.899-2.82 0.796-0.289 1.604-0.286 2.226-0.045v-9.193h1.504c0 1.579 4.226 3.961 2.105 7.261z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 766 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M16.875 3.75c-1.243 0-2.25 0.382-2.25 1.625v14.25c0 1.243 1.007 1.625 2.25 1.625s2.25-0.382 2.25-1.625v-14.25c0-1.243-1.007-1.625-2.25-1.625zM8.125 3.75c-1.243 0-2.25 0.382-2.25 1.625v14.25c0 1.243 1.007 1.625 2.25 1.625s2.25-0.382 2.25-1.625v-14.25c0-1.243-1.007-1.625-2.25-1.625z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 639 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M20.873 11.859l-10.701-6.63c-0.78-0.511-1.418-0.134-1.418 0.838v12.87c0 0.971 0.639 1.348 1.418 0.836l10.702-6.63c0 0 0.38-0.268 0.38-0.643-0.001-0.374-0.381-0.641-0.381-0.641z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 534 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M18.866 8.368h0.884v2.516l5.25-4.5-5.25-4.5v2.984h-0.884c-4.616 0-7.22 3.421-9.518 6.44-2.066 2.715-3.85 5.060-6.732 5.060h-2.616v3.501h2.616c4.618 0 7.22-3.423 9.519-6.441 2.065-2.716 3.849-5.060 6.731-5.060zM6.764 10.573c0.196-0.255 0.395-0.515 0.596-0.779 0.489-0.643 1.005-1.32 1.564-1.995-1.651-1.543-3.644-2.681-6.307-2.681h-2.616v3.5h2.616c1.659 0 2.954 0.779 4.148 1.955zM19.75 16.616h-0.884c-1.759 0-3.107-0.874-4.363-2.173-0.126 0.164-0.251 0.33-0.379 0.498-0.551 0.723-1.145 1.501-1.799 2.27 1.696 1.655 3.751 2.905 6.54 2.905h0.884v3l5.25-4.5-5.25-4.502v2.502z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 930 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by IcoMoon.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25" viewBox="0 0 25 25">
<g>
</g>
<path d="M23.709 16.798c0 1.101-0.901 2-2.001 2h-19.5c-1.1 0-1.229-0.461-0.285-1.027l20.070-11.318c0.944-0.566 1.716-0.129 1.716 0.971v9.374z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

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