Compare commits

..
198 Commits
Author SHA1 Message Date
Ozzie Isaacs 0af52f2053 Update Version
Update teststatus
2024-11-16 07:21:28 +01:00
Ozzie Isaacs 2fff002699 Fix for stable update version 2024-11-11 18:46:07 +01:00
Ozzie Isaacs 5a87c1c4b3 Add libmagic for windows installations 2024-11-10 14:52:55 +01:00
Ozzie Isaacs f0b770224a Fix updater nightly version check 2024-11-05 18:35:21 +01:00
Ozzie Isaacs 4c3fc90830 Fix for dependency check on python versions <3.10 2024-11-05 16:48:03 +01:00
Ozzie Isaacs d6798fb286 Fix error in requirements.txt 2024-11-04 21:20:34 +01:00
Ozzie Isaacs 6315f40800 Dependency check respects now python version restrictions 2024-11-04 21:10:18 +01:00
Ozzie Isaacs 1b73d8a7e4 Specify python versions in requirements file 2024-11-04 18:50:45 +01:00
Ozzie Isaacs 54a0ddaab5 Merge remote-tracking branch 'ko/master' 2024-11-02 20:21:09 +01:00
Ozzie Isaacs a84c316e3d Merge branch 'Develop' - Improved compability for python 3.12+ 2024-11-02 16:59:19 +01:00
limeade23 0a53719394 Fix : refine korean translation 2024-11-01 20:04:58 +09:00
limeade23 3cc89d4ca0 Fix : refine korean translation 2024-11-01 19:07:14 +09:00
limeade23 2eee5fbdd8 Fix : some fix korean (remove file) 2024-11-01 19:01:14 +09:00
limeade23 e0b2734d66 Fix : some fix korean 2024-11-01 18:59:30 +09:00
limeade23 9926e03009 Fix : some fix korean 2024-11-01 18:42:23 +09:00
limeade23 3aa0c82795 Fix : retranslate korean 2024-11-01 18:01:41 +09:00
Ozzie Isaacs bf89bbc8bc Fix series_index 0 isn't correctly synced to kobo (#3204)
Update Teststatus
2024-11-01 06:59:46 +01:00
Ozzie Isaacs deb1ff37b2 Escape characters in filename for kepub conversion 2024-10-31 19:09:00 +01:00
Ozzie Isaacs 2927b5d544 Updated requirements 2024-10-28 16:25:46 +01:00
Ozzie Isaacs bf9313ddeb Update test status
Updates after test run
2024-10-28 16:16:28 +01:00
Ozzie Isaacs c2ffa940cd Add cryptography to requirements 2024-10-27 07:05:50 +01:00
Ozzie Isaacs b94e1df518 Delete custom column related values on database change 2024-10-26 16:08:50 +02:00
Ozzie Isaacs 2eb4f77a23 Display error message if visiblity resticting custom column is no available (Fix for #3190) 2024-10-26 15:34:57 +02:00
Ozzie Isaacs d5059979d5 Marked 2 strings as raw strings to suppress warnings 2024-10-25 14:18:22 +02:00
Ozzie Isaacs 4fa7520598 Final fix for #3189 (also working on windows) 2024-10-25 12:26:35 +02:00
Ozzie Isaacs e8cb84136b Fix quotes for calibre convert on windows 2024-10-25 10:29:41 +02:00
Ozzie Isaacs 8a3409e911 Fix for caliber-convert parameter recognition bug with spaces in parameter (fix for #3189) 2024-10-25 10:21:44 +02:00
Ozzie Isaacs abedb13473 Improved ldap import user routine (fix for #3186) 2024-10-25 10:20:38 +02:00
Ozzie Isaacs cd01aaa69e Merge remote-tracking branch 'readme/patch-1' 2024-10-23 19:03:34 +02:00
Ozzie Isaacs d4502713ff Merge remote-tracking branch 'ios_bookmark/fix-impossible-to-press-bookmarks-button-in-safari' 2024-10-23 18:57:24 +02:00
Ozzie Isaacs c523463591 Merge remote-tracking branch 'sl/master' 2024-10-23 18:44:10 +02:00
Andrej Kralj cca8158bdf Updates Slovenian translation 2024-10-22 08:21:00 +02:00
Ozzie Isaacs b9085129a7 Merge branch 'pdf' 2024-10-19 06:47:32 +02:00
Ozzie Isaacs cb073daca3 Changed response code to opds download forbidden to 401 instead of 404 2024-10-19 06:36:03 +02:00
Ozzie Isaacs c40c8a2255 Format seriesindex everywhere to 2 digits 2024-10-18 20:24:49 +02:00
lucaparsani e42e11e2a4 fix bookmarks button for safari browser 2024-10-09 00:24:06 +02:00
contributor 07bf9047b0 pdf metadata - ignore NullObject /Keywords to fix http 500 upload error
this fixes http 500 error while uploading valid PDF document with empty /Keywords
2024-10-04 19:53:54 +03:00
Lucinda Quintal 230573dc7a Enhance README: Adds Troubleshooting section, expands Contribution section
- Adds Troubleshooting Section which has common issues, configuration errors, and user management issues to help users diagnose problems effectively.
- Expanded the Contributing Section to include guidelines for contributions, a code of conduct, bug reporting, and feature requests.
- In the Additional Resources section , provided links to documentation and community contributions for ease of reference
2024-09-29 07:32:50 -07:00
Ozzie Isaacs 94df767c28 Reupdate slovenian translation 2024-09-26 19:46:35 +02:00
Ozzie Isaacs 1eb9cd44dc Improve message on error cover downlaod 2024-09-21 13:12:48 +02:00
Ozzie Isaacs eb6563480f Fix #3153 (In Firefox, Github OAuth login goes into infinite redirect loops) 2024-09-21 13:01:48 +02:00
Ozzie Isaacs 2f39e51e1c Fix for #3151 (Invalid amazon link for uk) 2024-09-21 12:58:12 +02:00
Ozzie Isaacs 24252e09b4 Merge branch 'master' into Develop 2024-09-21 12:43:19 +02:00
Ozzie Isaacs 0e7eed4d3c Update generated pyproject.toml 2024-09-21 12:39:05 +02:00
Ozzie Isaacs 884f7666fa Integrate advocate and change netiface requirement 2024-09-21 12:38:41 +02:00
Ozzie Isaacs c54e9fe588 Updated slovenian language 2024-09-21 12:35:26 +02:00
Andrej Kralj d82944d08c Updated Slovenian translation
After checking the nightly build I have made some improvements.
2024-09-19 09:15:04 +02:00
Ozzie Isaacs 97764acbcd Added missing locale for datepicker in Slovenian language 2024-09-18 19:56:54 +02:00
Ozzie Isaacs 53f0647c5d New Slovenian translation 2024-09-18 19:50:57 +02:00
Ozzie Isaacs b5ee07eba8 Revert "Update Slovenian translation"
This reverts commit 5fe13f53bf.
2024-09-18 19:43:44 +02:00
Ozzie Isaacs 5fe13f53bf Update Slovenian translation 2024-09-18 19:13:24 +02:00
Ozzie Isaacs 13dc0b9c8e Updated requirements 2024-09-16 17:28:43 +02:00
Ozzie Isaacs 78d45f0208 Remove tzlocal warning
Updated requirements
2024-09-16 17:25:37 +02:00
Ozzie Isaacs d0e166140f Fix import pycountry on python 3.12 without warning
Fix shadowed "babel" name
2024-09-16 17:22:27 +02:00
Ozzie Isaacs 2617fa8af4 Improved Dependency check 2024-09-16 15:53:29 +02:00
Ozzie Isaacs 89a1dcfbf6 Fix for #3145 (improved contrast for epub reader headline) 2024-09-14 18:55:35 +02:00
Ozzie Isaacs 9d619ae5c7 Merge branch 'master' into Develop 2024-09-14 14:11:32 +02:00
Ozzie Isaacs 10d0542e05 Removed obsolete code 2024-09-14 11:15:04 +02:00
Ozzie Isaacs 111c93c391 Merge remote-tracking branch 'it/patch-1' 2024-09-14 10:53:30 +02:00
Ozzie Isaacs 1d9471b252 Possible fix for #3150 (Invalid cross-device link) without coping metadata 2024-09-14 10:46:02 +02:00
Ozzie Isaacs 88cb58c285 Fix for load metadata from amazon 2024-09-14 10:23:11 +02:00
Ozzie Isaacs eea01b0ff5 Simplified version string 2024-09-07 20:07:45 +02:00
Ozzie Isaacs d755878bd2 Migrate to pyproject.toml build 2024-09-07 19:59:02 +02:00
mapi68 2d7cf1e6d3 Update italian 2024-08-24 05:33:36 +02:00
Ozzie Isaacs b8a770a470 Merge branch 'master' into Develop 2024-08-23 11:13:48 +02:00
Ozzie Isaacs 481237dc77 Fix for #3131 (Enable/Disable "Separate Book Files from Library" issue) 2024-08-23 10:51:37 +02:00
Ozzie Isaacs 5e9dfc0724 Test update 2024-08-23 09:44:11 +02:00
Ozzie Isaacs ce720f40fe Shelf sort algorithm is now saved (#3003) 2024-08-21 19:30:33 +02:00
Ozzie Isaacs c92115faf3 Update save shelf sort algorithm 2024-08-21 19:25:50 +02:00
Ozzie Isaacs a1f9f03fc1 Fix for #3130 (only last e-mail address is saved when specifying multiple e-reader mail addresses in user settings) 2024-08-21 18:49:20 +02:00
Ozzie Isaacs 6a504673e5 Merge branch 'Develop':
Better handling of incompatible iso-639 module on python3.12
Music icon only visible once if more than one audio format available
Upload (multiple) book formats with progress and merge the corresponding metadata into the book (also via drag'n drop #2252)
2024-08-21 18:44:48 +02:00
Ozzie Isaacs 839118e4f1 Bugfixes from testrun 2024-08-21 18:21:19 +02:00
Ozzie Isaacs fd730f6609 Bugfix generate thumbnails on gdrive 2024-08-18 20:14:29 +02:00
Ozzie Isaacs 1be0ff9620 Bugfix load metadata
Bugfix xchange author and title
Better handling of incompatible iso-639 module on python3.12
2024-08-18 18:52:13 +02:00
Ozzie Isaacs 6717683ac3 Improvements merge metadata upload format
Music icon only visible once if more than one audio format available
2024-08-18 16:45:42 +02:00
Ozzie Isaacs 0f9b5d08e9 Fix cover handling on upload formats 2024-08-18 10:59:39 +02:00
Ozzie Isaacs 1eb77c9cd4 Upload (multiple) book formats with progress and merge the corresponding metadata into the book 2024-08-17 15:34:50 +02:00
Ozzie Isaacs ca92dbb168 Update german translation 2024-08-17 13:50:33 +02:00
Ozzie Isaacs 8bc1f03164 Bugfix generate thumbnails on gdrive 2024-08-17 09:30:52 +02:00
Ozzie Isaacs d8d9d405e9 First step format upload with progress 2024-08-16 21:14:41 +02:00
Ozzie Isaacs 8f2f6f5c91 Merge branch 'master' into Develop 2024-08-16 11:21:34 +02:00
Ozzie Isaacs 4c01af340f Update Testresults 2024-08-16 09:31:59 +02:00
Ozzie Isaacs 88783257b4 Update readme 2024-08-15 19:08:08 +02:00
Ozzie Isaacs c2474aa91d Fix for #2990 (Kobo browser not downloading kepub files)
Final fix for cover size during kobo sync (fix for #2523)
2024-08-15 19:02:11 +02:00
Ozzie Isaacs d2860fc374 Final fix for cover size during kobo sync (fix for #2523)
Update Teststatus
2024-08-15 18:35:48 +02:00
Ozzie Isaacs 87a1971d74 Merge remote-tracking branch 'spelling/master' 2024-08-14 19:29:40 +02:00
Ozzie Isaacs a9359bc1db Removed accidentally added translation file 2024-08-14 18:23:30 +02:00
Ozzie Isaacs c7f0293568 Implementation of sorting according to shelf added 2024-08-14 18:21:21 +02:00
Daniel M. Drucker, Ph.D. 22affedafc fix spelling of seperate to separate 2024-08-13 14:10:03 -04:00
Ozzie Isaacs 4d79c593f7 Fix for #2990 (Kobo browser not downloading kepub files) 2024-08-13 19:40:32 +02:00
Ozzie Isaacs 87b82424d5 Improved whitespace removal
Testresults
2024-08-13 07:07:54 +02:00
Ozzie Isaacs 5f81084e66 Updated testresults 2024-08-12 18:32:47 +02:00
Ozzie Isaacs 095074f977 Fix metadata search results 2024-08-12 18:32:35 +02:00
Ozzie Isaacs acd3c8d103 Merge branch 'master' into Develop 2024-08-11 19:56:20 +02:00
Ozzie Isaacs 3733fd442f Improved mp4, m4a, m4b import (coverformat png and multiple coverfiles)
Added support for metadata extraction in opus, ogv files
2024-08-11 19:45:12 +02:00
Ozzie Isaacs 4eb9845ff7 Improved asf, aac metadata import 2024-08-11 17:27:41 +02:00
Ozzie Isaacs ec6efafbed Added m4a, m4b files to parsable audio metadata 2024-08-11 12:20:16 +02:00
Ozzie Isaacs 10668571e8 Fixes for audio metadata extraction of oggvorbis and flac files (handle more than one cover and png covers as well) 2024-08-11 11:50:03 +02:00
Ozzie Isaacs 878fd85c63 Fixes for audio metadata extraction of oggvorbis and flac files (handle more than one cover and png covers as well) 2024-08-11 11:49:43 +02:00
Ozzie Isaacs 3827d46d66 Merge remote-tracking branch 'it/patch-1' 2024-08-11 08:41:46 +02:00
Ozzie Isaacs 7feff3b55c Merge branch 'Develop'
Extract metadata of audiofiles during upload
Updated pdf.js
2024-08-11 08:35:37 +02:00
Ozzie Isaacs 4523a12708 Fixes after testrun 2024-08-11 08:21:01 +02:00
Ozzie Isaacs 7a8d054b28 Make datetime utcnow compatible to older python versions 2024-08-10 21:05:29 +02:00
Ozzie Isaacs a56d1c80ae Remove deprecated utcnow method
Bugfix mp3 cover metadata extraction
2024-08-10 18:55:24 +02:00
Ozzie Isaacs 074aed6997 Fix deprecation warning datetime
Started metadata parsing for audio files
2024-08-07 17:23:41 +02:00
Ozzie Isaacs 2f8db1f7f0 Update pdf.js to 4.5.136 2024-08-07 15:16:38 +02:00
Ozzie Isaacs b3d878bae8 Fix deprecation warning datetime 2024-08-07 13:53:27 +02:00
mapi68 9b12852544 Update messages.po 2024-08-06 06:37:05 +02:00
Ozzie Isaacs a7d58386eb Get version info from importlib.metadata 2024-08-05 20:27:34 +02:00
Ozzie Isaacs 68531a44d8 Changed formating of float values. IF value ends not with "0" tailing "0" are printed
Version update
2024-08-05 18:58:19 +02:00
Ozzie Isaacs d3233b4b9c Prepare release 2024-08-05 18:42:13 +02:00
Ozzie Isaacs bdac7a3257 Changed formating of float values. IF value ends not with "0" tailing "0" are printed 2024-08-04 16:39:05 +02:00
Ozzie Isaacs 0d359e9e57 Add cookie prefix environment variable to config 2024-08-04 15:52:19 +02:00
Ozzie Isaacs c4fad3cd20 Fix archiving bookm for kobo sync
Fix back function without search parameter
Updated Testresults
2024-08-04 07:12:44 +02:00
Ozzie Isaacs 9b520b1941 Fix archiving bookm for kobo sync
Fix back function without search parameter
Updated Testresults
2024-08-04 07:12:22 +02:00
Ozzie Isaacs bcf4a479ac Merge branch 'master' into Develop 2024-08-03 16:03:52 +02:00
Ozzie Isaacs abc923ade4 Fix for back function on simple search 2024-08-03 12:57:55 +02:00
Ozzie Isaacs 3aa72c8afb Added error message to task list 2024-08-03 11:25:07 +02:00
Ozzie Isaacs 249017a4fa Added error message to task list 2024-08-02 18:08:18 +02:00
Ozzie Isaacs 84f432830c Fix for #3109 (search for custom int and float values within ranges) 2024-08-02 18:07:22 +02:00
Ozzie Isaacs e6ccd42f89 Fixed read statu search with linked column in advanced search 2024-07-31 19:25:01 +02:00
Ozzie Isaacs 12d46d33d9 Search custom integer, custom_float in range
Search custom bool like read status (unfinished)
2024-07-30 19:20:13 +02:00
Ozzie Isaacs 8d9f301aea Merge branch 'master' into Develop
# Conflicts:
#	test/Calibre-Web TestSummary_Linux.html
2024-07-30 18:52:22 +02:00
Ozzie Isaacs 0ff3f0f426 Fix for (#3111) exception on python 3.8/3.9 during storing of remember me cookie 2024-07-30 18:51:52 +02:00
Ozzie Isaacs b2f5853b12 Removed unused imports
Rearranged imports to output all missing libs
Update Testresults
Updated requirements
2024-07-29 20:08:17 +02:00
Ozzie Isaacs f8adcbb691 Make light theme default theme on epub reader
Fix csp Header for reader and google drive (#3108)
Testrun
2024-07-25 21:42:32 +02:00
Ozzie Isaacs a0728b07d0 Fix wrong quotes on calibre binary call for windows 2024-07-21 13:46:36 +02:00
Ozzie Isaacs e3b895755d Update test results 2024-07-21 13:46:36 +02:00
Ozzie Isaacs e33e4ac4ac Bugfix upload book format with language to book without language 2024-07-21 09:44:32 +02:00
Ozzie Isaacs 2f515b725b Schedules reconnect is now working even if reconnect route is not available ( #3107) 2024-07-20 18:14:54 +02:00
Ozzie Isaacs a1d7dc5c28 Merge branch 'master' into Develop 2024-07-20 14:01:36 +02:00
Ozzie Isaacs 1c5d1e24f7 Update test results 2024-07-20 13:58:53 +02:00
Ozzie Isaacs 8df45d150d Added hints for libmagic on Windows (fix #3104) 2024-07-20 13:51:45 +02:00
Ozzie Isaacs a59d20bd0f Output error message in case import of libmagic fails (#3104) 2024-07-20 11:31:31 +02:00
Ozzie Isaacs e3be7595e2 Attention flask-httpAuth is additionally needed to run calibre-web
Updated pdf.js viewer
Refactored login routine (#2623)
2024-07-18 20:42:25 +02:00
Ozzie Isaacs e33a7a3f08 Testupdate 2024-07-18 20:39:46 +02:00
Ozzie Isaacs 8c02ffb1cb Test update 2024-07-18 20:39:35 +02:00
Ozzie Isaacs 7367e96d2b Fix hiding console window on windows 2024-07-18 20:32:47 +02:00
Ozzie Isaacs 2d80187faa Clean expired sessions (also old sessions without expiry date) 2024-07-17 20:05:51 +02:00
Ozzie Isaacs 1d3a768dfe Update login routine (remember me working) 2024-07-17 19:42:12 +02:00
Ozzie Isaacs c09e1ed203 Update after testrun 2024-07-15 19:38:54 +02:00
Ozzie Isaacs 99e50bafc4 Fixes refactored user login from tests 2024-07-14 21:20:46 +02:00
Ozzie Isaacs 155cf04536 Added migration of user_session table, removed several old migration routes 2024-07-14 17:10:13 +02:00
Ozzie Isaacs ebe7cd7ba4 proxy login is now no longer saving cookies,
Cookies are saved in database for better Invalidation
Cookies expiry date is saved in database for further deletion (missing)
Database conversion is missing
2024-07-14 16:24:07 +02:00
Ozzie Isaacs 2d470e0ce1 Refactored opds login (proxy header login missing) 2024-07-13 12:17:48 +02:00
Ozzie Isaacs 87c0b15f5f Update pdf.js to version 4.4.168 2024-07-13 10:49:47 +02:00
Ozzie Isaacs 02d2c2bbf8 Better error message on failed convert because of running calibre instance (#3100) 2024-07-11 20:30:35 +02:00
Ozzie Isaacs 6996e813b5 Fixed error during debug logging on upload 2024-07-11 20:29:49 +02:00
Ozzie Isaacs 840f4d2010 Update to make upload of m4a and m4b files work (#3102) 2024-07-11 20:10:15 +02:00
Ozzie Isaacs 3cda0a0f32 Testupdate 2024-07-11 19:39:33 +02:00
Ozzie Isaacs a6931b4936 Merge branch 'master' into Develop:
Logging correct ip address behind reverse proxy,
add br to not sanitized tag for comments
code cleaning
2024-07-10 20:10:05 +02:00
Ozzie Isaacs 9536b2c322 Fix for renaming tags etc. upon upload (#3058) 2024-07-10 20:09:03 +02:00
Ozzie Isaacs 099dbf95e7 Fix error 500 on sending test email 2024-07-10 19:59:21 +02:00
Ozzie Isaacs 23d5463213 Fixes from testrun 2024-07-10 19:58:30 +02:00
Ozzie Isaacs a2ceeb678f Fix for similar names (upper case, umlauts, emphasis mark) in tags and so on 2024-07-09 19:37:24 +02:00
Ozzie Isaacs 092329c9e6 Merge remote-tracking branch 'origin/master' 2024-07-08 20:14:24 +02:00
Ozzie Isaacs de989783e8 Log Forwarded for address in accesslog instead of client address for tornado 2024-07-08 15:04:53 +02:00
Ozzie Isaacs 1d7dcce98d Log Forwarded for address in accesslog instead of client address for gevent 2024-07-08 14:42:48 +02:00
Ozzie Isaacs 551828f8cf Added "br" to not sanitized comments tag 2024-07-08 14:09:53 +02:00
Ozzie Isaacs d26964ab08 Added log message for missing python magic 2024-07-08 12:46:23 +02:00
Ozzie Isaacs 0fe12fe598 Added log message for missing python magic 2024-07-08 12:45:35 +02:00
Ozzie Isaacs b0e8024f08 Updated readme for setup calibre binaries
Added h tags to recognized tags for sanitizing comments
Avoid depreciation warning in lubimyczytac
Removed some unsed imports
2024-07-08 12:34:52 +02:00
Ozzie Isaacs bc1c097271 Updated readme for setup calibre binaries
Added h tags to recognized tags for sanitizing comments
Avoid depreciation warning in lubimyczytac
Removed some unsed imports
2024-07-08 12:34:10 +02:00
Ozzie Isaacs f98cb3d8fb Merge branch 'master' into Develop 2024-07-08 11:50:32 +02:00
Ozzie Isaacs 488005962f Update testresults 2024-07-08 11:49:53 +02:00
Ozzie Isaacs 6d6f8c1b1f Update redirect for login/logout in case of anonymous browsing enabled 2024-07-07 15:53:26 +02:00
Ozzie Isaacs c4a38087d9 Merge remote-tracking branch 'csv_kindle/master' 2024-07-07 14:55:53 +02:00
Ozzie Isaacs f468b9e6e3 Merge branch 'master' into Develop -> Epub progress indicator 2024-07-07 10:40:46 +02:00
Ozzie Isaacs 53a16c5a6f Merge remote-tracking branch 'epub_progress/progress_indicator' 2024-07-07 10:39:06 +02:00
Ozzie Isaacs 108caa0898 Merge branch 'Develop' 2024-07-07 10:32:02 +02:00
Ozzie Isaacs a0b776cbd1 Merge remote-tracking branch 'epub_reader_font/feat_font' into Develop 2024-07-07 10:31:49 +02:00
Ozzie Isaacs 4719797aff Merge branch 'Develop' epub reader themes 2024-07-07 10:22:56 +02:00
Ozzie Isaacs 60cc0c9395 Merge remote-tracking branch 'epub_theme/Develop' into Develop 2024-07-07 10:22:32 +02:00
Ozzie Isaacs ded4cb7a19 Merge remote-tracking branch 'it/patch-1' 2024-07-07 10:18:31 +02:00
Ozzie Isaacs f5f16a3dd2 Merge branch 'master' into Develop 2024-07-06 14:34:45 +02:00
Ozzie Isaacs c5f2323c8a Version bump 2024-07-06 14:21:49 +02:00
mapi68 ea89fe07d8 Update messages.po 2024-07-05 04:35:07 +02:00
Purbayan Chowdhury 5d86bb0ba0 Merge branch 'janeczku:Develop' into Develop 2024-05-23 01:45:36 +05:30
Purbayan Chowdhury 05032aed3c Updating theme logic and persisting theme change 2024-01-08 16:56:13 +05:30
lucas 7e46f9c0b1 Add font/spread customize for epub reader 2023-08-09 17:10:09 +08:00
quarz12 5e15cac598 deleted empty lines 2023-06-09 18:32:32 +02:00
quarz12 d0551274dc switched to using epub.min.js, deleted now unnecessary dependencies 2023-06-09 18:32:30 +02:00
quarz12 99c05650a1 -added pako and epub-cfi-resolver as dependencies
-cfi resolving is now done by that module, therefore removed validateChildNodes and adjusted cfiToXmlNode
2023-06-09 18:32:28 +02:00
quarz12 617ee3d781 now shows correct numbers, still needs bugfixes 2023-06-09 18:32:26 +02:00
quarz12 47aa3254c7 better css positioning 2023-06-09 18:32:24 +02:00
quarz12 0717b9493c added progress display, better rounding
TODO: getPrev gives wrong values,
design, testing
2023-06-09 18:32:22 +02:00
quarz12 824ebc4d76 added event on change position 2023-06-09 18:32:22 +02:00
quarz12 cbaa61bbb7 EpubParser now gets automatically loaded
TODO:detect page change, display number
2023-06-09 18:32:15 +02:00
quarz12 dbac685985 implemented current file process, needs testing but looks good 2023-06-09 18:32:11 +02:00
quarz12 b25c024b66 added getCurrentFileProgress,
TODO: resolve CFI to xml node
2023-06-09 18:32:10 +02:00
quarz12 bc65180c32 restructured and fixed a bug in getprevfilesize 2023-06-09 18:32:06 +02:00
quarz12 10c66b6e63 wrote a class to handle epub parsing, implemented bytesize of all previous files, total bytesize. still needs implementation of bytesize of current file until CFI point. 2023-06-09 18:32:05 +02:00
quarz12 c29b1696f7 added function to get bytelength of book text 2023-06-09 18:31:55 +02:00
gjutras c974cd048e minor formatting 2022-08-02 19:51:31 -04:00
gjutras 24e9bf2728 Merge branch 'janeczku:master' into master 2022-08-01 16:38:46 -04:00
gjutras ff46af9c49 fix indentation and spaces 2022-07-10 17:23:20 -04:00
gjutras 0a7345186d Merge branch 'janeczku-master'
# Conflicts:
#	cps/helper.py
#	cps/tasks/convert.py
#	cps/templates/user_edit.html
2022-07-08 16:42:14 -04:00
gjutras cb0d9ec95c Allow kindle_email to be CSV to allow sending to multiple kindles 2022-03-28 19:59:59 -04:00
173 changed files with 35877 additions and 16412 deletions
+2
View File
@@ -1 +1,3 @@
graft src/calibreweb
global-exclude __pycache__
global-exclude *.pyc
+104 -42
View File
@@ -1,10 +1,3 @@
# Short Notice from the maintainer
After 6 years of more or less intensive programming on Calibre-Web, I need a break.
The last few months, maintaining Calibre-Web has felt more like work than a hobby. I felt pressured and teased by people to solve "their" problems and merge PRs for "their" Calibre-Web.
I have turned off all notifications from Github/Discord and will now concentrate undisturbed on the development of “my” Calibre-Web over the next few weeks/months.
I will look into the issues and maybe also the PRs from time to time, but don't expect a quick response from me.
# Calibre-Web
Calibre-Web is a web app that offers a clean and intuitive interface for browsing, reading, and downloading eBooks using a valid [Calibre](https://calibre-ebook.com) database.
@@ -26,13 +19,13 @@ Calibre-Web is a web app that offers a clean and intuitive interface for browsin
- [Quick start](#quick-start)
- [Requirements](#requirements)
4. [Docker Images](#docker-images)
5. [Contributor Recognition](#contributor-recognition)
6. [Contact](#contact)
7. [Contributing to Calibre-Web](#contributing-to-calibre-web)
5. [Troubleshooting](#troubleshooting)
6. [Contributor Recognition](#contributor-recognition)
7. [Contact](#contact)
8. [Contributing to Calibre-Web](#contributing-to-calibre-web)
</details>
*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)
@@ -64,51 +57,103 @@ Calibre-Web is a web app that offers a clean and intuitive interface for browsin
## Installation
#### Installation via pip (recommended)
1. Create a virtual environment for Calibre-Web to avoid conflicts with existing Python dependencies
2. Install Calibre-Web via pip: `pip install calibreweb` (or `pip3` depending on your OS/distro)
3. Install optional features via pip as needed, see [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-and-Windows) for details
4. Start Calibre-Web by typing `cps`
### Installation via pip (recommended)
*Note: Raspberry Pi OS users may encounter issues during installation. If so, please update pip (`./venv/bin/python3 -m pip install --upgrade pip`) and/or install cargo (`sudo apt install cargo`) before retrying the installation.*
1. **Create a virtual environment**: It’s essential to isolate your Calibre-Web installation to avoid dependency conflicts. You can create a virtual environment by running:
```
python3 -m venv calibre-web-env
```
2. **Activate the virtual environment**:
```
source calibre-web-env/bin/activate
```
3. **Install Calibre-Web**: Use pip to install the application:
```
pip install calibreweb
```
4. **Install optional features**: For additional functionality, you may need to install optional features. Refer to [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-and-Windows) for details on what can be installed.
5. **Start Calibre-Web**: After installation, you can start the application with:
```
cps
```
Refer to the Wiki for additional installation examples: [manual installation](https://github.com/janeczku/calibre-web/wiki/Manual-installation), [Linux Mint](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-in-Linux-Mint-19-or-20), [Cloud Provider](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-on-a-Cloud-Provider).
*Note: Users of Raspberry Pi OS may encounter installation issues. If you do, try upgrading pip and/or installing cargo as follows:*
```
./venv/bin/python3 -m pip install --upgrade pip
sudo apt install cargo
```
### Important Links
- For additional installation examples, check the following:
- [Manual installation](https://github.com/janeczku/calibre-web/wiki/Manual-installation)
- [Linux Mint installation](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-in-Linux-Mint-19-or-20)
- [Cloud Provider setup](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-on-a-Cloud-Provider)
## Quick Start
1. Open your browser and navigate to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
2. Log in with the default admin credentials
3. If you don't have a Calibre database, you can use [this database](https://github.com/janeczku/calibre-web/raw/master/library/metadata.db) (move it out of the Calibre-Web folder to prevent overwriting during updates)
4. Set `Location of Calibre database` to the path of the folder containing your Calibre library (metadata.db) and click "Save"
5. Optionally, use Google Drive to host your Calibre library by following the [Google Drive integration guide](https://github.com/janeczku/calibre-web/wiki/G-Drive-Setup#using-google-drive-integration)
6. Configure your Calibre-Web instance via the admin page, referring to the [Basic Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#basic-configuration) and [UI Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#ui-configuration) guides
#### Default Admin Login:
- **Username:** admin
- **Password:** admin123
1. **Access Calibre-Web**: Open your browser and navigate to:
```
http://localhost:8083
```
or for the OPDS catalog:
```
http://localhost:8083/opds
```
2. **Log in**: Use the default admin credentials:
- **Username:** admin
- **Password:** admin123
3. **Database Setup**: If you do not have a Calibre database, download a sample from:
```
https://github.com/janeczku/calibre-web/raw/master/library/metadata.db
```
Move it out of the Calibre-Web folder to avoid overwriting during updates.
4. **Configure Calibre Database**: In the admin interface, set the `Location of Calibre database` to the path of the folder containing your Calibre library (where `metadata.db` is located) and click "Save".
5. **Google Drive Integration**: For hosting your Calibre library on Google Drive, refer to the [Google Drive integration guide](https://github.com/janeczku/calibre-web/wiki/G-Drive-Setup#using-google-drive-integration).
6. **Admin Configuration**: Configure your instance via the admin page, referring to the [Basic Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#basic-configuration) and [UI Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#ui-configuration) guides.
## Requirements
- Python 3.5+
- [Imagemagick](https://imagemagick.org/script/download.php) for cover extraction from EPUBs (Windows users may need to install [Ghostscript](https://ghostscript.com/releases/gsdnld.html) for PDF cover extraction)
- Optional: [Calibre desktop program](https://calibre-ebook.com/download) for on-the-fly conversion and metadata editing (set "calibre's converter tool" path on the setup page)
- Optional: [Kepubify tool](https://github.com/pgaskin/kepubify/releases/latest) for Kobo device support (place the binary in `/opt/kepubify` on Linux or `C:\Program Files\kepubify` on Windows)
- **Python Version**: Ensure you have Python 3.7 or newer.
- **Imagemagick**: Required for cover extraction from EPUBs. Windows users may also need to install [Ghostscript](https://ghostscript.com/releases/gsdnld.html) for PDF cover extraction.
- **Windows Users**: Install [libmagic](https://gnuwin32.sourceforge.net/downlinks/file.php) for 32bit python or [libmagic for 64bit python](https://github.com/nscaife/file-windows/releases/tag/20170108), depending on your Python version. Make sure these files are included in your path.
- **Optional Tools**:
- **Calibre desktop program**: Recommended for on-the-fly conversion and metadata editing. Set the path to Calibre’s converter tool on the setup page.
- **Kepubify tool**: Needed for Kobo device support. Download the tool and place the binary in `/opt/kepubify` on Linux or `C:\Program Files\kepubify` on Windows.
## Docker Images
Pre-built Docker images are available in the following Docker Hub repositories (maintained by the LinuxServer team):
Pre-built Docker images are available:
#### **LinuxServer - x64, aarch64**
- [Docker Hub](https://hub.docker.com/r/linuxserver/calibre-web)
- [GitHub](https://github.com/linuxserver/docker-calibre-web)
- [GitHub - Optional Calibre layer](https://github.com/linuxserver/docker-mods/tree/universal-calibre)
### **LinuxServer - x64, aarch64**
- **Docker Hub**: [linuxserver/calibre-web](https://hub.docker.com/r/linuxserver/calibre-web)
- **GitHub**: [linuxserver/docker-calibre-web](https://github.com/linuxserver/docker-calibre-web)
- **Optional Calibre layer**: [linuxserver/docker-mods](https://github.com/linuxserver/docker-mods/tree/universal-calibre)
Include the environment variable `DOCKER_MODS=linuxserver/mods:universal-calibre` in your Docker run/compose file to add the Calibre `ebook-convert` binary (x64 only). Omit this variable for a lightweight image.
To include the Calibre `ebook-convert` binary (x64 only), add the environment variable:
```
DOCKER_MODS=linuxserver/mods:universal-calibre
```
in your Docker run/compose file. Omit this variable for a lightweight image.
Both the Calibre-Web and Calibre-Mod images are automatically rebuilt on new releases and updates.
- **Paths Configuration**:
- Set **Path to Calibre Binaries** to `/usr/bin`.
- Set **Path to Unrar** to `/usr/bin/unrar`.
- Set "path to convertertool" to `/usr/bin/ebook-convert`
- Set "path to unrar" to `/usr/bin/unrar`
## Troubleshooting
- **Common Issues**:
- If you experience issues starting the application, check the log files located in the `logs` directory for error messages.
- If eBooks fail to load, verify that the `Location of Calibre database` is correctly set and that the database file is accessible.
- **Configuration Errors**: Ensure that your Calibre database is compatible and properly formatted. Refer to the Calibre documentation for guidance on maintaining the database.
- **Performance Problems**:
- If the application is slow, consider increasing the allocated resources (CPU/RAM) to your server or optimizing the Calibre database by removing duplicates and unnecessary entries.
- Regularly clear the cache in your web browser to improve loading times.
- **User Management Issues**: If users are unable to log in or register, check the user permission settings in the admin interface. Ensure that registration is enabled and that users are being assigned appropriate roles.
- **Support Resources**: For additional help, consider visiting the [FAQ section](https://github.com/janeczku/calibre-web/wiki/FAQ) of the wiki or posting your questions in the [Discord community](https://discord.gg/h2VsJ2NEfB).
## Contributor Recognition
@@ -122,4 +167,21 @@ For more information, How To's, and FAQs, please visit the [Wiki](https://github
## Contributing to Calibre-Web
Check out our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
To contribute, please check our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md). We welcome issues, feature requests, and pull requests from the community.
### Reporting Bugs
If you encounter bugs or issues, please report them in the [issues section](https://github.com/janeczku/calibre-web/issues) of the repository. Be sure to include detailed information about your setup and the problem encountered.
### Feature Requests
We welcome suggestions for new features. Please create a new issue in the repository to discuss your ideas.
## Additional Resources
- **Documentation**: Comprehensive documentation is available on the [Calibre-Web wiki](https://github.com/janeczku/calibre-web/wiki).
- **Community Contributions**: Explore the [community contributions](https://github.com/janeczku/calibre-web/pulls) to see ongoing work and how you can get involved.
---
Thank you for using Calibre-Web! We hope you enjoy managing your eBook library with our tool.
+40 -35
View File
@@ -10,41 +10,46 @@ To receive fixes for security vulnerabilities it is required to always upgrade t
## History
| Fixed in | Description |CVE number |
|---------------|--------------------------------------------------------------------------------------------------------------------|---------|
| 3rd July 2018 | Guest access acts as a backdoor ||
| V 0.6.7 | Hardcoded secret key for sessions |CVE-2020-12627 |
| V 0.6.13 | Calibre-Web Metadata cross site scripting |CVE-2021-25964|
| V 0.6.13 | Name of Shelves are only visible to users who can access the corresponding shelf Thanks to @ibarrionuevo ||
| V 0.6.13 | JavaScript could get executed in the description field. Thanks to @ranjit-git and Hagai Wechsler (WhiteSource) ||
| V 0.6.13 | JavaScript could get executed in a custom column of type "comment" field ||
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a title containing javascript code ||
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a username containing javascript code ||
| V 0.6.13 | JavaScript could get executed in the description series, categories or publishers title ||
| V 0.6.13 | JavaScript could get executed in the shelf title ||
| V 0.6.13 | Login with the old session cookie after logout. Thanks to @ibarrionuevo ||
| V 0.6.14 | CSRF was possible. Thanks to @mik317 and Hagai Wechsler (WhiteSource) |CVE-2021-25965|
| V 0.6.14 | Migrated some routes to POST-requests (CSRF protection). Thanks to @scara31 |CVE-2021-4164|
| V 0.6.15 | Fix for "javascript:" script links in identifier. Thanks to @scara31 |CVE-2021-4170|
| V 0.6.15 | Cross-Site Scripting vulnerability on uploaded cover file names. Thanks to @ibarrionuevo ||
| V 0.6.15 | Creating public shelfs is now denied if user is missing the edit public shelf right. Thanks to @ibarrionuevo ||
| V 0.6.15 | Changed error message in case of trying to delete a shelf unauthorized. Thanks to @ibarrionuevo ||
| V 0.6.16 | JavaScript could get executed on authors page. Thanks to @alicaz |CVE-2022-0352|
| V 0.6.16 | Localhost can no longer be used to upload covers. Thanks to @scara31 |CVE-2022-0339|
| V 0.6.16 | Another case where public shelfs could be created without permission is prevented. Thanks to @nhiephon |CVE-2022-0273|
| V 0.6.16 | It's prevented to get the name of a private shelfs. Thanks to @nhiephon |CVE-2022-0405|
| V 0.6.17 | The SSRF Protection can no longer be bypassed via an HTTP redirect. Thanks to @416e6e61 |CVE-2022-0767|
| V 0.6.17 | The SSRF Protection can no longer be bypassed via 0.0.0.0 and it's ipv6 equivalent. Thanks to @r0hanSH |CVE-2022-0766|
| V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) |CVE-2022-30765|
| V 0.6.18 | The SSRF protection no longer can be bypassed by IPV6/IPV4 embedding. Thanks to @416e6e61 |CVE-2022-0939|
| V 0.6.18 | The SSRF protection no longer can be bypassed to connect to other servers in the local network. Thanks to @michaellrowley |CVE-2022-0990|
| V 0.6.20 | Credentials for emails are now stored encrypted ||
| V 0.6.20 | Login is rate limited ||
| V 0.6.20 | Passwordstrength can be forced ||
| V 0.6.21 | SMTP server credentials are no longer returned to client ||
| V 0.6.21 | Cross-site scripting (XSS) stored in href bypasses filter using data wrapper no longer possible ||
| V 0.6.21 | Cross-site scripting (XSS) is no longer possible via pathchooser ||
| V 0.6.21 | Error Handling at non existent rating, language, and user downloaded books was fixed ||
| Fixed in | Description |CVE number |
|---------------|--------------------------------------------------------------------------------------------------------------------------------|---------|
| 3rd July 2018 | Guest access acts as a backdoor ||
| V 0.6.7 | Hardcoded secret key for sessions |CVE-2020-12627 |
| V 0.6.13 | Calibre-Web Metadata cross site scripting |CVE-2021-25964|
| V 0.6.13 | Name of Shelves are only visible to users who can access the corresponding shelf Thanks to @ibarrionuevo ||
| V 0.6.13 | JavaScript could get executed in the description field. Thanks to @ranjit-git and Hagai Wechsler (WhiteSource) ||
| V 0.6.13 | JavaScript could get executed in a custom column of type "comment" field ||
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a title containing javascript code ||
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a username containing javascript code ||
| V 0.6.13 | JavaScript could get executed in the description series, categories or publishers title ||
| V 0.6.13 | JavaScript could get executed in the shelf title ||
| V 0.6.13 | Login with the old session cookie after logout. Thanks to @ibarrionuevo ||
| V 0.6.14 | CSRF was possible. Thanks to @mik317 and Hagai Wechsler (WhiteSource) |CVE-2021-25965|
| V 0.6.14 | Migrated some routes to POST-requests (CSRF protection). Thanks to @scara31 |CVE-2021-4164|
| V 0.6.15 | Fix for "javascript:" script links in identifier. Thanks to @scara31 |CVE-2021-4170|
| V 0.6.15 | Cross-Site Scripting vulnerability on uploaded cover file names. Thanks to @ibarrionuevo ||
| V 0.6.15 | Creating public shelfs is now denied if user is missing the edit public shelf right. Thanks to @ibarrionuevo ||
| V 0.6.15 | Changed error message in case of trying to delete a shelf unauthorized. Thanks to @ibarrionuevo ||
| V 0.6.16 | JavaScript could get executed on authors page. Thanks to @alicaz |CVE-2022-0352|
| V 0.6.16 | Localhost can no longer be used to upload covers. Thanks to @scara31 |CVE-2022-0339|
| V 0.6.16 | Another case where public shelfs could be created without permission is prevented. Thanks to @nhiephon |CVE-2022-0273|
| V 0.6.16 | It's prevented to get the name of a private shelfs. Thanks to @nhiephon |CVE-2022-0405|
| V 0.6.17 | The SSRF Protection can no longer be bypassed via an HTTP redirect. Thanks to @416e6e61 |CVE-2022-0767|
| V 0.6.17 | The SSRF Protection can no longer be bypassed via 0.0.0.0 and it's ipv6 equivalent. Thanks to @r0hanSH |CVE-2022-0766|
| V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) |CVE-2022-30765|
| V 0.6.18 | The SSRF protection no longer can be bypassed by IPV6/IPV4 embedding. Thanks to @416e6e61 |CVE-2022-0939|
| V 0.6.18 | The SSRF protection no longer can be bypassed to connect to other servers in the local network. Thanks to @michaellrowley |CVE-2022-0990|
| V 0.6.20 | Credentials for emails are now stored encrypted ||
| V 0.6.20 | Login is rate limited ||
| V 0.6.20 | Passwordstrength can be forced ||
| V 0.6.21 | SMTP server credentials are no longer returned to client ||
| V 0.6.21 | Cross-site scripting (XSS) stored in href bypasses filter using data wrapper no longer possible ||
| V 0.6.21 | Cross-site scripting (XSS) is no longer possible via pathchooser ||
| V 0.6.21 | Error Handling at non existent rating, language, and user downloaded books was fixed ||
| V 0.6.22 | Upload mimetype is checked to prevent malicious file content in the books library ||
| V 0.6.22 | Cross-site scripting (XSS) stored in comments section is prevented better (switching from lxml to bleach for sanitizing strings) ||
| V 0.6.23 | Cookies are no longer stored for opds basic authentication and proxy authentication ||
## Statement regarding Log4j (CVE-2021-44228 and related)
+8 -12
View File
@@ -30,19 +30,15 @@ from cps.main import main
def hide_console_windows():
import ctypes
import os
hwnd = ctypes.windll.kernel32.GetConsoleWindow()
if hwnd != 0:
try:
import win32process
except ImportError:
print("To hide console window install 'pywin32' using 'pip install pywin32'")
return
ctypes.windll.user32.ShowWindow(hwnd, 0)
ctypes.windll.kernel32.CloseHandle(hwnd)
_, pid = win32process.GetWindowThreadProcessId(hwnd)
os.system('taskkill /PID ' + str(pid) + ' /f')
kernel32 = ctypes.WinDLL('kernel32')
user32 = ctypes.WinDLL('user32')
SW_HIDE = 0
hWnd = kernel32.GetConsoleWindow()
if hWnd:
user32.ShowWindow(hWnd, SW_HIDE)
if __name__ == '__main__':
+4 -20
View File
@@ -20,11 +20,8 @@
# 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 flask_login import LoginManager, confirm_login
from flask import session, current_app
from flask_login.utils import decode_cookie
from flask_login.signals import user_loaded_from_cookie
from .cw_login import LoginManager
from flask import session
class MyLoginManager(LoginManager):
@@ -36,18 +33,5 @@ class MyLoginManager(LoginManager):
return super(). _session_protection_failed()
return False
def _load_user_from_remember_cookie(self, cookie):
user_id = decode_cookie(cookie)
if user_id is not None:
session["_user_id"] = user_id
session["_fresh"] = False
user = None
if self._user_callback:
user = self._user_callback(user_id)
if user is not None:
app = current_app._get_current_object()
user_loaded_from_cookie.send(app, user=user)
# if session was restored from remember me cookie make login valid
confirm_login()
return user
return None
Executable → Regular
+10 -6
View File
@@ -31,12 +31,10 @@ from flask_principal import Principal
from . import logger
from .cli import CliParameter
from .constants import CONFIG_DIR
from .reverseproxy import ReverseProxied
from .server import WebServer
from .dep_check import dependency_check
from .updater import Updater
from .babel import babel, get_locale
from . import config_sql
from . import cache_buster
from . import ub, db
@@ -70,8 +68,11 @@ mimetypes.add_type('image/vnd.djv', '.djv')
mimetypes.add_type('image/vnd.djv', '.djvu')
mimetypes.add_type('application/mpeg', '.mpeg')
mimetypes.add_type('audio/mpeg', '.mp3')
mimetypes.add_type('application/mp4', '.m4a')
mimetypes.add_type('application/mp4', '.m4b')
mimetypes.add_type('audio/x-m4a', '.m4a')
mimetypes.add_type('audio/x-m4a', '.m4b')
mimetypes.add_type('audio/x-hx-aac-adts', '.aac')
mimetypes.add_type('audio/vnd.dolby.dd-raw', '.ac3')
mimetypes.add_type('video/x-ms-asf', '.asf')
mimetypes.add_type('audio/ogg', '.ogg')
mimetypes.add_type('application/ogg', '.oga')
mimetypes.add_type('text/css', '.css')
@@ -84,8 +85,10 @@ app = Flask(__name__)
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Lax',
REMEMBER_COOKIE_SAMESITE='Lax', # will be available in flask-login 0.5.1 earliest
WTF_CSRF_SSL_STRICT=False
REMEMBER_COOKIE_SAMESITE='Strict',
WTF_CSRF_SSL_STRICT=False,
SESSION_COOKIE_NAME=os.environ.get('COOKIE_PREFIX', "") + "session",
REMEMBER_COOKIE_NAME=os.environ.get('COOKIE_PREFIX', "") + "remember_token"
)
lm = MyLoginManager()
@@ -178,6 +181,7 @@ def create_app():
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
web_server.init_app(app, config)
from .cw_babel import babel, get_locale
if hasattr(babel, "localeselector"):
babel.init_app(app)
babel.localeselector(get_locale)
+8 -7
View File
@@ -23,15 +23,15 @@
import sys
import platform
import sqlite3
import importlib
from collections import OrderedDict
import flask
import flask_login
import jinja2
from flask_babel import gettext as _
from . import db, calibre_db, converter, uploader, constants, dep_check
from .render_template import render_title_template
from .usermanagement import user_login_required
about = flask.Blueprint('about', __name__)
@@ -41,17 +41,18 @@ req = dep_check.load_dependencies(False)
opt = dep_check.load_dependencies(True)
for i in (req + opt):
modules[i[1]] = i[0]
modules['Jinja2'] = jinja2.__version__
modules['pySqlite'] = sqlite3.version
modules['Jinja2'] = importlib.metadata.version("jinja2")
if sys.version_info < (3, 12):
modules['pySqlite'] = sqlite3.version
modules['SQLite'] = sqlite3.sqlite_version
sorted_modules = OrderedDict((sorted(modules.items(), key=lambda x: x[0].casefold())))
def collect_stats():
if constants.NIGHTLY_VERSION[0] == "$Format:%H$":
calibre_web_version = constants.STABLE_VERSION['version'].replace("b", " Beta")
calibre_web_version = constants.STABLE_VERSION.replace("b", " Beta")
else:
calibre_web_version = (constants.STABLE_VERSION['version'].replace("b", " Beta") + ' - '
calibre_web_version = (constants.STABLE_VERSION.replace("b", " Beta") + ' - '
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'))
@@ -74,7 +75,7 @@ def collect_stats():
@about.route("/stats")
@flask_login.login_required
@user_login_required
def stats():
counter = calibre_db.session.query(db.Books).count()
authors = calibre_db.session.query(db.Authors).count()
Executable → Regular
+108 -81
View File
@@ -34,10 +34,9 @@ from urllib.parse import urlparse
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
from markupsafe import Markup
from flask_login import login_required, current_user, logout_user
from .cw_login import current_user
from flask_babel import gettext as _
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
from flask import session as flask_session
from sqlalchemy import and_
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
@@ -52,8 +51,10 @@ from .embed_helper import get_calibre_binarypath
from .gdriveutils import is_gdrive_ready, gdrive_support
from .render_template import render_title_template, get_sidebar_config
from .services.worker import WorkerThread
from .babel import get_available_translations, get_available_locale, get_user_locale_language
from .usermanagement import user_login_required
from .cw_babel import get_available_translations, get_available_locale, get_user_locale_language
from . import debug_info
from .string_helper import strip_whitespaces
log = logger.create()
@@ -103,13 +104,13 @@ def admin_required(f):
@admi.before_app_request
def before_request():
try:
if not ub.check_user_session(current_user.id,
flask_session.get('_id')) and 'opds' not in request.path \
and config.config_session == 1:
logout_user()
except AttributeError:
pass # ? fails on requesting /ajax/emailstat during restart ?
#try:
#if not ub.check_user_session(current_user.id,
# flask_session.get('_id')) and 'opds' not in request.path \
# and config.config_session == 1:
# logout_user()
#except AttributeError:
# pass # ? fails on requesting /ajax/emailstat during restart ?
g.constants = constants
g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '')
g.allow_registration = config.config_public_reg
@@ -129,14 +130,14 @@ def before_request():
return redirect(url_for('admin.db_configuration'))
@admi.route("/admin")
@login_required
def admin_forbidden():
abort(403)
#@admi.route("/admin")
#@user_login_required
#def admin_forbidden():
# abort(403)
@admi.route("/shutdown", methods=["POST"])
@login_required
@user_login_required
@admin_required
def shutdown():
task = request.get_json().get('parameter', -1)
@@ -165,7 +166,7 @@ def shutdown():
@admi.route("/metadata_backup", methods=["POST"])
@login_required
@user_login_required
@admin_required
def queue_metadata_backup():
show_text = {}
@@ -189,7 +190,7 @@ def reconnect():
@admi.route("/ajax/updateThumbnails", methods=['POST'])
@admin_required
@login_required
@user_login_required
def update_thumbnails():
content = config.get_scheduled_task_settings()
if content['schedule_generate_book_covers']:
@@ -199,7 +200,7 @@ def update_thumbnails():
@admi.route("/admin/view")
@login_required
@user_login_required
@admin_required
def admin():
version = updater_thread.get_current_version_info()
@@ -233,7 +234,7 @@ def admin():
@admi.route("/admin/dbconfig", methods=["GET", "POST"])
@login_required
@user_login_required
@admin_required
def db_configuration():
if request.method == "POST":
@@ -242,7 +243,7 @@ def db_configuration():
@admi.route("/admin/config", methods=["GET"])
@login_required
@user_login_required
@admin_required
def configuration():
return render_title_template("config_edit.html",
@@ -253,28 +254,28 @@ def configuration():
@admi.route("/admin/ajaxconfig", methods=["POST"])
@login_required
@user_login_required
@admin_required
def ajax_config():
return _configuration_update_helper()
@admi.route("/admin/ajaxdbconfig", methods=["POST"])
@login_required
@user_login_required
@admin_required
def ajax_db_config():
return _db_configuration_update_helper()
@admi.route("/admin/alive", methods=["GET"])
@login_required
@user_login_required
@admin_required
def calibreweb_alive():
return "", 200
@admi.route("/admin/viewconfig")
@login_required
@user_login_required
@admin_required
def view_configuration():
read_column = calibre_db.session.query(db.CustomColumns) \
@@ -291,7 +292,7 @@ def view_configuration():
@admi.route("/admin/usertable")
@login_required
@user_login_required
@admin_required
def edit_user_table():
visibility = current_user.view_settings.get('useredit', {})
@@ -305,7 +306,15 @@ def edit_user_table():
.group_by(text('books_tags_link.tag')) \
.order_by(db.Tags.name).all()
if config.config_restricted_column:
custom_values = calibre_db.session.query(db.cc_classes[config.config_restricted_column]).all()
try:
custom_values = calibre_db.session.query(db.cc_classes[config.config_restricted_column]).all()
except (KeyError, AttributeError, IndexError):
custom_values = []
log.error("Custom Column No.{} does not exist in calibre database".format(
config.config_restricted_column))
flash(_("Custom Column No.%(column)d does not exist in calibre database",
column=config.config_restricted_column),
category="error")
else:
custom_values = []
if not config.config_anonbrowse:
@@ -326,7 +335,7 @@ def edit_user_table():
@admi.route("/ajax/listusers")
@login_required
@user_login_required
@admin_required
def list_users():
off = int(request.args.get("offset") or 0)
@@ -377,7 +386,7 @@ def list_users():
@admi.route("/ajax/deleteuser", methods=['POST'])
@login_required
@user_login_required
@admin_required
def delete_user():
user_ids = request.form.to_dict(flat=False)
@@ -412,7 +421,7 @@ def delete_user():
@admi.route("/ajax/getlocale")
@login_required
@user_login_required
@admin_required
def table_get_locale():
locale = get_available_locale()
@@ -424,7 +433,7 @@ def table_get_locale():
@admi.route("/ajax/getdefaultlanguage")
@login_required
@user_login_required
@admin_required
def table_get_default_lang():
languages = calibre_db.speaking_language()
@@ -436,7 +445,7 @@ def table_get_default_lang():
@admi.route("/ajax/editlistusers/<param>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def edit_list_user(param):
vals = request.form.to_dict(flat=False)
@@ -463,9 +472,9 @@ def edit_list_user(param):
if 'value[]' in vals:
setattr(user, param, prepare_tags(user, vals['action'][0], param, vals['value[]']))
else:
setattr(user, param, vals['value'].strip())
setattr(user, param, strip_whitespaces(vals['value']))
else:
vals['value'] = vals['value'].strip()
vals['value'] = strip_whitespaces(vals['value'])
if param == 'name':
if user.name == "Guest":
raise Exception(_("Guest Name can't be changed"))
@@ -541,7 +550,7 @@ def edit_list_user(param):
@admi.route("/ajax/user_table_settings", methods=['POST'])
@login_required
@user_login_required
@admin_required
def update_table_settings():
current_user.view_settings['useredit'] = json.loads(request.data)
@@ -558,7 +567,7 @@ def update_table_settings():
@admi.route("/admin/viewconfig", methods=["POST"])
@login_required
@user_login_required
@admin_required
def update_view_configuration():
to_save = request.form.to_dict()
@@ -566,7 +575,7 @@ def update_view_configuration():
_config_string(to_save, "config_calibre_web_title")
_config_string(to_save, "config_columns_to_ignore")
if _config_string(to_save, "config_title_regex"):
calibre_db.update_title_sort(config)
calibre_db.create_functions(config)
if not check_valid_read_column(to_save.get("config_read_column", "0")):
flash(_("Invalid Read Column"), category="error")
@@ -603,7 +612,7 @@ def update_view_configuration():
@admi.route("/ajax/loaddialogtexts/<element_id>", methods=['POST'])
@login_required
@user_login_required
def load_dialogtexts(element_id):
texts = {"header": "", "main": "", "valid": 1}
if element_id == "config_delete_kobo_token":
@@ -639,7 +648,7 @@ def load_dialogtexts(element_id):
@admi.route("/ajax/editdomain/<int:allow>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def edit_domain(allow):
# POST /post
@@ -653,7 +662,7 @@ def edit_domain(allow):
@admi.route("/ajax/adddomain/<int:allow>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def add_domain(allow):
domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower()
@@ -667,7 +676,7 @@ def add_domain(allow):
@admi.route("/ajax/deletedomain", methods=['POST'])
@login_required
@user_login_required
@admin_required
def delete_domain():
try:
@@ -685,12 +694,12 @@ def delete_domain():
@admi.route("/ajax/domainlist/<int:allow>")
@login_required
@user_login_required
@admin_required
def list_domain(allow):
answer = ub.session.query(ub.Registration).filter(ub.Registration.allow == allow).all()
json_dumps = json.dumps([{"domain": r.domain.replace('%', '*').replace('_', '?'), "id": r.id} for r in answer])
js = json.dumps(json_dumps.replace('"', "'")).lstrip('"').strip('"')
js = json.dumps(json_dumps.replace('"', "'")).strip('"')
response = make_response(js.replace("'", '"'))
response.headers["Content-Type"] = "application/json; charset=utf-8"
return response
@@ -698,7 +707,7 @@ def list_domain(allow):
@admi.route("/ajax/editrestriction/<int:res_type>", defaults={"user_id": 0}, methods=['POST'])
@admi.route("/ajax/editrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def edit_restriction(res_type, user_id):
element = request.form.to_dict()
@@ -764,14 +773,14 @@ def edit_restriction(res_type, user_id):
@admi.route("/ajax/addrestriction/<int:res_type>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def add_user_0_restriction(res_type):
return add_restriction(res_type, 0)
@admi.route("/ajax/addrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def add_restriction(res_type, user_id):
element = request.form.to_dict()
@@ -817,14 +826,14 @@ def add_restriction(res_type, user_id):
@admi.route("/ajax/deleterestriction/<int:res_type>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def delete_user_0_restriction(res_type):
return delete_restriction(res_type, 0)
@admi.route("/ajax/deleterestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def delete_restriction(res_type, user_id):
element = request.form.to_dict()
@@ -872,7 +881,7 @@ def delete_restriction(res_type, user_id):
@admi.route("/ajax/listrestriction/<int:res_type>", defaults={"user_id": 0})
@admi.route("/ajax/listrestriction/<int:res_type>/<int:user_id>")
@login_required
@user_login_required
@admin_required
def list_restriction(res_type, user_id):
if res_type == 0: # Tags as template
@@ -916,20 +925,20 @@ def list_restriction(res_type, user_id):
@admi.route("/ajax/fullsync", methods=["POST"])
@login_required
@user_login_required
def ajax_self_fullsync():
return do_full_kobo_sync(current_user.id)
@admi.route("/ajax/fullsync/<int:userid>", methods=["POST"])
@login_required
@user_login_required
@admin_required
def ajax_fullsync(userid):
return do_full_kobo_sync(userid)
@admi.route("/ajax/pathchooser/")
@login_required
@user_login_required
@admin_required
def ajax_pathchooser():
return pathchooser()
@@ -981,8 +990,14 @@ def prepare_tags(user, action, tags_name, id_list):
raise Exception(_("Tag not found"))
new_tags_list = [x.name for x in tags]
else:
tags = calibre_db.session.query(db.cc_classes[config.config_restricted_column]) \
.filter(db.cc_classes[config.config_restricted_column].id.in_(id_list)).all()
try:
tags = calibre_db.session.query(db.cc_classes[config.config_restricted_column]) \
.filter(db.cc_classes[config.config_restricted_column].id.in_(id_list)).all()
except (KeyError, AttributeError, IndexError):
log.error("Custom Column No.{} does not exist in calibre database".format(
config.config_restricted_column))
raise Exception(_("Custom Column No.%(column)d does not exist in calibre database",
column=config.config_restricted_column))
new_tags_list = [x.value for x in tags]
saved_tags_list = user.__dict__[tags_name].split(",") if len(user.__dict__[tags_name]) else []
if action == "remove":
@@ -1100,7 +1115,7 @@ def _config_checkbox_int(to_save, x):
def _config_string(to_save, x):
return config.set_from_dictionary(to_save, x, lambda y: y.strip().strip(u'\u200B\u200C\u200D\ufeff') if y else y)
return config.set_from_dictionary(to_save, x, lambda y: strip_whitespaces(y) if y else y)
def _configuration_gdrive_helper(to_save):
@@ -1246,7 +1261,7 @@ def _configuration_ldap_helper(to_save):
@admi.route("/ajax/simulatedbchange", methods=['POST'])
@login_required
@user_login_required
@admin_required
def simulatedbchange():
db_change, db_valid = _db_simulate_change()
@@ -1254,7 +1269,7 @@ def simulatedbchange():
@admi.route("/admin/user/new", methods=["GET", "POST"])
@login_required
@user_login_required
@admin_required
def new_user():
content = ub.User()
@@ -1276,7 +1291,7 @@ def new_user():
@admi.route("/admin/mailsettings", methods=["GET"])
@login_required
@user_login_required
@admin_required
def edit_mailsettings():
content = config.get_mail_settings()
@@ -1285,7 +1300,7 @@ def edit_mailsettings():
@admi.route("/admin/mailsettings", methods=["POST"])
@login_required
@user_login_required
@admin_required
def update_mailsettings():
to_save = request.form.to_dict()
@@ -1311,9 +1326,9 @@ def update_mailsettings():
if to_save.get("mail_password_e", ""):
_config_string(to_save, "mail_password_e")
_config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024)
config.mail_server = to_save.get('mail_server', "").strip()
config.mail_from = to_save.get('mail_from', "").strip()
config.mail_login = to_save.get('mail_login', "").strip()
config.mail_server = strip_whitespaces(to_save.get('mail_server', ""))
config.mail_from = strip_whitespaces(to_save.get('mail_from', ""))
config.mail_login = strip_whitespaces(to_save.get('mail_login', ""))
try:
config.save()
except (OperationalError, InvalidRequestError) as e:
@@ -1342,7 +1357,7 @@ def update_mailsettings():
@admi.route("/admin/scheduledtasks")
@login_required
@user_login_required
@admin_required
def edit_scheduledtasks():
content = config.get_scheduled_task_settings()
@@ -1363,7 +1378,7 @@ def edit_scheduledtasks():
@admi.route("/admin/scheduledtasks", methods=["POST"])
@login_required
@user_login_required
@admin_required
def update_scheduledtasks():
error = False
@@ -1406,7 +1421,7 @@ def update_scheduledtasks():
@admi.route("/admin/user/<int:user_id>", methods=["GET", "POST"])
@login_required
@user_login_required
@admin_required
def edit_user(user_id):
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
@@ -1435,7 +1450,7 @@ def edit_user(user_id):
@admi.route("/admin/resetpassword/<int:user_id>", methods=["POST"])
@login_required
@user_login_required
@admin_required
def reset_user_password(user_id):
if current_user is not None and current_user.is_authenticated:
@@ -1453,7 +1468,7 @@ def reset_user_password(user_id):
@admi.route("/admin/logfile")
@login_required
@user_login_required
@admin_required
def view_logfile():
logfiles = {0: logger.get_logfile(config.config_logfile),
@@ -1467,7 +1482,7 @@ def view_logfile():
@admi.route("/ajax/log/<int:logtype>")
@login_required
@user_login_required
@admin_required
def send_logfile(logtype):
if logtype == 1:
@@ -1483,7 +1498,7 @@ def send_logfile(logtype):
@admi.route("/admin/logdownload/<int:logtype>")
@login_required
@user_login_required
@admin_required
def download_log(logtype):
if logtype == 0:
@@ -1498,14 +1513,14 @@ def download_log(logtype):
@admi.route("/admin/debug")
@login_required
@user_login_required
@admin_required
def download_debug():
return debug_info.send_debug()
@admi.route("/get_update_status", methods=['GET'])
@login_required
@user_login_required
@admin_required
def get_update_status():
if feature_support['updater']:
@@ -1516,7 +1531,7 @@ def get_update_status():
@admi.route("/get_updater_status", methods=['GET', 'POST'])
@login_required
@user_login_required
@admin_required
def get_updater_status():
status = {}
@@ -1611,7 +1626,7 @@ def ldap_import_create_user(user, user_data):
@admi.route('/import_ldap_users', methods=["POST"])
@login_required
@user_login_required
@admin_required
def import_ldap_users():
showtext = {}
@@ -1666,7 +1681,7 @@ def import_ldap_users():
@admi.route("/ajax/canceltask", methods=['POST'])
@login_required
@user_login_required
@admin_required
def cancel_task():
task_id = request.get_json().get('task_id', None)
@@ -1678,10 +1693,10 @@ def cancel_task():
def _db_simulate_change():
param = request.form.to_dict()
to_save = dict()
to_save['config_calibre_dir'] = re.sub(r'[\\/]metadata\.db$',
to_save['config_calibre_dir'] = strip_whitespaces(re.sub(r'[\\/]metadata\.db$',
'',
param['config_calibre_dir'],
flags=re.IGNORECASE).strip()
flags=re.IGNORECASE))
db_valid, db_change = calibre_db.check_valid_db(to_save["config_calibre_dir"],
ub.app_DB_path,
config.config_calibre_uuid)
@@ -1715,6 +1730,13 @@ def _db_configuration_update_helper():
db_change = True
except Exception as ex:
return _db_configuration_result('{}'.format(ex), gdrive_error)
config.config_calibre_split = to_save.get('config_calibre_split', 0) == "on"
if config.config_calibre_split:
split_dir = to_save.get("config_calibre_split_dir")
if not os.path.exists(split_dir):
return _db_configuration_result(_("Books path not valid"), gdrive_error)
else:
_config_string(to_save, "config_calibre_split_dir")
if db_change or not db_valid or not config.db_configured \
or config.config_calibre_dir != to_save["config_calibre_dir"]:
@@ -1736,12 +1758,18 @@ def _db_configuration_update_helper():
ub.session.query(ub.KoboSyncedBooks).delete()
helper.delete_thumbnail_cache()
ub.session_commit()
# deleted visibilities based on custom column and tags
config.config_restricted_column = 0
config.config_denied_tags = ""
config.config_allowed_tags = ""
config.config_columns_to_ignore = ""
config.config_denied_column_value = ""
config.config_allowed_column_value = ""
config.config_read_column = 0
_config_string(to_save, "config_calibre_dir")
calibre_db.update_config(config)
if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK):
flash(_("DB is not Writeable"), category="warning")
_config_string(to_save, "config_calibre_split_dir")
config.config_calibre_split = to_save.get('config_calibre_split', 0) == "on"
calibre_db.update_config(config)
config.save()
return _db_configuration_result(None, gdrive_error)
@@ -1775,9 +1803,8 @@ def _configuration_update_helper():
if "config_upload_formats" in to_save:
to_save["config_upload_formats"] = ','.join(
helper.uniq([x.lstrip().rstrip().lower() for x in to_save["config_upload_formats"].split(',')]))
helper.uniq([x.strip().lower() for x in to_save["config_upload_formats"].split(',')]))
_config_string(to_save, "config_upload_formats")
# constants.EXTENSIONS_UPLOAD = config.config_upload_formats.split(',')
_config_string(to_save, "config_calibre")
_config_string(to_save, "config_binariesdir")
@@ -2076,7 +2103,7 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
def extract_user_data_from_field(user, field):
match = re.search(field + r"=([@\.\d\s\w-]+)", user, re.IGNORECASE | re.UNICODE)
match = re.search(field + r"=(.*?)($|(?<!\\),)", user, re.IGNORECASE | re.UNICODE)
if match:
return match.group(1)
else:
@@ -2084,7 +2111,7 @@ def extract_user_data_from_field(user, field):
def extract_dynamic_field_from_filter(user, filtr):
match = re.search("([a-zA-Z0-9-]+)=%s", filtr, re.IGNORECASE | re.UNICODE)
match = re.search(r"([a-zA-Z0-9-]+)=%s", filtr, re.IGNORECASE | re.UNICODE)
if match:
return match.group(1)
else:
+153
View File
@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2024 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 os
import mutagen
import base64
from . import cover, logger
from cps.constants import BookMeta
log = logger.create()
def get_audio_file_info(tmp_file_path, original_file_extension, original_file_name, no_cover_processing):
tmp_cover_name = None
audio_file = mutagen.File(tmp_file_path)
comments = None
if original_file_extension in [".mp3", ".wav", ".aiff"]:
cover_data = list()
for key, val in audio_file.tags.items():
if key.startswith("APIC:"):
cover_data.append(val)
if key.startswith("COMM:"):
comments = val.text[0]
title = audio_file.tags.get('TIT2').text[0] if "TIT2" in audio_file.tags else None
author = audio_file.tags.get('TPE1').text[0] if "TPE1" in audio_file.tags else None
if author is None:
author = audio_file.tags.get('TPE2').text[0] if "TPE2" in audio_file.tags else None
tags = audio_file.tags.get('TCON').text[0] if "TCON" in audio_file.tags else None # Genre
series = audio_file.tags.get('TALB').text[0] if "TALB" in audio_file.tags else None# Album
series_id = audio_file.tags.get('TRCK').text[0] if "TRCK" in audio_file.tags else None # track no.
publisher = audio_file.tags.get('TPUB').text[0] if "TPUB" in audio_file.tags else None
pubdate = str(audio_file.tags.get('TDRL').text[0]) if "TDRL" in audio_file.tags else None
if not pubdate:
pubdate = str(audio_file.tags.get('TDRC').text[0]) if "TDRC" in audio_file.tags else None
if not pubdate:
pubdate = str(audio_file.tags.get('TDOR').text[0]) if "TDOR" in audio_file.tags else None
if cover_data and not no_cover_processing:
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
cover_info = cover_data[0]
for dat in cover_data:
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
cover_info = dat
break
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
elif original_file_extension in [".ogg", ".flac", ".opus", ".ogv"]:
title = audio_file.tags.get('TITLE')[0] if "TITLE" in audio_file else None
author = audio_file.tags.get('ARTIST')[0] if "ARTIST" in audio_file else None
comments = audio_file.tags.get('COMMENTS')[0] if "COMMENTS" in audio_file else None
tags = audio_file.tags.get('GENRE')[0] if "GENRE" in audio_file else None # Genre
series = audio_file.tags.get('ALBUM')[0] if "ALBUM" in audio_file else None
series_id = audio_file.tags.get('TRACKNUMBER')[0] if "TRACKNUMBER" in audio_file else None
publisher = audio_file.tags.get('LABEL')[0] if "LABEL" in audio_file else None
pubdate = audio_file.tags.get('DATE')[0] if "DATE" in audio_file else None
cover_data = audio_file.tags.get('METADATA_BLOCK_PICTURE')
if not no_cover_processing:
if cover_data:
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
cover_info = mutagen.flac.Picture(base64.b64decode(cover_data[0]))
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
if hasattr(audio_file, "pictures"):
cover_info = audio_file.pictures[0]
for dat in audio_file.pictures:
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
cover_info = dat
break
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
elif original_file_extension in [".aac"]:
title = audio_file.tags.get('Title').value if "Title" in audio_file else None
author = audio_file.tags.get('Artist').value if "Artist" in audio_file else None
comments = audio_file.tags.get('Comment').value if "Comment" in audio_file else None
tags = audio_file.tags.get('Genre').value if "Genre" in audio_file else None
series = audio_file.tags.get('Album').value if "Album" in audio_file else None
series_id = audio_file.tags.get('Track').value if "Track" in audio_file else None
publisher = audio_file.tags.get('Label').value if "Label" in audio_file else None
pubdate = audio_file.tags.get('Year').value if "Year" in audio_file else None
cover_data = audio_file.tags['Cover Art (Front)']
if cover_data and not no_cover_processing:
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
with open(tmp_cover_name, "wb") as cover_file:
cover_file.write(cover_data.value.split(b"\x00",1)[1])
elif original_file_extension in [".asf"]:
title = audio_file.tags.get('Title')[0].value if "Title" in audio_file else None
author = audio_file.tags.get('Artist')[0].value if "Artist" in audio_file else None
comments = audio_file.tags.get('Comments')[0].value if "Comments" in audio_file else None
tags = audio_file.tags.get('Genre')[0].value if "Genre" in audio_file else None
series = audio_file.tags.get('Album')[0].value if "Album" in audio_file else None
series_id = audio_file.tags.get('Track')[0].value if "Track" in audio_file else None
publisher = audio_file.tags.get('Label')[0].value if "Label" in audio_file else None
pubdate = audio_file.tags.get('Year')[0].value if "Year" in audio_file else None
cover_data = audio_file.tags.get('WM/Picture', None)
if cover_data and not no_cover_processing:
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
with open(tmp_cover_name, "wb") as cover_file:
cover_file.write(cover_data[0].value)
elif original_file_extension in [".mp4", ".m4a", ".m4b"]:
title = audio_file.tags.get('©nam')[0] if "©nam" in audio_file.tags else None
author = audio_file.tags.get('©ART')[0] if "©ART" in audio_file.tags else None
comments = audio_file.tags.get('©cmt')[0] if "©cmt" in audio_file.tags else None
tags = audio_file.tags.get('©gen')[0] if "©gen" in audio_file.tags else None
series = audio_file.tags.get('©alb')[0] if "©alb" in audio_file.tags else None
series_id = str(audio_file.tags.get('trkn')[0][0]) if "trkn" in audio_file.tags else None
publisher = ""
pubdate = audio_file.tags.get('©day')[0] if "©day" in audio_file.tags else None
cover_data = audio_file.tags.get('covr', None)
if cover_data and not no_cover_processing:
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
cover_type = None
for c in cover_data:
if c.imageformat == mutagen.mp4.AtomDataType.JPEG:
cover_type =".jpg"
cover_bin = c
break
elif c.imageformat == mutagen.mp4.AtomDataType.PNG:
cover_type = ".png"
cover_bin = c
break
if cover_type:
cover.cover_processing(tmp_file_path, cover_bin, cover_type)
else:
logger.error("Unknown covertype in file {} ".format(original_file_name))
return BookMeta(
file_path=tmp_file_path,
extension=original_file_extension,
title=title or original_file_name ,
author="Unknown" if author is None else author,
cover=tmp_cover_name,
description="" if comments is None else comments,
tags="" if tags is None else tags,
series="" if series is None else series,
series_id="1" if series_id is None else series_id.split("/")[0],
languages="",
publisher= "" if publisher is None else publisher,
pubdate="" if pubdate is None else pubdate,
identifiers=[],
)
+1 -1
View File
@@ -35,7 +35,7 @@ def clean_string(unsafe_text, book_id=0):
try:
if bleach:
allowed_tags = list(ALLOWED_TAGS)
allowed_tags.extend(['p', 'span', 'div', 'pre'])
allowed_tags.extend(["p", "span", "div", "pre", "br", "h1", "h2", "h3", "h4", "h5", "h6"])
safe_text = clean_html(unsafe_text, tags=set(allowed_tags))
else:
safe_text = clean_html(unsafe_text)
+2 -2
View File
@@ -29,8 +29,8 @@ from .constants import DEFAULT_SETTINGS_FILE, DEFAULT_GDRIVE_FILE
def version_info():
if _NIGHTLY_VERSION[1].startswith('$Format'):
return "Calibre-Web version: %s - unknown git-clone" % _STABLE_VERSION['version'].replace("b", " Beta")
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'].replace("b", " Beta"), _NIGHTLY_VERSION[1])
return "Calibre-Web version: %s - unknown git-clone" % _STABLE_VERSION.replace("b", " Beta")
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION.replace("b", " Beta"), _NIGHTLY_VERSION[1])
class CliParameter(object):
+11 -4
View File
@@ -130,7 +130,7 @@ def _extract_cover(tmp_file_name, original_file_extension, rar_executable):
return cover.cover_processing(tmp_file_name, cover_data, extension)
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rar_executable):
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rar_executable, no_cover_processing):
if use_comic_meta:
try:
archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable)
@@ -155,14 +155,17 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
lang = loaded_metadata.language or ""
loaded_metadata.language = isoLanguages.get_lang3(lang)
if not no_cover_processing:
cover_file = _extract_cover(tmp_file_path, original_file_extension, rar_executable)
else:
cover_file = None
return BookMeta(
file_path=tmp_file_path,
extension=original_file_extension,
title=loaded_metadata.title or original_file_name,
author=" & ".join([credit["person"]
for credit in loaded_metadata.credits if credit["role"] == "Writer"]) or 'Unknown',
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
cover=cover_file,
description=loaded_metadata.comments or "",
tags="",
series=loaded_metadata.series or "",
@@ -171,13 +174,17 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
publisher="",
pubdate="",
identifiers=[])
if not no_cover_processing:
cover_file = _extract_cover(tmp_file_path, original_file_extension, rar_executable)
else:
cover_file = None
return BookMeta(
file_path=tmp_file_path,
extension=original_file_extension,
title=original_file_name,
author='Unknown',
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
cover=cover_file,
description="",
tags="",
series="",
+6 -26
View File
@@ -35,7 +35,7 @@ except ImportError:
from . import constants, logger
from .subproc_wrapper import process_wait
from .string_helper import strip_whitespaces
log = logger.create()
_Base = declarative_base()
@@ -182,26 +182,6 @@ class _Settings(_Base):
class ConfigSQL(object):
# pylint: disable=no-member
def __init__(self):
'''self.config_calibre_uuid = None
self.config_calibre_split_dir = None
self.dirty = None
self.config_logfile = None
self.config_upload_formats = None
self.mail_gmail_token = None
self.mail_server_type = None
self.mail_server = None
self.config_log_level = None
self.config_allowed_column_value = None
self.config_denied_column_value = None
self.config_allowed_tags = None
self.config_denied_tags = None
self.config_default_show = None
self.config_default_role = None
self.config_keyfile = None
self.config_certfile = None
self.config_rarfile_location = None
self.config_kepubifypath = None
self.config_binariesdir = None'''
self.__dict__["dirty"] = list()
def init_config(self, session, secret_key, cli):
@@ -288,19 +268,19 @@ class ConfigSQL(object):
def list_denied_tags(self):
mct = self.config_denied_tags or ""
return [t.strip() for t in mct.split(",")]
return [strip_whitespaces(t) for t in mct.split(",")]
def list_allowed_tags(self):
mct = self.config_allowed_tags or ""
return [t.strip() for t in mct.split(",")]
return [strip_whitespaces(t) for t in mct.split(",")]
def list_denied_column_values(self):
mct = self.config_denied_column_value or ""
return [t.strip() for t in mct.split(",")]
return [strip_whitespaces(t) for t in mct.split(",")]
def list_allowed_column_values(self):
mct = self.config_allowed_column_value or ""
return [t.strip() for t in mct.split(",")]
return [strip_whitespaces(t) for t in mct.split(",")]
def get_log_level(self):
return logger.get_level_name(self.config_log_level)
@@ -372,7 +352,7 @@ class ConfigSQL(object):
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
# constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
from . import cli_param
if os.environ.get('FLASK_DEBUG'):
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
+2 -5
View File
@@ -19,9 +19,6 @@
import sys
import os
from collections import namedtuple
from sqlalchemy import __version__ as sql_version
sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2, 0, 0])
# APP_MODE - production, development, or test
APP_MODE = os.environ.get('APP_MODE', 'production')
@@ -175,7 +172,7 @@ BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, d
'series_id, languages, publisher, pubdate, identifiers')
# python build process likes to have x.y.zbw -> b for beta and w a counting number
STABLE_VERSION = {'version': '0.6.22'}
STABLE_VERSION = '0.6.24'
NIGHTLY_VERSION = dict()
NIGHTLY_VERSION[0] = '$Format:%H$'
@@ -193,7 +190,7 @@ THUMBNAIL_TYPE_AUTHOR = 3
COVER_THUMBNAIL_ORIGINAL = 0
COVER_THUMBNAIL_SMALL = 1
COVER_THUMBNAIL_MEDIUM = 2
COVER_THUMBNAIL_LARGE = 3
COVER_THUMBNAIL_LARGE = 4
# clean-up the module namespace
del sys, os, namedtuple
+22
View File
@@ -0,0 +1,22 @@
#
# Copyright 2015 Jordan Milne
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Source: https://github.com/JordanMilne/Advocate
from .adapters import ValidatingHTTPAdapter
from .api import *
from .addrvalidator import AddrValidator
from .exceptions import UnacceptableAddressException
+48
View File
@@ -0,0 +1,48 @@
#
# Copyright 2015 Jordan Milne
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Source: https://github.com/JordanMilne/Advocate
from requests.adapters import HTTPAdapter, DEFAULT_POOLBLOCK
from .addrvalidator import AddrValidator
from .exceptions import ProxyDisabledException
from .poolmanager import ValidatingPoolManager
class ValidatingHTTPAdapter(HTTPAdapter):
__attrs__ = HTTPAdapter.__attrs__ + ['_validator']
def __init__(self, *args, **kwargs):
self._validator = kwargs.pop('validator', None)
if not self._validator:
self._validator = AddrValidator()
super().__init__(*args, **kwargs)
def init_poolmanager(self, connections, maxsize, block=DEFAULT_POOLBLOCK,
**pool_kwargs):
self._pool_connections = connections
self._pool_maxsize = maxsize
self._pool_block = block
self.poolmanager = ValidatingPoolManager(
num_pools=connections,
maxsize=maxsize,
block=block,
validator=self._validator,
**pool_kwargs
)
def proxy_manager_for(self, proxy, **proxy_kwargs):
raise ProxyDisabledException("Proxies cannot be used with Advocate")
+281
View File
@@ -0,0 +1,281 @@
#
# Copyright 2015 Jordan Milne
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Source: https://github.com/JordanMilne/Advocate
import functools
import fnmatch
import ipaddress
import re
try:
import netifaces
HAVE_NETIFACES = True
except ImportError:
netifaces = None
HAVE_NETIFACES = False
from .exceptions import NameserverException, ConfigException
def canonicalize_hostname(hostname):
"""Lowercase and punycodify a hostname"""
# We do the lowercasing after IDNA encoding because we only want to
# lowercase the *ASCII* chars.
# TODO: The differences between IDNA2003 and IDNA2008 might be relevant
# to us, but both specs are damn confusing.
return str(hostname.encode("idna").lower(), 'utf-8')
def determine_local_addresses():
"""Get all IPs that refer to this machine according to netifaces"""
if not HAVE_NETIFACES:
raise ConfigException("Tried to determine local addresses, "
"but netifaces module was not importable")
ips = []
for interface in netifaces.interfaces():
if_families = netifaces.ifaddresses(interface)
for family_kind in {netifaces.AF_INET, netifaces.AF_INET6}:
addrs = if_families.get(family_kind, [])
for addr in (x.get("addr", "") for x in addrs):
if family_kind == netifaces.AF_INET6:
# We can't do anything sensible with the scope here
addr = addr.split("%")[0]
ips.append(ipaddress.ip_network(addr))
return ips
def add_local_address_arg(func):
"""Add the "_local_addresses" kwarg if it's missing
IMO this information shouldn't be cached between calls (what if one of the
adapters got a new IP at runtime?,) and we don't want each function to
recalculate it. Just recalculate it if the caller didn't provide it for us.
"""
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
if "_local_addresses" not in kwargs:
if self.autodetect_local_addresses:
kwargs["_local_addresses"] = determine_local_addresses()
else:
kwargs["_local_addresses"] = []
return func(self, *args, **kwargs)
return wrapper
class AddrValidator:
_6TO4_RELAY_NET = ipaddress.ip_network("192.88.99.0/24")
# Just the well known prefix, DNS64 servers can set their own
# prefix, but in practice most probably don't.
_DNS64_WK_PREFIX = ipaddress.ip_network("64:ff9b::/96")
DEFAULT_PORT_WHITELIST = {80, 8080, 443, 8443, 8000}
def __init__(
self,
ip_blacklist=None,
ip_whitelist=None,
port_whitelist=None,
port_blacklist=None,
hostname_blacklist=None,
allow_ipv6=False,
allow_teredo=False,
allow_6to4=False,
allow_dns64=False,
# Must be explicitly set to "False" if you don't want to try
# detecting local interface addresses with netifaces.
autodetect_local_addresses=True,
):
if not port_blacklist and not port_whitelist:
# An assortment of common HTTPS? ports.
port_whitelist = self.DEFAULT_PORT_WHITELIST.copy()
self.ip_blacklist = ip_blacklist or set()
self.ip_whitelist = ip_whitelist or set()
self.port_blacklist = port_blacklist or set()
self.port_whitelist = port_whitelist or set()
# TODO: ATM this can contain either regexes or globs that are converted
# to regexes upon every check. Create a collection that automagically
# converts them to regexes on insert?
self.hostname_blacklist = hostname_blacklist or set()
self.allow_ipv6 = allow_ipv6
self.allow_teredo = allow_teredo
self.allow_6to4 = allow_6to4
self.allow_dns64 = allow_dns64
self.autodetect_local_addresses = autodetect_local_addresses
@add_local_address_arg
def is_ip_allowed(self, addr_ip, _local_addresses=None):
if not isinstance(addr_ip,
(ipaddress.IPv4Address, ipaddress.IPv6Address)):
addr_ip = ipaddress.ip_address(addr_ip)
# The whitelist should take precedence over the blacklist so we can
# punch holes in blacklisted ranges
if any(addr_ip in net for net in self.ip_whitelist):
return True
if any(addr_ip in net for net in self.ip_blacklist):
return False
if any(addr_ip in net for net in _local_addresses):
return False
if addr_ip.version == 4:
if not addr_ip.is_private:
# IPs for carrier-grade NAT. Seems weird that it doesn't set
# `is_private`, but we need to check `not is_global`
if not ipaddress.ip_network(addr_ip).is_global:
return False
elif addr_ip.version == 6:
# You'd better have a good reason for enabling IPv6
# because Advocate's techniques don't work well without NAT.
if not self.allow_ipv6:
return False
# v6 addresses can also map to IPv4 addresses! Tricky!
v4_nested = []
if addr_ip.ipv4_mapped:
v4_nested.append(addr_ip.ipv4_mapped)
# WTF IPv6? Why you gotta have a billion tunneling mechanisms?
# XXX: Do we even really care about these? If we're tunneling
# through public servers we shouldn't be able to access
# addresses on our private network, right?
if addr_ip.sixtofour:
if not self.allow_6to4:
return False
v4_nested.append(addr_ip.sixtofour)
if addr_ip.teredo:
if not self.allow_teredo:
return False
# Check both the client *and* server IPs
v4_nested.extend(addr_ip.teredo)
if addr_ip in self._DNS64_WK_PREFIX:
if not self.allow_dns64:
return False
# When using the well-known prefix the last 4 bytes
# are the IPv4 addr
v4_nested.append(ipaddress.ip_address(addr_ip.packed[-4:]))
if not all(self.is_ip_allowed(addr_v4) for addr_v4 in v4_nested):
return False
# fec0::*, apparently deprecated?
if addr_ip.is_site_local:
return False
else:
raise ValueError("Unsupported IP version(?): %r" % addr_ip)
# 169.254.XXX.XXX, AWS uses these for autoconfiguration
if addr_ip.is_link_local:
return False
# 127.0.0.1, ::1, etc.
if addr_ip.is_loopback:
return False
if addr_ip.is_multicast:
return False
# 192.168.XXX.XXX, 10.XXX.XXX.XXX
if addr_ip.is_private:
return False
# 255.255.255.255, ::ffff:XXXX:XXXX (v6->v4) mapping
if addr_ip.is_reserved:
return False
# There's no reason to connect directly to a 6to4 relay
if addr_ip in self._6TO4_RELAY_NET:
return False
# 0.0.0.0
if addr_ip.is_unspecified:
return False
# It doesn't look bad, so... it's must be ok!
return True
def _hostname_matches_pattern(self, hostname, pattern):
# If they specified a string, just assume they only want basic globbing.
# This stops people from not realizing they're dealing in REs and
# not escaping their periods unless they specifically pass in an RE.
# This has the added benefit of letting us sanely handle globbed
# IDNs by default.
if isinstance(pattern, str):
# convert the glob to a punycode glob, then a regex
pattern = fnmatch.translate(canonicalize_hostname(pattern))
hostname = canonicalize_hostname(hostname)
# Down the line the hostname may get treated as a null-terminated string
# (as with `socket.getaddrinfo`.) Try to account for that.
#
# >>> socket.getaddrinfo("example.com\x00aaaa", 80)
# [(2, 1, 6, '', ('93.184.216.34', 80)), [...]
no_null_hostname = hostname.split("\x00")[0]
return any(re.match(pattern, x.strip(".")) for x
in (no_null_hostname, hostname))
def is_hostname_allowed(self, hostname):
# Sometimes (like with "external" services that your IP has privileged
# access to) you might not always know the IP range to blacklist access
# to, or the `A` record might change without you noticing.
# For e.x.: `foocorp.external.org`.
#
# Another option is doing something like:
#
# for addrinfo in socket.getaddrinfo("foocorp.external.org", 80):
# global_validator.ip_blacklist.add(ip_address(addrinfo[4][0]))
#
# but that's not always a good idea if they're behind a third-party lb.
for pattern in self.hostname_blacklist:
if self._hostname_matches_pattern(hostname, pattern):
return False
return True
@add_local_address_arg
def is_addrinfo_allowed(self, addrinfo, _local_addresses=None):
assert(len(addrinfo) == 5)
# XXX: Do we care about any of the other elements? Guessing not.
family, socktype, proto, canonname, sockaddr = addrinfo
# The 4th elem inaddrinfo may either be a touple of two or four items,
# depending on whether we're dealing with IPv4 or v6
if len(sockaddr) == 2:
# v4
ip, port = sockaddr
elif len(sockaddr) == 4:
# v6
# XXX: what *are* `flow_info` and `scope_id`? Anything useful?
# Seems like we can figure out all we need about the scope from
# the `is_<x>` properties.
ip, port, flow_info, scope_id = sockaddr
else:
raise ValueError("Unexpected addrinfo format %r" % sockaddr)
# Probably won't help protect against SSRF, but might prevent our being
# used to attack others' non-HTTP services. See
# http://www.remote.org/jochen/sec/hfpa/
if self.port_whitelist and port not in self.port_whitelist:
return False
if port in self.port_blacklist:
return False
if self.hostname_blacklist:
if not canonname:
raise NameserverException(
"addrinfo must contain the canon name to do blacklisting "
"based on hostname. Make sure you use the "
"`socket.AI_CANONNAME` flag, and that each record contains "
"the canon name. Your DNS server might also be garbage."
)
if not self.is_hostname_allowed(canonname):
return False
return self.is_ip_allowed(ip, _local_addresses=_local_addresses)
+202
View File
@@ -0,0 +1,202 @@
#
# Copyright 2015 Jordan Milne
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Source: https://github.com/JordanMilne/Advocate
"""
advocate.api
~~~~~~~~~~~~
This module implements the Requests API, largely a copy/paste from `requests`
itself.
:copyright: (c) 2015 by Jordan Milne.
:license: Apache2, see LICENSE for more details.
"""
from collections import OrderedDict
import hashlib
import pickle
from requests import Session as RequestsSession
# import cw_advocate
from .adapters import ValidatingHTTPAdapter
from .exceptions import MountDisabledException
class Session(RequestsSession):
"""Convenience wrapper around `requests.Session` set up for `advocate`ing"""
__attrs__ = RequestsSession.__attrs__ + ["validator"]
DEFAULT_VALIDATOR = None
"""
User-replaceable default validator to use for all Advocate sessions,
includes sessions created by advocate.get()
"""
def __init__(self, *args, **kwargs):
self.validator = kwargs.pop("validator", None) or self.DEFAULT_VALIDATOR
adapter_kwargs = kwargs.pop("_adapter_kwargs", {})
# `Session.__init__()` calls `mount()` internally, so we need to allow
# it temporarily
self.__mount_allowed = True
RequestsSession.__init__(self, *args, **kwargs)
# Drop any existing adapters
self.adapters = OrderedDict()
self.mount("http://", ValidatingHTTPAdapter(validator=self.validator, **adapter_kwargs))
self.mount("https://", ValidatingHTTPAdapter(validator=self.validator, **adapter_kwargs))
self.__mount_allowed = False
def mount(self, *args, **kwargs):
"""Wrapper around `mount()` to prevent a protection bypass"""
if self.__mount_allowed:
super().mount(*args, **kwargs)
else:
raise MountDisabledException(
"mount() is disabled to prevent protection bypasses"
)
def session(*args, **kwargs):
return Session(*args, **kwargs)
def request(method, url, **kwargs):
"""Constructs and sends a :class:`Request <Request>`.
:param method: method for the new :class:`Request` object.
:param url: URL for the new :class:`Request` object.
:param params: (optional) Dictionary or bytes to be sent in the query string for the :class:`Request`.
:param data: (optional) Dictionary, bytes, or file-like object to send in the body of the :class:`Request`.
:param json: (optional) json data to send in the body of the :class:`Request`.
:param headers: (optional) Dictionary of HTTP Headers to send with the :class:`Request`.
:param cookies: (optional) Dict or CookieJar object to send with the :class:`Request`.
:param files: (optional) Dictionary of ``'name': file-like-objects`` (or ``{'name': ('filename', fileobj)}``) for multipart encoding upload.
:param auth: (optional) Auth tuple to enable Basic/Digest/Custom HTTP Auth.
:param timeout: (optional) How long to wait for the server to send data
before giving up, as a float, or a (`connect timeout, read timeout
<user/advanced.html#timeouts>`_) tuple.
:type timeout: float or tuple
:param allow_redirects: (optional) Boolean. Set to True if POST/PUT/DELETE redirect following is allowed.
:type allow_redirects: bool
:param proxies: (optional) Dictionary mapping protocol to the URL of the proxy.
:param verify: (optional) if ``True``, the SSL cert will be verified. A CA_BUNDLE path can also be provided.
:param stream: (optional) if ``False``, the response content will be immediately downloaded.
:param cert: (optional) if String, path to ssl client cert file (.pem). If Tuple, ('cert', 'key') pair.
:return: :class:`Response <Response>` object
:rtype: requests.Response
"""
validator = kwargs.pop("validator", None)
with Session(validator=validator) as sess:
response = sess.request(method=method, url=url, **kwargs)
return response
def get(url, **kwargs):
"""Sends a GET request.
:param url: URL for the new :class:`Request` object.
:param **kwargs: Optional arguments that ``request`` takes.
:return: :class:`Response <Response>` object
:rtype: requests.Response
"""
kwargs.setdefault('allow_redirects', True)
return request('get', url, **kwargs)
class RequestsAPIWrapper:
"""Provides a `requests.api`-like interface with a specific validator"""
# Due to how the classes are dynamically constructed pickling may not work
# correctly unless loaded within the same interpreter instance.
# Enable at your peril.
SUPPORT_WRAPPER_PICKLING = False
def __init__(self, validator):
# Do this here to avoid circular import issues
try:
from .futures import FuturesSession
have_requests_futures = True
except ImportError as e:
have_requests_futures = False
self.validator = validator
outer_self = self
class _WrappedSession(Session):
"""An `advocate.Session` that uses the wrapper's blacklist
the wrapper is meant to be a transparent replacement for `requests`,
so people should be able to subclass `wrapper.Session` and still
get the desired validation behaviour
"""
DEFAULT_VALIDATOR = outer_self.validator
self._make_wrapper_cls_global(_WrappedSession)
if have_requests_futures:
class _WrappedFuturesSession(FuturesSession):
"""Like _WrappedSession, but for `FuturesSession`s"""
DEFAULT_VALIDATOR = outer_self.validator
self._make_wrapper_cls_global(_WrappedFuturesSession)
self.FuturesSession = _WrappedFuturesSession
self.request = self._default_arg_wrapper(request)
self.get = self._default_arg_wrapper(get)
self.Session = _WrappedSession
def __getattr__(self, item):
# This class is meant to mimic the requests base module, so if we don't
# have this attribute, it might be on the base module (like the Request
# class, etc.)
try:
return object.__getattribute__(self, item)
except AttributeError:
from . import cw_advocate
return getattr(cw_advocate, item)
def _default_arg_wrapper(self, fun):
def wrapped_func(*args, **kwargs):
kwargs.setdefault("validator", self.validator)
return fun(*args, **kwargs)
return wrapped_func
def _make_wrapper_cls_global(self, cls):
if not self.SUPPORT_WRAPPER_PICKLING:
return
# Gnarly, but necessary to give pickle a consistent module-level
# reference for each wrapper.
wrapper_hash = hashlib.sha256(pickle.dumps(self)).hexdigest()
cls.__name__ = "_".join((cls.__name__, wrapper_hash))
cls.__qualname__ = ".".join((__name__, cls.__name__))
if not globals().get(cls.__name__):
globals()[cls.__name__] = cls
__all__ = (
"get",
"request",
"session",
"Session",
"RequestsAPIWrapper",
)
+201
View File
@@ -0,0 +1,201 @@
#
# Copyright 2015 Jordan Milne
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Source: https://github.com/JordanMilne/Advocate
import ipaddress
import socket
from socket import timeout as SocketTimeout
from urllib3.connection import HTTPSConnection, HTTPConnection
from urllib3.exceptions import ConnectTimeoutError
from urllib3.util.connection import _set_socket_options
from urllib3.util.connection import create_connection as old_create_connection
from . import addrvalidator
from .exceptions import UnacceptableAddressException
def advocate_getaddrinfo(host, port, get_canonname=False):
addrinfo = socket.getaddrinfo(
host,
port,
0,
socket.SOCK_STREAM,
0,
# We need what the DNS client sees the hostname as, correctly handles
# IDNs and tricky things like `private.foocorp.org\x00.google.com`.
# All IDNs will be converted to punycode.
socket.AI_CANONNAME if get_canonname else 0,
)
return fix_addrinfo(addrinfo)
def fix_addrinfo(records):
"""
Propagate the canonname across records and parse IPs
I'm not sure if this is just the behaviour of `getaddrinfo` on Linux, but
it seems like only the first record in the set has the canonname field
populated.
"""
def fix_record(record, canonname):
sa = record[4]
sa = (ipaddress.ip_address(sa[0]),) + sa[1:]
return record[0], record[1], record[2], canonname, sa
canonname = None
if records:
# Apparently the canonical name is only included in the first record?
# Add it to all of them.
assert(len(records[0]) == 5)
canonname = records[0][3]
return tuple(fix_record(x, canonname) for x in records)
# Lifted from requests' urllib3, which in turn lifted it from `socket.py`. Oy!
def validating_create_connection(address,
timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
source_address=None, socket_options=None,
validator=None):
"""Connect to *address* and return the socket object.
Convenience function. Connect to *address* (a 2-tuple ``(host,
port)``) and return the socket object. Passing the optional
*timeout* parameter will set the timeout on the socket instance
before attempting to connect. If no *timeout* is supplied, the
global default timeout setting returned by :func:`getdefaulttimeout`
is used. If *source_address* is set it must be a tuple of (host, port)
for the socket to bind as a source address before making the connection.
An host of '' or port 0 tells the OS to use the default.
"""
host, port = address
# We can skip asking for the canon name if we're not doing hostname-based
# blacklisting.
need_canonname = False
if validator.hostname_blacklist:
need_canonname = True
# We check both the non-canonical and canonical hostnames so we can
# catch both of these:
# CNAME from nonblacklisted.com -> blacklisted.com
# CNAME from blacklisted.com -> nonblacklisted.com
if not validator.is_hostname_allowed(host):
raise UnacceptableAddressException(host)
err = None
addrinfo = advocate_getaddrinfo(host, port, get_canonname=need_canonname)
if addrinfo:
if validator.autodetect_local_addresses:
local_addresses = addrvalidator.determine_local_addresses()
else:
local_addresses = []
for res in addrinfo:
# Are we allowed to connect with this result?
if not validator.is_addrinfo_allowed(
res,
_local_addresses=local_addresses,
):
continue
af, socktype, proto, canonname, sa = res
# Unparse the validated IP
sa = (sa[0].exploded,) + sa[1:]
sock = None
try:
sock = socket.socket(af, socktype, proto)
# If provided, set socket level options before connecting.
# This is the only addition urllib3 makes to this function.
_set_socket_options(sock, socket_options)
if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT:
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sa)
return sock
except socket.error as _:
err = _
if sock is not None:
sock.close()
sock = None
if err is None:
# If we got here, none of the results were acceptable
err = UnacceptableAddressException(address)
if err is not None:
raise err
else:
raise socket.error("getaddrinfo returns an empty list")
# TODO: Is there a better way to add this to multiple classes with different
# base classes? I tried a mixin, but it used the base method instead.
def _validating_new_conn(self):
""" Establish a socket connection and set nodelay settings on it.
:return: New socket connection.
"""
extra_kw = {}
if self.source_address:
extra_kw['source_address'] = self.source_address
if self.socket_options:
extra_kw['socket_options'] = self.socket_options
try:
# Hack around HTTPretty's patched sockets
# TODO: some better method of hacking around it that checks if we
# _would have_ connected to a private addr?
conn_func = validating_create_connection
if socket.getaddrinfo.__module__.startswith("httpretty"):
conn_func = old_create_connection
else:
extra_kw["validator"] = self._validator
conn = conn_func(
(self.host, self.port),
self.timeout,
**extra_kw
)
except SocketTimeout:
raise ConnectTimeoutError(
self, "Connection to %s timed out. (connect timeout=%s)" %
(self.host, self.timeout))
return conn
# Don't silently break if the private API changes across urllib3 versions
assert(hasattr(HTTPConnection, '_new_conn'))
assert(hasattr(HTTPSConnection, '_new_conn'))
class ValidatingHTTPConnection(HTTPConnection):
_new_conn = _validating_new_conn
def __init__(self, *args, **kwargs):
self._validator = kwargs.pop("validator")
HTTPConnection.__init__(self, *args, **kwargs)
class ValidatingHTTPSConnection(HTTPSConnection):
_new_conn = _validating_new_conn
def __init__(self, *args, **kwargs):
self._validator = kwargs.pop("validator")
HTTPSConnection.__init__(self, *args, **kwargs)
+39
View File
@@ -0,0 +1,39 @@
#
# Copyright 2015 Jordan Milne
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Source: https://github.com/JordanMilne/Advocate
from urllib3 import HTTPConnectionPool, HTTPSConnectionPool
from .connection import (
ValidatingHTTPConnection,
ValidatingHTTPSConnection,
)
# Don't silently break if the private API changes across urllib3 versions
assert(hasattr(HTTPConnectionPool, 'ConnectionCls'))
assert(hasattr(HTTPSConnectionPool, 'ConnectionCls'))
assert(hasattr(HTTPConnectionPool, 'scheme'))
assert(hasattr(HTTPSConnectionPool, 'scheme'))
class ValidatingHTTPConnectionPool(HTTPConnectionPool):
scheme = 'http'
ConnectionCls = ValidatingHTTPConnection
class ValidatingHTTPSConnectionPool(HTTPSConnectionPool):
scheme = 'https'
ConnectionCls = ValidatingHTTPSConnection
+39
View File
@@ -0,0 +1,39 @@
#
# Copyright 2015 Jordan Milne
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Source: https://github.com/JordanMilne/Advocate
class AdvocateException(Exception):
pass
class UnacceptableAddressException(AdvocateException):
pass
class NameserverException(AdvocateException):
pass
class MountDisabledException(AdvocateException):
pass
class ProxyDisabledException(NotImplementedError, AdvocateException):
pass
class ConfigException(AdvocateException):
pass
+61
View File
@@ -0,0 +1,61 @@
#
# Copyright 2015 Jordan Milne
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Source: https://github.com/JordanMilne/Advocate
import collections
import functools
from urllib3 import PoolManager
from urllib3.poolmanager import _default_key_normalizer, PoolKey
from .connectionpool import (
ValidatingHTTPSConnectionPool,
ValidatingHTTPConnectionPool,
)
pool_classes_by_scheme = {
"http": ValidatingHTTPConnectionPool,
"https": ValidatingHTTPSConnectionPool,
}
AdvocatePoolKey = collections.namedtuple('AdvocatePoolKey',
PoolKey._fields + ('key_validator',))
def key_normalizer(key_class, request_context):
request_context = request_context.copy()
# TODO: add ability to serialize validator rules to dict,
# allowing pool to be shared between sessions with the same
# rules.
request_context["validator"] = id(request_context["validator"])
return _default_key_normalizer(key_class, request_context)
key_fn_by_scheme = {
'http': functools.partial(key_normalizer, AdvocatePoolKey),
'https': functools.partial(key_normalizer, AdvocatePoolKey),
}
class ValidatingPoolManager(PoolManager):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make sure the API hasn't changed
assert (hasattr(self, 'pool_classes_by_scheme'))
self.pool_classes_by_scheme = pool_classes_by_scheme
self.key_fn_by_scheme = key_fn_by_scheme.copy()
+1 -1
View File
@@ -2,7 +2,7 @@ from babel import negotiate_locale
from flask_babel import Babel, Locale
from babel.core import UnknownLocaleError
from flask import request
from flask_login import current_user
from .cw_login import current_user
from . import logger
+98
View File
@@ -0,0 +1,98 @@
# from .__about__ import __version__
from .config import AUTH_HEADER_NAME
from .config import COOKIE_DURATION
from .config import COOKIE_HTTPONLY
from .config import COOKIE_NAME
from .config import COOKIE_SECURE
from .config import ID_ATTRIBUTE
from .config import LOGIN_MESSAGE
from .config import LOGIN_MESSAGE_CATEGORY
from .config import REFRESH_MESSAGE
from .config import REFRESH_MESSAGE_CATEGORY
from .login_manager import LoginManager
from .mixins import AnonymousUserMixin
from .mixins import UserMixin
from .signals import session_protected
from .signals import user_accessed
from .signals import user_loaded_from_cookie
from .signals import user_loaded_from_request
from .signals import user_logged_in
from .signals import user_logged_out
from .signals import user_login_confirmed
from .signals import user_needs_refresh
from .signals import user_unauthorized
# from .test_client import FlaskLoginClient
from .utils import confirm_login
from .utils import current_user
from .utils import decode_cookie
from .utils import encode_cookie
from .utils import fresh_login_required
from .utils import login_fresh
from .utils import login_remembered
from .utils import login_required
from .utils import login_url
from .utils import login_user
from .utils import logout_user
from .utils import make_next_param
from .utils import set_login_view
__version_info__ = ("0", "6", "3")
__version__ = ".".join(__version_info__)
__all__ = [
"__version__",
"AUTH_HEADER_NAME",
"COOKIE_DURATION",
"COOKIE_HTTPONLY",
"COOKIE_NAME",
"COOKIE_SECURE",
"ID_ATTRIBUTE",
"LOGIN_MESSAGE",
"LOGIN_MESSAGE_CATEGORY",
"REFRESH_MESSAGE",
"REFRESH_MESSAGE_CATEGORY",
"LoginManager",
"AnonymousUserMixin",
"UserMixin",
"session_protected",
"user_accessed",
"user_loaded_from_cookie",
"user_loaded_from_request",
"user_logged_in",
"user_logged_out",
"user_login_confirmed",
"user_needs_refresh",
"user_unauthorized",
# "FlaskLoginClient",
"confirm_login",
"current_user",
"decode_cookie",
"encode_cookie",
"fresh_login_required",
"login_fresh",
"login_remembered",
"login_required",
"login_url",
"login_user",
"logout_user",
"make_next_param",
"set_login_view",
]
def __getattr__(name):
if name == "user_loaded_from_header":
import warnings
from .signals import _user_loaded_from_header
warnings.warn(
"'user_loaded_from_header' is deprecated and will be"
" removed in Flask-Login 0.7. Use"
" 'user_loaded_from_request' instead.",
DeprecationWarning,
stacklevel=2,
)
return _user_loaded_from_header
raise AttributeError(name)
+55
View File
@@ -0,0 +1,55 @@
from datetime import timedelta
#: The default name of the "remember me" cookie (``remember_token``)
COOKIE_NAME = "remember_token"
#: The default time before the "remember me" cookie expires (365 days).
COOKIE_DURATION = timedelta(days=365)
#: Whether the "remember me" cookie requires Secure; defaults to ``False``
COOKIE_SECURE = False
#: Whether the "remember me" cookie uses HttpOnly or not; defaults to ``True``
COOKIE_HTTPONLY = True
#: Whether the "remember me" cookie requires same origin; defaults to ``None``
COOKIE_SAMESITE = None
#: The default flash message to display when users need to log in.
LOGIN_MESSAGE = "Please log in to access this page."
#: The default flash message category to display when users need to log in.
LOGIN_MESSAGE_CATEGORY = "message"
#: The default flash message to display when users need to reauthenticate.
REFRESH_MESSAGE = "Please reauthenticate to access this page."
#: The default flash message category to display when users need to
#: reauthenticate.
REFRESH_MESSAGE_CATEGORY = "message"
#: The default attribute to retreive the str id of the user
ID_ATTRIBUTE = "get_id"
#: Default name of the auth header (``Authorization``)
AUTH_HEADER_NAME = "Authorization"
#: A set of session keys that are populated by Flask-Login. Use this set to
#: purge keys safely and accurately.
SESSION_KEYS = {
"_user_id",
"_remember",
"_remember_seconds",
"_id",
"_fresh",
"next",
}
#: A set of HTTP methods which are exempt from `login_required` and
#: `fresh_login_required`. By default, this is just ``OPTIONS``.
EXEMPT_METHODS = {"OPTIONS"}
#: If true, the page the user is attempting to access is stored in the session
#: rather than a url parameter when redirecting to the login view; defaults to
#: ``False``.
USE_SESSION_FOR_NEXT = False
+555
View File
@@ -0,0 +1,555 @@
from datetime import datetime
from datetime import timezone
from datetime import timedelta
import hashlib
from flask import abort
from flask import current_app
from flask import flash
from flask import g
from flask import has_app_context
from flask import redirect
from flask import request
from flask import session
from itsdangerous import URLSafeSerializer
from flask.json.tag import TaggedJSONSerializer
from .config import AUTH_HEADER_NAME
from .config import COOKIE_DURATION
from .config import COOKIE_HTTPONLY
from .config import COOKIE_NAME
from .config import COOKIE_SAMESITE
from .config import COOKIE_SECURE
from .config import ID_ATTRIBUTE
from .config import LOGIN_MESSAGE
from .config import LOGIN_MESSAGE_CATEGORY
from .config import REFRESH_MESSAGE
from .config import REFRESH_MESSAGE_CATEGORY
from .config import SESSION_KEYS
from .config import USE_SESSION_FOR_NEXT
from .mixins import AnonymousUserMixin
from .signals import session_protected
from .signals import user_accessed
from .signals import user_loaded_from_cookie
from .signals import user_loaded_from_request
from .signals import user_needs_refresh
from .signals import user_unauthorized
from .utils import _create_identifier
from .utils import _user_context_processor
from .utils import confirm_login
from .utils import expand_login_view
from .utils import login_url as make_login_url
from .utils import make_next_param
class LoginManager:
"""This object is used to hold the settings used for logging in. Instances
of :class:`LoginManager` are *not* bound to specific apps, so you can
create one in the main body of your code and then bind it to your
app in a factory function.
"""
def __init__(self, app=None, add_context_processor=True):
#: A class or factory function that produces an anonymous user, which
#: is used when no one is logged in.
self.anonymous_user = AnonymousUserMixin
#: The name of the view to redirect to when the user needs to log in.
#: (This can be an absolute URL as well, if your authentication
#: machinery is external to your application.)
self.login_view = None
#: Names of views to redirect to when the user needs to log in,
#: per blueprint. If the key value is set to None the value of
#: :attr:`login_view` will be used instead.
self.blueprint_login_views = {}
#: The message to flash when a user is redirected to the login page.
self.login_message = LOGIN_MESSAGE
#: The message category to flash when a user is redirected to the login
#: page.
self.login_message_category = LOGIN_MESSAGE_CATEGORY
#: The name of the view to redirect to when the user needs to
#: reauthenticate.
self.refresh_view = None
#: The message to flash when a user is redirected to the 'needs
#: refresh' page.
self.needs_refresh_message = REFRESH_MESSAGE
#: The message category to flash when a user is redirected to the
#: 'needs refresh' page.
self.needs_refresh_message_category = REFRESH_MESSAGE_CATEGORY
#: The mode to use session protection in. This can be either
#: ``'basic'`` (the default) or ``'strong'``, or ``None`` to disable
#: it.
self.session_protection = "basic"
#: If present, used to translate flash messages ``self.login_message``
#: and ``self.needs_refresh_message``
self.localize_callback = None
self.unauthorized_callback = None
self.needs_refresh_callback = None
self.id_attribute = ID_ATTRIBUTE
self._user_callback = None
self._header_callback = None
self._request_callback = None
self._session_identifier_generator = _create_identifier
if app is not None:
self.init_app(app, add_context_processor)
def setup_app(self, app, add_context_processor=True): # pragma: no cover
"""
This method has been deprecated. Please use
:meth:`LoginManager.init_app` instead.
"""
import warnings
warnings.warn(
"'setup_app' is deprecated and will be removed in"
" Flask-Login 0.7. Use 'init_app' instead.",
DeprecationWarning,
stacklevel=2,
)
self.init_app(app, add_context_processor)
def init_app(self, app, add_context_processor=True):
"""
Configures an application. This registers an `after_request` call, and
attaches this `LoginManager` to it as `app.login_manager`.
:param app: The :class:`flask.Flask` object to configure.
:type app: :class:`flask.Flask`
:param add_context_processor: Whether to add a context processor to
the app that adds a `current_user` variable to the template.
Defaults to ``True``.
:type add_context_processor: bool
"""
app.login_manager = self
app.after_request(self._update_remember_cookie)
if add_context_processor:
app.context_processor(_user_context_processor)
def unauthorized(self):
"""
This is called when the user is required to log in. If you register a
callback with :meth:`LoginManager.unauthorized_handler`, then it will
be called. Otherwise, it will take the following actions:
- Flash :attr:`LoginManager.login_message` to the user.
- If the app is using blueprints find the login view for
the current blueprint using `blueprint_login_views`. If the app
is not using blueprints or the login view for the current
blueprint is not specified use the value of `login_view`.
- Redirect the user to the login view. (The page they were
attempting to access will be passed in the ``next`` query
string variable, so you can redirect there if present instead
of the homepage. Alternatively, it will be added to the session
as ``next`` if USE_SESSION_FOR_NEXT is set.)
If :attr:`LoginManager.login_view` is not defined, then it will simply
raise a HTTP 401 (Unauthorized) error instead.
This should be returned from a view or before/after_request function,
otherwise the redirect will have no effect.
"""
user_unauthorized.send(current_app._get_current_object())
if self.unauthorized_callback:
return self.unauthorized_callback()
if request.blueprint in self.blueprint_login_views:
login_view = self.blueprint_login_views[request.blueprint]
else:
login_view = self.login_view
if not login_view:
abort(401)
if self.login_message:
if self.localize_callback is not None:
flash(
self.localize_callback(self.login_message),
category=self.login_message_category,
)
else:
flash(self.login_message, category=self.login_message_category)
config = current_app.config
if config.get("USE_SESSION_FOR_NEXT", USE_SESSION_FOR_NEXT):
login_url = expand_login_view(login_view)
session["_id"] = self._session_identifier_generator()
session["next"] = make_next_param(login_url, request.url)
redirect_url = make_login_url(login_view)
else:
redirect_url = make_login_url(login_view, next_url=request.url)
return redirect(redirect_url)
def user_loader(self, callback):
"""
This sets the callback for reloading a user from the session. The
function you set should take a user ID (a ``str``) and return a
user object, or ``None`` if the user does not exist.
:param callback: The callback for retrieving a user object.
:type callback: callable
"""
self._user_callback = callback
return self.user_callback
@property
def user_callback(self):
"""Gets the user_loader callback set by user_loader decorator."""
return self._user_callback
def request_loader(self, callback):
"""
This sets the callback for loading a user from a Flask request.
The function you set should take Flask request object and
return a user object, or `None` if the user does not exist.
:param callback: The callback for retrieving a user object.
:type callback: callable
"""
self._request_callback = callback
return self.request_callback
@property
def request_callback(self):
"""Gets the request_loader callback set by request_loader decorator."""
return self._request_callback
def unauthorized_handler(self, callback):
"""
This will set the callback for the `unauthorized` method, which among
other things is used by `login_required`. It takes no arguments, and
should return a response to be sent to the user instead of their
normal view.
:param callback: The callback for unauthorized users.
:type callback: callable
"""
self.unauthorized_callback = callback
return callback
def needs_refresh_handler(self, callback):
"""
This will set the callback for the `needs_refresh` method, which among
other things is used by `fresh_login_required`. It takes no arguments,
and should return a response to be sent to the user instead of their
normal view.
:param callback: The callback for unauthorized users.
:type callback: callable
"""
self.needs_refresh_callback = callback
return callback
def needs_refresh(self):
"""
This is called when the user is logged in, but they need to be
reauthenticated because their session is stale. If you register a
callback with `needs_refresh_handler`, then it will be called.
Otherwise, it will take the following actions:
- Flash :attr:`LoginManager.needs_refresh_message` to the user.
- Redirect the user to :attr:`LoginManager.refresh_view`. (The page
they were attempting to access will be passed in the ``next``
query string variable, so you can redirect there if present
instead of the homepage.)
If :attr:`LoginManager.refresh_view` is not defined, then it will
simply raise a HTTP 401 (Unauthorized) error instead.
This should be returned from a view or before/after_request function,
otherwise the redirect will have no effect.
"""
user_needs_refresh.send(current_app._get_current_object())
if self.needs_refresh_callback:
return self.needs_refresh_callback()
if not self.refresh_view:
abort(401)
if self.needs_refresh_message:
if self.localize_callback is not None:
flash(
self.localize_callback(self.needs_refresh_message),
category=self.needs_refresh_message_category,
)
else:
flash(
self.needs_refresh_message,
category=self.needs_refresh_message_category,
)
config = current_app.config
if config.get("USE_SESSION_FOR_NEXT", USE_SESSION_FOR_NEXT):
login_url = expand_login_view(self.refresh_view)
session["_id"] = self._session_identifier_generator()
session["next"] = make_next_param(login_url, request.url)
redirect_url = make_login_url(self.refresh_view)
else:
login_url = self.refresh_view
redirect_url = make_login_url(login_url, next_url=request.url)
return redirect(redirect_url)
def _update_request_context_with_user(self, user=None):
"""Store the given user as ctx.user."""
if user is None:
user = self.anonymous_user()
g._login_user = user
def _load_user(self):
"""Loads user from session or remember_me cookie as applicable"""
if self._user_callback is None and self._request_callback is None:
raise Exception(
"Missing user_loader or request_loader. Refer to "
"https://flask-login.readthedocs.io/#how-it-works "
"for more info."
)
user_accessed.send(current_app._get_current_object())
# Check SESSION_PROTECTION
if self._session_protection_failed():
return self._update_request_context_with_user()
user = None
# Load user from Flask Session
user_id = session.get("_user_id")
user_random = session.get("_random")
user_session_key = session.get("_id")
if (user_id is not None
and user_random is not None
and user_session_key is not None
and self._user_callback is not None):
user = self._user_callback(user_id, user_random, user_session_key)
# Load user from Remember Me Cookie or Request Loader
if user is None:
config = current_app.config
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
header_name = config.get("AUTH_HEADER_NAME", AUTH_HEADER_NAME)
has_cookie = (
cookie_name in request.cookies and session.get("_remember") != "clear"
)
if has_cookie:
cookie = request.cookies[cookie_name]
user = self._load_user_from_remember_cookie(cookie)
elif self._request_callback:
user = self._load_user_from_request(request)
elif header_name in request.headers:
header = request.headers[header_name]
user = self._load_user_from_header(header)
if not user:
self._update_request_context_with_user()
return self._update_request_context_with_user(user)
def _session_protection_failed(self):
sess = session._get_current_object()
ident = self._session_identifier_generator()
app = current_app._get_current_object()
mode = app.config.get("SESSION_PROTECTION", self.session_protection)
if not mode or mode not in ["basic", "strong"]:
return False
# if the sess is empty, it's an anonymous user or just logged out
# so we can skip this
if sess and ident != sess.get("_id", None):
if mode == "basic" or sess.permanent:
if sess.get("_fresh") is not False:
sess["_fresh"] = False
session_protected.send(app)
return False
elif mode == "strong":
for k in SESSION_KEYS:
sess.pop(k, None)
sess["_remember"] = "clear"
session_protected.send(app)
return True
return False
def _load_user_from_remember_cookie(self, cookie):
signer_kwargs = dict(
key_derivation="hmac", digest_method=hashlib.sha1
)
try:
remember_dict = URLSafeSerializer(
current_app.secret_key,
salt="remember",
serializer=TaggedJSONSerializer(),
signer_kwargs=signer_kwargs,
).loads(cookie)
except Exception:
return None
if remember_dict['user'] is not None:
session["_user_id"] = remember_dict['user']
if "_random" not in session:
session["_random"] = remember_dict['random']
session["_fresh"] = False
user = None
if self._user_callback:
user = self._user_callback(remember_dict['user'], session["_random"], None)
if user is not None:
app = current_app._get_current_object()
user_loaded_from_cookie.send(app, user=user)
# if session was restored from remember me cookie make login valid
confirm_login()
return user
return None
def _load_user_from_header(self, header):
if self._header_callback:
user = self._header_callback(header)
if user is not None:
app = current_app._get_current_object()
from .signals import _user_loaded_from_header
_user_loaded_from_header.send(app, user=user)
return user
return None
def _load_user_from_request(self, request):
if self._request_callback:
user = self._request_callback(request)
if user is not None:
app = current_app._get_current_object()
user_loaded_from_request.send(app, user=user)
return user
return None
def _update_remember_cookie(self, response):
# Don't modify the session unless there's something to do.
if "_remember" not in session and current_app.config.get(
"REMEMBER_COOKIE_REFRESH_EACH_REQUEST"
):
session["_remember"] = "set"
if "_remember" in session:
operation = session.pop("_remember", None)
if operation == "set" and "_user_id" in session:
self._set_cookie(response)
elif operation == "clear":
self._clear_cookie(response)
return response
def _set_cookie(self, response):
# cookie settings
config = current_app.config
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
domain = config.get("REMEMBER_COOKIE_DOMAIN")
path = config.get("REMEMBER_COOKIE_PATH", "/")
secure = config.get("REMEMBER_COOKIE_SECURE", COOKIE_SECURE)
httponly = config.get("REMEMBER_COOKIE_HTTPONLY", COOKIE_HTTPONLY)
samesite = config.get("REMEMBER_COOKIE_SAMESITE", COOKIE_SAMESITE)
if "_remember_seconds" in session:
duration = timedelta(seconds=session["_remember_seconds"])
else:
duration = config.get("REMEMBER_COOKIE_DURATION", COOKIE_DURATION)
# prepare data
max_age = int(current_app.permanent_session_lifetime.total_seconds())
signer_kwargs = dict(
key_derivation="hmac", digest_method=hashlib.sha1
)
# save
data = URLSafeSerializer(
current_app.secret_key,
salt="remember",
serializer=TaggedJSONSerializer(),
signer_kwargs=signer_kwargs,
).dumps({"user":session["_user_id"], "random":session["_random"]})
if isinstance(duration, int):
duration = timedelta(seconds=duration)
try:
expires = datetime.now(timezone.utc) + duration
except TypeError as e:
raise Exception(
"REMEMBER_COOKIE_DURATION must be a datetime.timedelta,"
f" instead got: {duration}"
) from e
# actually set it
response.set_cookie(
cookie_name,
value=data,
expires=expires,
domain=domain,
path=path,
secure=secure,
httponly=httponly,
samesite=samesite,
)
def _clear_cookie(self, response):
config = current_app.config
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
domain = config.get("REMEMBER_COOKIE_DOMAIN")
path = config.get("REMEMBER_COOKIE_PATH", "/")
response.delete_cookie(cookie_name, domain=domain, path=path)
@property
def _login_disabled(self):
"""Legacy property, use app.config['LOGIN_DISABLED'] instead."""
import warnings
warnings.warn(
"'_login_disabled' is deprecated and will be removed in"
" Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
" instead.",
DeprecationWarning,
stacklevel=2,
)
if has_app_context():
return current_app.config.get("LOGIN_DISABLED", False)
return False
@_login_disabled.setter
def _login_disabled(self, newvalue):
"""Legacy property setter, use app.config['LOGIN_DISABLED'] instead."""
import warnings
warnings.warn(
"'_login_disabled' is deprecated and will be removed in"
" Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
" instead.",
DeprecationWarning,
stacklevel=2,
)
current_app.config["LOGIN_DISABLED"] = newvalue
+65
View File
@@ -0,0 +1,65 @@
class UserMixin:
"""
This provides default implementations for the methods that Flask-Login
expects user objects to have.
"""
# Python 3 implicitly set __hash__ to None if we override __eq__
# We set it back to its default implementation
__hash__ = object.__hash__
@property
def is_active(self):
return True
@property
def is_authenticated(self):
return self.is_active
@property
def is_anonymous(self):
return False
def get_id(self):
try:
return str(self.id)
except AttributeError:
raise NotImplementedError("No `id` attribute - override `get_id`") from None
def __eq__(self, other):
"""
Checks the equality of two `UserMixin` objects using `get_id`.
"""
if isinstance(other, UserMixin):
return self.get_id() == other.get_id()
return NotImplemented
def __ne__(self, other):
"""
Checks the inequality of two `UserMixin` objects using `get_id`.
"""
equal = self.__eq__(other)
if equal is NotImplemented:
return NotImplemented
return not equal
class AnonymousUserMixin:
"""
This is the default object for representing an anonymous user.
"""
@property
def is_authenticated(self):
return False
@property
def is_active(self):
return False
@property
def is_anonymous(self):
return True
def get_id(self):
return
+61
View File
@@ -0,0 +1,61 @@
from flask.signals import Namespace
_signals = Namespace()
#: Sent when a user is logged in. In addition to the app (which is the
#: sender), it is passed `user`, which is the user being logged in.
user_logged_in = _signals.signal("logged-in")
#: Sent when a user is logged out. In addition to the app (which is the
#: sender), it is passed `user`, which is the user being logged out.
user_logged_out = _signals.signal("logged-out")
#: Sent when the user is loaded from the cookie. In addition to the app (which
#: is the sender), it is passed `user`, which is the user being reloaded.
user_loaded_from_cookie = _signals.signal("loaded-from-cookie")
#: Sent when the user is loaded from the header. In addition to the app (which
#: is the #: sender), it is passed `user`, which is the user being reloaded.
_user_loaded_from_header = _signals.signal("loaded-from-header")
#: Sent when the user is loaded from the request. In addition to the app (which
#: is the #: sender), it is passed `user`, which is the user being reloaded.
user_loaded_from_request = _signals.signal("loaded-from-request")
#: Sent when a user's login is confirmed, marking it as fresh. (It is not
#: called for a normal login.)
#: It receives no additional arguments besides the app.
user_login_confirmed = _signals.signal("login-confirmed")
#: Sent when the `unauthorized` method is called on a `LoginManager`. It
#: receives no additional arguments besides the app.
user_unauthorized = _signals.signal("unauthorized")
#: Sent when the `needs_refresh` method is called on a `LoginManager`. It
#: receives no additional arguments besides the app.
user_needs_refresh = _signals.signal("needs-refresh")
#: Sent whenever the user is accessed/loaded
#: receives no additional arguments besides the app.
user_accessed = _signals.signal("accessed")
#: Sent whenever session protection takes effect, and a session is either
#: marked non-fresh or deleted. It receives no additional arguments besides
#: the app.
session_protected = _signals.signal("session-protected")
def __getattr__(name):
if name == "user_loaded_from_header":
import warnings
warnings.warn(
"'user_loaded_from_header' is deprecated and will be"
" removed in Flask-Login 0.7. Use"
" 'user_loaded_from_request' instead.",
DeprecationWarning,
stacklevel=2,
)
return _user_loaded_from_header
raise AttributeError(name)
+424
View File
@@ -0,0 +1,424 @@
import hmac
import os
from functools import wraps
from hashlib import sha512
from urllib.parse import parse_qs
from urllib.parse import urlencode
from urllib.parse import urlsplit
from urllib.parse import urlunsplit
from flask import current_app
from flask import g
from flask import has_request_context
from flask import request
from flask import session
from flask import url_for
from werkzeug.local import LocalProxy
from .config import COOKIE_NAME
from .config import EXEMPT_METHODS
from .signals import user_logged_in
from .signals import user_logged_out
from .signals import user_login_confirmed
#: A proxy for the current user. If no user is logged in, this will be an
#: anonymous user
current_user = LocalProxy(lambda: _get_user())
def encode_cookie(payload, key=None):
"""
This will encode a ``str`` value into a cookie, and sign that cookie
with the app's secret key.
:param payload: The value to encode, as `str`.
:type payload: str
:param key: The key to use when creating the cookie digest. If not
specified, the SECRET_KEY value from app config will be used.
:type key: str
"""
return f"{payload}|{_cookie_digest(payload, key=key)}"
def decode_cookie(cookie, key=None):
"""
This decodes a cookie given by `encode_cookie`. If verification of the
cookie fails, ``None`` will be implicitly returned.
:param cookie: An encoded cookie.
:type cookie: str
:param key: The key to use when creating the cookie digest. If not
specified, the SECRET_KEY value from app config will be used.
:type key: str
"""
try:
payload, digest = cookie.rsplit("|", 1)
if hasattr(digest, "decode"):
digest = digest.decode("ascii") # pragma: no cover
except ValueError:
return
if hmac.compare_digest(_cookie_digest(payload, key=key), digest):
return payload
def make_next_param(login_url, current_url):
"""
Reduces the scheme and host from a given URL so it can be passed to
the given `login` URL more efficiently.
:param login_url: The login URL being redirected to.
:type login_url: str
:param current_url: The URL to reduce.
:type current_url: str
"""
l_url = urlsplit(login_url)
c_url = urlsplit(current_url)
if (not l_url.scheme or l_url.scheme == c_url.scheme) and (
not l_url.netloc or l_url.netloc == c_url.netloc
):
return urlunsplit(("", "", c_url.path, c_url.query, ""))
return current_url
def expand_login_view(login_view):
"""
Returns the url for the login view, expanding the view name to a url if
needed.
:param login_view: The name of the login view or a URL for the login view.
:type login_view: str
"""
if login_view.startswith(("https://", "http://", "/")):
return login_view
return url_for(login_view)
def login_url(login_view, next_url=None, next_field="next"):
"""
Creates a URL for redirecting to a login page. If only `login_view` is
provided, this will just return the URL for it. If `next_url` is provided,
however, this will append a ``next=URL`` parameter to the query string
so that the login view can redirect back to that URL. Flask-Login's default
unauthorized handler uses this function when redirecting to your login url.
To force the host name used, set `FORCE_HOST_FOR_REDIRECTS` to a host. This
prevents from redirecting to external sites if request headers Host or
X-Forwarded-For are present.
:param login_view: The name of the login view. (Alternately, the actual
URL to the login view.)
:type login_view: str
:param next_url: The URL to give the login view for redirection.
:type next_url: str
:param next_field: What field to store the next URL in. (It defaults to
``next``.)
:type next_field: str
"""
base = expand_login_view(login_view)
if next_url is None:
return base
parsed_result = urlsplit(base)
md = parse_qs(parsed_result.query, keep_blank_values=True)
md[next_field] = make_next_param(base, next_url)
netloc = current_app.config.get("FORCE_HOST_FOR_REDIRECTS") or parsed_result.netloc
parsed_result = parsed_result._replace(
netloc=netloc, query=urlencode(md, doseq=True)
)
return urlunsplit(parsed_result)
def login_fresh():
"""
This returns ``True`` if the current login is fresh.
"""
return session.get("_fresh", False)
def login_remembered():
"""
This returns ``True`` if the current login is remembered across sessions.
"""
config = current_app.config
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
has_cookie = cookie_name in request.cookies and session.get("_remember") != "clear"
if has_cookie:
cookie = request.cookies[cookie_name]
user_id = decode_cookie(cookie)
return user_id is not None
return False
def login_user(user, remember=False, duration=None, force=False, fresh=True):
"""
Logs a user in. You should pass the actual user object to this. If the
user's `is_active` property is ``False``, they will not be logged in
unless `force` is ``True``.
This will return ``True`` if the log in attempt succeeds, and ``False`` if
it fails (i.e. because the user is inactive).
:param user: The user object to log in.
:type user: object
:param remember: Whether to remember the user after their session expires.
Defaults to ``False``.
:type remember: bool
:param duration: The amount of time before the remember cookie expires. If
``None`` the value set in the settings is used. Defaults to ``None``.
:type duration: :class:`datetime.timedelta`
:param force: If the user is inactive, setting this to ``True`` will log
them in regardless. Defaults to ``False``.
:type force: bool
:param fresh: setting this to ``False`` will log in the user with a session
marked as not "fresh". Defaults to ``True``.
:type fresh: bool
"""
if not force and not user.is_active:
return False
user_id = getattr(user, current_app.login_manager.id_attribute)()
session["_user_id"] = user_id
session["_fresh"] = fresh
session["_id"] = current_app.login_manager._session_identifier_generator()
session["_random"] = os.urandom(10).hex()
if remember:
session["_remember"] = "set"
if duration is not None:
try:
# equal to timedelta.total_seconds() but works with Python 2.6
session["_remember_seconds"] = (
duration.microseconds
+ (duration.seconds + duration.days * 24 * 3600) * 10**6
) / 10.0**6
except AttributeError as e:
raise Exception(
f"duration must be a datetime.timedelta, instead got: {duration}"
) from e
current_app.login_manager._update_request_context_with_user(user)
user_logged_in.send(current_app._get_current_object(), user=_get_user())
return True
def logout_user():
"""
Logs a user out. (You do not need to pass the actual user.) This will
also clean up the remember me cookie if it exists.
"""
user = _get_user()
if "_user_id" in session:
session.pop("_user_id")
if "_fresh" in session:
session.pop("_fresh")
if "_id" in session:
session.pop("_id")
if "_random" in session:
session.pop("_random")
cookie_name = current_app.config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
if cookie_name in request.cookies:
session["_remember"] = "clear"
if "_remember_seconds" in session:
session.pop("_remember_seconds")
user_logged_out.send(current_app._get_current_object(), user=user)
current_app.login_manager._update_request_context_with_user()
return True
def confirm_login():
"""
This sets the current session as fresh. Sessions become stale when they
are reloaded from a cookie.
"""
session["_fresh"] = True
session["_id"] = current_app.login_manager._session_identifier_generator()
user_login_confirmed.send(current_app._get_current_object())
def login_required(func):
"""
If you decorate a view with this, it will ensure that the current user is
logged in and authenticated before calling the actual view. (If they are
not, it calls the :attr:`LoginManager.unauthorized` callback.) For
example::
@app.route('/post')
@user_login_required
def post():
pass
If there are only certain times you need to require that your user is
logged in, you can do so with::
if not current_user.is_authenticated:
return current_app.login_manager.unauthorized()
...which is essentially the code that this function adds to your views.
It can be convenient to globally turn off authentication when unit testing.
To enable this, if the application configuration variable `LOGIN_DISABLED`
is set to `True`, this decorator will be ignored.
.. Note ::
Per `W3 guidelines for CORS preflight requests
<http://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_,
HTTP ``OPTIONS`` requests are exempt from login checks.
:param func: The view function to decorate.
:type func: function
"""
@wraps(func)
def decorated_view(*args, **kwargs):
if request.method in EXEMPT_METHODS or current_app.config.get("LOGIN_DISABLED"):
pass
elif not current_user.is_authenticated:
return current_app.login_manager.unauthorized()
# flask 1.x compatibility
# current_app.ensure_sync is only available in Flask >= 2.0
if callable(getattr(current_app, "ensure_sync", None)):
return current_app.ensure_sync(func)(*args, **kwargs)
return func(*args, **kwargs)
return decorated_view
def fresh_login_required(func):
"""
If you decorate a view with this, it will ensure that the current user's
login is fresh - i.e. their session was not restored from a 'remember me'
cookie. Sensitive operations, like changing a password or e-mail, should
be protected with this, to impede the efforts of cookie thieves.
If the user is not authenticated, :meth:`LoginManager.unauthorized` is
called as normal. If they are authenticated, but their session is not
fresh, it will call :meth:`LoginManager.needs_refresh` instead. (In that
case, you will need to provide a :attr:`LoginManager.refresh_view`.)
Behaves identically to the :func:`login_required` decorator with respect
to configuration variables.
.. Note ::
Per `W3 guidelines for CORS preflight requests
<http://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_,
HTTP ``OPTIONS`` requests are exempt from login checks.
:param func: The view function to decorate.
:type func: function
"""
@wraps(func)
def decorated_view(*args, **kwargs):
if request.method in EXEMPT_METHODS or current_app.config.get("LOGIN_DISABLED"):
pass
elif not current_user.is_authenticated:
return current_app.login_manager.unauthorized()
elif not login_fresh():
return current_app.login_manager.needs_refresh()
try:
# current_app.ensure_sync available in Flask >= 2.0
return current_app.ensure_sync(func)(*args, **kwargs)
except AttributeError: # pragma: no cover
return func(*args, **kwargs)
return decorated_view
def set_login_view(login_view, blueprint=None):
"""
Sets the login view for the app or blueprint. If a blueprint is passed,
the login view is set for this blueprint on ``blueprint_login_views``.
:param login_view: The user object to log in.
:type login_view: str
:param blueprint: The blueprint which this login view should be set on.
Defaults to ``None``.
:type blueprint: object
"""
num_login_views = len(current_app.login_manager.blueprint_login_views)
if blueprint is not None or num_login_views != 0:
(current_app.login_manager.blueprint_login_views[blueprint.name]) = login_view
if (
current_app.login_manager.login_view is not None
and None not in current_app.login_manager.blueprint_login_views
):
(
current_app.login_manager.blueprint_login_views[None]
) = current_app.login_manager.login_view
current_app.login_manager.login_view = None
else:
current_app.login_manager.login_view = login_view
def _get_user():
if has_request_context():
if "flask_httpauth_user" in g:
if g.flask_httpauth_user is not None:
return g.flask_httpauth_user
if "_login_user" not in g:
current_app.login_manager._load_user()
return g._login_user
return None
def _cookie_digest(payload, key=None):
key = _secret_key(key)
return hmac.new(key, payload.encode("utf-8"), sha512).hexdigest()
def _get_remote_addr():
address = request.headers.get("X-Forwarded-For", request.remote_addr)
if address is not None:
# An 'X-Forwarded-For' header includes a comma separated list of the
# addresses, the first address being the actual remote address.
address = address.encode("utf-8").split(b",")[0].strip()
return address
def _create_identifier():
user_agent = request.headers.get("User-Agent")
if user_agent is not None:
user_agent = user_agent.encode("utf-8")
base = f"{_get_remote_addr()}|{user_agent}"
if str is bytes:
base = str(base, "utf-8", errors="replace") # pragma: no cover
h = sha512()
h.update(base.encode("utf8"))
return h.hexdigest()
def _user_context_processor():
return dict(current_user=_get_user())
def _secret_key(key=None):
if key is None:
key = current_app.config["SECRET_KEY"]
if isinstance(key, str): # pragma: no cover
key = key.encode("latin1") # ensure bytes
return key
+30 -23
View File
@@ -20,9 +20,11 @@
import os
import re
import json
from datetime import datetime
from datetime import datetime, timezone
from urllib.parse import quote
import unidecode
from weakref import WeakSet
from uuid import uuid4
from sqlite3 import OperationalError as sqliteOperationalError
from sqlalchemy import create_engine
@@ -40,16 +42,14 @@ except ImportError:
from sqlalchemy.pool import StaticPool
from sqlalchemy.sql.expression import and_, true, false, text, func, or_
from sqlalchemy.ext.associationproxy import association_proxy
from flask_login import current_user
from .cw_login import current_user
from flask_babel import gettext as _
from flask_babel import get_locale
from flask import flash
from . import logger, ub, isoLanguages
from .pagination import Pagination
from weakref import WeakSet
from .string_helper import strip_whitespaces
log = logger.create()
@@ -114,7 +114,7 @@ class Identifiers(Base):
if format_type == 'amazon':
return "Amazon"
elif format_type.startswith("amazon_"):
return "Amazon.{0}".format(format_type[7:])
return "Amazon.{0}".format(format_type[7:].lower().replace("uk","co.uk"))
elif format_type == "isbn":
return "ISBN"
elif format_type == "doi":
@@ -149,7 +149,7 @@ class Identifiers(Base):
if format_type == "amazon" or format_type == "asin":
return "https://amazon.com/dp/{0}".format(self.val)
elif format_type.startswith('amazon_'):
return "https://amazon.{0}/dp/{1}".format(format_type[7:], self.val)
return "https://amazon.{0}/dp/{1}".format(format_type[7:].lower().replace("uk","co.uk"), self.val)
elif format_type == "isbn":
return "https://www.worldcat.org/isbn/{0}".format(self.val)
elif format_type == "doi":
@@ -379,10 +379,10 @@ class Books(Base):
title = Column(String(collation='NOCASE'), nullable=False, default='Unknown')
sort = Column(String(collation='NOCASE'))
author_sort = Column(String(collation='NOCASE'))
timestamp = Column(TIMESTAMP, default=datetime.utcnow)
timestamp = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc))
pubdate = Column(TIMESTAMP, default=DEFAULT_PUBDATE)
series_index = Column(String, nullable=False, default="1.0")
last_modified = Column(TIMESTAMP, default=datetime.utcnow)
last_modified = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc))
path = Column(String, default="", nullable=False)
has_cover = Column(Integer, default=0)
uuid = Column(String)
@@ -534,7 +534,7 @@ class CalibreDB:
def init_session(self, expire_on_commit=True):
self.session = self.session_factory()
self.session.expire_on_commit = expire_on_commit
self.update_title_sort(self.config)
self.create_functions(self.config)
@classmethod
def setup_db_cc_classes(cls, cc):
@@ -876,10 +876,11 @@ class CalibreDB:
authors_ordered = list()
# error = False
for auth in sort_authors:
results = self.session.query(Authors).filter(Authors.sort == auth.lstrip().strip()).all()
auth = strip_whitespaces(auth)
results = self.session.query(Authors).filter(Authors.sort == auth).all()
# ToDo: How to handle not found author name
if not len(results):
log.error("Author {} not found to display name in right order".format(auth.strip()))
log.error("Author {} not found to display name in right order".format(auth))
# error = True
break
for r in results:
@@ -901,7 +902,8 @@ class CalibreDB:
def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()):
query = query or ''
self.session.connection().connection.connection.create_function("lower", 1, lcase)
self.create_functions()
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
entries = self.session.query(database).filter(tag_filter). \
filter(func.lower(database.name).ilike("%" + query + "%")).all()
# json_dumps = json.dumps([dict(name=escape(r.name.replace(*replace))) for r in entries])
@@ -909,7 +911,8 @@ class CalibreDB:
return json_dumps
def check_exists_book(self, authr, title):
self.session.connection().connection.connection.create_function("lower", 1, lcase)
self.create_functions()
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
q = list()
author_terms = re.split(r'\s*&\s*', authr)
for author_term in author_terms:
@@ -919,8 +922,9 @@ class CalibreDB:
.filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first()
def search_query(self, term, config, *join):
term.strip().lower()
self.session.connection().connection.connection.create_function("lower", 1, lcase)
strip_whitespaces(term).lower()
self.create_functions()
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
q = list()
author_terms = re.split("[, ]+", term)
for author_term in author_terms:
@@ -1018,7 +1022,7 @@ class CalibreDB:
lang.name = isoLanguages.get_language_name(get_locale(), lang.lang_code)
return sorted(languages, key=lambda x: x.name, reverse=reverse_order)
def update_title_sort(self, config, conn=None):
def create_functions(self, config=None):
# user defined sort function for calibre databases (Series, etc.)
def _title_sort(title):
# calibre sort stuff
@@ -1027,16 +1031,19 @@ class CalibreDB:
if match:
prep = match.group(1)
title = title[len(prep):] + ', ' + prep
return title.strip()
return strip_whitespaces(title)
try:
# sqlalchemy <1.4.24
conn = conn or self.session.connection().connection.driver_connection
# sqlalchemy <1.4.24 and sqlalchemy 2.0
conn = self.session.connection().connection.driver_connection
except AttributeError:
# sqlalchemy >1.4.24 and sqlalchemy 2.0
conn = conn or self.session.connection().connection.connection
# sqlalchemy >1.4.24
conn = self.session.connection().connection.connection
try:
conn.create_function("title_sort", 1, _title_sort)
if config:
conn.create_function("title_sort", 1, _title_sort)
conn.create_function('uuid4', 0, lambda: str(uuid4()))
conn.create_function("lower", 1, lcase)
except sqliteOperationalError:
pass
+6 -3
View File
@@ -26,7 +26,8 @@ from flask_babel.speaklater import LazyString
import os
from flask import send_file, __version__
from flask import send_file
import importlib
from . import logger, config
from .about import collect_stats
@@ -49,7 +50,8 @@ def assemble_logfiles(file_name):
with open(f, 'rb') as fd:
shutil.copyfileobj(fd, wfd)
wfd.seek(0)
if int(__version__.split('.')[0]) < 2:
version = importlib.metadata.version("flask")
if int(version.split('.')[0]) < 2:
return send_file(wfd,
as_attachment=True,
attachment_filename=os.path.basename(file_name))
@@ -72,7 +74,8 @@ def send_debug():
for fp in file_list:
zf.write(fp, os.path.basename(fp))
memory_zip.seek(0)
if int(__version__.split('.')[0]) < 2:
version = importlib.metadata.version("flask")
if int(version.split('.')[0]) < 2:
return send_file(memory_zip,
as_attachment=True,
attachment_filename="Calibre-Web-debug-pack.zip")
+18 -2
View File
@@ -39,11 +39,27 @@ def load_dependencies(optional=False):
with open(req_path, 'r') as f:
for line in f:
if not line.startswith('#') and not line == '\n' and not line.startswith('git'):
res = re.match(r'(.*?)([<=>\s]+)([\d\.]+),?\s?([<=>\s]+)?([\d\.]+)?', line.strip())
res = re.match(r'(.*?)([<=>\s]+)([\d\.]+),?\s?([<=>\s]+)?([\d\.]+)?(?:\s?;\s?'
r'(?:(python_version)\s?([<=>]+)\s?\'([\d\.]+)\'|'
r'(sys_platform)\s?([\!=]+)\s?\'([\w]+)\'))?', line.strip())
try:
if getattr(sys, 'frozen', False):
dep_version = exe_deps[res.group(1).lower().replace('_', '-')]
else:
if res.group(7) and res.group(8):
val = res.group(8).split(".")
if not eval(str(sys.version_info[0]) + "." + "{:02d}".format(sys.version_info[1]) +
res.group(7) + val[0] + "." + "{:02d}".format(int(val[1]))):
continue
elif res.group(10) and res.group(11):
# only installed if platform is eqal, don't check if platform is not equal
if res.group(10) == "==":
if sys.platform != res.group(11):
continue
# installed if platform is not eqal, don't check if platform is equal
elif res.group(10) == "!=":
if sys.platform == res.group(11):
continue
if importlib:
dep_version = version(res.group(1))
else:
@@ -63,7 +79,7 @@ def dependency_check(optional=False):
deps = load_dependencies(optional)
for dep in deps:
try:
dep_version_int = [int(x) if x.isnumeric() else 0 for x in dep[0].split('.')]
dep_version_int = [int(x) if x.isnumeric() else 0 for x in dep[0].split('.')[:3]]
low_check = [int(x) for x in dep[3].split('.')]
high_check = [int(x) for x in dep[5].split('.')]
except AttributeError:
+328 -297
View File
@@ -21,10 +21,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
from datetime import datetime
from datetime import datetime, timezone
import json
from shutil import copyfile, move
from uuid import uuid4
from shutil import copyfile
from markupsafe import escape, Markup # dependency of flask
from functools import wraps
@@ -32,7 +32,7 @@ from flask import Blueprint, request, flash, redirect, url_for, abort, Response
from flask_babel import gettext as _
from flask_babel import lazy_gettext as N_
from flask_babel import get_locale
from flask_login import current_user, login_required
from .cw_login import current_user, login_required
from sqlalchemy.exc import OperationalError, IntegrityError, InterfaceError
from sqlalchemy.orm.exc import StaleDataError
from sqlalchemy.sql.expression import func
@@ -43,10 +43,11 @@ from . import config, ub, db, calibre_db
from .services.worker import WorkerThread
from .tasks.upload import TaskUpload
from .render_template import render_title_template
from .usermanagement import login_required_if_no_ano
from .kobo_sync_status import change_archived_books
from .redirect import get_redirect_location
from .file_helper import validate_mime_type
from .usermanagement import user_login_required, login_required_if_no_ano
from .string_helper import strip_whitespaces
editbook = Blueprint('edit-book', __name__)
log = logger.create()
@@ -73,14 +74,14 @@ def edit_required(f):
@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"])
@login_required
@user_login_required
def delete_book_from_details(book_id):
return Response(delete_book_from_table(book_id, "", True), mimetype='application/json')
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
@login_required
@user_login_required
def delete_book_ajax(book_id, book_format):
return delete_book_from_table(book_id, book_format, False, request.form.to_dict().get('location', ""))
@@ -96,156 +97,22 @@ def show_edit_book(book_id):
@login_required_if_no_ano
@edit_required
def edit_book(book_id):
modify_date = False
edit_error = False
# create the function for sorting...
calibre_db.update_title_sort(config)
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
# Book not found
if not book:
flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
category="error")
return redirect(url_for("web.index"))
to_save = request.form.to_dict()
try:
# Update folder of book on local disk
edited_books_id = None
title_author_error = None
# handle book title change
title_change = handle_title_on_edit(book, to_save["book_title"])
# handle book author change
input_authors, author_change = handle_author_on_edit(book, to_save["author_name"])
if author_change or title_change:
edited_books_id = book.id
modify_date = True
title_author_error = helper.update_dir_structure(edited_books_id,
config.get_book_path(),
input_authors[0])
if title_author_error:
flash(title_author_error, category="error")
calibre_db.session.rollback()
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
# handle upload other formats from local disk
meta = upload_single_file(request, book, book_id)
# only merge metadata if file was uploaded and no error occurred (meta equals not false or none)
if meta:
merge_metadata(to_save, meta)
# handle upload covers from local disk
cover_upload_success = upload_cover(request, book)
if cover_upload_success:
book.has_cover = 1
modify_date = True
# upload new covers or new file formats to google drive
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if to_save.get("cover_url", None):
if not current_user.role_upload():
edit_error = True
flash(_("User has no rights to upload cover"), category="error")
if to_save["cover_url"].endswith('/static/generic_cover.jpg'):
book.has_cover = 0
else:
result, error = helper.save_cover_from_url(to_save["cover_url"].strip(), book.path)
if result is True:
book.has_cover = 1
modify_date = True
helper.replace_cover_thumbnail_cache(book.id)
else:
flash(error, category="error")
# Add default series_index to book
modify_date |= edit_book_series_index(to_save["series_index"], book)
# Handle book comments/description
modify_date |= edit_book_comments(Markup(to_save['description']).unescape(), book)
# Handle identifiers
input_identifiers = identifier_list(to_save, book)
modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session)
if warning:
flash(_("Identifiers are not Case Sensitive, Overwriting Old Identifier"), category="warning")
modify_date |= modification
# Handle book tags
modify_date |= edit_book_tags(to_save['tags'], book)
# Handle book series
modify_date |= edit_book_series(to_save["series"], book)
# handle book publisher
modify_date |= edit_book_publisher(to_save['publisher'], book)
# handle book languages
try:
modify_date |= edit_book_languages(to_save['languages'], book)
except ValueError as e:
flash(str(e), category="error")
edit_error = True
# handle book ratings
modify_date |= edit_book_ratings(to_save, book)
# handle cc data
modify_date |= edit_all_cc_data(book_id, book, to_save)
if to_save.get("pubdate", None):
try:
book.pubdate = datetime.strptime(to_save["pubdate"], "%Y-%m-%d")
except ValueError as e:
book.pubdate = db.Books.DEFAULT_PUBDATE
flash(str(e), category="error")
edit_error = True
else:
book.pubdate = db.Books.DEFAULT_PUBDATE
if modify_date:
book.last_modified = datetime.utcnow()
kobo_sync_status.remove_synced_book(edited_books_id, all=True)
calibre_db.set_metadata_dirty(book.id)
calibre_db.session.merge(book)
calibre_db.session.commit()
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if meta is not False \
and edit_error is not True \
and title_author_error is not True \
and cover_upload_success is not False:
flash(_("Metadata successfully updated"), category="success")
if "detail_view" in to_save:
return redirect(url_for('web.show_book', book_id=book.id))
else:
return render_edit_book(book_id)
except ValueError as e:
log.error_or_exception("Error: {}".format(e))
calibre_db.session.rollback()
flash(str(e), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
except (OperationalError, IntegrityError, StaleDataError, InterfaceError) as e:
log.error_or_exception("Database error: {}".format(e))
calibre_db.session.rollback()
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
except Exception as ex:
log.error_or_exception(ex)
calibre_db.session.rollback()
flash(_("Error editing book: {}".format(ex)), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
return do_edit_book(book_id)
@editbook.route("/upload", methods=["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:
if len(request.files.getlist("btn-upload-format")):
book_id = request.form.get('book_id', -1)
return do_edit_book(book_id, request.files.getlist("btn-upload-format"))
elif len(request.files.getlist("btn-upload")):
for requested_file in request.files.getlist("btn-upload"):
try:
modify_date = False
# create the function for sorting...
calibre_db.update_title_sort(config)
calibre_db.session.connection().connection.connection.create_function('uuid4', 0, lambda: str(uuid4()))
calibre_db.create_functions(config)
meta, error = file_handling_on_upload(requested_file)
if error:
return error
@@ -273,9 +140,7 @@ def upload():
input_authors[0],
meta.file_path,
title_dir + meta.extension.lower())
move_coverfile(meta, db_book)
if modify_date:
calibre_db.set_metadata_dirty(book_id)
# save data to database, reread data
@@ -303,6 +168,7 @@ def upload():
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e),
category="error")
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
abort(404)
@editbook.route("/admin/book/convert/<int:book_id>", methods=['POST'])
@@ -331,7 +197,7 @@ def convert_bookformat(book_id):
@editbook.route("/ajax/getcustomenum/<int:c_id>")
@login_required
@user_login_required
def table_get_custom_enum(c_id):
ret = list()
cc = (calibre_db.session.query(db.CustomColumns)
@@ -438,7 +304,7 @@ def edit_list_book(param):
mimetype='application/json')
else:
return _("Parameter not found"), 400
book.last_modified = datetime.utcnow()
book.last_modified = datetime.now(timezone.utc)
calibre_db.session.commit()
# revert change for sort if automatic fields link is deactivated
@@ -455,7 +321,7 @@ def edit_list_book(param):
@editbook.route("/ajax/sort_value/<field>/<int:bookid>")
@login_required
@user_login_required
def get_sorted_entry(field, bookid):
if field in ['title', 'authors', 'sort', 'author_sort']:
book = calibre_db.get_filtered_book(bookid)
@@ -472,7 +338,7 @@ def get_sorted_entry(field, bookid):
@editbook.route("/ajax/simulatemerge", methods=['POST'])
@login_required
@user_login_required
@edit_required
def simulate_merge_list_book():
vals = request.get_json().get('Merge_books')
@@ -488,7 +354,7 @@ def simulate_merge_list_book():
@editbook.route("/ajax/mergebooks", methods=['POST'])
@login_required
@user_login_required
@edit_required
def merge_list_book():
vals = request.get_json().get('Merge_books')
@@ -526,7 +392,7 @@ def merge_list_book():
@editbook.route("/ajax/xchange", methods=['POST'])
@login_required
@user_login_required
@edit_required
def table_xchange_author_title():
vals = request.get_json().get('xchange')
@@ -554,7 +420,7 @@ def table_xchange_author_title():
# toDo: Handle error
edit_error = helper.update_dir_structure(edited_books_id, config.get_book_path(), input_authors[0])
if modify_date:
book.last_modified = datetime.utcnow()
book.last_modified = datetime.now(timezone.utc)
calibre_db.set_metadata_dirty(book.id)
try:
calibre_db.session.commit()
@@ -569,18 +435,166 @@ def table_xchange_author_title():
return ""
def merge_metadata(to_save, meta):
if to_save.get('author_name', "") == _('Unknown'):
to_save['author_name'] = ''
if to_save.get('book_title', "") == _('Unknown'):
to_save['book_title'] = ''
def do_edit_book(book_id, upload_formats=None):
modify_date = False
edit_error = False
# create the function for sorting...
calibre_db.create_functions(config)
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
# Book not found
if not book:
flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
category="error")
return redirect(url_for("web.index"))
to_save = request.form.to_dict()
try:
# Update folder of book on local disk
edited_books_id = None
title_author_error = None
# upload_mode = False
# handle book title change
if "title" in to_save:
title_change = handle_title_on_edit(book, to_save["title"])
# handle book author change
if not upload_formats:
input_authors, author_change = handle_author_on_edit(book, to_save["authors"])
if author_change or title_change:
edited_books_id = book.id
modify_date = True
title_author_error = helper.update_dir_structure(edited_books_id,
config.get_book_path(),
input_authors[0])
if title_author_error:
flash(title_author_error, category="error")
calibre_db.session.rollback()
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
# handle book ratings
modify_date |= edit_book_ratings(to_save, book)
else:
# handle upload other formats from local disk
to_save, edit_error = upload_book_formats(upload_formats, book, book_id, book.has_cover)
# handle upload covers from local disk
cover_upload_success = upload_cover(request, book)
if cover_upload_success or to_save.get("format_cover"):
book.has_cover = 1
modify_date = True
# upload new covers or new file formats to google drive
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if to_save.get("cover_url",):
if not current_user.role_upload():
edit_error = True
flash(_("User has no rights to upload cover"), category="error")
if to_save["cover_url"].endswith('/static/generic_cover.jpg'):
book.has_cover = 0
else:
result, error = helper.save_cover_from_url(to_save["cover_url"].strip(), book.path)
if result is True:
book.has_cover = 1
modify_date = True
helper.replace_cover_thumbnail_cache(book.id)
else:
edit_error = True
flash(error, category="error")
# Add default series_index to book
modify_date |= edit_book_series_index(to_save.get("series_index"), book)
# Handle book comments/description
modify_date |= edit_book_comments(Markup(to_save.get('comments')).unescape(), book)
# Handle identifiers
input_identifiers = identifier_list(to_save, book)
modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session)
if warning:
flash(_("Identifiers are not Case Sensitive, Overwriting Old Identifier"), category="warning")
modify_date |= modification
# Handle book tags
modify_date |= edit_book_tags(to_save.get('tags'), book)
# Handle book series
modify_date |= edit_book_series(to_save.get("series"), book)
# handle book publisher
modify_date |= edit_book_publisher(to_save.get('publisher'), book)
# handle book languages
try:
invalid = []
modify_date |= edit_book_languages(to_save.get('languages'), book, upload_mode=upload_formats,
invalid=invalid)
if invalid:
for lang in invalid:
flash(_("'%(langname)s' is not a valid language", langname=lang), category="warning")
except ValueError as e:
flash(str(e), category="error")
edit_error = True
# handle cc data
modify_date |= edit_all_cc_data(book_id, book, to_save)
if to_save.get("pubdate") is not None:
if to_save.get("pubdate"):
try:
book.pubdate = datetime.strptime(to_save["pubdate"], "%Y-%m-%d")
except ValueError as e:
book.pubdate = db.Books.DEFAULT_PUBDATE
flash(str(e), category="error")
edit_error = True
else:
book.pubdate = db.Books.DEFAULT_PUBDATE
if modify_date:
book.last_modified = datetime.now(timezone.utc)
kobo_sync_status.remove_synced_book(edited_books_id, all=True)
calibre_db.set_metadata_dirty(book.id)
calibre_db.session.merge(book)
calibre_db.session.commit()
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if edit_error is not True and title_author_error is not True and cover_upload_success is not False:
flash(_("Metadata successfully updated"), category="success")
if upload_formats:
resp = {"location": url_for('edit-book.show_edit_book', book_id=book_id)}
return Response(json.dumps(resp), mimetype='application/json')
if "detail_view" in to_save:
return redirect(url_for('web.show_book', book_id=book.id))
else:
return render_edit_book(book_id)
except ValueError as e:
log.error_or_exception("Error: {}".format(e))
calibre_db.session.rollback()
flash(str(e), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
except (OperationalError, IntegrityError, StaleDataError, InterfaceError) as e:
log.error_or_exception("Database error: {}".format(e))
calibre_db.session.rollback()
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
except Exception as ex:
log.error_or_exception(ex)
calibre_db.session.rollback()
flash(_("Error editing book: {}".format(ex)), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
def merge_metadata(book, meta, to_save):
if meta.cover:
to_save['cover_format'] = meta.cover
for s_field, m_field in [
('tags', 'tags'), ('author_name', 'author'), ('series', 'series'),
('tags', 'tags'), ('authors', '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()
('title', 'title'), ('comments', 'description')]:
try:
val = None if len(getattr(book, s_field)) else getattr(meta, m_field, '')
except TypeError:
val = None if len(str(getattr(book, s_field))) else getattr(meta, m_field, '')
if val:
to_save[s_field] = val
def identifier_list(to_save, book):
@@ -701,8 +715,8 @@ def create_book_on_upload(modify_date, meta):
pubdate = datetime(101, 1, 1)
# Calibre adds books with utc as timezone
db_book = db.Books(title, "", sort_authors, datetime.utcnow(), pubdate,
'1', datetime.utcnow(), path, meta.cover, db_author, [], "")
db_book = db.Books(title, "", sort_authors, datetime.now(timezone.utc), pubdate,
'1', datetime.now(timezone.utc), path, meta.cover, db_author, [], "")
modify_date |= modify_database_object(input_authors, db_book.authors, db.Authors, calibre_db.session,
'author')
@@ -969,7 +983,7 @@ def render_edit_book(book_id):
def edit_book_ratings(to_save, book):
changed = False
if to_save.get("rating", "").strip():
if strip_whitespaces(to_save.get("rating", "")):
old_rating = False
if len(book.ratings) > 0:
old_rating = book.ratings[0].rating
@@ -992,84 +1006,93 @@ def edit_book_ratings(to_save, book):
def edit_book_tags(tags, book):
input_tags = tags.split(',')
input_tags = list(map(lambda it: it.strip(), input_tags))
# Remove duplicates
input_tags = helper.uniq(input_tags)
return modify_database_object(input_tags, book.tags, db.Tags, calibre_db.session, 'tags')
if tags is not None:
input_tags = tags.split(',')
input_tags = list(map(lambda it: strip_whitespaces(it), input_tags))
# Remove duplicates
input_tags = helper.uniq(input_tags)
return modify_database_object(input_tags, book.tags, db.Tags, calibre_db.session, 'tags')
return False
def edit_book_series(series, book):
input_series = [series.strip()]
input_series = [x for x in input_series if x != '']
return modify_database_object(input_series, book.series, db.Series, calibre_db.session, 'series')
if series is not None:
input_series = [strip_whitespaces(series)]
input_series = [x for x in input_series if x != '']
return modify_database_object(input_series, book.series, db.Series, calibre_db.session, 'series')
return False
def edit_book_series_index(series_index, book):
# Add default series_index to book
modify_date = False
series_index = series_index or '1'
if not series_index.replace('.', '', 1).isdigit():
flash(_("%(seriesindex)s is not a valid number, skipping", seriesindex=series_index), category="warning")
return False
if str(book.series_index) != series_index:
book.series_index = series_index
modify_date = True
return modify_date
if series_index:
# Add default series_index to book
modify_date = False
series_index = series_index or '1'
if not series_index.replace('.', '', 1).isdigit():
flash(_("Seriesindex: %(seriesindex)s is not a valid number, skipping", seriesindex=series_index), category="warning")
return False
if str(book.series_index) != series_index:
book.series_index = series_index
modify_date = True
return modify_date
return False
# Handle book comments/description
def edit_book_comments(comments, book):
modify_date = False
if comments:
comments = clean_string(comments, book.id)
if len(book.comments):
if book.comments[0].text != comments:
book.comments[0].text = comments
modify_date = True
else:
if comments is not None:
modify_date = False
if comments:
book.comments.append(db.Comments(comment=comments, book=book.id))
modify_date = True
return modify_date
comments = clean_string(comments, book.id)
if len(book.comments):
if book.comments[0].text != comments:
book.comments[0].text = comments
modify_date = True
else:
if comments:
book.comments.append(db.Comments(comment=comments, book=book.id))
modify_date = True
return modify_date
def edit_book_languages(languages, book, upload_mode=False, invalid=None):
input_languages = languages.split(',')
unknown_languages = []
if not upload_mode:
input_l = isoLanguages.get_language_codes(get_locale(), input_languages, unknown_languages)
else:
input_l = isoLanguages.get_valid_language_codes(get_locale(), input_languages, unknown_languages)
for lang in unknown_languages:
log.error("'%s' is not a valid language", lang)
if isinstance(invalid, list):
invalid.append(lang)
if languages is not None:
input_languages = languages.split(',')
unknown_languages = []
if not upload_mode:
input_l = isoLanguages.get_language_code_from_name(get_locale(), input_languages, unknown_languages)
else:
raise ValueError(_("'%(langname)s' is not a valid language", langname=lang))
# ToDo: Not working correct
if upload_mode and len(input_l) == 1:
# 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 input_l[0] != current_user.filter_language() and current_user.filter_language() != "all":
input_l[0] = calibre_db.session.query(db.Languages). \
filter(db.Languages.lang_code == current_user.filter_language()).first().lang_code
# Remove duplicates
input_l = helper.uniq(input_l)
return modify_database_object(input_l, book.languages, db.Languages, calibre_db.session, 'languages')
input_l = isoLanguages.get_valid_language_codes_from_code(get_locale(), input_languages, unknown_languages)
for lang in unknown_languages:
log.error("'%s' is not a valid language", lang)
if isinstance(invalid, list):
invalid.append(lang)
else:
raise ValueError(_("'%(langname)s' is not a valid language", langname=lang))
# ToDo: Not working correct
if upload_mode and len(input_l) == 1:
# 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 input_l[0] != current_user.filter_language() and current_user.filter_language() != "all":
input_l[0] = calibre_db.session.query(db.Languages). \
filter(db.Languages.lang_code == current_user.filter_language()).first().lang_code
# Remove duplicates from normalized langcodes
input_l = helper.uniq(input_l)
return modify_database_object(input_l, book.languages, db.Languages, calibre_db.session, 'languages')
return False
def edit_book_publisher(publishers, book):
changed = False
if publishers:
publisher = publishers.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, calibre_db.session,
'publisher')
elif len(book.publishers):
changed |= modify_database_object([], book.publishers, db.Publishers, calibre_db.session, 'publisher')
return changed
if publishers is not None:
changed = False
if publishers:
publisher = strip_whitespaces(publishers)
if len(book.publishers) == 0 or (len(book.publishers) > 0 and publisher != book.publishers[0].name):
changed |= modify_database_object([publisher], book.publishers, db.Publishers, calibre_db.session,
'publisher')
elif len(book.publishers):
changed |= modify_database_object([], book.publishers, db.Publishers, calibre_db.session, 'publisher')
return changed
return False
def edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string):
changed = False
@@ -1109,7 +1132,7 @@ def edit_cc_data_string(book, c, to_save, cc_db_value, cc_string):
changed = False
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 strip_whitespaces(to_save[cc_string]) != cc_db_value:
if cc_db_value is not None:
# remove old cc_val
del_cc = getattr(book, cc_string)[0]
@@ -1119,15 +1142,15 @@ def edit_cc_data_string(book, c, to_save, cc_db_value, cc_string):
changed = True
cc_class = db.cc_classes[c.id]
new_cc = calibre_db.session.query(cc_class).filter(
cc_class.value == to_save[cc_string].strip()).first()
cc_class.value == strip_whitespaces(to_save[cc_string])).first()
# if no cc val is found add it
if new_cc is None:
new_cc = cc_class(value=to_save[cc_string].strip())
new_cc = cc_class(value=strip_whitespaces(to_save[cc_string]))
calibre_db.session.add(new_cc)
changed = True
calibre_db.session.flush()
new_cc = calibre_db.session.query(cc_class).filter(
cc_class.value == to_save[cc_string].strip()).first()
cc_class.value == strip_whitespaces(to_save[cc_string])).first()
# add cc value to book
getattr(book, cc_string).append(new_cc)
return changed, to_save
@@ -1150,61 +1173,66 @@ def edit_cc_data(book_id, book, to_save, cc):
changed = False
for c in cc:
cc_string = "custom_column_" + str(c.id)
if not c.is_multiple:
if len(getattr(book, cc_string)) > 0:
cc_db_value = getattr(book, cc_string)[0].value
else:
cc_db_value = None
if to_save[cc_string].strip():
if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]:
change, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string)
if to_save.get(cc_string) is not None:
if not c.is_multiple:
if len(getattr(book, cc_string)) > 0:
cc_db_value = getattr(book, cc_string)[0].value
else:
change, to_save = edit_cc_data_string(book, c, to_save, cc_db_value, cc_string)
changed |= change
cc_db_value = None
if strip_whitespaces(to_save[cc_string]):
if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]:
change, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string)
else:
change, to_save = edit_cc_data_string(book, c, to_save, cc_db_value, cc_string)
changed |= change
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:
calibre_db.session.delete(del_cc)
changed = True
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:
calibre_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],
calibre_db.session,
'custom')
input_tags = to_save[cc_string].split(',')
input_tags = list(map(lambda it: strip_whitespaces(it), input_tags))
changed |= modify_database_object(input_tags,
getattr(book, cc_string),
db.cc_classes[c.id],
calibre_db.session,
'custom')
return changed
# returns None if no file is uploaded
# returns False if an error occurs, in all other cases the ebook metadata is returned
def upload_single_file(file_request, book, book_id):
# returns False if an error occurs or no book is uploaded, in all other cases the ebook metadata to change is returned
def upload_book_formats(requested_files, book, book_id, no_cover=True):
# Check and handle Uploaded file
requested_file = file_request.files.get('btn-upload-format', None)
to_save = dict()
error = False
allowed_extensions = config.config_upload_formats.split(',')
if requested_file:
for requested_file in requested_files:
current_filename = requested_file.filename
if config.config_check_extensions and allowed_extensions != ['']:
if not validate_mime_type(requested_file, allowed_extensions):
flash(_("File type isn't allowed to be uploaded to this server"), category="error")
return False
# check for empty request
if requested_file.filename != '':
error = True
continue
if current_filename != '':
if not current_user.role_upload():
flash(_("User has no rights to upload additional file formats"), category="error")
return False
if '.' in requested_file.filename:
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
error = True
continue
if '.' in current_filename:
file_ext = current_filename.rsplit('.', 1)[-1].lower()
if file_ext not in allowed_extensions and '' not in allowed_extensions:
flash(_("File extension '%(ext)s' is not allowed to be uploaded to this server", ext=file_ext),
category="error")
return False
error = True
continue
else:
flash(_('File to be uploaded must have an extension'), category="error")
return False
error = True
continue
file_name = book.path.rsplit('/', 1)[-1]
filepath = os.path.normpath(os.path.join(config.get_book_path(), book.path))
@@ -1217,41 +1245,50 @@ def upload_single_file(file_request, book, book_id):
except OSError:
flash(_("Failed to create path %(path)s (Permission denied).", path=filepath),
category="error")
return False
error = True
continue
try:
requested_file.save(saved_filename)
except OSError:
flash(_("Failed to store file %(file)s.", file=saved_filename), category="error")
return False
error = True
continue
file_size = os.path.getsize(saved_filename)
is_format = calibre_db.get_book_format(book_id, file_ext.upper())
# Format entry already exists, no need to update the database
if is_format:
if calibre_db.get_book_format(book_id, file_ext.upper()):
log.warning('Book format %s already existing', file_ext.upper())
else:
try:
db_format = db.Data(book_id, file_ext.upper(), file_size, file_name)
calibre_db.session.add(db_format)
calibre_db.session.commit()
calibre_db.update_title_sort(config)
calibre_db.create_functions(config)
except (OperationalError, IntegrityError, StaleDataError) as e:
calibre_db.session.rollback()
log.error_or_exception("Database error: {}".format(e))
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e),
category="error")
return False # return redirect(url_for('web.show_book', book_id=book.id))
error = True
continue
# Queue uploader info
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book.id), escape(book.title))
upload_text = N_("File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=link)
WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(book.title)))
return uploader.process(
saved_filename, *os.path.splitext(requested_file.filename),
rar_executable=config.config_rarfile_location)
return None
meta = uploader.process(
saved_filename,
*os.path.splitext(current_filename),
rar_executable=config.config_rarfile_location,
no_cover=no_cover)
merge_metadata(book, meta, to_save)
#if to_save.get('languages'):
# langs = []
# for lang_code in to_save['languages'].split(','):
# langs.append(isoLanguages.get_language_name(get_locale(), lang_code))
# to_save['languages'] = ",".join(langs)
return to_save, error
def upload_cover(cover_request, book):
@@ -1274,7 +1311,7 @@ def upload_cover(cover_request, book):
def handle_title_on_edit(book, book_title):
# handle book title
book_title = book_title.rstrip().strip()
book_title = strip_whitespaces(book_title)
if book.title != book_title:
if book_title == '':
book_title = _(u'Unknown')
@@ -1285,10 +1322,8 @@ def handle_title_on_edit(book, book_title):
def handle_author_on_edit(book, author_name, update_stored=True):
change = False
# handle author(s)
input_authors = prepare_authors(author_name, config.get_book_path(), config.config_use_google_drive)
# change |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
# Search for each author if author is in database, if not, author name and sorted author name is generated new
# everything then is assembled for sorted author field in database
sort_authors_list = list()
@@ -1316,7 +1351,6 @@ def search_objects_remove(db_book_object, db_type, input_elements):
if db_type == 'custom':
type_elements = c_elements.value
else:
# type_elements = c_elements.name
type_elements = c_elements
for inp_element in input_elements:
if type_elements == inp_element:
@@ -1368,11 +1402,9 @@ def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
for add_element in add_elements:
# check if an element with that name exists
changed = True
# db_session.query(db.Tags).filter((func.lower(db.Tags.name).ilike("GênOt"))).all()
db_element = db_session.query(db_object).filter((func.lower(db_filter).ilike(add_element))).first()
# db_element = db_session.query(db_object).filter(func.lower(db_filter) == add_element.lower()).first()
db_element = db_session.query(db_object).filter((func.lower(db_filter).ilike(add_element))).all()
# if no element is found add it
if db_element is None:
if not db_element:
if db_type == 'author':
new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')))
elif db_type == 'series':
@@ -1386,12 +1418,11 @@ def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
db_session.add(new_element)
db_book_object.append(new_element)
else:
db_no_case = db_session.query(db_object).filter(db_filter == add_element).first()
if db_no_case:
# check for new case of element
db_element = create_objects_for_addition(db_element, add_element, db_type)
#else:
# db_element = create_objects_for_addition(db_element, add_element, db_type)
if len(db_element) == 1:
db_element = create_objects_for_addition(db_element[0], add_element, db_type)
else:
db_el = db_session.query(db_object).filter(db_filter == add_element).first()
db_element = db_element[0] if not db_el else db_el
# add element to book
db_book_object.append(db_element)
+2 -4
View File
@@ -28,16 +28,14 @@ log = logger.create()
def do_calibre_export(book_id, book_format):
try:
quotes = [3, 5, 7, 9]
quotes = [4, 6]
tmp_dir = get_temp_dir()
calibredb_binarypath = get_calibre_binarypath("calibredb")
temp_file_name = str(uuid4())
my_env = os.environ.copy()
if config.config_calibre_split:
my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db")
library_path = config.config_calibre_split_dir
else:
library_path = config.config_calibre_dir
library_path = config.get_book_path()
opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--with-library', library_path,
'--to-dir', tmp_dir, '--formats', book_format, "--template", "{}".format(temp_file_name),
str(book_id)]
+7 -3
View File
@@ -25,6 +25,7 @@ from . import config, logger
from .helper import split_authors
from .epub_helper import get_content_opf, default_ns
from .constants import BookMeta
from .string_helper import strip_whitespaces
log = logger.create()
@@ -65,7 +66,7 @@ def get_epub_layout(book, book_data):
return layout[0]
def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
def get_epub_info(tmp_file_path, original_file_name, original_file_extension, no_cover_processing):
ns = {
'n': 'urn:oasis:names:tc:opendocument:xmlns:container',
'pkg': 'http://www.idpf.org/2007/opf',
@@ -90,7 +91,7 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
elif s == 'date':
epub_metadata[s] = tmp[0][:10]
else:
epub_metadata[s] = tmp[0].strip()
epub_metadata[s] = strip_whitespaces(tmp[0])
else:
epub_metadata[s] = 'Unknown'
@@ -116,7 +117,10 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
epub_metadata = parse_epub_series(ns, tree, epub_metadata)
epub_zip = zipfile.ZipFile(tmp_file_path)
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path)
if not no_cover_processing:
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path)
else:
cover_file = None
identifiers = []
for node in p.xpath('dc:identifier', namespaces=ns):
+12 -9
View File
@@ -21,17 +21,18 @@ import os
import shutil
import zipfile
import mimetypes
import copy
from io import BytesIO
try:
import magic
except ImportError:
pass
from . import logger
log = logger.create()
try:
import magic
error = None
except ImportError as e:
error = "Cannot import python-magic, checking uploaded file metadata will not work: {}".format(e)
def get_temp_dir():
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
@@ -46,12 +47,15 @@ def del_temp_dir():
def validate_mime_type(file_buffer, allowed_extensions):
if error:
log.error(error)
return False
mime = magic.Magic(mime=True)
allowed_mimetypes =list()
allowed_mimetypes = list()
for x in allowed_extensions:
try:
allowed_mimetypes.append(mimetypes.types_map["." + x])
except KeyError as e:
except KeyError:
log.error("Unkown mimetype for Extension: {}".format(x))
tmp_mime_type = mime.from_buffer(file_buffer.read())
file_buffer.seek(0)
@@ -66,6 +70,5 @@ def validate_mime_type(file_buffer, allowed_extensions):
return True
except:
file_buffer.seek(0)
pass
log.error("Mimetype '{}' not found in allowed types".format(tmp_mime_type))
return False
+4 -4
View File
@@ -29,11 +29,11 @@ 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
from . import logger, gdriveutils, config, ub, calibre_db, csrf
from .admin import admin_required
from .file_helper import get_temp_dir
from .usermanagement import user_login_required
gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive')
log = logger.create()
@@ -49,7 +49,7 @@ gdrive_watch_callback_token = 'target=calibreweb-watch_files' # nosec
@gdrive.route("/authenticate")
@login_required
@user_login_required
@admin_required
def authenticate_google_drive():
try:
@@ -76,7 +76,7 @@ def google_drive_callback():
@gdrive.route("/watch/subscribe")
@login_required
@user_login_required
@admin_required
def watch_gdrive():
if not config.config_google_drive_watch_changes_response:
@@ -102,7 +102,7 @@ def watch_gdrive():
@gdrive.route("/watch/revoke")
@login_required
@user_login_required
@admin_required
def revoke_watch_gdrive():
last_watch_response = config.config_google_drive_watch_changes_response
+23 -1
View File
@@ -16,7 +16,7 @@
# 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 datetime import datetime
from gevent.pywsgi import WSGIHandler
@@ -27,4 +27,26 @@ class MyWSGIHandler(WSGIHandler):
env['RAW_URI'] = path
return env
def format_request(self):
now = datetime.now().replace(microsecond=0)
length = self.response_length or '-'
if self.time_finish:
delta = '%.6f' % (self.time_finish - self.time_start)
else:
delta = '-'
forwarded = self.environ.get('HTTP_X_FORWARDED_FOR', None)
if forwarded:
client_address = forwarded
else:
client_address = self.client_address[0] if isinstance(self.client_address, tuple) else self.client_address
return '%s - - [%s] "%s" %s %s %s' % (
client_address or '-',
now,
self.requestline or '',
# Use the native string version of the status, saved so we don't have to
# decode. But fallback to the encoded 'status' in case of subclasses
# (Is that really necessary? At least there's no overhead.)
(self._orig_status or self.status or '000').split()[0],
length,
delta)
+74 -97
View File
@@ -25,7 +25,7 @@ import re
import regex
import shutil
import socket
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
import requests
import unidecode
from uuid import uuid4
@@ -34,7 +34,7 @@ from flask import send_from_directory, make_response, abort, url_for, Response
from flask_babel import gettext as _
from flask_babel import lazy_gettext as N_
from flask_babel import get_locale
from flask_login import current_user
from .cw_login import current_user
from sqlalchemy.sql.expression import true, false, and_, or_, text, func
from sqlalchemy.exc import InvalidRequestError, OperationalError
from werkzeug.datastructures import Headers
@@ -43,15 +43,16 @@ from markupsafe import escape
from urllib.parse import quote
try:
import advocate
from advocate.exceptions import UnacceptableAddressException
from . import cw_advocate
from .cw_advocate.exceptions import UnacceptableAddressException
use_advocate = True
except ImportError:
except ImportError as e:
use_advocate = False
advocate = requests
UnacceptableAddressException = MissingSchema = BaseException
from . import calibre_db, cli_param
from .string_helper import strip_whitespaces
from .tasks.convert import TaskConvert
from . import logger, config, db, ub, fs
from . import gdriveutils as gd
@@ -117,9 +118,11 @@ def convert_book_format(book_id, calibre_path, old_book_format, new_book_format,
# Texts are not lazy translated as they are supposed to get send out as is
def send_test_mail(ereader_mail, user_name):
WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None,
config.get_mail_settings(), ereader_mail, N_("Test Email"),
_('This Email has been sent via Calibre-Web.')))
for email in ereader_mail.split(','):
email = strip_whitespaces(email)
WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None,
config.get_mail_settings(), email, N_("Test Email"),
_('This Email has been sent via Calibre-Web.')))
return
@@ -225,9 +228,11 @@ def send_mail(book_id, book_format, convert, ereader_mail, calibrepath, user_id)
converted_file_name = entry.name + '.' + book_format.lower()
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(book.title))
email_text = N_("%(book)s send to eReader", book=link)
WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
config.get_mail_settings(), ereader_mail,
email_text, _('This Email has been sent via Calibre-Web.'), book.id))
for email in ereader_mail.split(','):
email = strip_whitespaces(email)
WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
config.get_mail_settings(), email,
email_text, _('This Email has been sent via Calibre-Web.'), book.id))
return
return _("The requested file could not be read. Maybe wrong permissions?")
@@ -248,7 +253,7 @@ def get_valid_filename(value, replace_whitespace=True, chars=128):
# pipe has to be replaced with comma
value = re.sub(r'[|]+', ',', value, flags=re.U)
value = value.encode('utf-8')[:chars].decode('utf-8', errors='ignore').strip()
value = strip_whitespaces(value.encode('utf-8')[:chars].decode('utf-8', errors='ignore'))
if not value:
raise ValueError("Filename cannot be empty")
@@ -263,11 +268,11 @@ def split_authors(values):
commas = author.count(',')
if commas == 1:
author_split = author.split(',')
authors_list.append(author_split[1].strip() + ' ' + author_split[0].strip())
authors_list.append(strip_whitespaces(author_split[1]) + ' ' + strip_whitespaces(author_split[0]))
elif commas > 1:
authors_list.extend([x.strip() for x in author.split(',')])
authors_list.extend([strip_whitespaces(x) for x in author.split(',')])
else:
authors_list.append(author.strip())
authors_list.append(strip_whitespaces(author))
return authors_list
@@ -323,7 +328,7 @@ def edit_book_read_status(book_id, read_status=None):
ub.session_commit("Book {} readbit toggled".format(book_id))
else:
try:
calibre_db.update_title_sort(config)
calibre_db.create_functions(config)
book = calibre_db.get_filtered_book(book_id)
book_read_status = getattr(book, 'custom_column_' + str(config.config_read_column))
if len(book_read_status):
@@ -409,42 +414,10 @@ def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdriv
file_format.name = all_new_name
'''def rename_all_authors(first_author, renamed_author, calibre_path="", localbook=None, gdrive=False):
# Create new_author_dir from parameter or from database
# Create new title_dir from database and add id
if first_author:
new_authordir = get_valid_filename(first_author, chars=96)
for r in renamed_author:
new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == r).first()
old_author_dir = get_valid_filename(r, chars=96)
new_author_rename_dir = get_valid_filename(new_author.name, chars=96)
if gdrive:
g_file = gd.getFileFromEbooksFolder(None, old_author_dir)
if g_file:
gd.moveGdriveFolderRemote(g_file, new_author_rename_dir)
gd.updateDatabaseOnEdit(g_file['id'], new_author_rename_dir)
else:
if os.path.isdir(os.path.join(calibre_path, old_author_dir)):
old_author_path = os.path.join(calibre_path, old_author_dir)
new_author_path = os.path.join(calibre_path, new_author_rename_dir)
try:
shutil.move(os.path.normcase(old_author_path), os.path.normcase(new_author_path))
except OSError as ex:
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
log.debug(ex, exc_info=True)
return _("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
src=old_author_path, dest=new_author_path, error=str(ex))
else:
new_authordir = get_valid_filename(localbook.authors[0].name, chars=96)
return new_authordir'''
def rename_author_path(first_author, old_author_dir, renamed_author, calibre_path="", gdrive=False):
# Create new_author_dir from parameter or from database
# Create new title_dir from database and add id
new_authordir = get_valid_filename(first_author, chars=96)
# new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == renamed_author).first()
# old_author_dir = get_valid_filename(old_author_name, chars=96)
new_author_rename_dir = get_valid_filename(renamed_author, chars=96)
if gdrive:
g_file = gd.getFileFromEbooksFolder(None, old_author_dir)
@@ -455,12 +428,15 @@ def rename_author_path(first_author, old_author_dir, renamed_author, calibre_pat
old_author_path = os.path.join(calibre_path, old_author_dir)
new_author_path = os.path.join(calibre_path, new_author_rename_dir)
try:
shutil.move(old_author_path, new_author_path)
except OSError as ex:
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
log.debug(ex, exc_info=True)
return _("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
src=old_author_path, dest=new_author_path, error=str(ex))
os.rename(old_author_path, new_author_path)
except OSError:
try:
shutil.move(old_author_path, new_author_path)
except OSError as ex:
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
log.error_or_exception(ex)
raise Exception(_("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
src=old_author_path, dest=new_author_path, error=str(ex)))
return new_authordir
# Moves files in file storage during author/title rename, or from temp dir to file storage
@@ -490,7 +466,6 @@ def update_dir_structure_file(book_id, calibre_path, original_filepath, new_auth
db_filename,
original_filepath,
path)
# old_path = os.path.join(calibre_path, author_dir, new_title_dir).replace('\\', '/')
new_path = os.path.join(calibre_path, new_author_dir, new_title_dir).replace('\\', '/')
all_new_name = get_valid_filename(local_book.title, chars=42) + ' - ' \
+ get_valid_filename(new_author, chars=42)
@@ -499,8 +474,6 @@ def update_dir_structure_file(book_id, calibre_path, original_filepath, new_auth
if error:
return error
# Rename all files from old names to new names
return False
@@ -512,7 +485,7 @@ def upload_new_file_gdrive(book_id, first_author, title, title_dir, original_fil
title_dir + " (" + str(book_id) + ")")
book.path = gdrive_path.replace("\\", "/")
gd.uploadFileToEbooksFolder(os.path.join(gdrive_path, file_name).replace("\\", "/"), original_filepath)
return False # rename_files_on_change(first_author, renamed_author, local_book=book, gdrive=True)
return False
def update_dir_structure_gdrive(book_id, first_author):
@@ -545,20 +518,22 @@ def update_dir_structure_gdrive(book_id, first_author):
all_new_name = get_valid_filename(book.title, chars=42) + ' - ' \
+ get_valid_filename(new_authordir, chars=42)
rename_all_files_on_change(book, book.path, book.path, all_new_name, gdrive=True) # todo: Move filenames on gdrive
# change location in database to new author/title path
# book.path = os.path.join(authordir, new_titledir).replace('\\', '/')
return False
def move_files_on_change(calibre_path, new_author_dir, new_titledir, localbook, db_filename, original_filepath, path):
new_path = os.path.join(calibre_path, new_author_dir, new_titledir)
# new_name = get_valid_filename(localbook.title, chars=96) + ' - ' + new_author_dir
try:
if original_filepath:
if not os.path.isdir(new_path):
os.makedirs(new_path)
shutil.move(original_filepath, os.path.join(new_path, db_filename))
log.debug("Moving title: %s to %s/%s", original_filepath, new_path)
try:
shutil.move(original_filepath, os.path.join(new_path, db_filename))
except OSError:
log.error("Rename title from {} to {} failed with error, trying to "
"move without metadata".format(path, new_path))
shutil.move(original_filepath, os.path.join(new_path, db_filename), copy_function=shutil.copy)
log.debug("Moving title: %s to %s", original_filepath, new_path)
else:
# Check new path is not valid path
if not os.path.exists(new_path):
@@ -684,23 +659,26 @@ def check_email(email):
def check_username(username):
username = username.strip()
username = strip_whitespaces(username)
if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).scalar():
log.error("This username is already taken")
raise Exception(_("This username is already taken"))
return username
def valid_email(email):
email = email.strip()
# if email is not deleted
if email:
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
email):
log.error("Invalid Email address format")
raise Exception(_("Invalid Email address format"))
return email
def valid_email(emails):
valid_emails = []
for email in emails.split(','):
email = strip_whitespaces(email)
# if email is not deleted
if email:
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
email):
log.error("Invalid Email address format for {}".format(email))
raise Exception(_("Invalid Email address format"))
valid_emails.append(email)
return ",".join(valid_emails)
def valid_password(check_password):
@@ -810,24 +788,23 @@ def get_book_cover_internal(book, resolution=None):
def get_book_cover_thumbnail(book, resolution):
if book and book.has_cover:
return ub.session \
.query(ub.Thumbnail) \
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER) \
.filter(ub.Thumbnail.entity_id == book.id) \
.filter(ub.Thumbnail.resolution == resolution) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
.first()
return (ub.session
.query(ub.Thumbnail)
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER)
.filter(ub.Thumbnail.entity_id == book.id)
.filter(ub.Thumbnail.resolution == resolution)
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc)))
.first())
def get_series_thumbnail_on_failure(series_id, resolution):
book = calibre_db.session \
.query(db.Books) \
.join(db.books_series_link) \
.join(db.Series) \
.filter(db.Series.id == series_id) \
.filter(db.Books.has_cover == 1) \
.first()
book = (calibre_db.session
.query(db.Books)
.join(db.books_series_link)
.join(db.Series)
.filter(db.Series.id == series_id)
.filter(db.Books.has_cover == 1)
.first())
return get_book_cover_internal(book, resolution=resolution)
@@ -849,13 +826,13 @@ def get_series_cover_internal(series_id, resolution=None):
def get_series_thumbnail(series_id, resolution):
return ub.session \
.query(ub.Thumbnail) \
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES) \
.filter(ub.Thumbnail.entity_id == series_id) \
.filter(ub.Thumbnail.resolution == resolution) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
.first()
return (ub.session
.query(ub.Thumbnail)
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES)
.filter(ub.Thumbnail.entity_id == series_id)
.filter(ub.Thumbnail.resolution == resolution)
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc)))
.first())
# saves book cover from url
@@ -864,7 +841,7 @@ def save_cover_from_url(url, book_path):
if cli_param.allow_localhost:
img = requests.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
elif use_advocate:
img = advocate.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
img = cw_advocate.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
else:
log.error("python module advocate is not installed but is needed")
return False, _("Python module 'advocate' is not installed but is needed for cover uploads")
+16 -18
View File
@@ -15,24 +15,17 @@
#
# 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 .iso_language_names import LANGUAGE_NAMES as _LANGUAGE_NAMES
from . import logger
from .string_helper import strip_whitespaces
log = logger.create()
try:
from iso639 import languages, __version__
get = languages.get
except ImportError:
from pycountry import languages as pyc_languages
try:
import pkg_resources
__version__ = pkg_resources.get_distribution('pycountry').version + ' (PyCountry)'
del pkg_resources
except (ImportError, Exception):
__version__ = "? (PyCountry)"
def _copy_fields(l):
l.part1 = getattr(l, 'alpha_2', None)
@@ -46,6 +39,11 @@ except ImportError:
return _copy_fields(pyc_languages.get(alpha_2=part1))
if name is not None:
return _copy_fields(pyc_languages.get(name=name))
except ImportError as ex:
if sys.version_info >= (3, 12):
print("Python 3.12 isn't compatible with iso-639. Please install pycountry.")
from iso639 import languages
get = languages.get
def get_language_names(locale):
@@ -69,20 +67,20 @@ def get_language_name(locale, lang_code):
return name
def get_language_codes(locale, language_names, remainder=None):
language_names = set(x.strip().lower() for x in language_names if x)
def get_language_code_from_name(locale, language_names, remainder=None):
language_names = set(strip_whitespaces(x).lower() for x in language_names if x)
lang = list()
for k, v in get_language_names(locale).items():
v = v.lower()
if v in language_names:
lang.append(k)
language_names.remove(v)
for key, val in get_language_names(locale).items():
val = val.lower()
if val in language_names:
lang.append(key)
language_names.remove(val)
if remainder is not None and language_names:
remainder.extend(language_names)
return lang
def get_valid_language_codes(locale, language_names, remainder=None):
def get_valid_language_codes_from_code(locale, language_names, remainder=None):
lang = list()
if "" in language_names:
language_names.remove("")
@@ -103,6 +101,6 @@ def get_lang3(lang):
ret_value = lang
else:
ret_value = ""
except KeyError:
except (KeyError, AttributeError):
ret_value = lang
return ret_value
+378
View File
@@ -8138,6 +8138,384 @@ LANGUAGE_NAMES = {
"zul": "Zulu",
"zun": "Zuni"
},
"sl": {
"abk": "abhazijski",
"ace": "achinese",
"ach": "Acoli",
"ada": "Adangme",
"ady": "Adyghe",
"aar": "afarski",
"afh": "Afrihili",
"afr": "afrikanski",
"ain": "Ainu (Japan)",
"aka": "Akan",
"akk": "akadski",
"sqi": "albanščina",
"ale": "aleutski",
"amh": "amharski",
"anp": "Angika",
"ara": "arabski",
"arg": "aragonski",
"arp": "Arapaho",
"arw": "araukanski",
"hye": "armenščina",
"asm": "asamski",
"ast": "Asturian",
"ava": "avarski",
"ave": "avestijski jeziki",
"awa": "Awadhi",
"aym": "Aymara",
"aze": "azerbajdžanski",
"ban": "balijščina",
"bal": "belučijski",
"bam": "bambarski",
"bas": "Basa (Cameroon)",
"bak": "baškirski",
"eus": "baskovščina",
"bej": "Beja",
"bel": "beloruščina",
"bem": "Bemba (Zambia)",
"ben": "bengalščina",
"bit": "Berinomo",
"bho": "Bhojpuri",
"bik": "bikolščina",
"byn": "Bilin",
"bin": "Bini",
"bis": "bislama",
"zbl": "Blissymbols",
"bos": "bošnjaščina",
"bra": "Braj",
"bre": "bretonščina",
"bug": "buginščina",
"bul": "bolgarščina",
"bua": "burjatščina",
"mya": "burmanščina",
"cad": "kadajščina?",
"cat": "katalonščina",
"ceb": "cebuanščina",
"chg": "Chagatai",
"cha": "čamorščina",
"che": "čečenščina",
"chr": "čerokeščina",
"chy": "čejenščina",
"chb": "čibčevščina",
"zho": "kitajščina",
"chn": "Chinook jargon",
"chp": "čipevščina",
"cho": "Choctaw",
"cht": "Cholón",
"chk": "Chuukese",
"chv": "čuvaščina",
"cop": "koptščina",
"cor": "kornijščina",
"cos": "korzijščina",
"cre": "krijščina",
"mus": "Creek",
"hrv": "hrvaščina",
"ces": "češčina",
"dak": "Dakota",
"dan": "danski",
"dar": "Dargwa",
"del": "Delaware",
"div": "Dhivehi",
"din": "Dinka",
"doi": "Dogri (macrolanguage)",
"dgr": "Dogrib",
"dua": "Duala",
"nld": "nizozemščina",
"dse": "Dutch Sign Language",
"dyu": "Dyula",
"dzo": "dzongkha",
"efi": "Efik",
"egy": "egipčanski",
"eka": "Ekajuk",
"elx": "elamščina",
"eng": "angleščina",
"enu": "Enu",
"myv": "Erzya",
"epo": "esperanto",
"est": "estonščina",
"ewe": "evenščina",
"ewo": "Ewondo",
"fan": "Fang (Equatorial Guinea)",
"fat": "Fanti",
"fao": "ferščina",
"fij": "fidžijščina",
"fil": "Filipino",
"fin": "finščina",
"fon": "Fon",
"fra": "francoščina",
"fur": "furlanščina",
"ful": "fulščina",
"gaa": "Ga",
"glg": "Galician",
"lug": "Ganda",
"gay": "gajščina?",
"gba": "Gbaya (Central African Republic)",
"hmj": "Ge",
"gez": "etiopščina?",
"kat": "gruzinščina",
"deu": "nemški",
"gil": "gilbertščina",
"gon": "Gondi",
"gor": "Gorontalo",
"got": "gotščina",
"grb": "Grebo",
"grn": "gvaranijščina",
"guj": "gudžaratščina",
"gwi": "Gwichʼin",
"hai": "haidščina",
"hau": "havščina",
"haw": "havajščina",
"heb": "hebrejščina",
"her": "Herero",
"hil": "hilingajnonščina",
"hin": "hindijščina",
"hmo": "hiri motu",
"hit": "hetitščina",
"hmn": "hmonščina; miaojščina",
"hun": "madžarščina",
"hup": "hupščina",
"iba": "ibanščina",
"isl": "islandščina",
"ido": "Ido",
"ibo": "Igbo",
"ilo": "Iloko",
"ind": "indonezijščina",
"inh": "inguščina",
"ina": "interlingva",
"ile": "Interlingue",
"iku": "inuktituščina",
"ipk": "Inupiaq",
"gle": "irščina",
"ita": "italijanščina",
"jpn": "japonščina",
"jav": "javanščina",
"jrb": "Judeo-Arabic",
"jpr": "Judeo-Persian",
"kbd": "kabardinščina",
"kab": "Kabyle",
"kac": "Kachin",
"kal": "Kalaallisut",
"xal": "Kalmyk",
"kam": "Kamba (Kenya)",
"kan": "kanareščina",
"kau": "Kanuri",
"kaa": "Kara-Kalpak",
"krc": "Karachay-Balkar",
"krl": "Karelian",
"kas": "kašmirščina",
"csb": "Kashubian",
"kaw": "kavi",
"kaz": "kazaščina",
"kha": "Khasi",
"kho": "Khotanese",
"kik": "kikujščina",
"kmb": "Kimbundu",
"kin": "Kinyarwanda",
"kir": "kirgiščina",
"tlh": "Klingon",
"kom": "komijščina",
"kon": "Kongo",
"kok": "Konkani (macrolanguage)",
"kor": "korejščina",
"kos": "Kosraean",
"kpe": "Kpelle",
"kua": "Kuanyama",
"kum": "kumiščina",
"kur": "kurdščina",
"kru": "Kurukh",
"kut": "kutenajščina",
"lad": "ladinščina",
"lah": "Lahnda",
"lam": "Lamba",
"lao": "laoščina",
"lat": "latinščina",
"lav": "latvijščina",
"lez": "lezginščina",
"lim": "Limburgan",
"lin": "lingala",
"lit": "litvanščina",
"jbo": "Lojban",
"loz": "Lozi",
"lub": "Luba-Katanga",
"lua": "lubalulujščina",
"lui": "Luiseno",
"smj": "Lule Sami",
"lun": "Lunda",
"luo": "Luo (Kenya and Tanzania)",
"lus": "Lushai",
"ltz": "Luxembourgish",
"mkd": "makedonščina",
"mad": "madurščina",
"mag": "Magahi",
"mai": "Maithili",
"mak": "makasarščina",
"mlg": "malgaščina",
"msa": "Malay (macrolanguage)",
"mal": "malajalščina",
"mlt": "malteščina",
"mnc": "Manchu",
"mdr": "Mandar",
"man": "Mandingo",
"mni": "manipurščina",
"glv": "manska gelščina",
"mri": "maorščina",
"arn": "Mapudungun",
"mar": "maratščina",
"chm": "Mari (Russia)",
"mah": "Marshallese",
"mwr": "Marwari",
"mas": "masajščina",
"men": "Mende (Sierra Leone)",
"mic": "Mi'kmaq",
"min": "Minangkabau",
"mwl": "Mirandese",
"moh": "mohoščina",
"mdf": "Moksha",
"lol": "Mongo",
"mon": "mongolščina",
"mos": "mosanščina",
"mul": "Več jezikov",
"nqo": "N'Ko",
"nau": "Nauru",
"nav": "navaščina",
"ndo": "Ndonga",
"nap": "napolitanščina",
"nia": "niaščina",
"niu": "niuejščina",
"zxx": "No linguistic content",
"nog": "Nogai",
"nor": "norveščina",
"nob": "Norwegian Bokmål",
"nno": "norveščina; nynorsk",
"nym": "Nyamwezi",
"nya": "Nyanja",
"nyn": "Nyankole",
"nyo": "Nyoro",
"nzi": "Nzima",
"oci": "Occitan (post 1500)",
"oji": "Ojibwa",
"orm": "Oromo",
"osa": "Osage",
"oss": "Ossetian",
"pal": "Pahlavi",
"pau": "palavanščina",
"pli": "Pali",
"pam": "Pampanga",
"pag": "pangasinanščina",
"pan": "Panjabi",
"pap": "papiamentu",
"fas": "perzijščina",
"phn": "feničanščina",
"pon": "Pohnpeian",
"pol": "poljščina",
"por": "portugalđščina",
"pus": "paštu",
"que": "Quechua",
"raj": "radžastanščina",
"rap": "rapanujščina",
"ron": "romunščina",
"roh": "Romansh",
"rom": "romščina",
"run": "rundščina",
"rus": "ruščina",
"smo": "samoanščina",
"sad": "Sandawe",
"sag": "Sango",
"san": "sanskrt",
"sat": "santalščina",
"srd": "sardinščina",
"sas": "Sasak",
"sco": "škotščina",
"sel": "selkupščina",
"srp": "srbščina",
"srr": "Serer",
"shn": "šanščina",
"sna": "šonščina",
"scn": "sicilijanščina",
"sid": "Sidamo",
"bla": "Siksika",
"snd": "sindščina",
"sin": "Sinhala",
"den": "Slave (Athapascan)",
"slk": "slovaščina",
"slv": "slovenščina",
"sog": "Sogdian",
"som": "Somali",
"snk": "Soninke",
"spa": "španščina",
"srn": "Sranan Tongo",
"suk": "Sukuma",
"sux": "sumerščina",
"sun": "sundščina",
"sus": "susuamijščina?",
"swa": "Swahili (macrolanguage)",
"ssw": "svazijščina?",
"swe": "švedščina",
"syr": "sirščina",
"tgl": "tagaloščina",
"tah": "tahitijščina",
"tgk": "tadžiščina",
"tmh": "Tamashek",
"tam": "tamilščina",
"tat": "tatarščina",
"tel": "Telugu",
"ter": "Tereno",
"tet": "Tetum",
"tha": "tajščina",
"bod": "tibetanščina",
"tig": "Tigre",
"tir": "Tigrinya",
"tem": "Timne",
"tiv": "Tiv",
"tli": "Tlingit",
"tpi": "tok pisin",
"tkl": "Tokelau",
"tog": "Tonga (Nyasa)",
"ton": "tonganščina",
"tsi": "tsimšijščina",
"tso": "Tsonga",
"tsn": "Tswana",
"tum": "Tumbuka",
"tur": "turščina",
"tuk": "turkmenščina",
"tvl": "tuvalujščina",
"tyv": "Tuvinian",
"twi": "Twi",
"udm": "Udmurt",
"uga": "ugaritščina",
"uig": "ujgurščina",
"ukr": "ukrajinščina",
"umb": "Umbundu",
"mis": "Uncoded languages",
"und": "nedoločen",
"urd": "urdujščina",
"uzb": "uzbeščina",
"vai": "vajščina",
"ven": "Venda",
"vie": "vietnamščina",
"vol": "Volapük",
"vot": "votjaščina",
"wln": "valonščina",
"war": "Waray (Philippines)",
"was": "Washo",
"cym": "valižanščina",
"wal": "Wolaytta",
"wol": "Wolof",
"xho": "koščina",
"sah": "jakutščina",
"yao": "jaojščina",
"yap": "Yapese",
"yid": "jidiš",
"yor": "jorubščina",
"zap": "Zapotec",
"zza": "Zaza",
"zen": "Zenaga",
"zha": "Zhuang",
"zul": "zulujščina",
"zun": "Zuni"
},
"sv": {
"aar": "Afar",
"abk": "Abchaziska",
+19 -6
View File
@@ -27,10 +27,9 @@ import datetime
import mimetypes
from uuid import uuid4
# from babel.dates import format_date
from flask import Blueprint, request, url_for
from flask import Blueprint, request, url_for, g
from flask_babel import format_date
from flask_login import current_user
from .cw_login import current_user
from . import constants, logger
@@ -112,11 +111,15 @@ def yesno(value, yes, no):
@jinjia.app_template_filter('formatfloat')
def formatfloat(value, decimals=1):
value = 0 if not value else value
return ('{0:.' + str(decimals) + 'f}').format(value).rstrip('0').rstrip('.')
if not value:
return value
formated_value = ('{0:.' + str(decimals) + 'f}').format(value)
if formated_value.endswith('.' + "0" * decimals):
formated_value = formated_value.rstrip('0').rstrip('.')
return formated_value
@jinjia.app_template_filter('formatseriesindex')
'''@jinjia.app_template_filter('formatseriesindex')
def formatseriesindex_filter(series_index):
if series_index:
try:
@@ -127,6 +130,7 @@ def formatseriesindex_filter(series_index):
except (ValueError, TypeError):
return series_index
return 0
'''
@jinjia.app_template_filter('escapedlink')
@@ -180,3 +184,12 @@ def get_cover_srcset(series):
url = url_for('web.get_series_cover', series_id=series.id, resolution=shortname, c=cache_timestamp())
srcset.append(f'{url} {resolution}x')
return ', '.join(srcset)
@jinjia.app_template_filter('music')
def contains_music(book_formats):
result = False
for format in book_formats:
if format.format.lower() in g.constants.EXTENSIONS_AUDIO:
result = True
return result
+27 -21
View File
@@ -18,7 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import datetime
from datetime import datetime, timezone
import os
import uuid
import zipfile
@@ -36,7 +36,7 @@ from flask import (
redirect,
abort
)
from flask_login import current_user
from .cw_login import current_user
from werkzeug.datastructures import Headers
from sqlalchemy import func
from sqlalchemy.sql.expression import and_, or_
@@ -44,11 +44,10 @@ from sqlalchemy.exc import StatementError
from sqlalchemy.sql import select
import requests
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
from . import isoLanguages
from .epub import get_epub_layout
from .constants import COVER_THUMBNAIL_SMALL
from .constants import COVER_THUMBNAIL_SMALL, COVER_THUMBNAIL_MEDIUM, COVER_THUMBNAIL_LARGE
from .helper import get_download_link
from .services import SyncToken as SyncToken
from .web import download_required
@@ -132,7 +131,7 @@ def convert_to_kobo_timestamp_string(timestamp):
return timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
except AttributeError as exc:
log.debug("Timestamp not valid: {}".format(exc))
return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@kobo.route("/v1/library/sync")
@@ -151,15 +150,15 @@ def HandleSyncRequest():
# if no books synced don't respect sync_token
if not ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.user_id == current_user.id).count():
sync_token.books_last_modified = datetime.datetime.min
sync_token.books_last_created = datetime.datetime.min
sync_token.reading_state_last_modified = datetime.datetime.min
sync_token.books_last_modified = datetime.min
sync_token.books_last_created = datetime.min
sync_token.reading_state_last_modified = datetime.min
new_books_last_modified = sync_token.books_last_modified # needed for sync selected shelfs only
new_books_last_created = sync_token.books_last_created # needed to distinguish between new and changed entitlement
new_reading_state_last_modified = sync_token.reading_state_last_modified
new_archived_last_modified = datetime.datetime.min
new_archived_last_modified = datetime.min
sync_results = []
# We reload the book database so that the user gets a fresh view of the library
@@ -376,7 +375,7 @@ def create_book_entitlement(book, archived):
book_uuid = str(book.uuid)
return {
"Accessibility": "Full",
"ActivePeriod": {"From": convert_to_kobo_timestamp_string(datetime.datetime.utcnow())},
"ActivePeriod": {"From": convert_to_kobo_timestamp_string(datetime.now(timezone.utc))},
"Created": convert_to_kobo_timestamp_string(book.timestamp),
"CrossRevisionId": book_uuid,
"Id": book_uuid,
@@ -424,7 +423,7 @@ def get_series(book):
def get_seriesindex(book):
return book.series_index or 1
return book.series_index if isinstance(book.series_index, float) else 1
def get_language(book):
@@ -487,14 +486,16 @@ def get_metadata(book):
if get_series(book):
name = get_series(book)
metadata["Series"] = {
"Name": get_series(book),
"Number": get_seriesindex(book), # ToDo Check int() ?
"NumberFloat": float(get_seriesindex(book)),
# Get a deterministic id based on the series name.
"Id": str(uuid.uuid3(uuid.NAMESPACE_DNS, name)),
}
try:
metadata["Series"] = {
"Name": get_series(book),
"Number": get_seriesindex(book), # ToDo Check int() ?
"NumberFloat": float(get_seriesindex(book)),
# Get a deterministic id based on the series name.
"Id": str(uuid.uuid3(uuid.NAMESPACE_DNS, name)),
}
except Exception as e:
print(e)
return metadata
@@ -796,7 +797,7 @@ def HandleStateRequest(book_uuid):
if new_book_read_status == ub.ReadBook.STATUS_IN_PROGRESS \
and new_book_read_status != book_read.read_status:
book_read.times_started_reading += 1
book_read.last_time_started_reading = datetime.datetime.utcnow()
book_read.last_time_started_reading = datetime.now(timezone.utc)
book_read.read_status = new_book_read_status
update_results_response["StatusInfoResult"] = {"Result": "Success"}
except (KeyError, TypeError, ValueError, StatementError):
@@ -904,7 +905,12 @@ def get_current_bookmark_response(current_bookmark):
@requires_kobo_auth
def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
try:
resolution = None if int(height) > 1000 else COVER_THUMBNAIL_SMALL
if int(height) > 1000:
resolution = COVER_THUMBNAIL_LARGE
elif int(height) > 500:
resolution = COVER_THUMBNAIL_MEDIUM
else:
resolution = COVER_THUMBNAIL_SMALL
except ValueError:
log.error("Requested height %s of book %s is invalid" % (book_uuid, height))
resolution = COVER_THUMBNAIL_SMALL
+5 -3
View File
@@ -65,12 +65,14 @@ from os import urandom
from functools import wraps
from flask import g, Blueprint, abort, request
from flask_login import login_user, current_user, login_required
from .cw_login import login_user, current_user
from flask_babel import gettext as _
from flask_limiter import RateLimitExceeded
from . import logger, config, calibre_db, db, helper, ub, lm, limiter
from .render_template import render_title_template
from .usermanagement import user_login_required
log = logger.create()
@@ -78,7 +80,7 @@ kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
@kobo_auth.route("/generate_auth_token/<int:user_id>")
@login_required
@user_login_required
def generate_auth_token(user_id):
warning = False
host_list = request.host.rsplit(':')
@@ -120,7 +122,7 @@ def generate_auth_token(user_id):
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
@login_required
@user_login_required
def delete_auth_token(user_id):
# Invalidate any previously generated Kobo Auth token for this user
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
+4 -4
View File
@@ -17,11 +17,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from flask_login import current_user
from .cw_login import current_user
from . import ub
import datetime
from datetime import datetime, timezone
from sqlalchemy.sql.expression import or_, and_, true
from sqlalchemy import exc
# from sqlalchemy import exc
# Add the current book id to kobo_synced_books table for current user, if entry is already present,
@@ -58,7 +58,7 @@ def change_archived_books(book_id, state=None, message=None):
archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id)
archived_book.is_archived = state if state else not archived_book.is_archived
archived_book.last_modified = datetime.datetime.utcnow() # toDo. Check utc timestamp
archived_book.last_modified = datetime.now(timezone.utc) # toDo. Check utc timestamp
ub.session.merge(archived_book)
ub.session_commit(message)
+1 -1
View File
@@ -20,7 +20,6 @@ import sys
from . import create_app, limiter
from .jinjia import jinjia
from .remotelogin import remotelogin
from flask import request
@@ -42,6 +41,7 @@ def main():
from .shelf import shelf
from .tasks_status import tasks
from .error_handler import init_errorhandler
from .remotelogin import remotelogin
try:
from .kobo import kobo, get_kobo_activated
from .kobo_auth import kobo_auth
+18 -17
View File
@@ -38,14 +38,16 @@ class Amazon(Metadata):
__name__ = "Amazon"
__id__ = "amazon"
headers = {'upgrade-insecure-requests': '1',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36',
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
'sec-gpc': '1',
'sec-fetch-site': 'none',
'sec-fetch-mode': 'navigate',
'sec-fetch-user': '?1',
'sec-fetch-dest': 'document',
'accept-encoding': 'gzip, deflate, br',
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0',
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
'Sec-Fetch-Site': 'same-origin',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-User': '?1',
'Sec-Fetch-Dest': 'document',
'Upgrade-Insecure-Requests': '1',
'Alt-Used' : 'www.amazon.com',
'Priority' : 'u=0, i',
'accept-encoding': 'gzip, deflate, br, zstd',
'accept-language': 'en-US,en;q=0.9'}
session = requests.Session()
session.headers=headers
@@ -53,7 +55,6 @@ class Amazon(Metadata):
def search(
self, query: str, generic_cover: str = "", locale: str = "en"
) -> Optional[List[MetaRecord]]:
#timer=time()
def inner(link, index) -> [dict, int]:
with self.session as session:
try:
@@ -61,11 +62,11 @@ class Amazon(Metadata):
r.raise_for_status()
except Exception as ex:
log.warning(ex)
return None
return []
long_soup = BS(r.text, "lxml") #~4sec :/
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"})
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-ppd_csm_instrumentation_wrapper"})
if soup2 is None:
return None
return []
try:
match = MetaRecord(
title = "",
@@ -88,7 +89,7 @@ class Amazon(Metadata):
soup2.find("div", attrs={"data-feature-name": "bookDescription"}).stripped_strings)\
.replace("\xa0"," ")[:-9].strip().strip("\n")
except (AttributeError, TypeError):
return None # if there is no description it is not a book and therefore should be ignored
return [] # if there is no description it is not a book and therefore should be ignored
try:
match.title = soup2.find("span", attrs={"id": "productTitle"}).text
except (AttributeError, TypeError):
@@ -107,13 +108,13 @@ class Amazon(Metadata):
except (AttributeError, ValueError):
match.rating = 0
try:
match.cover = soup2.find("img", attrs={"class": "a-dynamic-image frontImage"})["src"]
match.cover = soup2.find("img", attrs={"class": "a-dynamic-image"})["src"]
except (AttributeError, TypeError):
match.cover = ""
return match, index
except Exception as e:
log.error_or_exception(e)
return None
return []
val = list()
if self.active:
@@ -133,7 +134,7 @@ class Amazon(Metadata):
links_list = [next(filter(lambda i: "digital-text" in i["href"], x.findAll("a")))["href"] for x in
soup.findAll("div", attrs={"data-component-type": "s-search-result"})]
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:5])}
val = list(map(lambda x : x.result() ,concurrent.futures.as_completed(fut)))
fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:3])}
val = list(map(lambda x : x.result(), concurrent.futures.as_completed(fut)))
result = list(filter(lambda x: x, val))
return [x[0] for x in sorted(result, key=itemgetter(1))] #sort by amazons listing order for best relevance
+1 -1
View File
@@ -54,7 +54,7 @@ class Google(Metadata):
results.raise_for_status()
except Exception as e:
log.warning(e)
return None
return []
for result in results.json().get("items", []):
val.append(
self._parse_search_result(
+1 -1
View File
@@ -137,7 +137,7 @@ class LubimyCzytac(Metadata):
def _prepare_query(self, title: str) -> str:
query = ""
characters_to_remove = "\?()\/"
characters_to_remove = r"\?()\/"
pattern = "[" + characters_to_remove + "]"
title = re.sub(pattern, "", title)
title = title.replace("_", " ")
+4 -3
View File
@@ -30,8 +30,9 @@ 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 oauthlib.oauth2 import TokenExpiredError, InvalidGrantError
from flask_login import login_user, current_user, login_required
from .cw_login import login_user, current_user
from sqlalchemy.orm.exc import NoResultFound
from .usermanagement import user_login_required
from . import constants, logger, config, app, ub
@@ -340,7 +341,7 @@ def github_login():
@oauth.route('/unlink/github', methods=["GET"])
@login_required
@user_login_required
def github_login_unlink():
return unlink_oauth(oauthblueprints[0]['id'])
@@ -364,6 +365,6 @@ def google_login():
@oauth.route('/unlink/google', methods=["GET"])
@login_required
@user_login_required
def google_login_unlink():
return unlink_oauth(oauthblueprints[1]['id'])
+29 -31
View File
@@ -25,14 +25,15 @@ import json
from urllib.parse import unquote_plus
from flask import Blueprint, request, render_template, make_response, abort, Response, g
from flask_login import current_user
from flask_babel import get_locale
from flask_babel import gettext as _
from sqlalchemy.sql.expression import func, text, or_, and_, true
from sqlalchemy.exc import InvalidRequestError, OperationalError
from . import logger, config, db, calibre_db, ub, isoLanguages, constants
from .usermanagement import requires_basic_auth_if_no_ano
from .usermanagement import requires_basic_auth_if_no_ano, auth
from .helper import get_download_link, get_book_cover
from .pagination import Pagination
from .web import render_read_books
@@ -94,7 +95,7 @@ def feed_letter_books(book_id):
@opds.route("/opds/new")
@requires_basic_auth_if_no_ano
def feed_new():
if not current_user.check_visibility(constants.SIDEBAR_RECENT):
if not auth.current_user().check_visibility(constants.SIDEBAR_RECENT):
abort(404)
off = request.args.get("offset") or 0
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
@@ -106,7 +107,7 @@ def feed_new():
@opds.route("/opds/discover")
@requires_basic_auth_if_no_ano
def feed_discover():
if not current_user.check_visibility(constants.SIDEBAR_RANDOM):
if not auth.current_user().check_visibility(constants.SIDEBAR_RANDOM):
abort(404)
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page)
@@ -117,7 +118,7 @@ def feed_discover():
@opds.route("/opds/rated")
@requires_basic_auth_if_no_ano
def feed_best_rated():
if not current_user.check_visibility(constants.SIDEBAR_BEST_RATED):
if not auth.current_user().check_visibility(constants.SIDEBAR_BEST_RATED):
abort(404)
off = request.args.get("offset") or 0
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
@@ -130,7 +131,7 @@ def feed_best_rated():
@opds.route("/opds/hot")
@requires_basic_auth_if_no_ano
def feed_hot():
if not current_user.check_visibility(constants.SIDEBAR_HOT):
if not auth.current_user().check_visibility(constants.SIDEBAR_HOT):
abort(404)
off = request.args.get("offset") or 0
all_books = ub.session.query(ub.Downloads, func.count(ub.Downloads.book_id)).order_by(
@@ -154,7 +155,7 @@ def feed_hot():
@opds.route("/opds/author")
@requires_basic_auth_if_no_ano
def feed_authorindex():
if not current_user.check_visibility(constants.SIDEBAR_AUTHOR):
if not auth.current_user().check_visibility(constants.SIDEBAR_AUTHOR):
abort(404)
return render_element_index(db.Authors.sort, db.books_authors_link, 'opds.feed_letter_author')
@@ -162,7 +163,7 @@ def feed_authorindex():
@opds.route("/opds/author/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_author(book_id):
if not current_user.check_visibility(constants.SIDEBAR_AUTHOR):
if not auth.current_user().check_visibility(constants.SIDEBAR_AUTHOR):
abort(404)
off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id)
@@ -185,7 +186,7 @@ def feed_author(book_id):
@opds.route("/opds/publisher")
@requires_basic_auth_if_no_ano
def feed_publisherindex():
if not current_user.check_visibility(constants.SIDEBAR_PUBLISHER):
if not auth.current_user().check_visibility(constants.SIDEBAR_PUBLISHER):
abort(404)
off = request.args.get("offset") or 0
entries = calibre_db.session.query(db.Publishers)\
@@ -208,7 +209,7 @@ def feed_publisher(book_id):
@opds.route("/opds/category")
@requires_basic_auth_if_no_ano
def feed_categoryindex():
if not current_user.check_visibility(constants.SIDEBAR_CATEGORY):
if not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY):
abort(404)
return render_element_index(db.Tags.name, db.books_tags_link, 'opds.feed_letter_category')
@@ -216,7 +217,7 @@ def feed_categoryindex():
@opds.route("/opds/category/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_category(book_id):
if not current_user.check_visibility(constants.SIDEBAR_CATEGORY):
if not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY):
abort(404)
off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
@@ -241,7 +242,7 @@ def feed_category(book_id):
@opds.route("/opds/series")
@requires_basic_auth_if_no_ano
def feed_seriesindex():
if not current_user.check_visibility(constants.SIDEBAR_SERIES):
if not auth.current_user().check_visibility(constants.SIDEBAR_SERIES):
abort(404)
return render_element_index(db.Series.sort, db.books_series_link, 'opds.feed_letter_series')
@@ -249,7 +250,7 @@ def feed_seriesindex():
@opds.route("/opds/series/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_series(book_id):
if not current_user.check_visibility(constants.SIDEBAR_SERIES):
if not auth.current_user().check_visibility(constants.SIDEBAR_SERIES):
abort(404)
off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
@@ -280,7 +281,7 @@ def feed_series(book_id):
@opds.route("/opds/ratings")
@requires_basic_auth_if_no_ano
def feed_ratingindex():
if not current_user.check_visibility(constants.SIDEBAR_RATING):
if not auth.current_user().check_visibility(constants.SIDEBAR_RATING):
abort(404)
off = request.args.get("offset") or 0
entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
@@ -308,7 +309,7 @@ def feed_ratings(book_id):
@opds.route("/opds/formats")
@requires_basic_auth_if_no_ano
def feed_formatindex():
if not current_user.check_visibility(constants.SIDEBAR_FORMAT):
if not auth.current_user().check_visibility(constants.SIDEBAR_FORMAT):
abort(404)
off = request.args.get("offset") or 0
entries = calibre_db.session.query(db.Data).join(db.Books)\
@@ -339,14 +340,14 @@ def feed_format(book_id):
@opds.route("/opds/language/")
@requires_basic_auth_if_no_ano
def feed_languagesindex():
if not current_user.check_visibility(constants.SIDEBAR_LANGUAGE):
if not auth.current_user().check_visibility(constants.SIDEBAR_LANGUAGE):
abort(404)
off = request.args.get("offset") or 0
if current_user.filter_language() == "all":
if auth.current_user().filter_language() == "all":
languages = calibre_db.speaking_language()
else:
languages = calibre_db.session.query(db.Languages).filter(
db.Languages.lang_code == current_user.filter_language()).all()
db.Languages.lang_code == auth.current_user().filter_language()).all()
languages[0].name = isoLanguages.get_language_name(get_locale(), languages[0].lang_code)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(languages))
@@ -368,11 +369,11 @@ def feed_languages(book_id):
@opds.route("/opds/shelfindex")
@requires_basic_auth_if_no_ano
def feed_shelfindex():
if not (current_user.is_authenticated or g.allow_anonymous):
if not (auth.current_user().is_authenticated or g.allow_anonymous):
abort(404)
off = request.args.get("offset") or 0
shelf = ub.session.query(ub.Shelf).filter(
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == current_user.id)).order_by(ub.Shelf.name).all()
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == auth.current_user().id)).order_by(ub.Shelf.name).all()
number = len(shelf)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
number)
@@ -382,14 +383,14 @@ def feed_shelfindex():
@opds.route("/opds/shelf/<int:book_id>")
@requires_basic_auth_if_no_ano
def feed_shelf(book_id):
if not (current_user.is_authenticated or g.allow_anonymous):
if not (auth.current_user().is_authenticated or g.allow_anonymous):
abort(404)
off = request.args.get("offset") or 0
if current_user.is_anonymous:
if auth.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),
shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(auth.current_user().id),
ub.Shelf.id == book_id),
and_(ub.Shelf.is_public == 1,
ub.Shelf.id == book_id))).first()
@@ -422,12 +423,9 @@ def feed_shelf(book_id):
@opds.route("/opds/download/<book_id>/<book_format>/")
@requires_basic_auth_if_no_ano
def opds_download_link(book_id, book_format):
if not current_user.role_download():
return abort(403)
if "Kobo" in request.headers.get('User-Agent'):
client = "kobo"
else:
client = ""
if not auth.current_user().role_download():
return abort(401)
client = "kobo" if "Kobo" in request.headers.get('User-Agent') else ""
return get_download_link(book_id, book_format.lower(), client)
@@ -468,7 +466,7 @@ def feed_get_cover(book_id):
@opds.route("/opds/readbooks")
@requires_basic_auth_if_no_ano
def feed_read_books():
if not (current_user.check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not current_user.is_anonymous):
if not (auth.current_user().check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not auth.current_user().is_anonymous):
return abort(403)
off = request.args.get("offset") or 0
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
@@ -478,7 +476,7 @@ def feed_read_books():
@opds.route("/opds/unreadbooks")
@requires_basic_auth_if_no_ano
def feed_unread_books():
if not (current_user.check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not current_user.is_anonymous):
if not (auth.current_user().check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not auth.current_user().is_anonymous):
return abort(403)
off = request.args.get("offset") or 0
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)
Executable → Regular
View File
+3 -2
View File
@@ -25,12 +25,13 @@ from datetime import datetime
from functools import wraps
from flask import Blueprint, request, make_response, abort, url_for, flash, redirect
from flask_login import login_required, current_user, login_user
from .cw_login import login_user, current_user
from flask_babel import gettext as _
from sqlalchemy.sql.expression import true
from . import config, logger, ub
from .render_template import render_title_template
from .usermanagement import user_login_required
remotelogin = Blueprint('remotelogin', __name__)
@@ -65,7 +66,7 @@ def remote_login():
@remotelogin.route('/verify/<token>')
@remote_login_required
@login_required
@user_login_required
def verify_token(token):
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()
+1 -2
View File
@@ -19,14 +19,13 @@
from flask import render_template, g, abort, request
from flask_babel import gettext as _
from werkzeug.local import LocalProxy
from flask_login import current_user
from .cw_login import current_user
from sqlalchemy.sql.expression import or_
from . import config, constants, logger, ub
from .ub import User
log = logger.create()
def get_sidebar_config(kwargs=None):
+3 -3
View File
@@ -21,7 +21,7 @@ import datetime
from . import config, constants
from .services.background_scheduler import BackgroundScheduler, CronTrigger, use_APScheduler
from .tasks.database import TaskReconnectDatabase
from .tasks.tempFolder import TaskDeleteTempFolder
from .tasks.clean import TaskClean
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
from .services.worker import WorkerThread
from .tasks.metadata_backup import TaskBackupMetadata
@@ -33,7 +33,7 @@ def get_scheduled_tasks(reconnect=True):
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
# Delete temp folder
tasks.append([lambda: TaskDeleteTempFolder(), 'delete temp', True])
tasks.append([lambda: TaskClean(), 'delete temp', True])
# Generate metadata.opf file for each changed book
if config.schedule_metadata_backup:
@@ -94,7 +94,7 @@ def register_startup_tasks():
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration):
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
else:
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskDeleteTempFolder(), 'delete temp', True]])
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskClean(), 'delete temp', True]])
def should_task_be_running(start, duration):
+47 -20
View File
@@ -19,17 +19,19 @@ from datetime import datetime
from flask import Blueprint, request, redirect, url_for, flash
from flask import session as flask_session
from flask_login import current_user
from .cw_login import current_user
from flask_babel import format_date
from flask_babel import gettext as _
from sqlalchemy.sql.expression import func, not_, and_, or_, text, true
from sqlalchemy.sql.functions import coalesce
from . import logger, db, calibre_db, config, ub
from .string_helper import strip_whitespaces
from .usermanagement import login_required_if_no_ano
from .render_template import render_title_template
from .pagination import Pagination
search = Blueprint('search', __name__)
log = logger.create()
@@ -80,16 +82,27 @@ def adv_search_custom_columns(cc, term, q):
if custom_end:
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
func.datetime(db.cc_classes[c.id].value) <= func.datetime(custom_end)))
elif c.datatype in ["int", "float"]:
custom_low = term.get('custom_column_' + str(c.id) + '_low')
custom_high = term.get('custom_column_' + str(c.id) + '_high')
if custom_low:
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
db.cc_classes[c.id].value >= custom_low))
if custom_high:
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
db.cc_classes[c.id].value <= custom_high))
else:
custom_query = term.get('custom_column_' + str(c.id))
if custom_query != '' and custom_query is not None:
if c.datatype == 'bool':
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
db.cc_classes[c.id].value == (custom_query == "True")))
elif c.datatype == 'int' or c.datatype == 'float':
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
db.cc_classes[c.id].value == custom_query))
elif c.datatype == 'rating':
if c.datatype == 'bool':
if custom_query != "Any":
if custom_query == "":
q = q.filter(~getattr(db.Books, 'custom_column_' + str(c.id)).
any(db.cc_classes[c.id].value >= 0))
else:
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
db.cc_classes[c.id].value == bool(custom_query == "True")))
elif custom_query != '' and custom_query is not None:
if c.datatype == 'rating':
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
db.cc_classes[c.id].value == int(float(custom_query) * 2)))
else:
@@ -128,10 +141,10 @@ def adv_search_read_status(read_status):
db_filter = coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED
else:
try:
if read_status == "True":
db_filter = db.cc_classes[config.config_read_column].value == True
if read_status == "":
db_filter = coalesce(db.cc_classes[config.config_read_column].value, 2) == 2
else:
db_filter = coalesce(db.cc_classes[config.config_read_column].value, False) != True
db_filter = db.cc_classes[config.config_read_column].value == bool(read_status == "True")
except (KeyError, AttributeError, IndexError):
log.error("Custom Column No.{} does not exist in calibre database".format(config.config_read_column))
flash(_("Custom Column No.%(column)d does not exist in calibre database",
@@ -231,7 +244,8 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
pagination = None
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase)
calibre_db.create_functions()
# calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase)
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\
.outerjoin(db.Series)\
@@ -244,21 +258,21 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
tags['include_' + element] = term.get('include_' + element)
tags['exclude_' + element] = term.get('exclude_' + element)
author_name = term.get("author_name")
book_title = term.get("book_title")
author_name = term.get("authors")
book_title = term.get("title")
publisher = term.get("publisher")
pub_start = term.get("publishstart")
pub_end = term.get("publishend")
rating_low = term.get("ratinghigh")
rating_high = term.get("ratinglow")
description = term.get("comment")
description = term.get("comments")
read_status = term.get("read_status")
if author_name:
author_name = author_name.strip().lower().replace(',', '|')
author_name = strip_whitespaces(author_name).lower().replace(',', '|')
if book_title:
book_title = book_title.strip().lower()
book_title = strip_whitespaces(book_title).lower()
if publisher:
publisher = publisher.strip().lower()
publisher = strip_whitespaces(publisher).lower()
search_term = []
cc_present = False
@@ -274,10 +288,23 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
cc_present = True
if column_end:
search_term.extend(["{} <= {}".format(c.name,
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
format='medium')
)])
cc_present = True
if c.datatype in ["int", "float"]:
column_low = term.get('custom_column_' + str(c.id) + '_low')
column_high = term.get('custom_column_' + str(c.id) + '_high')
if column_low:
search_term.extend(["{} >= {}".format(c.name, column_low)])
cc_present = True
if column_high:
search_term.extend(["{} <= {}".format(c.name,column_high)])
cc_present = True
elif c.datatype == "bool":
if term.get('custom_column_' + str(c.id)) != "Any":
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
cc_present = True
elif term.get('custom_column_' + str(c.id)):
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
cc_present = True
+6 -8
View File
@@ -24,16 +24,15 @@ import os
import sys
from flask import Blueprint, Response, request, url_for
from flask_login import current_user
from flask_login import login_required
from .cw_login import current_user
from flask_babel import get_locale
from sqlalchemy.exc import InvalidRequestError, OperationalError
from sqlalchemy.orm.attributes import flag_modified
from cps.services.Metadata import Metadata
from . import constants, logger, ub, web_server
from .usermanagement import user_login_required
# current_milli_time = lambda: int(round(time() * 1000))
meta = Blueprint("metadata", __name__)
@@ -81,7 +80,7 @@ cl = list_classes(new_list)
@meta.route("/metadata/provider")
@login_required
@user_login_required
def metadata_provider():
active = current_user.view_settings.get("metadata", {})
provider = list()
@@ -95,7 +94,7 @@ def metadata_provider():
@meta.route("/metadata/provider", methods=["POST"])
@meta.route("/metadata/provider/<prov_name>", methods=["POST"])
@login_required
@user_login_required
def metadata_change_active_provider(prov_name):
new_state = request.get_json()
active = current_user.view_settings.get("metadata", {})
@@ -122,7 +121,7 @@ def metadata_change_active_provider(prov_name):
@meta.route("/metadata/search", methods=["POST"])
@login_required
@user_login_required
def metadata_search():
query = request.form.to_dict().get("query")
data = list()
@@ -130,7 +129,7 @@ def metadata_search():
locale = get_locale()
if query:
static_cover = url_for("static", filename="generic_cover.jpg")
# start = current_milli_time()
# ret = cl[0].search(query, static_cover, locale)
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
meta = {
executor.submit(c.search, query, static_cover, locale): c
@@ -139,5 +138,4 @@ def metadata_search():
}
for future in concurrent.futures.as_completed(meta):
data.extend([asdict(x) for x in future.result() if x])
# log.info({'Time elapsed {}'.format(current_milli_time()-start)})
return Response(json.dumps(data), mimetype="application/json")
-1
View File
@@ -21,7 +21,6 @@ import os
import errno
import signal
import socket
import asyncio
try:
from gevent.pywsgi import WSGIServer
+1
View File
@@ -42,6 +42,7 @@ class BackgroundScheduler:
if cls._instance is None:
cls._instance = super(BackgroundScheduler, cls).__new__(cls)
cls.log = logger.create()
logger.logging.getLogger('tzlocal').setLevel(logger.logging.WARNING)
cls.scheduler = BScheduler()
cls.scheduler.start()
+47 -37
View File
@@ -21,17 +21,17 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
from datetime import datetime
from datetime import datetime, timezone
from flask import Blueprint, flash, redirect, request, url_for, abort
from flask_babel import gettext as _
from flask_login import current_user, login_required
from .cw_login import current_user
from sqlalchemy.exc import InvalidRequestError, OperationalError
from sqlalchemy.sql.expression import func, true
from . import calibre_db, config, db, logger, ub
from .render_template import render_title_template
from .usermanagement import login_required_if_no_ano
from .usermanagement import login_required_if_no_ano, user_login_required
log = logger.create()
@@ -39,7 +39,7 @@ shelf = Blueprint('shelf', __name__)
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
@login_required
@user_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()
@@ -80,7 +80,7 @@ def add_to_shelf(shelf_id, book_id):
return "%s is a invalid Book Id. Could not be added to Shelf" % book_id, 400
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1))
shelf.last_modified = datetime.utcnow()
shelf.last_modified = datetime.now(timezone.utc)
try:
ub.session.merge(shelf)
ub.session.commit()
@@ -103,7 +103,7 @@ def add_to_shelf(shelf_id, book_id):
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
@login_required
@user_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:
@@ -139,7 +139,7 @@ def search_to_shelf(shelf_id):
for book in books_for_shelf:
maxOrder += 1
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder))
shelf.last_modified = datetime.utcnow()
shelf.last_modified = datetime.now(timezone.utc)
try:
ub.session.merge(shelf)
ub.session.commit()
@@ -155,7 +155,7 @@ def search_to_shelf(shelf_id):
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>", methods=["POST"])
@login_required
@user_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()
@@ -185,7 +185,7 @@ def remove_from_shelf(shelf_id, book_id):
try:
ub.session.delete(book_shelf)
shelf.last_modified = datetime.utcnow()
shelf.last_modified = datetime.now(timezone.utc)
ub.session.commit()
except (OperationalError, InvalidRequestError) as e:
ub.session.rollback()
@@ -212,14 +212,14 @@ def remove_from_shelf(shelf_id, book_id):
@shelf.route("/shelf/create", methods=["GET", "POST"])
@login_required
@user_login_required
def create_shelf():
shelf = ub.Shelf()
return create_edit_shelf(shelf, page_title=_("Create a Shelf"), page="shelfcreate")
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
@login_required
@user_login_required
def edit_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if not check_shelf_edit_permissions(shelf):
@@ -229,7 +229,7 @@ def edit_shelf(shelf_id):
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
@login_required
@user_login_required
def delete_shelf(shelf_id):
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
try:
@@ -250,7 +250,7 @@ def show_simpleshelf(shelf_id):
return render_show_shelf(2, shelf_id, 1, None)
@shelf.route("/shelf/<int:shelf_id>", defaults={"sort_param": "order", 'page': 1})
@shelf.route("/shelf/<int:shelf_id>", defaults={"sort_param": "stored", 'page': 1})
@shelf.route("/shelf/<int:shelf_id>/<sort_param>", defaults={'page': 1})
@shelf.route("/shelf/<int:shelf_id>/<sort_param>/<int:page>")
@login_required_if_no_ano
@@ -259,7 +259,7 @@ def show_shelf(shelf_id, sort_param, page):
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
@login_required
@user_login_required
def order_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf and check_shelf_view_permissions(shelf):
@@ -271,7 +271,7 @@ def order_shelf(shelf_id):
for book in books_in_shelf:
setattr(book, 'order', to_save[str(book.book_id)])
counter += 1
# if order different from before -> shelf.last_modified = datetime.utcnow()
# if order different from before -> shelf.last_modified = datetime.now(timezone.utc)
try:
ub.session.commit()
except (OperationalError, InvalidRequestError) as e:
@@ -418,29 +418,37 @@ def change_shelf_order(shelf_id, order):
def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
status = current_user.get_view_property("shelf", 'man')
# check user is allowed to access shelf
if shelf and check_shelf_view_permissions(shelf):
if shelf_type == 1:
# order = [ub.BookShelf.order.asc()]
if sort_param == 'pubnew':
change_shelf_order(shelf_id, [db.Books.pubdate.desc()])
if sort_param == 'pubold':
change_shelf_order(shelf_id, [db.Books.pubdate])
if sort_param == 'abc':
change_shelf_order(shelf_id, [db.Books.sort])
if sort_param == 'zyx':
change_shelf_order(shelf_id, [db.Books.sort.desc()])
if sort_param == 'new':
change_shelf_order(shelf_id, [db.Books.timestamp.desc()])
if sort_param == 'old':
change_shelf_order(shelf_id, [db.Books.timestamp])
if sort_param == 'authaz':
change_shelf_order(shelf_id, [db.Books.author_sort.asc(), db.Series.name, db.Books.series_index])
if sort_param == 'authza':
change_shelf_order(shelf_id, [db.Books.author_sort.desc(),
db.Series.name.desc(),
db.Books.series_index.desc()])
if status != 'on':
if sort_param == 'stored':
sort_param = current_user.get_view_property("shelf", 'stored')
else:
current_user.set_view_property("shelf", 'stored', sort_param)
if sort_param == 'pubnew':
change_shelf_order(shelf_id, [db.Books.pubdate.desc()])
if sort_param == 'pubold':
change_shelf_order(shelf_id, [db.Books.pubdate])
if sort_param == 'shelfnew':
change_shelf_order(shelf_id, [ub.BookShelf.date_added.desc()])
if sort_param == 'shelfold':
change_shelf_order(shelf_id, [ub.BookShelf.date_added])
if sort_param == 'abc':
change_shelf_order(shelf_id, [db.Books.sort])
if sort_param == 'zyx':
change_shelf_order(shelf_id, [db.Books.sort.desc()])
if sort_param == 'new':
change_shelf_order(shelf_id, [db.Books.timestamp.desc()])
if sort_param == 'old':
change_shelf_order(shelf_id, [db.Books.timestamp])
if sort_param == 'authaz':
change_shelf_order(shelf_id, [db.Books.author_sort.asc(), db.Series.name, db.Books.series_index])
if sort_param == 'authza':
change_shelf_order(shelf_id, [db.Books.author_sort.desc(),
db.Series.name.desc(),
db.Books.series_index.desc()])
page = "shelf.html"
pagesize = 0
else:
@@ -453,7 +461,7 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
[ub.BookShelf.order.asc()],
True, config.config_read_column,
ub.BookShelf, ub.BookShelf.book_id == db.Books.id)
# delete chelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web
# delete shelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web
wrong_entries = calibre_db.session.query(ub.BookShelf) \
.join(db.Books, ub.BookShelf.book_id == db.Books.id, isouter=True) \
.filter(db.Books.id == None).all()
@@ -472,7 +480,9 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
pagination=pagination,
title=_("Shelf: '%(name)s'", name=shelf.name),
shelf=shelf,
page="shelf")
page="shelf",
status=status,
order=sort_param)
else:
flash(_("Error opening shelf. Shelf does not exist or is not accessible"), category="error")
return redirect(url_for("web.index"))
+7
View File
@@ -3268,6 +3268,10 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] > div.
left: auto !important
}
ul.dropdown-menu.offscreen {
margin: 2px -160px 0
}
div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] .dropdown-menu.offscreen {
position: fixed;
top: 120px;
@@ -4333,6 +4337,7 @@ body.advanced_search > div.container-fluid > div.row-fluid > div.col-sm-10 > div
.navbar-right > li > ul.dropdown-menu.offscreen {
right: -10px
}
.login .plexBack, body.login > div.container-fluid > div.row-fluid > div.col-sm-2, body.login > div.navbar.navbar-default.navbar-static-top > div > form {
@@ -7951,3 +7956,5 @@ div.comments[data-readmore] {
transition: height 300ms;
overflow: hidden
}
.dropdown-menu > .offscreen
+1 -1
View File
@@ -15,5 +15,5 @@
.blackTheme {
background: #000;
color: #fff
color: #fff;
}
+15
View File
@@ -1894,6 +1894,8 @@
width:100%;
height:100%;
margin:0;
top:0;
left:0;
}
.annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor) > .resizers{
@@ -2646,6 +2648,10 @@
}
}
.pdfViewer.copyAll{
cursor:wait;
}
.pdfViewer .canvasWrapper{
overflow:hidden;
width:100%;
@@ -3010,6 +3016,15 @@ body{
scrollbar-color:var(--scrollbar-color) var(--scrollbar-bg-color);
}
body.wait::before{
content:"";
position:fixed;
width:100%;
height:100%;
z-index:100000;
cursor:wait;
}
.hidden,
[hidden]{
display:none !important;
+8 -2
View File
@@ -77,7 +77,6 @@ body {
}
#panels a {
visibility: hidden;
width: 18px;
height: 20px;
overflow: hidden;
@@ -551,7 +550,7 @@ input:-moz-placeholder { color: #454545; }
position: relative;
border-radius: 3px;
margin: 0 auto;
height: 320px;
/* height: 320px; */
}
.md-content > div {
@@ -826,3 +825,10 @@ input:-moz-placeholder { color: #454545; }
.icon-columns::before { content: '\e810'; } /* '' */
.icon-list::before { content: '\e800'; } /* '' */
.icon-resize-small::before { content: '\e808'; } /* '' */
#progress{
right: 4rem;
bottom: 4rem;
width: fit-content;
position: absolute;
}
+18
View File
@@ -27,3 +27,21 @@
border: none;
outline: none;
}
.item {
font-size: 20px;
font-weight: 400;
font-family: Open Sans;
padding-right: 10px;
color: white;
}
.item~button {
display: inline-block;
border: none;
text-align: center;
text-decoration: none;
margin-top: 5%;
margin-right: 1%;
font-size: 16px;
}
Executable → Regular
View File
+8 -8
View File
@@ -3,9 +3,9 @@
*/
/* global Bloodhound, language, Modernizr, tinymce, getPath */
if ($("#description").length) {
if ($("#comments").length && typeof tinymce !== "undefined") {
tinymce.init({
selector: "#description",
selector: "#comments",
plugins: 'code',
branding: false,
menubar: "edit view format",
@@ -93,7 +93,7 @@ var authors = new Bloodhound({
},
});
$(".form-group #bookAuthor").typeahead(
$(".form-group #authors").typeahead(
{
highlight: true,
minLength: 1,
@@ -243,13 +243,13 @@ $("#search").on("change input.typeahead:selected", function(event) {
});
});
$("#btn-upload-format").on("change", function () {
/*$("#btn-upload-format").on("change", function () {
var filename = $(this).val();
if (filename.substring(3, 11) === "fakepath") {
filename = filename.substring(12);
} // Remove c:\fake at beginning from localhost chrome
$("#upload-format").text(filename);
});
});*/
$("#btn-upload-cover").on("change", function () {
var filename = $(this).val();
@@ -261,8 +261,8 @@ $("#btn-upload-cover").on("change", function () {
$("#xchange").click(function () {
this.blur();
var title = $("#book_title").val();
$("#book_title").val($("#bookAuthor").val());
$("#bookAuthor").val(title);
var title = $("#title").val();
$("#title").val($("#authors").val());
$("#authors").val(title);
});
+4 -4
View File
@@ -38,12 +38,12 @@ $(function () {
}
function populateForm (book) {
tinymce.get("description").setContent(book.description);
tinymce.get("comments").setContent(book.description);
var uniqueTags = getUniqueValues('tags', book)
var uniqueLanguages = getUniqueValues('languages', book)
var ampSeparatedAuthors = (book.authors || []).join(" & ");
$("#bookAuthor").val(ampSeparatedAuthors);
$("#book_title").val(book.title);
$("#authors").val(ampSeparatedAuthors);
$("#title").val(book.title);
$("#tags").val(uniqueTags.join(", "));
$("#languages").val(uniqueLanguages.join(", "));
$("#rating").data("rating").setValue(Math.round(book.rating));
@@ -172,7 +172,7 @@ $(function () {
$("#get_meta").click(function () {
populate_provider();
var bookTitle = $("#book_title").val();
var bookTitle = $("#title").val();
$("#keyword").val(bookTitle);
keyword = bookTitle;
doSearch(bookTitle);
@@ -0,0 +1 @@
!function(a){a.fn.datepicker.dates.sl={days:["Nedelja","Ponedeljek","Torek","Sreda","Četrtek","Petek","Sobota"],daysShort:["Ned","Pon","Tor","Sre","Čet","Pet","Sob"],daysMin:["Ne","Po","To","Sr","Če","Pe","So"],months:["Januar","Februar","Marec","April","Maj","Junij","Julij","Avgust","September","Oktober","November","December"],monthsShort:["Jan","Feb","Mar","Apr","Maj","Jun","Jul","Avg","Sep","Okt","Nov","Dec"],today:"Danes",weekStart:1}}(jQuery);
+5653 -326
View File
File diff suppressed because it is too large Load Diff
+5641 -253
View File
File diff suppressed because one or more lines are too long
+446 -290
View File
File diff suppressed because it is too large Load Diff
+44 -8
View File
@@ -130,8 +130,13 @@ $(".container-fluid").bind('drop', function (e) {
}
});
if (dt.files.length) {
$("#btn-upload")[0].files = dt.files;
$("#form-upload").submit();
if($("#btn-upload-format").length) {
$("#btn-upload-format")[0].files = dt.files;
$("#form-upload-format").submit();
} else {
$("#btn-upload")[0].files = dt.files;
$("#form-upload").submit();
}
}
}
});
@@ -140,12 +145,25 @@ $("#btn-upload").change(function() {
$("#form-upload").submit();
});
$("#btn-upload-format").change(function() {
$("#form-upload-format").submit();
});
$("#form-upload").uploadprogress({
redirect_url: getPath() + "/", //"{{ url_for('web.index')}}",
uploadedMsg: $("#form-upload").data("message"), //"{{_('Upload done, processing, please wait...')}}",
modalTitle: $("#form-upload").data("title"), //"{{_('Uploading...')}}",
modalFooter: $("#form-upload").data("footer"), //"{{_('Close')}}",
modalTitleFailed: $("#form-upload").data("failed") //"{{_('Error')}}"
redirect_url: getPath() + "/",
uploadedMsg: $("#form-upload").data("message"),
modalTitle: $("#form-upload").data("title"),
modalFooter: $("#form-upload").data("footer"),
modalTitleFailed: $("#form-upload").data("failed")
});
$("#form-upload-format").uploadprogress({
redirect_url: getPath() + "/",
uploadedMsg: $("#form-upload-format").data("message"),
modalTitle: $("#form-upload-format").data("title"),
modalFooter: $("#form-upload-format").data("footer"),
modalTitleFailed: $("#form-upload-format").data("failed")
});
$(document).ready(function() {
@@ -160,15 +178,21 @@ $(document).ready(function() {
$(".session").click(function() {
window.sessionStorage.setItem("back", window.location.pathname);
window.sessionStorage.setItem("search", window.location.search);
});
$("#back").click(function() {
var loc = sessionStorage.getItem("back");
var param = sessionStorage.getItem("search");
if (!loc) {
loc = $(this).data("back");
}
sessionStorage.removeItem("back");
window.location.href = loc;
sessionStorage.removeItem("search");
if (param === null) {
param = "";
}
window.location.href = loc + param;
});
@@ -598,6 +622,7 @@ $(function() {
});
$("#toggle_order_shelf").click(function() {
$("#toggle_order_shelf").toggleClass("dummy");
$("#new").toggleClass("disabled");
$("#old").toggleClass("disabled");
$("#asc").toggleClass("disabled");
@@ -606,9 +631,20 @@ $(function() {
$("#auth_za").toggleClass("disabled");
$("#pub_new").toggleClass("disabled");
$("#pub_old").toggleClass("disabled");
$("#shelf_new").toggleClass("disabled");
$("#shelf_old").toggleClass("disabled");
var alternative_text = $("#toggle_order_shelf").data('alt-text');
var status = $("#toggle_order_shelf").hasClass("dummy") ? "on" : "off";
$("#toggle_order_shelf").data('alt-text', $("#toggle_order_shelf").html());
$("#toggle_order_shelf").html(alternative_text);
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/view",
data: "{\"shelf\": {\"man\": \"" + status + "\"}}",
});
});
$("#btndeluser").click(function() {
+54
View File
@@ -0,0 +1,54 @@
/**
* waits until queue is finished, meaning the book is done loading
* @param callback
*/
function qFinished(callback){
let timeout=setInterval(()=>{
if(reader.rendition.q.running===undefined)
clearInterval(timeout);
callback();
},300
)
}
function calculateProgress(){
let data=reader.rendition.location.end;
return Math.round(epub.locations.percentageFromCfi(data.cfi)*100);
}
// register new event emitter locationchange that fires on urlchange
// source: https://stackoverflow.com/a/52809105/21941129
(() => {
let oldPushState = history.pushState;
history.pushState = function pushState() {
let ret = oldPushState.apply(this, arguments);
window.dispatchEvent(new Event('locationchange'));
return ret;
};
let oldReplaceState = history.replaceState;
history.replaceState = function replaceState() {
let ret = oldReplaceState.apply(this, arguments);
window.dispatchEvent(new Event('locationchange'));
return ret;
};
window.addEventListener('popstate', () => {
window.dispatchEvent(new Event('locationchange'));
});
})();
window.addEventListener('locationchange',()=>{
let newPos=calculateProgress();
progressDiv.textContent=newPos+"%";
});
var epub=ePub(calibre.bookUrl)
let progressDiv=document.getElementById("progress");
qFinished(()=>{
epub.locations.generate().then(()=> {
window.dispatchEvent(new Event('locationchange'))
});
})
+7 -6
View File
@@ -13,10 +13,9 @@ var reader;
bookmarks: calibre.bookmark ? [calibre.bookmark] : []
});
reader.rendition.themes.register("lightTheme", "/static/css/epub_themes.css");
reader.rendition.themes.register("darkTheme", "/static/css/epub_themes.css");
reader.rendition.themes.register("sepiaTheme", "/static/css/epub_themes.css");
reader.rendition.themes.register("blackTheme", "/static/css/epub_themes.css");
Object.keys(themes).forEach(function (theme) {
reader.rendition.themes.register(theme, themes[theme].css_path);
});
if (calibre.useBookmarks) {
reader.on("reader:bookmarked", updateBookmark.bind(reader, "add"));
@@ -78,6 +77,8 @@ var reader;
alert(error);
});
}
// Default settings load
const theme = localStorage.getItem("calibre.reader.theme") ?? "lightTheme";
selectTheme(theme);
})();
+12
View File
@@ -685,6 +685,17 @@ function ratingFormatter(value, row) {
return (value/2);
}
function seriesIndexFormatter(value, row) {
if (!value) {
return value;
}
formated_value = Number(value).toFixed(2);
if (formated_value.endsWith(".00")) {
formated_value = parseInt(formated_value).toString();
}
return formated_value;
}
/* Do some hiding disabling after user list is loaded */
function loadSuccess() {
@@ -849,6 +860,7 @@ function BookCheckboxChange(checkbox, userId, field) {
},
success: handleListServerResponse
});
console.log("test");
}
+5 -6
View File
@@ -49,12 +49,6 @@ pdfjs-download-button =
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-download-button-label = Pellgargañ
pdfjs-bookmark-button-label = Pajenn a-vremañ
# Used in Firefox for Android.
pdfjs-open-in-app-button =
.title = Digeriñ en arload
# Used in Firefox for Android.
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-open-in-app-button-label = Digeriñ en arload
## Secondary toolbar and context menu
@@ -214,6 +208,7 @@ pdfjs-find-next-button =
pdfjs-find-next-button-label = War-lerc'h
pdfjs-find-highlight-checkbox = Usskediñ pep tra
pdfjs-find-match-case-checkbox-label = Teurel evezh ouzh ar pennlizherennoù
pdfjs-find-match-diacritics-checkbox-label = Doujañ d’an tiredoù
pdfjs-find-entire-word-checkbox-label = Gerioù a-bezh
pdfjs-find-reached-top = Tizhet eo bet derou ar bajenn, kenderc'hel diouzh an diaz
pdfjs-find-reached-bottom = Tizhet eo bet dibenn ar bajenn, kenderc'hel diouzh ar c'hrec'h
@@ -311,3 +306,7 @@ pdfjs-editor-alt-text-save-button = Enrollañ
## Color picker
## Show all highlights
## This is a toggle button to show/hide all the highlights.
+1 -9
View File
@@ -51,12 +51,6 @@ pdfjs-download-button-label = Sækja
pdfjs-bookmark-button =
.title = Núverandi síða (Skoða vefslóð frá núverandi síðu)
pdfjs-bookmark-button-label = Núverandi síða
# Used in Firefox for Android.
pdfjs-open-in-app-button =
.title = Opna í smáforriti
# Used in Firefox for Android.
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-open-in-app-button-label = Opna í smáforriti
## Secondary toolbar and context menu
@@ -284,7 +278,7 @@ pdfjs-text-annotation-type =
## Password
pdfjs-password-label = Sláðu inn lykilorð til að opna þessa PDF skrá.
pdfjs-password-label = Settu inn lykilorð til að opna þessa PDF-skrá.
pdfjs-password-invalid = Ógilt lykilorð. Reyndu aftur.
pdfjs-password-ok-button = Í lagi
pdfjs-password-cancel-button = Hætta við
@@ -304,8 +298,6 @@ pdfjs-editor-stamp-button-label = Bæta við eða breyta myndum
pdfjs-editor-highlight-button =
.title = Áherslulita
pdfjs-editor-highlight-button-label = Áherslulita
pdfjs-highlight-floating-button =
.title = Áherslulita
pdfjs-highlight-floating-button1 =
.title = Áherslulita
.aria-label = Áherslulita
+1 -3
View File
@@ -279,7 +279,7 @@ pdfjs-text-annotation-type =
## Password
pdfjs-password-label = この PDF ファイルを開くためのパスワードを入力してください。
pdfjs-password-invalid = 無効なパスワードです。もう一度やり直してください。
pdfjs-password-invalid = パスワードが正しくありません。もう一度試してください。
pdfjs-password-ok-button = OK
pdfjs-password-cancel-button = キャンセル
pdfjs-web-fonts-disabled = ウェブフォントが無効になっています: 埋め込まれた PDF のフォントを使用できません。
@@ -298,8 +298,6 @@ pdfjs-editor-stamp-button-label = 画像を追加または編集
pdfjs-editor-highlight-button =
.title = 強調します
pdfjs-editor-highlight-button-label = 強調
pdfjs-highlight-floating-button =
.title = 強調
pdfjs-highlight-floating-button1 =
.title = 強調
.aria-label = 強調します
+57 -8
View File
@@ -51,12 +51,6 @@ pdfjs-download-button-label = Sader
pdfjs-bookmark-button =
.title = Asebter amiran (Sken-d tansa URL seg usebter amiran)
pdfjs-bookmark-button-label = Asebter amiran
# Used in Firefox for Android.
pdfjs-open-in-app-button =
.title = Ldi deg usnas
# Used in Firefox for Android.
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-open-in-app-button-label = Ldi deg usnas
## Secondary toolbar and context menu
@@ -301,8 +295,27 @@ pdfjs-editor-ink-button-label = Suneɣ
pdfjs-editor-stamp-button =
.title = Rnu neɣ ẓreg tugniwin
pdfjs-editor-stamp-button-label = Rnu neɣ ẓreg tugniwin
pdfjs-editor-remove-button =
.title = Kkes
pdfjs-editor-highlight-button =
.title = Derrer
pdfjs-editor-highlight-button-label = Derrer
pdfjs-highlight-floating-button1 =
.title = Derrer
.aria-label = Derrer
pdfjs-highlight-floating-button-label = Derrer
## Remove button for the various kind of editor.
pdfjs-editor-remove-ink-button =
.title = Kkes asuneɣ
pdfjs-editor-remove-freetext-button =
.title = Kkes aḍris
pdfjs-editor-remove-stamp-button =
.title = Kkes tugna
pdfjs-editor-remove-highlight-button =
.title = Kkes aderrer
##
# Editor Parameters
pdfjs-editor-free-text-color-input = Initen
pdfjs-editor-free-text-size-input = Teɣzi
@@ -312,6 +325,8 @@ pdfjs-editor-ink-opacity-input = Tebrek
pdfjs-editor-stamp-add-image-button =
.title = Rnu tawlaft
pdfjs-editor-stamp-add-image-button-label = Rnu tawlaft
# This refers to the thickness of the line used for free highlighting (not bound to text)
pdfjs-editor-free-highlight-thickness-input = Tuzert
pdfjs-free-text =
.aria-label = Amaẓrag n uḍris
pdfjs-free-text-default-content = Bdu tira...
@@ -335,3 +350,37 @@ pdfjs-editor-alt-text-decorative-tooltip = Yettwacreḍ d adlag
## Editor resizers
## This is used in an aria label to help to understand the role of the resizer.
pdfjs-editor-resizer-label-top-left = Tiɣmert n ufella n zelmeḍ — semsawi teɣzi
pdfjs-editor-resizer-label-top-middle = Talemmat n ufella — semsawi teɣzi
pdfjs-editor-resizer-label-top-right = Tiɣmert n ufella n yeffus — semsawi teɣzi
pdfjs-editor-resizer-label-middle-right = Talemmast tayeffust — semsawi teɣzi
pdfjs-editor-resizer-label-bottom-right = Tiɣmert n wadda n yeffus — semsawi teɣzi
pdfjs-editor-resizer-label-bottom-middle = Talemmat n wadda — semsawi teɣzi
pdfjs-editor-resizer-label-bottom-left = Tiɣmert n wadda n zelmeḍ — semsawi teɣzi
pdfjs-editor-resizer-label-middle-left = Talemmast tazelmdaḍt — semsawi teɣzi
## Color picker
# This means "Color used to highlight text"
pdfjs-editor-highlight-colorpicker-label = Ini n uderrer
pdfjs-editor-colorpicker-button =
.title = Senfel ini
pdfjs-editor-colorpicker-dropdown =
.aria-label = Afran n yiniten
pdfjs-editor-colorpicker-yellow =
.title = Awraɣ
pdfjs-editor-colorpicker-green =
.title = Azegzaw
pdfjs-editor-colorpicker-blue =
.title = Amidadi
pdfjs-editor-colorpicker-pink =
.title = Axuxi
pdfjs-editor-colorpicker-red =
.title = Azggaɣ
## Show all highlights
## This is a toggle button to show/hide all the highlights.
pdfjs-editor-highlight-show-all-button-label = Sken akk
pdfjs-editor-highlight-show-all-button =
.title = Sken akk
+40 -6
View File
@@ -51,12 +51,6 @@ pdfjs-download-button-label = Last ned
pdfjs-bookmark-button =
.title = Gjeldande side (sjå URL frå gjeldande side)
pdfjs-bookmark-button-label = Gjeldande side
# Used in Firefox for Android.
pdfjs-open-in-app-button =
.title = Opne i app
# Used in Firefox for Android.
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-open-in-app-button-label = Opne i app
## Secondary toolbar and context menu
@@ -301,9 +295,24 @@ pdfjs-editor-ink-button-label = Teikne
pdfjs-editor-stamp-button =
.title = Legg til eller rediger bilde
pdfjs-editor-stamp-button-label = Legg til eller rediger bilde
pdfjs-editor-highlight-button =
.title = Markere
pdfjs-editor-highlight-button-label = Markere
pdfjs-highlight-floating-button1 =
.title = Markere
.aria-label = Markere
pdfjs-highlight-floating-button-label = Markere
## Remove button for the various kind of editor.
pdfjs-editor-remove-ink-button =
.title = Fjern teikninga
pdfjs-editor-remove-freetext-button =
.title = Fjern tekst
pdfjs-editor-remove-stamp-button =
.title = Fjern bildet
pdfjs-editor-remove-highlight-button =
.title = Fjern utheving
##
@@ -316,6 +325,10 @@ pdfjs-editor-ink-opacity-input = Ugjennomskinleg
pdfjs-editor-stamp-add-image-button =
.title = Legg til bilde
pdfjs-editor-stamp-add-image-button-label = Legg til bilde
# This refers to the thickness of the line used for free highlighting (not bound to text)
pdfjs-editor-free-highlight-thickness-input = Tjukkleik
pdfjs-editor-free-highlight-thickness-title =
.title = Endre tjukn når du markerer andre element enn tekst
pdfjs-free-text =
.aria-label = Tekstredigering
pdfjs-free-text-default-content = Byrje å skrive…
@@ -345,9 +358,23 @@ pdfjs-editor-alt-text-textarea =
## Editor resizers
## This is used in an aria label to help to understand the role of the resizer.
pdfjs-editor-resizer-label-top-left = Øvste venstre hjørne – endre størrelse
pdfjs-editor-resizer-label-top-middle = Øvst i midten — endre størrelse
pdfjs-editor-resizer-label-top-right = Øvste høgre hjørne – endre størrelse
pdfjs-editor-resizer-label-middle-right = Midt til høgre – endre størrelse
pdfjs-editor-resizer-label-bottom-right = Nedste høgre hjørne – endre størrelse
pdfjs-editor-resizer-label-bottom-middle = Nedst i midten — endre størrelse
pdfjs-editor-resizer-label-bottom-left = Nedste venstre hjørne – endre størrelse
pdfjs-editor-resizer-label-middle-left = Midt til venstre — endre størrelse
## Color picker
# This means "Color used to highlight text"
pdfjs-editor-highlight-colorpicker-label = Uthevingsfarge
pdfjs-editor-colorpicker-button =
.title = Endre farge
pdfjs-editor-colorpicker-dropdown =
.aria-label = Fargeval
pdfjs-editor-colorpicker-yellow =
.title = Gul
pdfjs-editor-colorpicker-green =
@@ -358,3 +385,10 @@ pdfjs-editor-colorpicker-pink =
.title = Rosa
pdfjs-editor-colorpicker-red =
.title = Raud
## Show all highlights
## This is a toggle button to show/hide all the highlights.
pdfjs-editor-highlight-show-all-button-label = Vis alle
pdfjs-editor-highlight-show-all-button =
.title = Vis alle
+4
View File
@@ -302,6 +302,10 @@ pdfjs-editor-stamp-button-label = Dodajanje ali urejanje slik
pdfjs-editor-highlight-button =
.title = Označevalnik
pdfjs-editor-highlight-button-label = Označevalnik
pdfjs-highlight-floating-button1 =
.title = Označi
.aria-label = Označi
pdfjs-highlight-floating-button-label = Označi
## Remove button for the various kind of editor.
+3 -23
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2019 decentral1se
# Copyright (C) 2024 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
@@ -16,28 +15,9 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# """Calibre-web distribution package setuptools installer."""
from setuptools import setup
import os
import re
import codecs
here = os.path.abspath(os.path.dirname(__file__))
def read(*parts):
with codecs.open(os.path.join(here, *parts), 'r') as fp:
return fp.read()
def strip_whitespaces(text):
return re.sub(r"(^[\s\u200B-\u200D\ufeff]+)|([\s\u200B-\u200D\ufeff]+$)","", text)
def find_version(*file_paths):
version_file = read(*file_paths)
version_match = re.search(r"^STABLE_VERSION\s+=\s+{['\"]version['\"]:\s*['\"](.*)['\"]}",
version_file, re.M)
if version_match:
return version_match.group(1)
raise RuntimeError("Unable to find version string.")
setup(
version=find_version("src", "calibreweb", "cps", "constants.py")
)
+22 -5
View File
@@ -16,31 +16,48 @@
# 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 urllib.request import urlopen
import datetime
from flask_babel import lazy_gettext as N_
from sqlalchemy.sql.expression import or_
from cps import logger, file_helper
from cps import logger, file_helper, ub
from cps.services.worker import CalibreTask
class TaskDeleteTempFolder(CalibreTask):
class TaskClean(CalibreTask):
def __init__(self, task_message=N_('Delete temp folder contents')):
super(TaskDeleteTempFolder, self).__init__(task_message)
super(TaskClean, self).__init__(task_message)
self.log = logger.create()
self.app_db_session = ub.get_new_session_instance()
def run(self, worker_thread):
# delete temp folder
try:
file_helper.del_temp_dir()
except FileNotFoundError:
pass
except (PermissionError, OSError) as e:
self.log.error("Error deleting temp folder: {}".format(e))
# delete expired session keys
self.log.debug("Deleted expired session_keys" )
expiry = int(datetime.datetime.now().timestamp())
try:
self.app_db_session.query(ub.User_Sessions).filter(or_(ub.User_Sessions.expiry < expiry,
ub.User_Sessions.expiry == None)).delete()
self.app_db_session.commit()
except Exception as ex:
self.log.debug('Error deleting expired session keys: ' + str(ex))
self._handleError('Error deleting expired session keys: ' + str(ex))
self.app_db_session.rollback()
return
self._handleSuccess()
self.app_db_session.remove()
@property
def name(self):
return "Delete Temp Folder"
return "Clean up"
@property
def is_cancellable(self):
+351 -329
View File
@@ -1,329 +1,351 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2020 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/>.
import os
import re
from glob import glob
from shutil import copyfile, copyfileobj
from markupsafe import escape
from time import time
from uuid import uuid4
from sqlalchemy.exc import SQLAlchemyError
from flask_babel import lazy_gettext as N_
from cps.services.worker import CalibreTask
from cps import db
from cps import logger, config
from cps.subproc_wrapper import process_open
from flask_babel import gettext as _
from cps.kobo_sync_status import remove_synced_book
from cps.ub import init_db_thread
from cps.file_helper import get_temp_dir
from cps.tasks.mail import TaskEmail
from cps import gdriveutils, helper
from cps.constants import SUPPORTED_CALIBRE_BINARIES
log = logger.create()
current_milli_time = lambda: int(round(time() * 1000))
class TaskConvert(CalibreTask):
def __init__(self, file_path, book_id, task_message, settings, ereader_mail, user=None):
super(TaskConvert, self).__init__(task_message)
self.worker_thread = None
self.file_path = file_path
self.book_id = book_id
self.title = ""
self.settings = settings
self.ereader_mail = ereader_mail
self.user = user
self.results = dict()
def run(self, worker_thread):
self.worker_thread = worker_thread
if config.config_use_google_drive:
worker_db = db.CalibreDB(expire_on_commit=False, init=True)
cur_book = worker_db.get_book(self.book_id)
self.title = cur_book.title
data = worker_db.get_book_format(self.book_id, self.settings['old_book_format'])
df = gdriveutils.getFileFromEbooksFolder(cur_book.path,
data.name + "." + self.settings['old_book_format'].lower())
df_cover = gdriveutils.getFileFromEbooksFolder(cur_book.path, "cover.jpg")
if df:
datafile_cover = None
datafile = os.path.join(config.get_book_path(),
cur_book.path,
data.name + "." + self.settings['old_book_format'].lower())
if df_cover:
datafile_cover = os.path.join(config.get_book_path(),
cur_book.path, "cover.jpg")
if not os.path.exists(os.path.join(config.get_book_path(), cur_book.path)):
os.makedirs(os.path.join(config.get_book_path(), cur_book.path))
df.GetContentFile(datafile)
if df_cover:
df_cover.GetContentFile(datafile_cover)
worker_db.session.close()
else:
# ToDo Include cover in error handling
error_message = _("%(format)s not found on Google Drive: %(fn)s",
format=self.settings['old_book_format'],
fn=data.name + "." + self.settings['old_book_format'].lower())
worker_db.session.close()
return self._handleError(error_message)
filename = self._convert_ebook_format()
if config.config_use_google_drive:
os.remove(self.file_path + '.' + self.settings['old_book_format'].lower())
if df_cover:
os.remove(os.path.join(config.config_calibre_dir, cur_book.path, "cover.jpg"))
if filename:
if config.config_use_google_drive:
# Upload files to gdrive
gdriveutils.updateGdriveCalibreFromLocal()
self._handleSuccess()
if self.ereader_mail:
# if we're sending to E-Reader after converting, create a one-off task and run it immediately
# todo: figure out how to incorporate this into the progress
try:
EmailText = N_("%(book)s send to E-Reader", book=escape(self.title))
worker_thread.add(self.user, TaskEmail(self.settings['subject'],
self.results["path"],
filename,
self.settings,
self.ereader_mail,
EmailText,
self.settings['body'],
id=self.book_id,
internal=True)
)
except Exception as ex:
return self._handleError(str(ex))
def _convert_ebook_format(self):
error_message = None
local_db = db.CalibreDB(expire_on_commit=False, init=True)
file_path = self.file_path
book_id = self.book_id
format_old_ext = '.' + self.settings['old_book_format'].lower()
format_new_ext = '.' + self.settings['new_book_format'].lower()
# check to see if destination format already exists - or if book is in database
# if it does - mark the conversion task as complete and return a success
# this will allow to send to E-Reader workflow to continue to work
if os.path.isfile(file_path + format_new_ext) or\
local_db.get_book_format(self.book_id, self.settings['new_book_format']):
log.info("Book id %d already converted to %s", book_id, format_new_ext)
cur_book = local_db.get_book(book_id)
self.title = cur_book.title
self.results['path'] = cur_book.path
self.results['title'] = self.title
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id)\
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
if not new_format:
new_format = db.Data(name=os.path.basename(file_path),
book_format=self.settings['new_book_format'].upper(),
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
try:
local_db.session.merge(new_format)
local_db.session.commit()
except SQLAlchemyError as e:
local_db.session.rollback()
log.error("Database error: %s", e)
local_db.session.close()
self._handleError(N_("Oops! Database Error: %(error)s.", error=e))
return
self._handleSuccess()
local_db.session.close()
return os.path.basename(file_path + format_new_ext)
else:
log.info("Book id %d - target format of %s does not exist. Moving forward with convert.",
book_id,
format_new_ext)
if config.config_kepubifypath and format_old_ext == '.epub' and format_new_ext == '.kepub':
check, error_message = self._convert_kepubify(file_path,
format_old_ext,
format_new_ext)
else:
# check if calibre converter-executable is existing
if not os.path.exists(config.config_converterpath):
self._handleError(N_("Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath))
return
has_cover = local_db.get_book(book_id).has_cover
check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext, has_cover)
if check == 0:
cur_book = local_db.get_book(book_id)
if os.path.isfile(file_path + format_new_ext):
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id) \
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
if not new_format:
new_format = db.Data(name=cur_book.data[0].name,
book_format=self.settings['new_book_format'].upper(),
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
try:
local_db.session.merge(new_format)
local_db.session.commit()
if self.settings['new_book_format'].upper() in ['KEPUB', 'EPUB', 'EPUB3']:
ub_session = init_db_thread()
remove_synced_book(book_id, True, ub_session)
ub_session.close()
except SQLAlchemyError as e:
local_db.session.rollback()
log.error("Database error: %s", e)
local_db.session.close()
self._handleError(error_message)
return
self.results['path'] = cur_book.path
self.title = cur_book.title
self.results['title'] = self.title
if not config.config_use_google_drive:
self._handleSuccess()
return os.path.basename(file_path + format_new_ext)
else:
error_message = N_('%(format)s format not found on disk', format=format_new_ext.upper())
local_db.session.close()
log.info("ebook converter failed with error while converting book")
if not error_message:
error_message = N_('Ebook converter failed with unknown error')
else:
log.error(error_message)
self._handleError(error_message)
return
def _convert_kepubify(self, file_path, format_old_ext, format_new_ext):
if config.config_embed_metadata and config.config_binariesdir:
tmp_dir, temp_file_name = helper.do_calibre_export(self.book_id, format_old_ext[1:])
filename = os.path.join(tmp_dir, temp_file_name + format_old_ext)
temp_file_path = tmp_dir
else:
filename = file_path + format_old_ext
temp_file_path = os.path.dirname(file_path)
quotes = [1, 3]
command = [config.config_kepubifypath, filename, '-o', temp_file_path, '-i']
try:
p = process_open(command, quotes)
except OSError as e:
return 1, N_("Kepubify-converter failed: %(error)s", error=e)
self.progress = 0.01
while True:
nextline = p.stdout.readlines()
nextline = [x.strip('\n') for x in nextline if x != '\n']
for line in nextline:
log.debug(line)
if p.poll() is not None:
break
# process returncode
check = p.returncode
# move file
if check == 0:
converted_file = glob(os.path.splitext(filename)[0] + "*.kepub.epub")
if len(converted_file) == 1:
copyfile(converted_file[0], (file_path + format_new_ext))
os.unlink(converted_file[0])
else:
return 1, N_("Converted file not found or more than one file in folder %(folder)s",
folder=os.path.dirname(file_path))
return check, None
def _convert_calibre(self, file_path, format_old_ext, format_new_ext, has_cover):
path_tmp_opf = None
try:
# path_tmp_opf = self._embed_metadata()
if config.config_embed_metadata:
quotes = [3, 5]
tmp_dir = get_temp_dir()
calibredb_binarypath = os.path.join(config.config_binariesdir, SUPPORTED_CALIBRE_BINARIES["calibredb"])
my_env = os.environ.copy()
if config.config_calibre_split:
my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db")
library_path = config.config_calibre_split_dir
else:
library_path = config.config_calibre_dir
opf_command = [calibredb_binarypath, 'show_metadata', '--as-opf', str(self.book_id),
'--with-library', library_path]
p = process_open(opf_command, quotes, my_env)
p.wait()
path_tmp_opf = os.path.join(tmp_dir, "metadata_" + str(uuid4()) + ".opf")
with open(path_tmp_opf, 'w') as fd:
copyfileobj(p.stdout, fd)
quotes = [1, 2, 4, 6]
command = [config.config_converterpath, (file_path + format_old_ext),
(file_path + format_new_ext)]
if config.config_embed_metadata:
command.extend(['--from-opf', path_tmp_opf])
if has_cover:
command.extend(['--cover', os.path.join(os.path.dirname(file_path), 'cover.jpg')])
quotes_index = 3
if config.config_calibre:
parameters = config.config_calibre.split(" ")
for param in parameters:
command.append(param)
quotes.append(quotes_index)
quotes_index += 1
p = process_open(command, quotes, newlines=False)
except OSError as e:
return 1, N_("Ebook-converter failed: %(error)s", error=e)
while p.poll() is None:
nextline = p.stdout.readline()
if isinstance(nextline, bytes):
nextline = nextline.decode('utf-8', errors="ignore").strip('\r\n')
if nextline:
log.debug(nextline)
# parse progress string from calibre-converter
progress = re.search(r"(\d+)%\s.*", nextline)
if progress:
self.progress = int(progress.group(1)) / 100
if config.config_use_google_drive:
self.progress *= 0.9
# process returncode
check = p.returncode
calibre_traceback = p.stderr.readlines()
error_message = ""
for ele in calibre_traceback:
ele = ele.decode('utf-8', errors="ignore").strip('\n')
log.debug(ele)
if not ele.startswith('Traceback') and not ele.startswith(' File'):
error_message = N_("Calibre failed with error: %(error)s", error=ele)
return check, error_message
@property
def name(self):
return N_("Convert")
def __str__(self):
if self.ereader_mail:
return "Convert Book {} and mail it to {}".format(self.book_id, self.ereader_mail)
else:
return "Convert Book {}".format(self.book_id)
@property
def is_cancellable(self):
return False
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2020 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/>.
import os
import re
import glob
from shutil import copyfile, copyfileobj
from markupsafe import escape
from time import time
from uuid import uuid4
from sqlalchemy.exc import SQLAlchemyError
from flask_babel import lazy_gettext as N_
from cps.services.worker import CalibreTask
from cps import db
from cps import logger, config
from cps.subproc_wrapper import process_open
from flask_babel import gettext as _
from cps.kobo_sync_status import remove_synced_book
from cps.ub import init_db_thread
from cps.file_helper import get_temp_dir
from cps.tasks.mail import TaskEmail
from cps import gdriveutils, helper
from cps.constants import SUPPORTED_CALIBRE_BINARIES
from cps.string_helper import strip_whitespaces
log = logger.create()
current_milli_time = lambda: int(round(time() * 1000))
class TaskConvert(CalibreTask):
def __init__(self, file_path, book_id, task_message, settings, ereader_mail, user=None):
super(TaskConvert, self).__init__(task_message)
self.worker_thread = None
self.file_path = file_path
self.book_id = book_id
self.title = ""
self.settings = settings
self.ereader_mail = ereader_mail
self.user = user
self.results = dict()
def run(self, worker_thread):
self.worker_thread = worker_thread
if config.config_use_google_drive:
worker_db = db.CalibreDB(expire_on_commit=False, init=True)
cur_book = worker_db.get_book(self.book_id)
self.title = cur_book.title
data = worker_db.get_book_format(self.book_id, self.settings['old_book_format'])
df = gdriveutils.getFileFromEbooksFolder(cur_book.path,
data.name + "." + self.settings['old_book_format'].lower())
df_cover = gdriveutils.getFileFromEbooksFolder(cur_book.path, "cover.jpg")
if df:
datafile_cover = None
datafile = os.path.join(config.get_book_path(),
cur_book.path,
data.name + "." + self.settings['old_book_format'].lower())
if df_cover:
datafile_cover = os.path.join(config.get_book_path(),
cur_book.path, "cover.jpg")
if not os.path.exists(os.path.join(config.get_book_path(), cur_book.path)):
os.makedirs(os.path.join(config.get_book_path(), cur_book.path))
df.GetContentFile(datafile)
if df_cover:
df_cover.GetContentFile(datafile_cover)
worker_db.session.close()
else:
# ToDo Include cover in error handling
error_message = _("%(format)s not found on Google Drive: %(fn)s",
format=self.settings['old_book_format'],
fn=data.name + "." + self.settings['old_book_format'].lower())
worker_db.session.close()
return self._handleError(error_message)
filename = self._convert_ebook_format()
if config.config_use_google_drive:
os.remove(self.file_path + '.' + self.settings['old_book_format'].lower())
if df_cover:
os.remove(os.path.join(config.config_calibre_dir, cur_book.path, "cover.jpg"))
if filename:
if config.config_use_google_drive:
# Upload files to gdrive
gdriveutils.updateGdriveCalibreFromLocal()
self._handleSuccess()
if self.ereader_mail:
# if we're sending to E-Reader after converting, create a one-off task and run it immediately
# todo: figure out how to incorporate this into the progress
try:
EmailText = N_(u"%(book)s send to E-Reader", book=escape(self.title))
for email in self.ereader_mail.split(','):
email = strip_whitespaces(email)
worker_thread.add(self.user, TaskEmail(self.settings['subject'],
self.results["path"],
filename,
self.settings,
email,
EmailText,
self.settings['body'],
id=self.book_id,
internal=True)
)
except Exception as ex:
return self._handleError(str(ex))
def _convert_ebook_format(self):
error_message = None
local_db = db.CalibreDB(expire_on_commit=False, init=True)
file_path = self.file_path
book_id = self.book_id
format_old_ext = '.' + self.settings['old_book_format'].lower()
format_new_ext = '.' + self.settings['new_book_format'].lower()
# check to see if destination format already exists - or if book is in database
# if it does - mark the conversion task as complete and return a success
# this will allow to send to E-Reader workflow to continue to work
if os.path.isfile(file_path + format_new_ext) or\
local_db.get_book_format(self.book_id, self.settings['new_book_format']):
log.info("Book id %d already converted to %s", book_id, format_new_ext)
cur_book = local_db.get_book(book_id)
self.title = cur_book.title
self.results['path'] = cur_book.path
self.results['title'] = self.title
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id)\
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
if not new_format:
new_format = db.Data(name=os.path.basename(file_path),
book_format=self.settings['new_book_format'].upper(),
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
try:
local_db.session.merge(new_format)
local_db.session.commit()
except SQLAlchemyError as e:
local_db.session.rollback()
log.error("Database error: %s", e)
local_db.session.close()
self._handleError(N_("Oops! Database Error: %(error)s.", error=e))
return
self._handleSuccess()
local_db.session.close()
return os.path.basename(file_path + format_new_ext)
else:
log.info("Book id %d - target format of %s does not exist. Moving forward with convert.",
book_id,
format_new_ext)
if config.config_kepubifypath and format_old_ext == '.epub' and format_new_ext == '.kepub':
check, error_message = self._convert_kepubify(file_path,
format_old_ext,
format_new_ext)
else:
# check if calibre converter-executable is existing
if not os.path.exists(config.config_converterpath):
self._handleError(N_("Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath))
return
has_cover = local_db.get_book(book_id).has_cover
check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext, has_cover)
if check == 0:
cur_book = local_db.get_book(book_id)
if os.path.isfile(file_path + format_new_ext):
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id) \
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
if not new_format:
new_format = db.Data(name=cur_book.data[0].name,
book_format=self.settings['new_book_format'].upper(),
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
try:
local_db.session.merge(new_format)
local_db.session.commit()
if self.settings['new_book_format'].upper() in ['KEPUB', 'EPUB', 'EPUB3']:
ub_session = init_db_thread()
remove_synced_book(book_id, True, ub_session)
ub_session.close()
except SQLAlchemyError as e:
local_db.session.rollback()
log.error("Database error: %s", e)
local_db.session.close()
self._handleError(error_message)
return
self.results['path'] = cur_book.path
self.title = cur_book.title
self.results['title'] = self.title
if not config.config_use_google_drive:
self._handleSuccess()
return os.path.basename(file_path + format_new_ext)
else:
error_message = N_('%(format)s format not found on disk', format=format_new_ext.upper())
local_db.session.close()
log.info("ebook converter failed with error while converting book")
if not error_message:
error_message = N_('Ebook converter failed with unknown error')
else:
log.error(error_message)
self._handleError(error_message)
return
def _convert_kepubify(self, file_path, format_old_ext, format_new_ext):
if config.config_embed_metadata and config.config_binariesdir:
tmp_dir, temp_file_name = helper.do_calibre_export(self.book_id, format_old_ext[1:])
filename = os.path.join(tmp_dir, temp_file_name + format_old_ext)
temp_file_path = tmp_dir
else:
filename = file_path + format_old_ext
temp_file_path = os.path.dirname(file_path)
quotes = [1, 3]
command = [config.config_kepubifypath, filename, '-o', temp_file_path, '-i']
try:
p = process_open(command, quotes)
except OSError as e:
return 1, N_("Kepubify-converter failed: %(error)s", error=e)
self.progress = 0.01
while True:
nextline = p.stdout.readlines()
nextline = [x.strip('\n') for x in nextline if x != '\n']
for line in nextline:
log.debug(line)
if p.poll() is not None:
break
# process returncode
check = p.returncode
# move file
if check == 0:
converted_file = glob.glob(glob.escape(os.path.splitext(filename)[0]) + "*.kepub.epub")
if len(converted_file) == 1:
copyfile(converted_file[0], (file_path + format_new_ext))
os.unlink(converted_file[0])
else:
return 1, N_("Converted file not found or more than one file in folder %(folder)s",
folder=os.path.dirname(file_path))
return check, None
def _convert_calibre(self, file_path, format_old_ext, format_new_ext, has_cover):
path_tmp_opf = None
try:
# path_tmp_opf = self._embed_metadata()
if config.config_embed_metadata:
quotes = [5]
tmp_dir = get_temp_dir()
calibredb_binarypath = os.path.join(config.config_binariesdir, SUPPORTED_CALIBRE_BINARIES["calibredb"])
my_env = os.environ.copy()
if config.config_calibre_split:
my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db")
library_path = config.config_calibre_split_dir
else:
library_path = config.config_calibre_dir
opf_command = [calibredb_binarypath, 'show_metadata', '--as-opf', str(self.book_id),
'--with-library', library_path]
p = process_open(opf_command, quotes, my_env, newlines=False)
lines = list()
while p.poll() is None:
lines.append(p.stdout.readline())
check = p.returncode
calibre_traceback = p.stderr.readlines()
if check == 0:
path_tmp_opf = os.path.join(tmp_dir, "metadata_" + str(uuid4()) + ".opf")
with open(path_tmp_opf, 'wb') as fd:
fd.write(b''.join(lines))
else:
error_message = ""
for ele in calibre_traceback:
if not ele.startswith('Traceback') and not ele.startswith(' File'):
error_message = N_("Calibre failed with error: %(error)s", error=ele)
return check, error_message
quotes = [1, 2]
quotes_index = 3
command = [config.config_converterpath, (file_path + format_old_ext),
(file_path + format_new_ext)]
if config.config_embed_metadata:
quotes.append(4)
quotes_index = 5
command.extend(['--from-opf', path_tmp_opf])
if has_cover:
quotes.append(6)
command.extend(['--cover', os.path.join(os.path.dirname(file_path), 'cover.jpg')])
quotes_index = 7
if config.config_calibre:
parameters = re.findall(r"(--[\w-]+)(?:(\s(?:(\".+\")|(?:.+?)))(?:\s|$))?",
config.config_calibre, re.IGNORECASE | re.UNICODE)
if parameters:
for param in parameters:
command.append(strip_whitespaces(param[0]))
quotes_index += 1
if param[1] != "":
parsed = strip_whitespaces(param[1]).strip("\"")
command.append(parsed)
quotes.append(quotes_index)
quotes_index += 1
p = process_open(command, quotes, newlines=False)
except OSError as e:
return 1, N_("Ebook-converter failed: %(error)s", error=e)
while p.poll() is None:
nextline = p.stdout.readline()
if isinstance(nextline, bytes):
nextline = nextline.decode('utf-8', errors="ignore").strip('\r\n')
if nextline:
log.debug(nextline)
# parse progress string from calibre-converter
progress = re.search(r"(\d+)%\s.*", nextline)
if progress:
self.progress = int(progress.group(1)) / 100
if config.config_use_google_drive:
self.progress *= 0.9
# process returncode
check = p.returncode
calibre_traceback = p.stderr.readlines()
error_message = ""
for ele in calibre_traceback:
ele = ele.decode('utf-8', errors="ignore").strip('\n')
log.debug(ele)
if not ele.startswith('Traceback') and not ele.startswith(' File'):
error_message = N_("Calibre failed with error: %(error)s", error=ele)
return check, error_message
@property
def name(self):
return N_("Convert")
def __str__(self):
if self.ereader_mail:
return "Convert Book {} and mail it to {}".format(self.book_id, self.ereader_mail)
else:
return "Convert Book {}".format(self.book_id)
@property
def is_cancellable(self):
return False
+5 -13
View File
@@ -16,11 +16,9 @@
# 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 urllib.request import urlopen
from flask_babel import lazy_gettext as N_
from cps import config, logger
from cps import config, logger, db, ub
from cps.services.worker import CalibreTask
@@ -28,18 +26,12 @@ class TaskReconnectDatabase(CalibreTask):
def __init__(self, task_message=N_('Reconnecting Calibre database')):
super(TaskReconnectDatabase, self).__init__(task_message)
self.log = logger.create()
self.listen_address = config.get_config_ipaddress()
self.listen_port = config.config_port
self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
def run(self, worker_thread):
address = self.listen_address if self.listen_address else 'localhost'
port = self.listen_port if self.listen_port else 8083
try:
urlopen('http://' + address + ':' + str(port) + '/reconnect')
self._handleSuccess()
except Exception as ex:
self._handleError('Unable to reconnect Calibre database: ' + str(ex))
self.calibre_db.reconnect_db(config, ub.app_DB_path)
self.calibre_db.session.close()
self._handleSuccess()
@property
def name(self):
+3 -2
View File
@@ -34,6 +34,7 @@ from cps.services import gmail
from cps.embed_helper import do_calibre_export
from cps import logger, config
from cps import gdriveutils
from cps.string_helper import strip_whitespaces
import uuid
log = logger.create()
@@ -127,9 +128,9 @@ class TaskEmail(CalibreTask):
try:
# Parse out the address from the From line, and then the domain from that
from_email = parseaddr(self.settings["mail_from"])[1]
msgid_domain = from_email.partition('@')[2].strip()
msgid_domain = strip_whitespaces(from_email.partition('@')[2])
# This can sometimes sneak through parseaddr if the input is malformed
msgid_domain = msgid_domain.rstrip('>').strip()
msgid_domain = strip_whitespaces(msgid_domain.rstrip('>'))
except Exception:
msgid_domain = ''
return msgid_domain or 'calibre-web.com'
+16 -15
View File
@@ -20,14 +20,13 @@ import os
from shutil import copyfile, copyfileobj
from urllib.request import urlopen
from io import BytesIO
from datetime import datetime, timezone
from .. import constants
from cps import config, db, fs, gdriveutils, logger, ub
from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED
from datetime import datetime
from sqlalchemy import func, text, or_
from flask_babel import lazy_gettext as N_
try:
from wand.image import Image
use_IM = True
@@ -36,7 +35,7 @@ except (ImportError, RuntimeError) as e:
def get_resize_height(resolution):
return int(225 * resolution)
return int(255 * resolution)
def get_resize_width(resolution, original_width, original_height):
@@ -70,11 +69,11 @@ class TaskGenerateCoverThumbnails(CalibreTask):
self.log = logger.create()
self.book_id = book_id
self.app_db_session = ub.get_new_session_instance()
# self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
self.cache = fs.FileSystem()
self.resolutions = [
constants.COVER_THUMBNAIL_SMALL,
constants.COVER_THUMBNAIL_MEDIUM
constants.COVER_THUMBNAIL_MEDIUM,
constants.COVER_THUMBNAIL_LARGE
]
def run(self, worker_thread):
@@ -124,7 +123,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
.query(ub.Thumbnail) \
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
.filter(ub.Thumbnail.entity_id == book_id) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc))) \
.all()
def create_book_cover_thumbnails(self, book):
@@ -166,7 +165,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
self.app_db_session.rollback()
def update_book_cover_thumbnail(self, book, thumbnail):
thumbnail.generated_at = datetime.utcnow()
thumbnail.generated_at = datetime.now(timezone.utc)
try:
self.app_db_session.commit()
@@ -198,9 +197,11 @@ class TaskGenerateCoverThumbnails(CalibreTask):
img.format = thumbnail.format
img.save(filename=filename)
else:
with open(filename, 'rb') as fd:
stream.seek(0)
with open(filename, 'wb') as fd:
copyfileobj(stream, fd)
except Exception as ex:
# Bubble exception to calling function
self.log.debug('Error generating thumbnail file: ' + str(ex))
@@ -323,12 +324,12 @@ class TaskGenerateSeriesThumbnails(CalibreTask):
.all()
def get_series_thumbnails(self, series_id):
return self.app_db_session \
.query(ub.Thumbnail) \
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES) \
.filter(ub.Thumbnail.entity_id == series_id) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
.all()
return (self.app_db_session
.query(ub.Thumbnail)
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES)
.filter(ub.Thumbnail.entity_id == series_id)
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc)))
.all())
def create_series_thumbnail(self, series, series_books, resolution):
thumbnail = ub.Thumbnail()
@@ -347,7 +348,7 @@ class TaskGenerateSeriesThumbnails(CalibreTask):
self.app_db_session.rollback()
def update_series_thumbnail(self, series_books, thumbnail):
thumbnail.generated_at = datetime.utcnow()
thumbnail.generated_at = datetime.now(timezone.utc)
try:
self.app_db_session.commit()
+5 -3
View File
@@ -17,7 +17,7 @@
from markupsafe import escape
from flask import Blueprint, jsonify
from flask_login import login_required, current_user
from .cw_login import current_user
from flask_babel import gettext as _
from flask_babel import format_datetime
from babel.units import format_unit
@@ -26,6 +26,7 @@ from . import logger
from .render_template import render_title_template
from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS, STAT_ENDED, \
STAT_CANCELLED
from .usermanagement import user_login_required
tasks = Blueprint('tasks', __name__)
@@ -33,14 +34,14 @@ log = logger.create()
@tasks.route("/ajax/emailstat")
@login_required
@user_login_required
def get_email_status_json():
tasks = WorkerThread.get_instance().tasks
return jsonify(render_task_status(tasks))
@tasks.route("/tasks")
@login_required
@user_login_required
def get_tasks_status():
# if current user admin, show all email, otherwise only own emails
return render_title_template('tasks.html', title=_("Tasks"), page="tasks")
@@ -81,6 +82,7 @@ def render_task_status(tasklist):
ret['task_id'] = task.id
ret['stat'] = task.stat
ret['is_cancellable'] = task.is_cancellable
ret['error'] = task.error
rendered_tasklist.append(ret)
+4 -6
View File
@@ -62,18 +62,16 @@
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
{% endif %}
{% endfor %}
{% for format in entry.Books.data %}
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
{% if entry.Books.data|music %}
<span class="glyphicon glyphicon-music"></span>
{% endif %}
{% endfor %}
{% endif %}
</p>
{% if entry.Books.series.__len__() > 0 %}
<p class="series">
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.Books.series[0].id )}}">
{{entry.Books.series[0].name}}
</a>
({{entry.Books.series_index|formatseriesindex}})
({{entry.Books.series_index|formatfloat(2)}})
</p>
{% endif %}
{% if entry.Books.ratings.__len__() > 0 %}
@@ -124,7 +122,7 @@
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id )}}">
{{entry.series[0].name}}
</a>
({{entry.series_index|formatseriesindex}})
({{entry.series_index|formatfloat(2)}})
</p>
{% endif %}
<div class="rating">
+22 -14
View File
@@ -47,26 +47,41 @@
</form>
</div>
{% endif %}
{% if current_user.role_upload() and g.allow_upload %}
<div class="text-center more-stuff"><!--h4 aria-label="Upload new book format"></h4-->
<form id="form-upload-format" action="{{ url_for('edit-book.upload') }}" data-title="{{_('Uploading...')}}" data-footer="{{_('Close')}}" data-failed="{{_('Error')}}" data-message="{{_('Upload done, processing, please wait...')}}" method="post" enctype="multipart/form-data">
<div class="text-center">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="book_id" value="{{ book.id }}">
<div role="group" aria-label="Upload new book format">
<label class="btn btn-primary btn-file" for="btn-upload-format">{{ _('Upload Format') }}</label>
<div class="upload-format-input-text" id="upload-format"></div>
<input id="btn-upload-format" name="btn-upload-format" type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}" multiple>
</div>
</div>
</form>
</div>
{% endif %}
</div>
<form role="form" action="{{ url_for('edit-book.edit_book', book_id=book.id) }}" method="post" enctype="multipart/form-data" id="book_edit_frm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="col-sm-9 col-xs-12">
<div class="form-group">
<label for="book_title">{{_('Book Title')}}</label>
<input type="text" class="form-control" name="book_title" id="book_title" value="{{book.title}}">
<label for="title">{{_('Book Title')}}</label>
<input type="text" class="form-control" name="title" id="title" value="{{book.title}}">
</div>
<div class="text-center">
<button type="button" class="btn btn-default" id="xchange" ><span class="glyphicon glyphicon-arrow-up"></span><span class="glyphicon glyphicon-arrow-down"></span></button>
</div>
<div id="author_div" class="form-group">
<label for="bookAuthor">{{_('Author')}}</label>
<input type="text" class="form-control typeahead" autocomplete="off" name="author_name" id="bookAuthor" value="{{' & '.join(authors)}}">
<input type="text" class="form-control typeahead" autocomplete="off" name="authors" id="authors" value="{{' & '.join(authors)}}">
</div>
<div class="form-group">
<label for="description">{{_('Description')}}</label>
<textarea class="form-control" name="description" id="description" rows="7">{% if book.comments %}{{book.comments[0].text}}{%endif%}</textarea>
<label for="comments">{{_('Description')}}</label>
<textarea class="form-control" name="comments" id="comments" rows="7">{% if book.comments %}{{book.comments[0].text}}{%endif%}</textarea>
</div>
<div class="form-group">
@@ -93,7 +108,7 @@
</div>
<div class="form-group">
<label for="series_index">{{_('Series ID')}}</label>
<input type="number" step="0.01" min="0" placeholder="1" class="form-control" name="series_index" id="series_index" value="{{book.series_index}}">
<input type="number" step="0.01" min="0" placeholder="1" class="form-control" name="series_index" id="series_index" value="{{book.series_index|formatfloat(2)}}">
</div>
<div class="form-group">
<label for="rating">{{_('Rating')}}</label>
@@ -196,13 +211,6 @@
</div>
{% endfor %}
{% endif %}
{% if current_user.role_upload() and g.allow_upload %}
<div role="group" aria-label="Upload new book format">
<label class="btn btn-primary btn-file" for="btn-upload-format">{{ _('Upload Format') }}</label>
<div class="upload-format-input-text" id="upload-format"></div>
<input id="btn-upload-format" name="btn-upload-format" type="file">
</div>
{% endif %}
<div class="checkbox">
<label>
@@ -288,7 +296,7 @@
'no_result': {{_('No Result(s) found! Please try another keyword.')|safe|tojson}},
'author': {{_('Author')|safe|tojson}},
'publisher': {{_('Publisher')|safe|tojson}},
'description': {{_('Description')|safe|tojson}},
'comments': {{_('Description')|safe|tojson}},
'source': {{_('Source')|safe|tojson}},
};
var language = '{{ current_user.locale }}';
+1 -1
View File
@@ -66,7 +66,7 @@
{{ text_table_row('authors', _('Enter Authors'),_('Authors'), true, true) }}
{{ text_table_row('tags', _('Enter Categories'),_('Categories'), false, true) }}
{{ text_table_row('series', _('Enter Series'),_('Series'), false, true) }}
<th data-field="series_index" id="series_index" data-visible="{{visiblility.get('series_index')}}" data-edit-validate="{{ _('This Field is Required') }}" data-sortable="true" {% if current_user.role_edit() %} data-editable-type="number" data-editable-placeholder="1" data-editable-step="0.01" data-editable-min="0" data-editable-url="{{ url_for('edit-book.edit_list_book', param='series_index')}}" data-edit="true" data-editable-title="{{_('Enter Title')}}"{% endif %}>{{_('Series Index')}}</th>
<th data-field="series_index" id="series_index" data-visible="{{visiblility.get('series_index')}}" data-formatter="seriesIndexFormatter" data-edit-validate="{{ _('This Field is Required') }}" data-sortable="true" {% if current_user.role_edit() %} data-editable-type="number" data-editable-placeholder="1" data-editable-step="0.01" data-editable-min="0" data-editable-url="{{ url_for('edit-book.edit_list_book', param='series_index')}}" data-edit="true" data-editable-title="{{_('Enter Title')}}"{% endif %}>{{_('Series Index')}}</th>
{{ text_table_row('languages', _('Enter Languages'),_('Languages'), false, true) }}
<!--th data-field="pubdate" data-type="date" data-visible="{{visiblility.get('pubdate')}}" data-viewformat="dd.mm.yyyy" id="pubdate" data-sortable="true">{{_('Publishing Date')}}</th-->
{{ text_table_row('publishers', _('Enter Publishers'),_('Publishers'), false, true) }}
Executable → Regular
View File
+3 -3
View File
@@ -20,7 +20,7 @@
{{ _('Download') }} :
</button>
{% for format in entry.data %}
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower) }}"
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}"
id="btnGroupDrop1{{ format.format|lower }}" class="btn btn-primary"
role="button">
<span class="glyphicon glyphicon-download"></span>{{ format.format }}
@@ -36,7 +36,7 @@
<ul class="dropdown-menu" aria-labelledby="btnGroupDrop1">
{% for format in entry.data %}
<li>
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower) }}">{{ format.format }}
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}">{{ format.format }}
({{ format.uncompressed_size|filesizeformat }})</a></li>
{% endfor %}
</ul>
@@ -147,7 +147,7 @@
</div>
{% endif %}
{% if entry.series|length > 0 %}
<p>{{ _("Book %(index)s of %(range)s", index=entry.series_index | formatfloat(2), range=(url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id)|escapedlink(entry.series[0].name))|safe) }}</p>
<p>{{ _("Book %(index)s of %(range)s", index=entry.series_index|formatfloat(2), range=(url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id)|escapedlink(entry.series[0].name))|safe) }}</p>
{% endif %}
+1 -1
View File
@@ -15,6 +15,6 @@
<img
srcset="{{ srcset }}"
src="{{ url_for('web.get_series_cover', series_id=series.id, resolution='og', c='day'|cache_timestamp) }}"
alt="{{ book_title }}"
alt="{{ title }}"
/>
{%- endmacro %}
+4 -6
View File
@@ -42,7 +42,7 @@
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.Books.series[0].id )}}">
{{entry.Books.series[0].name}}
</a>
({{entry.Books.series_index|formatseriesindex}})
({{entry.Books.series_index|formatfloat(2)}})
</p>
{% endif %}
{% if entry.Books.ratings.__len__() > 0 %}
@@ -119,11 +119,9 @@
<a class="author-name" href="{{url_for('web.books_list', data='author', book_id=author.id, sort_param='stored') }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
{% endif %}
{% endfor %}
{% for format in entry.Books.data %}
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
{% if entry.Books.data|music %}
<span class="glyphicon glyphicon-music"></span>
{% endif %}
{%endfor%}
{% endif %}
</p>
{% if entry.Books.series.__len__() > 0 %}
<p class="series">
@@ -134,7 +132,7 @@
{% else %}
<span>{{entry.Books.series[0].name}}</span>
{% endif %}
({{entry.Books.series_index|formatseriesindex}})
({{entry.Books.series_index|formatfloat(2)}})
</p>
{% endif %}
{% if entry.Books.ratings.__len__() > 0 %}
+5 -4
View File
@@ -62,13 +62,13 @@
{% if g.current_theme == 1 %}
<li class="dropdown"><a href="#" class="dropdown-toggle profileDrop" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><span class="glyphicon glyphicon-user"></span></a>
<ul class="dropdown-menu profileDropli">
<li><a id="top_user" data-text="{{_('Account')}}" href="{{url_for('web.profile')}}"><span class="glyphicon glyphicon-user"></span> <span class="hidden-sm">{{current_user.name}}</span></a></li>
<li><a id="top_user" data-text="{{_('Account')}}" href="{{url_for('web.profile')}}" ><span class="glyphicon glyphicon-user"></span> <span class="hidden-sm">{{current_user.name}}</span></a></li>
{% if g.allow_registration and not current_user.is_authenticated %}
<li><a id="login" href="{{url_for('web.login')}}"><span class="glyphicon glyphicon-log-in"></span> {{_('Login')}}</a></li>
<li><a id="register" href="{{url_for('web.register')}}"><span class="glyphicon glyphicon-user"></span> {{_('Register')}}</a></li>
{% endif %}
{% if not current_user.is_anonymous %}
<li><a id="logout" href="{{url_for('web.logout')}}"><span class="glyphicon glyphicon-log-out"></span> <span class="hidden-sm">{{_('Logout')}}</span></a></li>
<li><a id="logout" href="{{url_for('web.logout', next=(request.path + "?" + request.query_string.decode("utf-8")).rstrip("?"))}}"><span class="glyphicon glyphicon-log-out"></span> <span class="hidden-sm">{{_('Logout')}}</span></a></li>
{% endif %}
</ul>
</li>
@@ -80,6 +80,7 @@
<div class="form-group">
<span class="btn btn-default btn-file">{{_('Upload')}}<input id="btn-upload" name="btn-upload"
type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}" multiple></span>
<input class="hide" id="btn-upload2" name="btn-upload2" type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}">
</div>
</form>
</li>
@@ -91,9 +92,9 @@
<li><a id="top_admin" data-text="{{_('Settings')}}" href="{{url_for('admin.admin')}}"><span class="glyphicon glyphicon-dashboard"></span> <span class="hidden-sm">{{_('Admin')}}</span></a></li>
{% endif %}
{% if g.current_theme == 0 %}
<li><a id="top_user" data-text="{{_('Account')}}" href="{{url_for('web.profile')}}"><span class="glyphicon glyphicon-user"></span> <span class="hidden-sm">{{current_user.name}}</span></a></li>
<li><a id="top_user" data-text="{{_('Account')}}" href="{% if not current_user.is_anonymous %}{{url_for('web.profile')}}{% else %}{{url_for('web.login', next=(request.path + "?" + request.query_string.decode("utf-8")).rstrip("?"))}}{% endif %}"><span class="glyphicon glyphicon-user"></span> <span class="hidden-sm">{{current_user.name}}</span></a></li>
{% if not current_user.is_anonymous %}
<li><a id="logout" href="{{url_for('web.logout')}}"><span class="glyphicon glyphicon-log-out"></span> <span class="hidden-sm">{{_('Logout')}}</span></a></li>
<li><a id="logout" href="{{url_for('web.logout', next=(request.path + "?" + request.query_string.decode("utf-8")).rstrip("?"))}}"><span class="glyphicon glyphicon-log-out"></span> <span class="hidden-sm">{{_('Logout')}}</span></a></li>
{% endif %}
{% endif %}
{% endif %}
+1 -1
View File
@@ -59,7 +59,7 @@
</div>
{% endif %}
{% if entry.series|length > 0 %}
<p>{{_("Book %(index)s of %(range)s", index=entry.series_index | formatfloat(2), range=(url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id)|escapedlink(entry.series[0].name))|safe)}}</p>
<p>{{_("Book %(index)s of %(range)s", index=entry.series_index|formatfloat(2), range=(url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id)|escapedlink(entry.series[0].name))|safe)}}</p>
{% endif %}
-10
View File
@@ -1,10 +0,0 @@
{
"input": {
"placeholder": "a placeholder"
},
"nav": {
"home": "Home",
"page1": "Page One",
"page2": "Page Two"
}
}
+171 -105
View File
@@ -1,33 +1,35 @@
<!DOCTYPE html>
<html class="no-js">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>{{_('epub Reader')}} | {{title}}</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
{% if g.google_site_verification|length > 0 %}
<meta name="google-site-verification" content="{{g.google_site_verification}}">
{% endif %}
<link rel="apple-touch-icon" sizes="140x140" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/libs/normalize.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/popup.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/reader.css') }}">
</head>
<body>
<div id="sidebar">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>{{_('epub Reader')}} | {{title}}</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
{% if g.google_site_verification|length > 0 %}
<meta name="google-site-verification" content="{{g.google_site_verification}}">
{% endif %}
<link rel="apple-touch-icon" sizes="140x140" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/libs/normalize.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/popup.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/reader.css') }}">
</head>
<body>
<div id="sidebar">
<div id="panels">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!--input id="searchBox" placeholder="search" type="search"-->
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!--input id="searchBox" placeholder="search" type="search"-->
<!--a id="show-Search" class="show_view icon-search" data-view="Search">Search</a-->
<a id="show-Toc" class="show_view icon-list-1 active" data-view="Toc">TOC</a>
<a id="show-Bookmarks" class="show_view icon-bookmark" data-view="Bookmarks">Bookmarks</a>
<!--a id="show-Notes" class="show_view icon-edit" data-view="Notes">Notes</a-->
<!--a id="show-Search" class="show_view icon-search" data-view="Search">Search</a-->
<a id="show-Toc" class="show_view icon-list-1 active" data-view="Toc">TOC</a>
<a id="show-Bookmarks" class="show_view icon-bookmark" data-view="Bookmarks">Bookmarks</a>
<!--a id="show-Notes" class="show_view icon-edit" data-view="Notes">Notes</a-->
</div>
<div id="tocView" class="view">
@@ -36,7 +38,7 @@
<ul id="searchResults"></ul>
</div-->
<div id="bookmarksView" class="view">
<ul id="bookmarks"></ul>
<ul id="bookmarks"></ul>
</div>
<!--div id="notesView" class="view">
<div id="new-note">
@@ -45,110 +47,174 @@
</div>
<ol id="notes"></ol>
</div-->
</div>
<div id="main">
</div>
<div id="main">
<div id="titlebar">
<div id="opener">
<a id="slider" class="icon-menu">Menu</a>
</div>
<div id="metainfo">
<span id="book-title"></span>
<span id="title-seperator">&nbsp;&nbsp;–&nbsp;&nbsp;</span>
<span id="chapter-title"></span>
</div>
<div id="title-controls">
<a id="bookmark" class="icon-bookmark-empty">Bookmark</a>
<a id="setting" class="icon-cog">Settings</a>
<a id="fullscreen" class="icon-resize-full">Fullscreen</a>
</div>
</div>
<div id="opener">
<a id="slider" class="icon-menu">Menu</a>
</div>
<div id="metainfo">
<span id="book-title"></span>
<span id="title-seperator">&nbsp;&nbsp;–&nbsp;&nbsp;</span>
<span id="chapter-title"></span>
</div>
<div id="title-controls">
<a id="bookmark" class="icon-bookmark-empty">Bookmark</a>
<a id="setting" class="icon-cog">Settings</a>
<a id="fullscreen" class="icon-resize-full">Fullscreen</a>
</div>
</div>
<div id="divider"></div>
<div id="prev" class="arrow">‹</div>
<div id="viewer"></div>
<div id="next" class="arrow">›</div>
<div id="divider"></div>
<div id="prev" class="arrow">‹</div>
<div id="viewer"></div>
<div id="next" class="arrow">›</div>
<div id="progress">0%</div>
<div id="loader"><img src="{{ url_for('static', filename='img/loader.gif') }}"></div>
</div>
<div class="modal md-effect-1" id="settings-modal">
<div class="md-content">
<h3>{{_('Settings')}}</h3>
<div class="form-group themes" id="themes">
Choose a theme below: <br />
<div id="loader"><img src="{{ url_for('static', filename='img/loader.gif') }}"></div>
</div>
<div class="modal md-effect-1" id="settings-modal">
<div class="md-content">
<h3>{{_('Settings')}}</h3>
<div class="themes" id="themes">
Choose a theme below: <br />
<!-- Hardcoded a tick in the light theme button because it is the "default" theme. Need to find a way to do this dynamically on startup-->
<button type="button" id="lightTheme" class="lightTheme" onclick="selectTheme(this.id)"><span id="lightSelected">✓</span>{{_('Light')}}</button>
<button type="button" id="darkTheme" class="darkTheme" onclick="selectTheme(this.id)"><span id="darkSelected"> </span>{{_('Dark')}}</button>
<button type="button" id="sepiaTheme" class="sepiaTheme" onclick="selectTheme(this.id)"><span id="sepiaSelected"> </span>{{_('Sepia')}}</button>
<button type="button" id="blackTheme" class="blackTheme" onclick="selectTheme(this.id)"><span id="blackSelected"> </span>{{_('Black')}}</button>
</div>
<div>
<p>
<input type="checkbox" id="sidebarReflow" name="sidebarReflow">{{_('Reflow text when sidebars are open.')}}
</p>
</div>
<div class="fontSizeWrapper">
<!-- Hardcoded a tick in the light theme button because it is the "default" theme. Need to find a way to do this dynamically on startup-->
<button type="button" id="lightTheme" class="lightTheme" onclick="selectTheme(this.id)"><span
id="lightSelected">✓</span>{{_('Light')}}</button>
<button type="button" id="darkTheme" class="darkTheme" onclick="selectTheme(this.id)"><span
id="darkSelected"> </span>{{_('Dark')}}</button>
<button type="button" id="sepiaTheme" class="sepiaTheme" onclick="selectTheme(this.id)"><span
id="sepiaSelected"> </span>{{_('Sepia')}}</button>
<button type="button" id="blackTheme" class="blackTheme" onclick="selectTheme(this.id)"><span
id="blackSelected"> </span>{{_('Black')}}</button>
</div>
<div class="form-group">
<p>
<input type="checkbox" id="sidebarReflow"
name="sidebarReflow">{{_('Reflow text when sidebars are open.')}}
</p>
</div>
<div class="form-group fontSizeWrapper">
<div class="slider">
<label for="fader">{{ _('Font Sizes') }}</label>
<input type="range" min="75" max="200" value="100" id="fontSizeFader" step="25">
</div>
</div>
</div>
<div class="font" id="font">
<label class="item">{{_('Font')}}:</label>
<button type="button" id="default" onclick="selectFont(this.id)"><span>✓</span>{{_('Default')}}</button>
<button type="button" id="Yahei" onclick="selectFont(this.id)"><span></span>{{_('Yahei')}}</button>
<button type="button" id="SimSun" onclick="selectFont(this.id)"><span></span>{{_('SimSun')}}</button>
<button type="button" id="KaiTi" onclick="selectFont(this.id)"><span></span>{{_('KaiTi')}}</button>
<button type="button" id="Arial" onclick="selectFont(this.id)"><span></span>{{_('Arial')}}</button>
</div>
<div class="closer icon-cancel-circled"></div>
</div>
</div>
<div class="overlay"></div>
<script src="{{ url_for('static', filename='js/libs/jquery.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/compress/jszip_epub.min.js') }}">
</script> <script src="{{ url_for('static', filename='js/libs/epub.min.js') }}"></script>
<script type="text/javascript">
window.calibre = {
filePath: "{{ url_for('static', filename='js/libs/') }}",
cssPath: "{{ url_for('static', filename='css/') }}",
bookmarkUrl: "{{ url_for('web.set_bookmark', book_id=bookid, book_format='EPUB') }}",
bookUrl: "{{ url_for('web.serve_book', book_id=bookid, book_format='epub', anyname='file.epub') }}",
bookmark: "{{ bookmark.bookmark_key if bookmark != None }}",
useBookmarks: "{{ current_user.is_authenticated | tojson }}"
};
<div class="layou" id="layout">
<label class="item">{{ _('Spread') }}:</label>
<button type="button" id="spread" onclick="spread(this.id)"><span>✓</span>{{_('Two columns')}}</button>
<button type="button" id="nonespread" onclick="spread(this.id)"><span></span>{{_('One column')}}</button>
</div>
<div class="closer icon-cancel-circled"></div>
</div>
</div>
<div class="overlay"></div>
<script src="{{ url_for('static', filename='js/libs/jquery.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/compress/jszip_epub.min.js') }}">
</script>
<script src="{{ url_for('static', filename='js/libs/epub.min.js') }}"></script>
<script type="text/javascript">
window.calibre = {
filePath: "{{ url_for('static', filename='js/libs/') }}",
cssPath: "{{ url_for('static', filename='css/') }}",
bookmarkUrl: "{{ url_for('web.set_bookmark', book_id=bookid, book_format='EPUB') }}",
bookUrl: "{{ url_for('web.serve_book', book_id=bookid, book_format='epub', anyname='file.epub') }}",
bookmark: "{{ bookmark.bookmark_key if bookmark != None }}",
useBookmarks: "{{ current_user.is_authenticated | tojson }}"
};
window.themes = {
"darkTheme": {
"bgColor": "#202124",
"css_path": "{{ url_for('static', filename='css/epub_themes.css') }}",
"title-color": "#fff"
},
"lightTheme": {
"bgColor": "white",
"css_path": "{{ url_for('static', filename='css/epub_themes.css') }}",
"title-color": "#4f4f4f"
},
"sepiaTheme": {
"bgColor": "#ece1ca",
"css_path": "{{ url_for('static', filename='css/epub_themes.css') }}",
"title-color": "#4f4f4f"
},
"blackTheme": {
"bgColor": "black",
"css_path": "{{ url_for('static', filename='css/epub_themes.css') }}",
"title-color": "#fff"
},
};
function selectTheme (id) {
let tickSpans = document.getElementById("themes").querySelectorAll("span");
function selectTheme(id) {
tickSpans = document.getElementById("themes").querySelectorAll("span");
// Remove tick mark from all theme buttons
for(var i = 0; i < tickSpans.length; i++) {
document.getElementById(tickSpans[i].id).textContent = "";
}
tickSpans.forEach(function (tickSpan) {
document.getElementById(tickSpan.id).textContent = "";
});
// Add tick mark to the button corresponding to the currently selected theme
document.getElementById(id).querySelector("span").textContent = "✓";
// Saving theme to local storage
localStorage.setItem("calibre.reader.theme", id);
// Apply theme to epubjs iframe
reader.rendition.themes.select(id);
// Apply theme to rest of the page. TODO - Do this smarter
if (id == "darkTheme") {
document.getElementById("main").style.backgroundColor = "#202124";
}
else if (id == "lightTheme") {
document.getElementById("main").style.backgroundColor = "white";
}
else if (id == "sepiaTheme") {
document.getElementById("main").style.backgroundColor = "#ece1ca";
}
else if (id == "blackTheme") {
document.getElementById("main").style.backgroundColor = "black";
}
// Apply theme to rest of the page.
document.getElementById("main").style.backgroundColor = themes[id]["bgColor"];
document.getElementById("titlebar").style.color = themes[id]["title-color"] || "#fff";
}
// font size settings logic
let fontSizeFader = document.getElementById('fontSizeFader');
fontSizeFader.addEventListener ("change", function () {
reader.rendition.themes.fontSize(`${this.value}%`)
fontSizeFader.addEventListener("change", function () {
reader.rendition.themes.fontSize(`${this.value}%`);
});
let defaultFont;
function selectFont(id) {
if (!defaultFont) {
defaultFont = reader.rendition.getContents()[0]?.css('font-family');
}
spans = document.getElementById("font").querySelectorAll("span");
for(var i = 0; i < spans.length; i++) {
spans[i].textContent = "";
}
document.getElementById(id).querySelector("span").textContent = "✓";
if (id == 'default') {
reader.rendition.themes.font(defaultFont);
return;
}
reader.rendition.themes.font(id);
}
function spread(id) {
spans = document.getElementById("layout").querySelectorAll("span");
for(var i = 0; i < spans.length; i++) {
spans[i].textContent = "";
}
document.getElementById(id).querySelector("span").textContent = "✓";
reader.rendition.spread(id==='spread'?true:'none');
}
</script>
<script src="{{ url_for('static', filename='js/libs/screenfull.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/reader.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/reading/epub.js') }}"></script>
<script src="{{ url_for('static', filename='js/reading/epub-progress.js') }}"></script>
</body>
</html>

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