Compare commits

...
374 Commits
Author SHA1 Message Date
Ozzie Isaacs 70298c69b6 Merge remote-tracking branch 'origin/simple_view' into simple_view 2024-08-23 12:00:21 +02:00
Ozzie Isaacs f0ca56522d Merge remote-tracking branch 'simple_view/master' into simple_view 2024-08-23 12:00:08 +02:00
Ozzie Isaacs ee124ba50c Merge remote-tracking branch 'simple_view/master' into simple_view 2024-08-23 11:15:59 +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
Paulo Silva 29b05b48a4 css adjustments for old kindle 2024-08-18 16:24:01 +01:00
Chris Arderne 98967c926b Merge branch 'janeczku:master' into master 2024-08-18 16:11:38 +01: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
Chris Arderne 7d7e762469 Merge pull request #2 from janeczku/master
merge upstream
2024-07-13 00:02:03 +01: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
Ozzie Isaacs e9bccc83c5 Version bump 2024-07-06 13:29:17 +02:00
Ozzie Isaacs 53ba352ed9 Improved some things in caliblur theme (hide back button) 2024-07-05 20:43:50 +02:00
Ozzie Isaacs bbc3219d31 Update lubimycztac metadata provider (fixes #3090) 2024-07-05 19:16:22 +02:00
mapi68 ea89fe07d8 Update messages.po 2024-07-05 04:35:07 +02:00
Ozzie Isaacs ab2620a265 Merge branch 'Develop' (Fix for #3058, refactored rename author and improved duplicate check for tags/publishers, series, ...) 2024-07-03 15:46:19 +02:00
Ozzie Isaacs a20a4a201e Test update 2024-07-03 15:38:47 +02:00
Ozzie Isaacs ba0718f3d0 Enabled covers with gdrive 2024-07-02 20:53:08 +02:00
Ozzie Isaacs 60dd3df861 Fix after testrun for G-Drive 2024-07-02 20:02:11 +02:00
Ozzie Isaacs 762074596d Fix after testrun on Windows 2024-06-30 17:08:41 +02:00
Ozzie Isaacs 789912d0d4 Merge branch 'Develop'
Update translations
2024-06-30 12:20:42 +02:00
Ozzie Isaacs daf8dd6f90 Fix author rename on google drive
Bugfixes after testrun
2024-06-30 12:18:52 +02:00
Ozzie Isaacs e02610e2f0 Bugfixes change author 2024-06-29 20:45:56 +02:00
Ozzie Isaacs 897188ff18 Merge remote-tracking branch 'origin/Develop' into Develop 2024-06-29 09:52:05 +02:00
Ozzie Isaacs 09c0794f1a Refactored rename_authors
Bugfixes from tests
2024-06-29 09:50:00 +02:00
Ozzie Isaacs b28a3635f8 Merge branch 'master' into Develop
# Conflicts:
#	cps/editbooks.py
#	requirements.txt
#	setup.cfg
#	test/Calibre-Web TestSummary_Linux.html
2024-06-24 14:42:00 +02:00
Ozzie Isaacs 1a1f1498a5 Bugfixes from Tests 2024-06-24 14:39:28 +02:00
Ozzie Isaacs 92ffce1dc8 Rename Archived in Book Detail page 2024-06-23 13:47:54 +02:00
Ozzie Isaacs 6441f5f96b Prevent non unique authors on upload 2024-06-23 13:07:28 +02:00
Ozzie Isaacs d5a57e3b07 Merge remote-tracking branch 'mimetype/python_magic_poc' into Develop 2024-06-23 12:16:40 +02:00
Ozzie Isaacs fef20c831a Changed limiter message 2024-06-22 16:23:04 +02:00
Ozzie Isaacs e52e8c6fdf Merge branch 'master' into Develop 2024-06-22 16:07:01 +02:00
Ozzie Isaacs 89ac9ff498 Updated requirements 2024-06-22 13:35:53 +02:00
Ozzie Isaacs 059bdf1649 Bugfix from code cosmetics 2024-06-22 13:31:18 +02:00
Ozzie Isaacs 266ef11940 Bugfix for gevent as server 2024-06-22 13:30:32 +02:00
Ozzie Isaacs 64e4a1155c Code cosmetics
Bugfix missing bleach tags (#3080)
Bugfix pdfreader
2024-06-20 19:12:46 +02:00
Ozzie Isaacs f7d5480811 Make bleach mandatory 2024-06-15 08:18:38 +02:00
aptalca 7aa122e6cd Attempt pywin32 install only on Windows
Currently, doing a `pip install -r optional-requirements.txt` on a non-Windows platform results in an error due to pywin32 being a Windows only package.
2024-06-11 18:06:06 +02:00
Ozzie Isaacs cfdc07e6b2 Merge remote-tracking branch 'loglevel/handle_regions_in_locales' 2024-06-09 12:57:42 +02:00
Ozzie Isaacs 1ca1ac973a Merge remote-tracking branch 'loglevel/handle_regions_in_locales' into Develop 2024-06-09 12:57:30 +02:00
Ozzie Isaacs ba4235a39e Merge branch 'master' into Develop 2024-06-09 12:33:25 +02:00
Ozzie Isaacs 7295ebaee1 Merge remote-tracking branch 'loglevel/log_exceptions_for_own_modules_when_debug_logging_is_enabled' 2024-06-09 12:32:43 +02:00
Ozzie Isaacs d74ea7dcf2 Merge Text corrections 2024-06-09 12:30:44 +02:00
Ozzie Isaacs ee23ee97d9 Update pdf Reader to 4.3.136 2024-06-09 12:00:10 +02:00
Ozzie Isaacs c73ec52842 Update pdf Reader to 4.3.136 2024-06-09 11:59:38 +02:00
Ozzie Isaacs 0e3d3876f3 Merge branch 'master' into Develop 2024-06-09 11:10:20 +02:00
Ozzie Isaacs 76d086f536 Finish hinde_console_windows 2024-06-09 11:10:01 +02:00
Ozzie Isaacs 6022f48040 Hide console on Windows after start 2024-06-09 11:09:29 +02:00
Ozzie Isaacs e0be168779 Fix 2024-06-09 10:34:20 +02:00
Ozzie Isaacs d3118c0aa9 Merge remote-tracking branch 'it/master' 2024-06-03 20:41:52 +02:00
mapi68 04370944b9 Fix many italian strings 2024-06-01 05:55:41 +02:00
yunimoo af52748aca Update requirements to add python-magic 2024-05-31 18:08:04 -04:00
yunimoo 7eece7603b Add mime_type checks on file uploads 2024-05-31 18:08:04 -04:00
yunimoo d6aa29b095 Fix typos and text consistency 2024-05-31 14:30:58 -04:00
Purbayan Chowdhury 5d86bb0ba0 Merge branch 'janeczku:Develop' into Develop 2024-05-23 01:45:36 +05:30
Ozzie Isaacs ab11919c0b Merge remote-tracking branch 'Synctoken/fix/kobo-sync-token' 2024-05-12 17:56:49 +02:00
Ozzie Isaacs 014a247847 Merge remote-tracking branch 'Synctoken/fix/kobo-sync-token' into Develop 2024-05-12 17:56:19 +02:00
Ozzie Isaacs 58c269881f Merge remote-tracking branch 'barnesnnoble/db-barnesnoble' 2024-05-12 09:03:40 +02:00
Ozzie Isaacs e91dd5729a Finish OPDS-access 2024-05-12 09:01:00 +02:00
Ozzie Isaacs 6760d6971c Fix for #2983 (visible OPDS categories reflect settings in web UI)
Merge branch 'feature/OPDS-access'
2024-05-12 08:57:55 +02:00
Kreeblah ad05534ed2 Add support for barnesnoble identifier 2024-05-11 23:56:37 -07:00
Ozzie Isaacs ee451fb236 Testrun 2024-05-12 08:56:20 +02:00
Ozzie Isaacs ab13fcf60c Merge remote-tracking branch 'origin/feature/OPDS-access' into feature/OPDS-access 2024-05-11 18:37:10 +02:00
Ozzie Isaacs c7fa2ac71a Merge branch 'master' into Develop 2024-05-11 18:27:51 +02:00
Ozzie Isaacs 6f60ec7b99 Change order of imports for goodreads to make import error message clear agan 2024-05-11 18:27:35 +02:00
Ozzie Isaacs 93f1ccbea9 Merge branch 'master' into Develop 2024-05-11 18:21:29 +02:00
Ozzie Isaacs 894fd9d30a get rid of apscheduler timezone warning 2024-05-11 18:21:03 +02:00
Ozzie Isaacs 2b1efdb50e Bugfixes opds feed 2024-05-11 14:26:43 +02:00
Ozzie Isaacs fc9a9cb9ac Fix for #3016 (Parsing lubimyczytac: Tags instead of categories are taken, translator is appended to description) 2024-05-11 09:03:22 +02:00
Ozzie Isaacs e99be72ff7 Merge branch 'Develop' into feature/OPDS-access
# Conflicts:
#	test/Calibre-Web TestSummary_Linux.html
2024-05-11 07:12:46 +02:00
Ozzie Isaacs f1ceff2b52 Merge branch 'master' into Develop (goodreads) 2024-05-11 07:11:22 +02:00
Ozzie Isaacs 7e85894b3a Bugfix for goodreads (html formated info for authors now visible) 2024-05-11 07:10:41 +02:00
Ozzie Isaacs 5c49c8cdd7 Fix for Flask-SimpleLDAP 2.0.0 2024-05-10 20:23:41 +02:00
Ozzie Isaacs c8c3b3cba3 Fix for goodreads not working anymore (due to blocked requests calls by goodreads.com) 2024-05-10 15:24:24 +02:00
Ozzie Isaacs 4911843146 Bugfix for flask-ldap 2.0.0 2024-05-10 11:30:47 +02:00
Ozzie Isaacs 2c37546598 Code cosmetics 2024-05-10 10:41:04 +02:00
Ozzie Isaacs 25a875b628 Fix for goodreads blocking "requests" 2024-05-10 09:42:44 +02:00
Ozzie Isaacs 506f0a33cf Merge branch 'master' into Develop (apply fix for #3050) 2024-05-10 09:06:11 +02:00
Ozzie Isaacs 921caf6716 Fix for #3050 (metadata extraction for cb7 files not working) 2024-05-10 09:05:31 +02:00
Ozzie Isaacs 60ed1904f5 testrun 2024-05-10 09:04:50 +02:00
Ozzie Isaacs cb62d36e44 Bugfix for visibility opds feed 2024-05-07 08:27:55 +02:00
Ozzie Isaacs 737d758362 Return 404 if current element is not visible 2024-05-07 07:30:57 +02:00
Ozzie Isaacs 8e27912ff5 Merge branch 'master' into Develop
Bugfix for too new lxml
2024-05-07 07:16:54 +02:00
Ozzie Isaacs 3a603cec22 Handle error on uploading a book with lxml too new and no bleach, nh3 installation 2024-05-05 11:18:31 +02:00
eggy b1d7badef4 fix: change b64-encoded token to unicode string 2024-05-02 03:06:53 -04:00
Ozzie Isaacs e591211b57 Fix for #3037 (catch OError on detect epub layout) 2024-04-27 07:22:58 +02:00
Ozzie Isaacs a305c35de4 Merge remote-tracking branch 'it/patch-1' 2024-04-27 07:22:23 +02:00
growfrow 51d306b11d chore: fix some typos in comments
Signed-off-by: growfrow <growfrow@outlook.com>
2024-04-20 20:49:56 +02:00
mapi68 abb418fe86 Update messages.po 2024-04-17 07:45:40 +02:00
Ozzie Isaacs 0925f34557 Merge remote-tracking branch 'caliblur/issue/caliBlur-3002' 2024-04-07 18:51:00 +02:00
Ozzie Isaacs 15952a764c Fix for #3021 (speed of calculating number of pages) 2024-04-07 18:49:18 +02:00
Chris Arderne a48a047287 update basic css to work on older kindles 2024-04-04 09:59:49 +01:00
Chris Arderne ec8afde6b2 add basic home button 2024-04-03 13:26:56 +01:00
Chris Arderne 458576a141 add pagination to basic 2024-04-03 13:12:39 +01:00
Chris Arderne ae5ccf6e91 fix pagination jinja custom function 2024-04-03 13:12:31 +01:00
Chris Arderne 51eb6bddf6 improve basic CSS, add cover image 2024-04-03 10:40:13 +01:00
Chris Arderne 57759e378c Merge branch 'janeczku:master' into master 2024-04-03 10:52:44 +02:00
Daniel Edwards 28c810b514 feat: base is_debug on effective level vs root log level 2024-03-19 11:05:04 +11:00
Daniel Edwards cfe61a26d0 Refactor get_language_name function to handle missing language names 2024-03-19 10:54:48 +11:00
Ozzie Isaacs fcc95bd895 Improvements for password verify (addresses: https://github.com/iiab/calibre-web/pull/138) 2024-03-10 16:39:51 +01:00
Ghighi Eftimie 964e7de920 fixes 3002 2024-03-06 15:42:10 +02:00
Ozzie Isaacs 14b578dd3a Implement correct password verification of Umlaunts, kyrillic, greek.. charactersets, CJK-Characters, and special characters (#2964) 2024-02-29 12:08:58 +01:00
Ozzie Isaacs becb84a73d Finish Fix_Umlauts 2024-02-29 12:06:48 +01:00
Ozzie Isaacs c901ccbb01 Improved js password strength check
Improved check of CJK-Characters
2024-02-29 11:48:07 +01:00
Chris Arderne aecc68d11e add basic view for kindle 2024-02-28 18:41:02 +00:00
Ozzie Isaacs f987fb0aba Fixed recognition of capital umlauts 2024-02-27 19:48:33 +01:00
Ozzie Isaacs c30460d76b Merge branch 'Develop'
- Back function for delete and edit books
- configure ratelimiter backend possible
- embed metadata during send to ereader
- bugfixes split library
- updated requirements
2024-02-27 06:03:54 +01:00
Ozzie Isaacs 97380b4b3f Updated teststatus 2024-02-27 06:01:11 +01:00
Ozzie Isaacs 4fbd064b85 Merge remote-tracking branch 'origin/back' into Develop 2024-02-26 18:42:48 +01:00
Ozzie Isaacs abbd9a5888 Revert logging line termination feature 2024-02-26 18:07:24 +01:00
Ozzie Isaacs e860b4e097 Back function implemented for delete book and edit book 2024-02-26 15:07:59 +01:00
Ozzie Isaacs 23a8a4657d Merge branch 'master' into Develop
(Fix for #3005 and #2993)
2024-02-25 20:07:40 +01:00
Ozzie Isaacs b38a1b2298 Admin can now force full sync for users (fix for #2993 2024-02-25 20:03:38 +01:00
Ozzie Isaacs 0ebfba8d05 Added blobs to csp for reader page (fix for #3005) 2024-02-25 19:32:04 +01:00
Ozzie Isaacs 990ad8d72d Update Requirements 2024-02-25 16:11:57 +01:00
Ozzie Isaacs c3fc125501 Added command line option or overwriting limiter backend
Added logger functions to remove newlines in messages
CalibreTask has now a default name
2024-02-25 16:02:01 +01:00
Ozzie Isaacs 3c4ed0de1a Added ratelimiterbackends 2024-02-25 09:00:49 +01:00
Ozzie Isaacs 117c92233d Added sending email to embed metadata text
Updated test result
2024-02-25 06:54:35 +01:00
Ozzie Isaacs 2ba14acf4f Merge branch 'master' into Develop
# Conflicts:
#	cps/helper.py
2024-02-24 14:51:53 +01:00
Ozzie Isaacs 80a2d07009 fix embed metadata, split library and download file / convert files 2024-02-24 14:48:56 +01:00
Ozzie Isaacs ff9e1ed7c8 Implemented embed metadata on send to ereader 2024-02-24 13:59:49 +01:00
Ozzie Isaacs 8e5bee5352 Merge remote-tracking branch 'embed_metadata/embed_metadata_on_send' into Develop 2024-02-18 14:48:04 +01:00
Ozzie Isaacs d659430116 Bugfix view None ratings caused error 500 in certain cases 2024-02-18 14:17:43 +01:00
Ozzie Isaacs 859dac462b Update Teststatus 2024-02-18 13:29:03 +01:00
Ozzie Isaacs 2bea4dbd06 Show List with no file formats available
Bugfix save order of format list view
2024-02-17 11:13:49 +01:00
Ozzie Isaacs 0180b4b6b5 Better error handling on next parameter 2024-02-12 20:58:26 +01:00
Ozzie Isaacs 2bfb02c448 Updated requirements
Updated testresults
2024-02-11 07:31:57 +01:00
Ozzie Isaacs 4864254e37 Merge branch 'Develop'
# Conflicts:
#	cps/config_sql.py
2024-02-10 10:55:02 +01:00
Ozzie Isaacs 09dce28a0e Merge remote-tracking branch 'embed_metadata/embed_metadata_on_convert' into Develop 2024-02-10 10:54:29 +01:00
Ozzie Isaacs e55d09d8bb Merge branch 'Develop' into embed_metadata_on_convert 2024-02-10 10:52:16 +01:00
Ozzie Isaacs 92c162b2fd Merge remote-tracking branch 'embed_metadata/embed_metadata_on_download'
# Conflicts:
#	cps/helper.py
#	cps/tasks/convert.py
#	cps/templates/config_edit.html
2024-02-10 10:49:09 +01:00
Ozzie Isaacs 57fb5001e2 Merge branch 'Develop' 2024-02-10 10:43:58 +01:00
Ozzie Isaacs 64e5314148 Merge remote-tracking branch 'identifier/master' into Develop 2024-02-10 10:31:57 +01:00
Ozzie Isaacs 873602a5c9 Deleted unused code 2024-02-10 10:30:41 +01:00
Ozzie Isaacs 09e966e18a Merge remote-tracking branch 'optimize_details_html/only-render-cc-with-data' into Develop 2024-02-10 10:28:36 +01:00
Ozzie Isaacs f7718cae0c Fix typo in send password message 2024-02-10 10:07:10 +01:00
Ozzie Isaacs 90e728516c Merge remote-tracking branch 'it/master' 2024-02-10 10:04:01 +01:00
Ozzie Isaacs 7c04b68c88 Merge remote-tracking branch 'icon/master' 2024-02-10 10:01:16 +01:00
Ozzie Isaacs 8549689a0f Merge branch 'Develop'
Separate library from metadata file
embed metadata on download
2024-02-10 09:57:28 +01:00
Ozzie Isaacs d8f5c17518 Mark separate library as highly experimental 2024-02-10 09:56:32 +01:00
mapi68 05367d2df5 Update messages.po 2024-01-25 07:46:31 +01:00
Webysther Sperandio eb6fbfc90c HiDPI icons 2024-01-23 04:24:21 +01:00
Ozzie Isaacs c2267b6902 Bugfix add to shelf 2024-01-22 13:17:27 +01:00
Ozzie Isaacs 0e5520a261 Bugfix add to shelf 2024-01-22 13:16:58 +01:00
Ozzie Isaacs 6f5e9f167e Fix typo 2024-01-21 14:42:39 +01:00
Ozzie Isaacs ce83fb6816 Update bug_report.md 2024-01-21 14:37:12 +01:00
Ozzie Isaacs fbfb7adef6 Update issue templates 2024-01-21 14:33:50 +01:00
Ozzie Isaacs cc52ad5d27 Added Notice 2024-01-21 14:27:38 +01:00
Ozzie Isaacs 706b9c4013 Improved errorhandling for adding invalid book_id to shelf 2024-01-21 08:12:49 +01:00
Ozzie Isaacs 6972c1b841 Improved errorhandling for adding invalid book_id to shelf 2024-01-21 08:12:28 +01:00
Ozzie Isaacs b9c329535d Fix for #2980 (invalid seriesindex causes no longer a crash) 2024-01-18 19:41:46 +01:00
Ozzie Isaacs 8fdf7a94ab Updated testresult 2024-01-18 19:37:33 +01:00
Ozzie Isaacs 31a344b410 Bugfix from testrun 2024-01-17 20:29:47 +01:00
Ozzie Isaacs 3814fbf08f Bugfixes from testrun 2024-01-14 14:28:08 +01:00
Ozzie Isaacs ffc13a5565 Merge branch 'master' into Develop 2024-01-13 14:48:09 +01:00
Ozzie Isaacs 74c61d9685 Merge branch 'metadata' into Develop 2024-01-13 14:47:48 +01:00
Ozzie Isaacs b8031cd53f Add possibility to replace kepub metadata on download 2024-01-13 14:31:42 +01:00
Ozzie Isaacs 898e76fc37 re enable gevent 2024-01-13 12:32:54 +01:00
Ozzie Isaacs af71a1a2ed Fix for #2973 (No unix sockets on windows available) 2024-01-13 12:26:53 +01:00
Ozzie Isaacs e0327db08f Enable embedd metadata of kepub file during conversion 2024-01-08 20:51:38 +01:00
Purbayan Chowdhury 05032aed3c Updating theme logic and persisting theme change 2024-01-08 16:56:13 +05:30
Ozzie Isaacs bf2ac97c47 Update setup.cfg 2024-01-07 13:28:31 +01:00
Ozzie Isaacs 902fa254b0 Merge branch 'master' into Develop 2024-01-07 08:03:40 +01:00
Ozzie Isaacs f0cc93abd3 Sanitze username for logging 2024-01-06 16:08:14 +01:00
Ozzie Isaacs 977f07364b Fix for #2961 (empty comment with newline causes error 500 on upload)
Language of error message for kobo sync improved
2024-01-06 16:08:14 +01:00
Ozzie Isaacs 00acd745f4 Fix tornado deprecation warning 2024-01-06 16:08:14 +01:00
Ozzie Isaacs d272f43424 Show error message if user is missing download permission for kobo sync 2024-01-06 16:08:14 +01:00
Whatever Cloud 7a8d8375d0 Refactor detail.html to optimize custom_column rendering
The div with class "real_custom_columns" is now only rendered if the custom_column data exists, removing blank space in details view.
2024-01-01 20:38:05 +01:00
Johannes H 3aa75ef4a7 fix: link to Linux Mind installation in README.md
the link the wiki page had an typo (missing '-') https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-in-Linux-Mint-19-or-20
2023-12-30 14:19:39 +01:00
Ozzie Isaacs 25fb8d934f Merge remote-tracking branch 'nl/master' 2023-12-21 13:47:02 +01:00
GONCALVES Nelson (T0025615) f08c8faaff Generate identifiers in opf metadata file 2023-12-21 11:02:45 +01:00
Michiel Cornelissen bc0ebdb78d Updated Dutch translation. 2023-12-20 21:52:09 +01:00
Ozzie Isaacs 2a4b3cb7af Update Teststatus
Add hint for calibre binary
2023-12-17 08:04:16 +01:00
Ozzie Isaacs 4401cf66d1 Remove remains of ie8 support 2023-12-16 17:23:20 +01:00
Ozzie Isaacs d353c9b6d3 Fix #2945 (handle illegal multibyte sequence) 2023-12-16 10:48:49 +01:00
Ozzie Isaacs 0aba96c032 Update Teststatus 2023-12-16 10:31:36 +01:00
Ozzie Isaacs c60b7e9192 Update teststatus 2023-12-14 07:09:50 +01:00
Ozzie Isaacs 23033255b8 Bugfix updater 2023-12-12 18:15:48 +01:00
Ozzie Isaacs 31c8909dea Merge branch 'Develop' into metadata -> Now all tests should run "pass" now
# Conflicts:
#	cps/constants.py
#	cps/helper.py
#	cps/tasks/convert.py
#	test/Calibre-Web TestSummary_Linux.html
2023-12-11 14:23:12 +01:00
Ozzie Isaacs 9ef89dbcc3 Bugfix convert file from gdrive without cover
Update Teststatus
2023-12-10 15:23:26 +01:00
Ozzie Isaacs 1086296d1d Make embed metadata configurable 2023-12-09 11:29:11 +01:00
Ozzie Isaacs d341faf204 Make Version setuptools compatible and still have the "Beta" in the User interface 2023-12-09 09:36:28 +01:00
Ozzie Isaacs 2334e8f9c9 Refactored calibre executable detection for better error messages 2023-12-07 16:47:10 +01:00
Ozzie Isaacs 90ad570578 Show only folders for selecting converter binaries 2023-12-07 16:22:34 +01:00
Ozzie Isaacs fd90d6e375 Delete temp dir not shown in tasks overview
Handle case no cover during convert ebook
2023-12-06 19:33:22 +01:00
Ozzie Isaacs 7fbbb85f47 Temp folder is deleted on regular base 2023-12-04 19:26:43 +01:00
Ozzie Isaacs 52c7557878 Merge remote-tracking branch 'caliblur/issue/caliBlur2931'
Updated optional requirements
2023-12-04 19:05:39 +01:00
Ozzie Isaacs 794cd354ca Added ToDo's 2023-12-04 18:57:50 +01:00
Ghighi Eftimie 389e3f09f5 small fix for mobile resolution - add to shelf button 2023-12-04 16:40:56 +02:00
Ghighi Eftimie 285979b68d fix for 2931 2023-12-04 16:00:00 +02:00
Ozzie Isaacs 3a012c900e Merge branch 'master' into Develop 2023-11-28 18:00:26 +01:00
Ozzie Isaacs ec45de3212 Merge remote-tracking branch 'douban/master' 2023-11-28 17:59:48 +01:00
Ozzie Isaacs f644a2a136 Merge remote-tracking branch 'kobo_cover/patch-1' 2023-11-28 17:56:36 +01:00
Russell 01108aac42 Remove trailing slash
Signed-off-by: Russell <russell@troxel.io>
2023-11-26 22:01:26 -08:00
Russell Troxel 400c745692 Update KOBO_IMAGEHOST_URL to new CDN endpoint
Kobo has introduced a vanity URL to their Akamai CDN config - "https://https://cdn.kobo.com". As a result, requests sent to the old raw Akamai endpoint now through 404s. This is a simple PR that updates to the new URL.

This is visible in the config of a newly bought Kobo Sage (mine):
```
image_host=https://cdn.kobo.com/book-images/
image_url_quality_template=https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg
image_url_template=https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg
```

Example:

OLD:
https://kbimages1-a.akamaihd.net/1abfb307-457b-4597-b2ea-74beb2eb1478/149/223/false/image.jpg
(Throws 404)

New:
https://cdn.kobo.com/book-images/1abfb307-457b-4597-b2ea-74beb2eb1478/149/223/false/image.jpg
(Renders Properly)

fixes #2371 
ref #2731
2023-11-26 14:31:24 -08:00
ye 9841a4d068 fix the the problem that metadata_provider/douban.py can't get tags info 2023-11-26 09:06:25 +08:00
Ozzie Isaacs 7fd1d10fca Implement gdrive metadata on download 2023-11-11 15:26:05 +01:00
Ozzie Isaacs 4f6bbfa8b8 Little refactoring do_download 2023-11-11 15:00:12 +01:00
Ozzie Isaacs cf6810db87 Refactored get_temp_dir
bugfixes export_metadata
2023-11-11 14:48:59 +01:00
Ozzie Isaacs 5afff2231e Fix for #2743 (read status is no longer implicitly added to advanced search query) 2023-11-11 10:43:50 +01:00
Ozzie Isaacs d611582b78 Fix for #2743 (read status is no longer implicitly added to advanced search query) 2023-11-11 10:43:23 +01:00
Ozzie Isaacs 3bbd8ee27e Use belach or nh3 for cleaning html (fix for #2874) 2023-11-09 17:59:20 +01:00
Ozzie Isaacs f78e0ff938 Use belach or nh3 for cleaning html (fix for #2874) 2023-11-09 17:59:06 +01:00
Ozzie Isaacs bd71391bfb Possible fix for #2849 (404 error during sync request) 2023-11-09 17:43:02 +01:00
Ozzie Isaacs 20b2936cc1 Merge branch 'master' into Develop 2023-11-09 17:41:39 +01:00
Ozzie Isaacs 19825a635a Possible fix for 2849 (404 error during sync request) 2023-11-09 17:41:20 +01:00
Ozzie Isaacs 0d611d35de Updated Testresult
Updated requirements
2023-11-09 06:39:25 +01:00
Ozzie Isaacs effd026fe2 Merge branch 'master' into Develop 2023-11-08 20:36:49 +01:00
Ozzie Isaacs d68e57c4fc Implement split library and books
Bugfix arrows in comic reader
Fix kobo download link
Updated requirement
2023-11-08 20:11:03 +01:00
Ozzie Isaacs 184ce23351 Catch error with tornado on restart 2023-11-06 18:05:36 +01:00
Ozzie Isaacs 2fbc3da451 Added Slovak translation 2023-11-06 16:51:08 +01:00
Ozzie Isaacs fad6550ff1 Show "all" opds feed entries only if there is at least one entry 2023-11-06 16:35:39 +01:00
Ozzie Isaacs b7aaa0f24d Add metadata change code 2023-11-02 17:05:02 +01:00
Ozzie Isaacs 5040bb762c Merge remote-tracking branch 'vi/master' 2023-11-02 16:35:54 +01:00
Ozzie Isaacs 55deca1ec8 Catch additional errors on logfile
Fix error on exception in session management
2023-11-02 16:34:04 +01:00
Ozzie Isaacs 40a16f4717 Reenabled gevent as server 2023-11-01 07:47:29 +01:00
Ozzie Isaacs d26e60724a Socket listening and socket activation enabled on tornado
Refactoring of server code
Updates requirements
Improved requirements parsing for versions containing letters
2023-10-30 14:39:22 +01:00
Ozzie Isaacs d877fa1c68 Merge remote-tracking branch 'socket-activation/socket-activated' 2023-10-29 08:20:03 +01:00
Ozzie Isaacs d55bafdfa9 Added hint for beta release format 2023-10-28 19:10:44 +02:00
Ozzie Isaacs a2a431802a Added prc to supported convert from formats (#2801)
Improved error message for not able to listen to ipv6 address with gevent
2023-10-28 13:49:16 +02:00
Ozzie Isaacs b2e4907165 Merge branch 'master' into Develop 2023-10-28 09:06:51 +02:00
Ozzie Isaacs 6c2e40f544 Added favicon in opds feed
Merge remote-tracking branch 'favicon/master'
2023-10-28 09:05:24 +02:00
Ozzie Isaacs 5e3d0ec2ad Fix location of config files for executable files (#2917) 2023-10-28 08:48:13 +02:00
Ozzie Isaacs c550d6c90d Fix comic reader arrow visiblilty for one paged comic files
Changed logging during kobo sync
2023-10-28 08:48:13 +02:00
bacpd 3b1d0b4013 Update messages.po 2023-10-25 15:08:10 +07:00
Ozzie Isaacs 3d07efbb4f Update Italian and German translation 2023-10-21 15:47:35 +02:00
mapi68 c0ae5bb381 Update messages.po 2023-10-21 15:34:09 +02:00
Ozzie Isaacs 6e755a26f9 Version update
Updated security file
2023-10-21 14:37:21 +02:00
Ozzie Isaacs e32312b54a Merge branch 'master' into Develop 2023-10-14 15:53:10 +02:00
Ozzie Isaacs d7ea569e5d Merge remote-tracking branch 'upload_text/patch-1' into Develop 2023-10-14 15:52:37 +02:00
databoy2k b3d1558df8 Add Favicon to OPDS Feed
<icon> is a standard of the OPDS spec, and this commit adds the favicon to the feed. Certain OPDS readers, e.g. FBReader on Windows, respect this tag and will show the same icon as the main page.
2023-09-08 13:09:40 -06:00
PhracturedBlue 074e611705 Add support for systemd socket-activation 2023-08-23 20:52:05 -07:00
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
Thore Schillmann 9bcbe523d7 (draft) metadata embedding when sending to device 2022-07-22 08:58:28 +00:00
Thore Schillmann e176d63ca6 Merge branch 'embed_metadata_on_convert' into embed_metadata_on_download 2022-07-14 13:39:58 +00:00
Thore Schillmann 80b0e88650 fix for GDrive integration 2022-07-14 13:34:42 +00:00
Thore Schillmann 0b4731913e created do_calibre_export function 2022-07-14 09:25:37 +00:00
Thore Schillmann fc7ce8da2d cleanup 2022-07-13 15:39:01 +00: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
Thore Schillmann c89bc12c9b Merge branch 'embed_metadata_on_convert' into embed_metadata_on_download 2022-07-07 14:09:15 +00:00
Thore Schillmann 4913673e8f added subprocess.wait() when getting metadata 2022-07-07 11:47:10 +00:00
Thore Schillmann fc004f4f0c moved get_calibre_binarypath() to helper.py 2022-07-07 11:41:51 +00:00
Thore Schillmann c5c3874243 first implementation 2022-07-01 16:04:25 +00:00
Thore Schillmann 0d34f41a48 cleanup of autodetect_converter_binary 2022-07-01 12:06:33 +00:00
Thore Schillmann a77aef83c6 automatically set config_converterpath 2022-06-30 13:05:36 +00:00
Thore Schillmann e39c6130c3 cleanup and better error handling 2022-06-29 19:54:53 +00:00
Thore Schillmann 03359599ed input validation for calibre binary directory 2022-06-23 20:02:54 +00:00
Thore Schillmann 3c4330ba51 refactoring of calibre binary detection 2022-06-21 17:04:44 +00:00
Thore Schillmann 8c781ad4a4 code cleanup and implements cover change 2022-06-19 23:20:59 +00:00
Thore Schillmann 5e9ec706c5 first draft of embedding metadata on conversion 2022-06-19 22:59:54 +00:00
gjutras cb0d9ec95c Allow kindle_email to be CSV to allow sending to multiple kindles 2022-03-28 19:59:59 -04:00
458 changed files with 140564 additions and 130214 deletions
+17 -1
View File
@@ -6,12 +6,23 @@ labels: ''
assignees: '' assignees: ''
--- ---
<!-- Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md) -->
## 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.
Please also have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
**Describe the bug/problem** **Describe the bug/problem**
A clear and concise description of what the bug is. If you are asking for support, please check our [Wiki](https://github.com/janeczku/calibre-web/wiki) if your question is already answered there. A clear and concise description of what the bug is. If you are asking for support, please check our [Wiki](https://github.com/janeczku/calibre-web/wiki) if your question is already answered there.
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
@@ -19,15 +30,19 @@ Steps to reproduce the behavior:
4. See error 4. See error
**Logfile** **Logfile**
Add content of calibre-web.log file or the relevant error, try to reproduce your problem with "debug" log-level to get more output. Add content of calibre-web.log file or the relevant error, try to reproduce your problem with "debug" log-level to get more output.
**Expected behavior** **Expected behavior**
A clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.
**Screenshots** **Screenshots**
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):** **Environment (please complete the following information):**
- OS: [e.g. Windows 10/Raspberry Pi OS] - OS: [e.g. Windows 10/Raspberry Pi OS]
- Python version: [e.g. python2.7] - Python version: [e.g. python2.7]
- Calibre-Web version: [e.g. 0.6.8 or 087c4c59 (git rev-parse --short HEAD)]: - Calibre-Web version: [e.g. 0.6.8 or 087c4c59 (git rev-parse --short HEAD)]:
@@ -37,3 +52,4 @@ If applicable, add screenshots to help explain your problem.
**Additional context** **Additional context**
Add any other context about the problem here. [e.g. access via reverse proxy, database background sync, special database location] Add any other context about the problem here. [e.g. access via reverse proxy, database background sync, special database location]
+8 -1
View File
@@ -7,7 +7,14 @@ assignees: ''
--- ---
<!-- Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md) --> # 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.
Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
**Is your feature request related to a problem? Please describe.** **Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+5 -4
View File
@@ -65,7 +65,7 @@ Calibre-Web is a web app that offers a clean and intuitive interface for browsin
*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.* *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.*
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). 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).
## Quick Start ## Quick Start
@@ -82,8 +82,9 @@ Refer to the Wiki for additional installation examples: [manual installation](ht
## Requirements ## Requirements
- Python 3.5+ - Python 3.7+
- [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) - [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)
- Windows users need to install [libmagic for 32bit python](https://gnuwin32.sourceforge.net/downlinks/file.php) or [libmagic for 64bit python](https://github.com/nscaife/file-windows/releases/tag/20170108), depending on the python version; The files need to be installed in path (e.g. script folder of your Calibre-Web venv, or in the root folder of Calibre-Web
- 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: [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) - 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)
@@ -100,8 +101,8 @@ Pre-built Docker images are available in the following Docker Hub repositories (
Both the Calibre-Web and Calibre-Mod images are automatically rebuilt on new releases and updates. Both the Calibre-Web and Calibre-Mod images are automatically rebuilt on new releases and updates.
- Set "path to convertertool" to `/usr/bin/ebook-convert` - Set "Path to Calibre Binaries" to `/usr/bin`
- Set "path to unrar" to `/usr/bin/unrar` - Set "Path to Unrar" to `/usr/bin/unrar`
## Contributor Recognition ## Contributor Recognition
+13 -1
View File
@@ -11,7 +11,7 @@ To receive fixes for security vulnerabilities it is required to always upgrade t
## History ## History
| Fixed in | Description |CVE number | | Fixed in | Description |CVE number |
|---------------|--------------------------------------------------------------------------------------------------------------------|---------| |---------------|--------------------------------------------------------------------------------------------------------------------------------|---------|
| 3rd July 2018 | Guest access acts as a backdoor || | 3rd July 2018 | Guest access acts as a backdoor ||
| V 0.6.7 | Hardcoded secret key for sessions |CVE-2020-12627 | | 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 | Calibre-Web Metadata cross site scripting |CVE-2021-25964|
@@ -38,6 +38,18 @@ To receive fixes for security vulnerabilities it is required to always upgrade t
| 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 | 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 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.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) ## Statement regarding Log4j (CVE-2021-44228 and related)
+16
View File
@@ -27,7 +27,23 @@ sys.path.insert(0, path)
from cps.main import main from cps.main import main
def hide_console_windows():
import ctypes
kernel32 = ctypes.WinDLL('kernel32')
user32 = ctypes.WinDLL('user32')
SW_HIDE = 0
hWnd = kernel32.GetConsoleWindow()
if hWnd:
user32.ShowWindow(hWnd, SW_HIDE)
if __name__ == '__main__': if __name__ == '__main__':
if os.name == "nt":
hide_console_windows()
main() main()
+4 -19
View File
@@ -20,11 +20,9 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from .cw_login import LoginManager
from flask import session
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
class MyLoginManager(LoginManager): class MyLoginManager(LoginManager):
def _session_protection_failed(self): def _session_protection_failed(self):
@@ -35,18 +33,5 @@ class MyLoginManager(LoginManager):
return super(). _session_protection_failed() return super(). _session_protection_failed()
return False 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
+45 -33
View File
@@ -56,23 +56,29 @@ except ImportError:
mimetypes.init() mimetypes.init()
mimetypes.add_type('application/xhtml+xml', '.xhtml') mimetypes.add_type('application/xhtml+xml', '.xhtml')
mimetypes.add_type('application/epub+zip', '.epub') mimetypes.add_type('application/epub+zip', '.epub')
mimetypes.add_type('application/fb2+zip', '.fb2') mimetypes.add_type('application/epub+zip', '.kepub')
mimetypes.add_type('application/x-mobipocket-ebook', '.mobi') mimetypes.add_type('text/xml', '.fb2')
mimetypes.add_type('application/x-mobipocket-ebook', '.prc') mimetypes.add_type('application/octet-stream', '.mobi')
mimetypes.add_type('application/octet-stream', '.prc')
mimetypes.add_type('application/vnd.amazon.ebook', '.azw') mimetypes.add_type('application/vnd.amazon.ebook', '.azw')
mimetypes.add_type('application/x-mobi8-ebook', '.azw3') mimetypes.add_type('application/x-mobi8-ebook', '.azw3')
mimetypes.add_type('application/x-cbr', '.cbr') mimetypes.add_type('application/x-rar', '.cbr')
mimetypes.add_type('application/x-cbz', '.cbz') mimetypes.add_type('application/zip', '.cbz')
mimetypes.add_type('application/x-cbt', '.cbt') mimetypes.add_type('application/x-tar', '.cbt')
mimetypes.add_type('application/x-cb7', '.cb7') mimetypes.add_type('application/x-7z-compressed', '.cb7')
mimetypes.add_type('image/vnd.djv', '.djv') mimetypes.add_type('image/vnd.djv', '.djv')
mimetypes.add_type('image/vnd.djv', '.djvu')
mimetypes.add_type('application/mpeg', '.mpeg') mimetypes.add_type('application/mpeg', '.mpeg')
mimetypes.add_type('application/mpeg', '.mp3') mimetypes.add_type('audio/mpeg', '.mp3')
mimetypes.add_type('application/mp4', '.m4a') mimetypes.add_type('audio/x-m4a', '.m4a')
mimetypes.add_type('application/mp4', '.m4b') mimetypes.add_type('audio/x-m4a', '.m4b')
mimetypes.add_type('application/ogg', '.ogg') 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('application/ogg', '.oga')
mimetypes.add_type('text/css', '.css') mimetypes.add_type('text/css', '.css')
mimetypes.add_type('application/x-ms-reader', '.lit')
mimetypes.add_type('text/javascript; charset=UTF-8', '.js') mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
log = logger.create() log = logger.create()
@@ -80,9 +86,11 @@ log = logger.create()
app = Flask(__name__) app = Flask(__name__)
app.config.update( app.config.update(
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Lax', SESSION_COOKIE_SAMESITE='Strict',
REMEMBER_COOKIE_SAMESITE='Lax', # will be available in flask-login 0.5.1 earliest REMEMBER_COOKIE_SAMESITE='Strict',
WTF_CSRF_SSL_STRICT=False 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() lm = MyLoginManager()
@@ -103,10 +111,11 @@ web_server = WebServer()
updater_thread = Updater() updater_thread = Updater()
if limiter_present: if limiter_present:
limiter = Limiter(key_func=True, headers_enabled=True, auto_check=False, swallow_errors=True) limiter = Limiter(key_func=True, headers_enabled=True, auto_check=False, swallow_errors=False)
else: else:
limiter = None limiter = None
def create_app(): def create_app():
if csrf: if csrf:
csrf.init_app(app) csrf.init_app(app)
@@ -125,13 +134,6 @@ def create_app():
ub.password_change(cli_param.user_credentials) ub.password_change(cli_param.user_credentials)
if not limiter:
log.info('*** "flask-limiter" is needed for calibre-web to run. '
'Please install it using pip: "pip install flask-limiter" ***')
print('*** "flask-limiter" is needed for calibre-web to run. '
'Please install it using pip: "pip install flask-limiter" ***')
web_server.stop(True)
sys.exit(8)
if sys.version_info < (3, 0): if sys.version_info < (3, 0):
log.info( log.info(
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, ' '*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, '
@@ -141,13 +143,6 @@ def create_app():
'please update your installation to Python3 ***') 'please update your installation to Python3 ***')
web_server.stop(True) web_server.stop(True)
sys.exit(5) sys.exit(5)
if not wtf_present:
log.info('*** "flask-WTF" is needed for calibre-web to run. '
'Please install it using pip: "pip install flask-WTF" ***')
print('*** "flask-WTF" is needed for calibre-web to run. '
'Please install it using pip: "pip install flask-WTF" ***')
web_server.stop(True)
sys.exit(7)
lm.login_view = 'web.login' lm.login_view = 'web.login'
lm.anonymous_user = ub.Anonymous lm.anonymous_user = ub.Anonymous
@@ -158,13 +153,21 @@ def create_app():
calibre_db.init_db() calibre_db.init_db()
updater_thread.init_updater(config, web_server) updater_thread.init_updater(config, web_server)
# Perform dry run of updater and exit afterwards # Perform dry run of updater and exit afterward
if cli_param.dry_run: if cli_param.dry_run:
updater_thread.dry_run() updater_thread.dry_run()
sys.exit(0) sys.exit(0)
updater_thread.start() updater_thread.start()
requirements = dependency_check()
for res in dependency_check() + dependency_check(True): for res in requirements:
if res['found'] == "not installed":
message = ('Cannot import {name} module, it is needed to run calibre-web, '
'please install it using "pip install {name}"').format(name=res["name"])
log.info(message)
print("*** " + message + " ***")
web_server.stop(True)
sys.exit(8)
for res in requirements + dependency_check(True):
log.info('*** "{}" version does not meet the requirements. ' log.info('*** "{}" version does not meet the requirements. '
'Should: {}, Found: {}, please consider installing required version ***' 'Should: {}, Found: {}, please consider installing required version ***'
.format(res['name'], .format(res['name'],
@@ -192,11 +195,20 @@ def create_app():
services.ldap.init_app(app, config) services.ldap.init_app(app, config)
if services.goodreads_support: if services.goodreads_support:
services.goodreads_support.connect(config.config_goodreads_api_key, services.goodreads_support.connect(config.config_goodreads_api_key,
config.config_goodreads_api_secret_e,
config.config_use_goodreads) config.config_use_goodreads)
config.store_calibre_uuid(calibre_db, db.Library_Id) config.store_calibre_uuid(calibre_db, db.Library_Id)
# Configure rate limiter # Configure rate limiter
# https://limits.readthedocs.io/en/stable/storage.html
app.config.update(RATELIMIT_ENABLED=config.config_ratelimiter) app.config.update(RATELIMIT_ENABLED=config.config_ratelimiter)
if config.config_limiter_uri != "" and not cli_param.memory_backend:
app.config.update(RATELIMIT_STORAGE_URI=config.config_limiter_uri)
if config.config_limiter_options != "":
app.config.update(RATELIMIT_STORAGE_OPTIONS=config.config_limiter_options)
try:
limiter.init_app(app)
except Exception as e:
log.error('Wrong Flask Limiter configuration, falling back to default: {}'.format(e))
app.config.update(RATELIMIT_STORAGE_URI=None)
limiter.init_app(app) limiter.init_app(app)
# Register scheduled tasks # Register scheduled tasks
+9 -6
View File
@@ -23,15 +23,15 @@
import sys import sys
import platform import platform
import sqlite3 import sqlite3
import importlib
from collections import OrderedDict from collections import OrderedDict
import flask import flask
import flask_login
import jinja2
from flask_babel import gettext as _ from flask_babel import gettext as _
from . import db, calibre_db, converter, uploader, constants, dep_check from . import db, calibre_db, converter, uploader, constants, dep_check
from .render_template import render_title_template from .render_template import render_title_template
from .usermanagement import user_login_required
about = flask.Blueprint('about', __name__) about = flask.Blueprint('about', __name__)
@@ -41,17 +41,20 @@ req = dep_check.load_dependencies(False)
opt = dep_check.load_dependencies(True) opt = dep_check.load_dependencies(True)
for i in (req + opt): for i in (req + opt):
modules[i[1]] = i[0] modules[i[1]] = i[0]
modules['Jinja2'] = jinja2.__version__ modules['Jinja2'] = importlib.metadata.version("jinja2")
try:
modules['pySqlite'] = sqlite3.version modules['pySqlite'] = sqlite3.version
except Exception:
pass
modules['SQLite'] = sqlite3.sqlite_version modules['SQLite'] = sqlite3.sqlite_version
sorted_modules = OrderedDict((sorted(modules.items(), key=lambda x: x[0].casefold()))) sorted_modules = OrderedDict((sorted(modules.items(), key=lambda x: x[0].casefold())))
def collect_stats(): def collect_stats():
if constants.NIGHTLY_VERSION[0] == "$Format:%H$": if constants.NIGHTLY_VERSION[0] == "$Format:%H$":
calibre_web_version = constants.STABLE_VERSION['version'] calibre_web_version = constants.STABLE_VERSION['version'].replace("b", " Beta")
else: else:
calibre_web_version = (constants.STABLE_VERSION['version'] + ' - ' calibre_web_version = (constants.STABLE_VERSION['version'].replace("b", " Beta") + ' - '
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - ' + constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
+ constants.NIGHTLY_VERSION[1].replace('%', '%%')) + constants.NIGHTLY_VERSION[1].replace('%', '%%'))
@@ -74,7 +77,7 @@ def collect_stats():
@about.route("/stats") @about.route("/stats")
@flask_login.login_required @user_login_required
def stats(): def stats():
counter = calibre_db.session.query(db.Books).count() counter = calibre_db.session.query(db.Books).count()
authors = calibre_db.session.query(db.Authors).count() authors = calibre_db.session.query(db.Authors).count()
+114 -83
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 flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
from markupsafe import Markup 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 gettext as _
from flask_babel import get_locale, format_time, format_datetime, format_timedelta from flask_babel import get_locale, format_time, format_datetime, format_timedelta
from flask import session as flask_session
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
@@ -48,11 +47,14 @@ from . import db, calibre_db, ub, web_server, config, updater_thread, gdriveutil
kobo_sync_status, schedule kobo_sync_status, schedule
from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \ from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \
valid_email, check_username valid_email, check_username
from .embed_helper import get_calibre_binarypath
from .gdriveutils import is_gdrive_ready, gdrive_support from .gdriveutils import is_gdrive_ready, gdrive_support
from .render_template import render_title_template, get_sidebar_config from .render_template import render_title_template, get_sidebar_config
from .services.worker import WorkerThread from .services.worker import WorkerThread
from .usermanagement import user_login_required
from .babel import get_available_translations, get_available_locale, get_user_locale_language from .babel import get_available_translations, get_available_locale, get_user_locale_language
from . import debug_info from . import debug_info
from .string_helper import strip_whitespaces
log = logger.create() log = logger.create()
@@ -102,10 +104,13 @@ def admin_required(f):
@admi.before_app_request @admi.before_app_request
def before_request(): def before_request():
if not ub.check_user_session(current_user.id, #try:
flask_session.get('_id')) and 'opds' not in request.path \ #if not ub.check_user_session(current_user.id,
and config.config_session == 1: # flask_session.get('_id')) and 'opds' not in request.path \
logout_user() # and config.config_session == 1:
# logout_user()
#except AttributeError:
# pass # ? fails on requesting /ajax/emailstat during restart ?
g.constants = constants g.constants = constants
g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '') g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '')
g.allow_registration = config.config_public_reg g.allow_registration = config.config_public_reg
@@ -125,14 +130,14 @@ def before_request():
return redirect(url_for('admin.db_configuration')) return redirect(url_for('admin.db_configuration'))
@admi.route("/admin") #@admi.route("/admin")
@login_required #@user_login_required
def admin_forbidden(): #def admin_forbidden():
abort(403) # abort(403)
@admi.route("/shutdown", methods=["POST"]) @admi.route("/shutdown", methods=["POST"])
@login_required @user_login_required
@admin_required @admin_required
def shutdown(): def shutdown():
task = request.get_json().get('parameter', -1) task = request.get_json().get('parameter', -1)
@@ -161,7 +166,7 @@ def shutdown():
@admi.route("/metadata_backup", methods=["POST"]) @admi.route("/metadata_backup", methods=["POST"])
@login_required @user_login_required
@admin_required @admin_required
def queue_metadata_backup(): def queue_metadata_backup():
show_text = {} show_text = {}
@@ -185,7 +190,7 @@ def reconnect():
@admi.route("/ajax/updateThumbnails", methods=['POST']) @admi.route("/ajax/updateThumbnails", methods=['POST'])
@admin_required @admin_required
@login_required @user_login_required
def update_thumbnails(): def update_thumbnails():
content = config.get_scheduled_task_settings() content = config.get_scheduled_task_settings()
if content['schedule_generate_book_covers']: if content['schedule_generate_book_covers']:
@@ -195,7 +200,7 @@ def update_thumbnails():
@admi.route("/admin/view") @admi.route("/admin/view")
@login_required @user_login_required
@admin_required @admin_required
def admin(): def admin():
version = updater_thread.get_current_version_info() version = updater_thread.get_current_version_info()
@@ -214,7 +219,7 @@ def admin():
form_date += timedelta(hours=int(commit[20:22]), minutes=int(commit[23:])) form_date += timedelta(hours=int(commit[20:22]), minutes=int(commit[23:]))
commit = format_datetime(form_date - tz, format='short') commit = format_datetime(form_date - tz, format='short')
else: else:
commit = version['version'] commit = version['version'].replace("b", " Beta")
all_user = ub.session.query(ub.User).all() all_user = ub.session.query(ub.User).all()
# email_settings = mail_config.get_mail_settings() # email_settings = mail_config.get_mail_settings()
@@ -229,7 +234,7 @@ def admin():
@admi.route("/admin/dbconfig", methods=["GET", "POST"]) @admi.route("/admin/dbconfig", methods=["GET", "POST"])
@login_required @user_login_required
@admin_required @admin_required
def db_configuration(): def db_configuration():
if request.method == "POST": if request.method == "POST":
@@ -238,7 +243,7 @@ def db_configuration():
@admi.route("/admin/config", methods=["GET"]) @admi.route("/admin/config", methods=["GET"])
@login_required @user_login_required
@admin_required @admin_required
def configuration(): def configuration():
return render_title_template("config_edit.html", return render_title_template("config_edit.html",
@@ -249,28 +254,28 @@ def configuration():
@admi.route("/admin/ajaxconfig", methods=["POST"]) @admi.route("/admin/ajaxconfig", methods=["POST"])
@login_required @user_login_required
@admin_required @admin_required
def ajax_config(): def ajax_config():
return _configuration_update_helper() return _configuration_update_helper()
@admi.route("/admin/ajaxdbconfig", methods=["POST"]) @admi.route("/admin/ajaxdbconfig", methods=["POST"])
@login_required @user_login_required
@admin_required @admin_required
def ajax_db_config(): def ajax_db_config():
return _db_configuration_update_helper() return _db_configuration_update_helper()
@admi.route("/admin/alive", methods=["GET"]) @admi.route("/admin/alive", methods=["GET"])
@login_required @user_login_required
@admin_required @admin_required
def calibreweb_alive(): def calibreweb_alive():
return "", 200 return "", 200
@admi.route("/admin/viewconfig") @admi.route("/admin/viewconfig")
@login_required @user_login_required
@admin_required @admin_required
def view_configuration(): def view_configuration():
read_column = calibre_db.session.query(db.CustomColumns) \ read_column = calibre_db.session.query(db.CustomColumns) \
@@ -287,7 +292,7 @@ def view_configuration():
@admi.route("/admin/usertable") @admi.route("/admin/usertable")
@login_required @user_login_required
@admin_required @admin_required
def edit_user_table(): def edit_user_table():
visibility = current_user.view_settings.get('useredit', {}) visibility = current_user.view_settings.get('useredit', {})
@@ -322,7 +327,7 @@ def edit_user_table():
@admi.route("/ajax/listusers") @admi.route("/ajax/listusers")
@login_required @user_login_required
@admin_required @admin_required
def list_users(): def list_users():
off = int(request.args.get("offset") or 0) off = int(request.args.get("offset") or 0)
@@ -373,7 +378,7 @@ def list_users():
@admi.route("/ajax/deleteuser", methods=['POST']) @admi.route("/ajax/deleteuser", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def delete_user(): def delete_user():
user_ids = request.form.to_dict(flat=False) user_ids = request.form.to_dict(flat=False)
@@ -408,7 +413,7 @@ def delete_user():
@admi.route("/ajax/getlocale") @admi.route("/ajax/getlocale")
@login_required @user_login_required
@admin_required @admin_required
def table_get_locale(): def table_get_locale():
locale = get_available_locale() locale = get_available_locale()
@@ -420,7 +425,7 @@ def table_get_locale():
@admi.route("/ajax/getdefaultlanguage") @admi.route("/ajax/getdefaultlanguage")
@login_required @user_login_required
@admin_required @admin_required
def table_get_default_lang(): def table_get_default_lang():
languages = calibre_db.speaking_language() languages = calibre_db.speaking_language()
@@ -432,7 +437,7 @@ def table_get_default_lang():
@admi.route("/ajax/editlistusers/<param>", methods=['POST']) @admi.route("/ajax/editlistusers/<param>", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def edit_list_user(param): def edit_list_user(param):
vals = request.form.to_dict(flat=False) vals = request.form.to_dict(flat=False)
@@ -459,9 +464,9 @@ def edit_list_user(param):
if 'value[]' in vals: if 'value[]' in vals:
setattr(user, param, prepare_tags(user, vals['action'][0], param, vals['value[]'])) setattr(user, param, prepare_tags(user, vals['action'][0], param, vals['value[]']))
else: else:
setattr(user, param, vals['value'].strip()) setattr(user, param, strip_whitespaces(vals['value']))
else: else:
vals['value'] = vals['value'].strip() vals['value'] = strip_whitespaces(vals['value'])
if param == 'name': if param == 'name':
if user.name == "Guest": if user.name == "Guest":
raise Exception(_("Guest Name can't be changed")) raise Exception(_("Guest Name can't be changed"))
@@ -537,7 +542,7 @@ def edit_list_user(param):
@admi.route("/ajax/user_table_settings", methods=['POST']) @admi.route("/ajax/user_table_settings", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def update_table_settings(): def update_table_settings():
current_user.view_settings['useredit'] = json.loads(request.data) current_user.view_settings['useredit'] = json.loads(request.data)
@@ -554,7 +559,7 @@ def update_table_settings():
@admi.route("/admin/viewconfig", methods=["POST"]) @admi.route("/admin/viewconfig", methods=["POST"])
@login_required @user_login_required
@admin_required @admin_required
def update_view_configuration(): def update_view_configuration():
to_save = request.form.to_dict() to_save = request.form.to_dict()
@@ -562,7 +567,7 @@ def update_view_configuration():
_config_string(to_save, "config_calibre_web_title") _config_string(to_save, "config_calibre_web_title")
_config_string(to_save, "config_columns_to_ignore") _config_string(to_save, "config_columns_to_ignore")
if _config_string(to_save, "config_title_regex"): 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")): if not check_valid_read_column(to_save.get("config_read_column", "0")):
flash(_("Invalid Read Column"), category="error") flash(_("Invalid Read Column"), category="error")
@@ -599,7 +604,7 @@ def update_view_configuration():
@admi.route("/ajax/loaddialogtexts/<element_id>", methods=['POST']) @admi.route("/ajax/loaddialogtexts/<element_id>", methods=['POST'])
@login_required @user_login_required
def load_dialogtexts(element_id): def load_dialogtexts(element_id):
texts = {"header": "", "main": "", "valid": 1} texts = {"header": "", "main": "", "valid": 1}
if element_id == "config_delete_kobo_token": if element_id == "config_delete_kobo_token":
@@ -635,7 +640,7 @@ def load_dialogtexts(element_id):
@admi.route("/ajax/editdomain/<int:allow>", methods=['POST']) @admi.route("/ajax/editdomain/<int:allow>", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def edit_domain(allow): def edit_domain(allow):
# POST /post # POST /post
@@ -649,7 +654,7 @@ def edit_domain(allow):
@admi.route("/ajax/adddomain/<int:allow>", methods=['POST']) @admi.route("/ajax/adddomain/<int:allow>", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def add_domain(allow): def add_domain(allow):
domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower() domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower()
@@ -663,7 +668,7 @@ def add_domain(allow):
@admi.route("/ajax/deletedomain", methods=['POST']) @admi.route("/ajax/deletedomain", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def delete_domain(): def delete_domain():
try: try:
@@ -681,12 +686,12 @@ def delete_domain():
@admi.route("/ajax/domainlist/<int:allow>") @admi.route("/ajax/domainlist/<int:allow>")
@login_required @user_login_required
@admin_required @admin_required
def list_domain(allow): def list_domain(allow):
answer = ub.session.query(ub.Registration).filter(ub.Registration.allow == allow).all() 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]) 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 = make_response(js.replace("'", '"'))
response.headers["Content-Type"] = "application/json; charset=utf-8" response.headers["Content-Type"] = "application/json; charset=utf-8"
return response return response
@@ -694,7 +699,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>", defaults={"user_id": 0}, methods=['POST'])
@admi.route("/ajax/editrestriction/<int:res_type>/<int:user_id>", methods=['POST']) @admi.route("/ajax/editrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def edit_restriction(res_type, user_id): def edit_restriction(res_type, user_id):
element = request.form.to_dict() element = request.form.to_dict()
@@ -760,14 +765,14 @@ def edit_restriction(res_type, user_id):
@admi.route("/ajax/addrestriction/<int:res_type>", methods=['POST']) @admi.route("/ajax/addrestriction/<int:res_type>", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def add_user_0_restriction(res_type): def add_user_0_restriction(res_type):
return add_restriction(res_type, 0) return add_restriction(res_type, 0)
@admi.route("/ajax/addrestriction/<int:res_type>/<int:user_id>", methods=['POST']) @admi.route("/ajax/addrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def add_restriction(res_type, user_id): def add_restriction(res_type, user_id):
element = request.form.to_dict() element = request.form.to_dict()
@@ -813,14 +818,14 @@ def add_restriction(res_type, user_id):
@admi.route("/ajax/deleterestriction/<int:res_type>", methods=['POST']) @admi.route("/ajax/deleterestriction/<int:res_type>", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def delete_user_0_restriction(res_type): def delete_user_0_restriction(res_type):
return delete_restriction(res_type, 0) return delete_restriction(res_type, 0)
@admi.route("/ajax/deleterestriction/<int:res_type>/<int:user_id>", methods=['POST']) @admi.route("/ajax/deleterestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def delete_restriction(res_type, user_id): def delete_restriction(res_type, user_id):
element = request.form.to_dict() element = request.form.to_dict()
@@ -868,7 +873,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>", defaults={"user_id": 0})
@admi.route("/ajax/listrestriction/<int:res_type>/<int:user_id>") @admi.route("/ajax/listrestriction/<int:res_type>/<int:user_id>")
@login_required @user_login_required
@admin_required @admin_required
def list_restriction(res_type, user_id): def list_restriction(res_type, user_id):
if res_type == 0: # Tags as template if res_type == 0: # Tags as template
@@ -912,21 +917,32 @@ def list_restriction(res_type, user_id):
@admi.route("/ajax/fullsync", methods=["POST"]) @admi.route("/ajax/fullsync", methods=["POST"])
@login_required @user_login_required
def ajax_fullsync(): def ajax_self_fullsync():
count = ub.session.query(ub.KoboSyncedBooks).filter(current_user.id == ub.KoboSyncedBooks.user_id).delete() return do_full_kobo_sync(current_user.id)
message = _("{} sync entries deleted").format(count)
ub.session_commit(message)
return Response(json.dumps([{"type": "success", "message": message}]), mimetype='application/json') @admi.route("/ajax/fullsync/<int:userid>", methods=["POST"])
@user_login_required
@admin_required
def ajax_fullsync(userid):
return do_full_kobo_sync(userid)
@admi.route("/ajax/pathchooser/") @admi.route("/ajax/pathchooser/")
@login_required @user_login_required
@admin_required @admin_required
def ajax_pathchooser(): def ajax_pathchooser():
return pathchooser() return pathchooser()
def do_full_kobo_sync(userid):
count = ub.session.query(ub.KoboSyncedBooks).filter(userid == ub.KoboSyncedBooks.user_id).delete()
message = _("{} sync entries deleted").format(count)
ub.session_commit(message)
return Response(json.dumps([{"type": "success", "message": message}]), mimetype='application/json')
def check_valid_read_column(column): def check_valid_read_column(column):
if column != "0": if column != "0":
if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \ if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
@@ -984,10 +1000,7 @@ def get_drives(current):
for d in string.ascii_uppercase: for d in string.ascii_uppercase:
if os.path.exists('{}:'.format(d)) and current[0].lower() != d.lower(): if os.path.exists('{}:'.format(d)) and current[0].lower() != d.lower():
drive = "{}:\\".format(d) drive = "{}:\\".format(d)
data = {"name": drive, "fullpath": drive} data = {"name": drive, "fullpath": drive, "type": "dir", "size": "", "sort": "_" + drive.lower()}
data["sort"] = "_" + data["fullpath"].lower()
data["type"] = "dir"
data["size"] = ""
drive_letters.append(data) drive_letters.append(data)
return drive_letters return drive_letters
@@ -1088,7 +1101,7 @@ def _config_checkbox_int(to_save, x):
def _config_string(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): def _configuration_gdrive_helper(to_save):
@@ -1234,7 +1247,7 @@ def _configuration_ldap_helper(to_save):
@admi.route("/ajax/simulatedbchange", methods=['POST']) @admi.route("/ajax/simulatedbchange", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def simulatedbchange(): def simulatedbchange():
db_change, db_valid = _db_simulate_change() db_change, db_valid = _db_simulate_change()
@@ -1242,7 +1255,7 @@ def simulatedbchange():
@admi.route("/admin/user/new", methods=["GET", "POST"]) @admi.route("/admin/user/new", methods=["GET", "POST"])
@login_required @user_login_required
@admin_required @admin_required
def new_user(): def new_user():
content = ub.User() content = ub.User()
@@ -1264,7 +1277,7 @@ def new_user():
@admi.route("/admin/mailsettings", methods=["GET"]) @admi.route("/admin/mailsettings", methods=["GET"])
@login_required @user_login_required
@admin_required @admin_required
def edit_mailsettings(): def edit_mailsettings():
content = config.get_mail_settings() content = config.get_mail_settings()
@@ -1273,7 +1286,7 @@ def edit_mailsettings():
@admi.route("/admin/mailsettings", methods=["POST"]) @admi.route("/admin/mailsettings", methods=["POST"])
@login_required @user_login_required
@admin_required @admin_required
def update_mailsettings(): def update_mailsettings():
to_save = request.form.to_dict() to_save = request.form.to_dict()
@@ -1299,9 +1312,9 @@ def update_mailsettings():
if to_save.get("mail_password_e", ""): if to_save.get("mail_password_e", ""):
_config_string(to_save, "mail_password_e") _config_string(to_save, "mail_password_e")
_config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024) _config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024)
config.mail_server = to_save.get('mail_server', "").strip() config.mail_server = strip_whitespaces(to_save.get('mail_server', ""))
config.mail_from = to_save.get('mail_from', "").strip() config.mail_from = strip_whitespaces(to_save.get('mail_from', ""))
config.mail_login = to_save.get('mail_login', "").strip() config.mail_login = strip_whitespaces(to_save.get('mail_login', ""))
try: try:
config.save() config.save()
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
@@ -1330,7 +1343,7 @@ def update_mailsettings():
@admi.route("/admin/scheduledtasks") @admi.route("/admin/scheduledtasks")
@login_required @user_login_required
@admin_required @admin_required
def edit_scheduledtasks(): def edit_scheduledtasks():
content = config.get_scheduled_task_settings() content = config.get_scheduled_task_settings()
@@ -1351,7 +1364,7 @@ def edit_scheduledtasks():
@admi.route("/admin/scheduledtasks", methods=["POST"]) @admi.route("/admin/scheduledtasks", methods=["POST"])
@login_required @user_login_required
@admin_required @admin_required
def update_scheduledtasks(): def update_scheduledtasks():
error = False error = False
@@ -1394,7 +1407,7 @@ def update_scheduledtasks():
@admi.route("/admin/user/<int:user_id>", methods=["GET", "POST"]) @admi.route("/admin/user/<int:user_id>", methods=["GET", "POST"])
@login_required @user_login_required
@admin_required @admin_required
def edit_user(user_id): def edit_user(user_id):
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
@@ -1423,7 +1436,7 @@ def edit_user(user_id):
@admi.route("/admin/resetpassword/<int:user_id>", methods=["POST"]) @admi.route("/admin/resetpassword/<int:user_id>", methods=["POST"])
@login_required @user_login_required
@admin_required @admin_required
def reset_user_password(user_id): def reset_user_password(user_id):
if current_user is not None and current_user.is_authenticated: if current_user is not None and current_user.is_authenticated:
@@ -1441,7 +1454,7 @@ def reset_user_password(user_id):
@admi.route("/admin/logfile") @admi.route("/admin/logfile")
@login_required @user_login_required
@admin_required @admin_required
def view_logfile(): def view_logfile():
logfiles = {0: logger.get_logfile(config.config_logfile), logfiles = {0: logger.get_logfile(config.config_logfile),
@@ -1455,7 +1468,7 @@ def view_logfile():
@admi.route("/ajax/log/<int:logtype>") @admi.route("/ajax/log/<int:logtype>")
@login_required @user_login_required
@admin_required @admin_required
def send_logfile(logtype): def send_logfile(logtype):
if logtype == 1: if logtype == 1:
@@ -1471,7 +1484,7 @@ def send_logfile(logtype):
@admi.route("/admin/logdownload/<int:logtype>") @admi.route("/admin/logdownload/<int:logtype>")
@login_required @user_login_required
@admin_required @admin_required
def download_log(logtype): def download_log(logtype):
if logtype == 0: if logtype == 0:
@@ -1486,14 +1499,14 @@ def download_log(logtype):
@admi.route("/admin/debug") @admi.route("/admin/debug")
@login_required @user_login_required
@admin_required @admin_required
def download_debug(): def download_debug():
return debug_info.send_debug() return debug_info.send_debug()
@admi.route("/get_update_status", methods=['GET']) @admi.route("/get_update_status", methods=['GET'])
@login_required @user_login_required
@admin_required @admin_required
def get_update_status(): def get_update_status():
if feature_support['updater']: if feature_support['updater']:
@@ -1504,7 +1517,7 @@ def get_update_status():
@admi.route("/get_updater_status", methods=['GET', 'POST']) @admi.route("/get_updater_status", methods=['GET', 'POST'])
@login_required @user_login_required
@admin_required @admin_required
def get_updater_status(): def get_updater_status():
status = {} status = {}
@@ -1599,7 +1612,7 @@ def ldap_import_create_user(user, user_data):
@admi.route('/import_ldap_users', methods=["POST"]) @admi.route('/import_ldap_users', methods=["POST"])
@login_required @user_login_required
@admin_required @admin_required
def import_ldap_users(): def import_ldap_users():
showtext = {} showtext = {}
@@ -1616,7 +1629,10 @@ def import_ldap_users():
imported = 0 imported = 0
for username in new_users: for username in new_users:
if isinstance(username, bytes):
user = username.decode('utf-8') user = username.decode('utf-8')
else:
user = username
if '=' in user: if '=' in user:
# if member object field is empty take user object as filter # if member object field is empty take user object as filter
if config.config_ldap_member_user_object: if config.config_ldap_member_user_object:
@@ -1651,7 +1667,7 @@ def import_ldap_users():
@admi.route("/ajax/canceltask", methods=['POST']) @admi.route("/ajax/canceltask", methods=['POST'])
@login_required @user_login_required
@admin_required @admin_required
def cancel_task(): def cancel_task():
task_id = request.get_json().get('task_id', None) task_id = request.get_json().get('task_id', None)
@@ -1663,10 +1679,10 @@ def cancel_task():
def _db_simulate_change(): def _db_simulate_change():
param = request.form.to_dict() param = request.form.to_dict()
to_save = 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'], 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"], db_valid, db_change = calibre_db.check_valid_db(to_save["config_calibre_dir"],
ub.app_DB_path, ub.app_DB_path,
config.config_calibre_uuid) config.config_calibre_uuid)
@@ -1700,6 +1716,13 @@ def _db_configuration_update_helper():
db_change = True db_change = True
except Exception as ex: except Exception as ex:
return _db_configuration_result('{}'.format(ex), gdrive_error) 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 \ if db_change or not db_valid or not config.db_configured \
or config.config_calibre_dir != to_save["config_calibre_dir"]: or config.config_calibre_dir != to_save["config_calibre_dir"]:
@@ -1725,6 +1748,7 @@ def _db_configuration_update_helper():
calibre_db.update_config(config) calibre_db.update_config(config)
if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK): if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK):
flash(_("DB is not Writeable"), category="warning") flash(_("DB is not Writeable"), category="warning")
calibre_db.update_config(config)
config.save() config.save()
return _db_configuration_result(None, gdrive_error) return _db_configuration_result(None, gdrive_error)
@@ -1745,6 +1769,7 @@ def _configuration_update_helper():
_config_checkbox_int(to_save, "config_uploading") _config_checkbox_int(to_save, "config_uploading")
_config_checkbox_int(to_save, "config_unicode_filename") _config_checkbox_int(to_save, "config_unicode_filename")
_config_checkbox_int(to_save, "config_embed_metadata")
# Reboot on config_anonbrowse with enabled ldap, as decoraters are changed in this case # Reboot on config_anonbrowse with enabled ldap, as decoraters are changed in this case
reboot_required |= (_config_checkbox_int(to_save, "config_anonbrowse") reboot_required |= (_config_checkbox_int(to_save, "config_anonbrowse")
and config.config_login_type == constants.LOGIN_LDAP) and config.config_login_type == constants.LOGIN_LDAP)
@@ -1756,13 +1781,18 @@ def _configuration_update_helper():
if "config_upload_formats" in to_save: if "config_upload_formats" in to_save:
to_save["config_upload_formats"] = ','.join( 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") _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_calibre")
_config_string(to_save, "config_converterpath") _config_string(to_save, "config_binariesdir")
_config_string(to_save, "config_kepubifypath") _config_string(to_save, "config_kepubifypath")
if "config_binariesdir" in to_save:
calibre_status = helper.check_calibre(config.config_binariesdir)
if calibre_status:
return _configuration_result(calibre_status)
to_save["config_converterpath"] = get_calibre_binarypath("ebook-convert")
_config_string(to_save, "config_converterpath")
reboot_required |= _config_int(to_save, "config_login_type") reboot_required |= _config_int(to_save, "config_login_type")
@@ -1781,11 +1811,8 @@ def _configuration_update_helper():
# Goodreads configuration # Goodreads configuration
_config_checkbox(to_save, "config_use_goodreads") _config_checkbox(to_save, "config_use_goodreads")
_config_string(to_save, "config_goodreads_api_key") _config_string(to_save, "config_goodreads_api_key")
if to_save.get("config_goodreads_api_secret_e", ""):
_config_string(to_save, "config_goodreads_api_secret_e")
if services.goodreads_support: if services.goodreads_support:
services.goodreads_support.connect(config.config_goodreads_api_key, services.goodreads_support.connect(config.config_goodreads_api_key,
config.config_goodreads_api_secret_e,
config.config_use_goodreads) config.config_use_goodreads)
_config_int(to_save, "config_updatechannel") _config_int(to_save, "config_updatechannel")
@@ -1805,10 +1832,12 @@ def _configuration_update_helper():
reboot_required |= reboot reboot_required |= reboot
# security configuration # security configuration
_config_checkbox(to_save, "config_check_extensions")
_config_checkbox(to_save, "config_password_policy") _config_checkbox(to_save, "config_password_policy")
_config_checkbox(to_save, "config_password_number") _config_checkbox(to_save, "config_password_number")
_config_checkbox(to_save, "config_password_lower") _config_checkbox(to_save, "config_password_lower")
_config_checkbox(to_save, "config_password_upper") _config_checkbox(to_save, "config_password_upper")
_config_checkbox(to_save, "config_password_character")
_config_checkbox(to_save, "config_password_special") _config_checkbox(to_save, "config_password_special")
if 0 < int(to_save.get("config_password_min_length", "0")) < 41: if 0 < int(to_save.get("config_password_min_length", "0")) < 41:
_config_int(to_save, "config_password_min_length") _config_int(to_save, "config_password_min_length")
@@ -1816,6 +1845,8 @@ def _configuration_update_helper():
return _configuration_result(_('Password length has to be between 1 and 40')) return _configuration_result(_('Password length has to be between 1 and 40'))
reboot_required |= _config_int(to_save, "config_session") reboot_required |= _config_int(to_save, "config_session")
reboot_required |= _config_checkbox(to_save, "config_ratelimiter") reboot_required |= _config_checkbox(to_save, "config_ratelimiter")
reboot_required |= _config_string(to_save, "config_limiter_uri")
reboot_required |= _config_string(to_save, "config_limiter_options")
# Rarfile Content configuration # Rarfile Content configuration
_config_string(to_save, "config_rarfile_location") _config_string(to_save, "config_rarfile_location")
+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=[],
)
+2 -1
View File
@@ -2,7 +2,7 @@ from babel import negotiate_locale
from flask_babel import Babel, Locale from flask_babel import Babel, Locale
from babel.core import UnknownLocaleError from babel.core import UnknownLocaleError
from flask import request from flask import request
from flask_login import current_user from .cw_login import current_user
from . import logger from . import logger
@@ -10,6 +10,7 @@ log = logger.create()
babel = Babel() babel = Babel()
def get_locale(): def get_locale():
# if a user is logged in, use the locale from the user settings # if a user is logged in, use the locale from the user settings
if current_user is not None and hasattr(current_user, "locale"): if current_user is not None and hasattr(current_user, "locale"):
+90
View File
@@ -0,0 +1,90 @@
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
# apetresc, nanu-c, mutschler, carderne
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from cps.pagination import Pagination
from flask import Blueprint
from flask_babel import gettext as _
from flask_babel import get_locale
from flask import request, redirect, url_for
from . import logger, isoLanguages
from . import db, config
from . import calibre_db
from .usermanagement import login_required_if_no_ano
from .render_template import render_title_template
from .web import get_sort_function
try:
from natsort import natsorted as sort
except ImportError:
sort = sorted # Just use regular sort then, may cause issues with badly named pages in cbz/cbr files
basic = Blueprint('basic', __name__)
log = logger.create()
@basic.route("/basic", methods=["GET"])
@login_required_if_no_ano
def index():
term = request.args.get("query", "") # default to showing all books
limit = 15
page = int(request.args.get("page") or 1)
off = (page - 1) * limit
order = get_sort_function("stored", "search")
join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series
entries, result_count, pagination = calibre_db.get_search_results(term,
config,
off,
order,
limit,
*join)
return render_title_template('basic_index.html',
searchterm=term,
pagination=pagination,
query=term,
adv_searchterm=term,
entries=entries,
result_count=result_count,
title=_("Search"),
page="search",
order=order[1])
@basic.route("/basic_book/<int:book_id>")
@login_required_if_no_ano
def show_book(book_id):
entries = calibre_db.get_book_read_archived(book_id, config.config_read_column, allow_show_archived=True)
if entries:
entry = entries[0]
for lang_index in range(0, len(entry.languages)):
entry.languages[lang_index].language_name = isoLanguages.get_language_name(get_locale(), entry.languages[
lang_index].lang_code)
entry.ordered_authors = calibre_db.order_authors([entry])
return render_title_template('basic_detail.html',
entry=entry,
is_xhr=request.headers.get('X-Requested-With') == 'XMLHttpRequest',
title=entry.title,
page="book")
else:
log.debug("Selected book is unavailable. File does not exist or is not accessible")
return redirect(url_for("basic.index"))
+48
View File
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018-2019 OzzieIsaacs
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from . import logger
from lxml.etree import ParserError
log = logger.create()
try:
# at least bleach 6.0 is needed -> incomplatible change from list arguments to set arguments
from bleach import clean as clean_html
from bleach.sanitizer import ALLOWED_TAGS
bleach = True
except ImportError:
from nh3 import clean as clean_html
bleach = False
def clean_string(unsafe_text, book_id=0):
try:
if bleach:
allowed_tags = list(ALLOWED_TAGS)
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)
except ParserError as e:
log.error("Comments of book {} are corrupted: {}".format(book_id, e))
safe_text = ""
except TypeError as e:
log.error("Comments can't be parsed, maybe 'lxml' is too new, try installing 'bleach': {}".format(e))
safe_text = ""
return safe_text
+28 -12
View File
@@ -29,12 +29,25 @@ from .constants import DEFAULT_SETTINGS_FILE, DEFAULT_GDRIVE_FILE
def version_info(): def version_info():
if _NIGHTLY_VERSION[1].startswith('$Format'): if _NIGHTLY_VERSION[1].startswith('$Format'):
return "Calibre-Web version: %s - unknown git-clone" % _STABLE_VERSION['version'] return "Calibre-Web version: %s - unknown git-clone" % _STABLE_VERSION['version'].replace("b", " Beta")
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'], _NIGHTLY_VERSION[1]) return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'].replace("b", " Beta"), _NIGHTLY_VERSION[1])
class CliParameter(object): class CliParameter(object):
def __init__(self):
self.user_credentials = None
self.ip_address = None
self.allow_localhost = None
self.reconnect_enable = None
self.memory_backend = None
self.dry_run = None
self.certfilepath = None
self.keyfilepath = None
self.gd_path = None
self.settings_path = None
self.logpath = None
def init(self): def init(self):
self.arg_parser() self.arg_parser()
@@ -44,21 +57,25 @@ class CliParameter(object):
prog='cps.py') prog='cps.py')
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db') parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db') parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db')
parser.add_argument('-c', metavar='path', help='path and name to SSL certfile, e.g. /opt/test.cert, ' parser.add_argument('-c', metavar='path', help='path and name to SSL certfile, '
'works only in combination with keyfile') 'e.g. /opt/test.cert, works only in combination with keyfile')
parser.add_argument('-k', metavar='path', help='path and name to SSL keyfile, e.g. /opt/test.key, ' parser.add_argument('-k', metavar='path', help='path and name to SSL keyfile, e.g. /opt/test.key, '
'works only in combination with certfile') 'works only in combination with certfile')
parser.add_argument('-o', metavar='path', help='path and name Calibre-Web logfile') parser.add_argument('-o', metavar='path', help='path and name Calibre-Web logfile')
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web', parser.add_argument('-v', '--version', action='version', help='Shows version number '
'and exits Calibre-Web',
version=version_info()) version=version_info())
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen') parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
parser.add_argument('-m', action='store_true',
help='Use Memory-backend as limiter backend, use this parameter '
'in case of miss configured backend')
parser.add_argument('-s', metavar='user:pass', parser.add_argument('-s', metavar='user:pass',
help='Sets specific username to new password and exits Calibre-Web') help='Sets specific username to new password and exits Calibre-Web')
parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version')
parser.add_argument('-l', action='store_true', help='Allow loading covers from localhost') parser.add_argument('-l', action='store_true', help='Allow loading covers from localhost')
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions in advance ' parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions '
'and exits Calibre-Web') 'in advance and exits Calibre-Web')
parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect') parser.add_argument('-r', action='store_true', help='Enable public database reconnect '
'route under /reconnect')
args = parser.parse_args() args = parser.parse_args()
self.logpath = args.o or "" self.logpath = args.o or ""
@@ -98,6 +115,8 @@ class CliParameter(object):
if args.k == "": if args.k == "":
self.keyfilepath = "" self.keyfilepath = ""
# overwrite limiter backend
self.memory_backend = args.m or None
# dry run updater # dry run updater
self.dry_run = args.d or None self.dry_run = args.d or None
# enable reconnect endpoint for docker database reconnect # enable reconnect endpoint for docker database reconnect
@@ -127,6 +146,3 @@ class CliParameter(object):
if self.user_credentials and ":" not in self.user_credentials: if self.user_credentials and ":" not in self.user_credentials:
print("No valid 'username:password' format") print("No valid 'username:password' format")
sys.exit(3) sys.exit(3)
if args.f:
print("Warning: -f flag is depreciated and will be removed in next version")
+12 -5
View File
@@ -102,7 +102,7 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
extension = ext[1].lower() extension = ext[1].lower()
if extension in cover.COVER_EXTENSIONS: if extension in cover.COVER_EXTENSIONS:
try: try:
cover_data = cf.read(name)[name].read() cover_data = cf.read([name])[name].read()
except (py7zr.Bad7zFile, OSError) as ex: except (py7zr.Bad7zFile, OSError) as ex:
log.error('7Zip file failed with error: {}'.format(ex)) log.error('7Zip file failed with error: {}'.format(ex))
break break
@@ -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) 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: if use_comic_meta:
try: try:
archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable) 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 "" lang = loaded_metadata.language or ""
loaded_metadata.language = isoLanguages.get_lang3(lang) 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( return BookMeta(
file_path=tmp_file_path, file_path=tmp_file_path,
extension=original_file_extension, extension=original_file_extension,
title=loaded_metadata.title or original_file_name, title=loaded_metadata.title or original_file_name,
author=" & ".join([credit["person"] author=" & ".join([credit["person"]
for credit in loaded_metadata.credits if credit["role"] == "Writer"]) or 'Unknown', 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 "", description=loaded_metadata.comments or "",
tags="", tags="",
series=loaded_metadata.series or "", series=loaded_metadata.series or "",
@@ -171,13 +174,17 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
publisher="", publisher="",
pubdate="", pubdate="",
identifiers=[]) identifiers=[])
if not no_cover_processing:
cover_file = _extract_cover(tmp_file_path, original_file_extension, rar_executable)
else:
cover_file = None
return BookMeta( return BookMeta(
file_path=tmp_file_path, file_path=tmp_file_path,
extension=original_file_extension, extension=original_file_extension,
title=original_file_name, title=original_file_name,
author='Unknown', author='Unknown',
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable), cover=cover_file,
description="", description="",
tags="", tags="",
series="", series="",
+58 -31
View File
@@ -34,7 +34,8 @@ except ImportError:
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from . import constants, logger from . import constants, logger
from .subproc_wrapper import process_wait
from .string_helper import strip_whitespaces
log = logger.create() log = logger.create()
_Base = declarative_base() _Base = declarative_base()
@@ -47,6 +48,7 @@ class _Flask_Settings(_Base):
flask_session_key = Column(BLOB, default=b"") flask_session_key = Column(BLOB, default=b"")
def __init__(self, key): def __init__(self, key):
super().__init__()
self.flask_session_key = key self.flask_session_key = key
@@ -69,6 +71,8 @@ class _Settings(_Base):
config_calibre_dir = Column(String) config_calibre_dir = Column(String)
config_calibre_uuid = Column(String) config_calibre_uuid = Column(String)
config_calibre_split = Column(Boolean, default=False)
config_calibre_split_dir = Column(String)
config_port = Column(Integer, default=constants.DEFAULT_PORT) config_port = Column(Integer, default=constants.DEFAULT_PORT)
config_external_port = Column(Integer, default=constants.DEFAULT_PORT) config_external_port = Column(Integer, default=constants.DEFAULT_PORT)
config_certfile = Column(String) config_certfile = Column(String)
@@ -79,7 +83,9 @@ class _Settings(_Base):
config_random_books = Column(Integer, default=4) config_random_books = Column(Integer, default=4)
config_authors_max = Column(Integer, default=0) config_authors_max = Column(Integer, default=0)
config_read_column = Column(Integer, default=0) config_read_column = Column(Integer, default=0)
config_title_regex = Column(String, default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+') config_title_regex = Column(String,
default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine'
r'|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+')
config_theme = Column(Integer, default=0) config_theme = Column(Integer, default=0)
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL) config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
@@ -111,8 +117,6 @@ class _Settings(_Base):
config_use_goodreads = Column(Boolean, default=False) config_use_goodreads = Column(Boolean, default=False)
config_goodreads_api_key = Column(String) config_goodreads_api_key = Column(String)
config_goodreads_api_secret_e = Column(String)
config_goodreads_api_secret = Column(String)
config_register_email = Column(Boolean, default=False) config_register_email = Column(Boolean, default=False)
config_login_type = Column(Integer, default=0) config_login_type = Column(Integer, default=0)
@@ -138,10 +142,12 @@ class _Settings(_Base):
config_kepubifypath = Column(String, default=None) config_kepubifypath = Column(String, default=None)
config_converterpath = Column(String, default=None) config_converterpath = Column(String, default=None)
config_binariesdir = Column(String, default=None)
config_calibre = Column(String) config_calibre = Column(String)
config_rarfile_location = Column(String, default=None) config_rarfile_location = Column(String, default=None)
config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD)) config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD))
config_unicode_filename = Column(Boolean, default=False) config_unicode_filename = Column(Boolean, default=False)
config_embed_metadata = Column(Boolean, default=True)
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE) config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
@@ -160,9 +166,13 @@ class _Settings(_Base):
config_password_number = Column(Boolean, default=True) config_password_number = Column(Boolean, default=True)
config_password_lower = Column(Boolean, default=True) config_password_lower = Column(Boolean, default=True)
config_password_upper = Column(Boolean, default=True) config_password_upper = Column(Boolean, default=True)
config_password_character = Column(Boolean, default=True)
config_password_special = Column(Boolean, default=True) config_password_special = Column(Boolean, default=True)
config_session = Column(Integer, default=1) config_session = Column(Integer, default=1)
config_ratelimiter = Column(Boolean, default=True) config_ratelimiter = Column(Boolean, default=True)
config_limiter_uri = Column(String, default="")
config_limiter_options = Column(String, default="")
config_check_extensions = Column(Boolean, default=True)
def __repr__(self): def __repr__(self):
return self.__class__.__name__ return self.__class__.__name__
@@ -184,15 +194,17 @@ class ConfigSQL(object):
self.load() self.load()
change = False change = False
if self.config_converterpath == None: # pylint: disable=access-member-before-definition
change = True
self.config_converterpath = autodetect_calibre_binary()
if self.config_kepubifypath == None: # pylint: disable=access-member-before-definition if self.config_binariesdir is None:
change = True
self.config_binariesdir = autodetect_calibre_binaries()
self.config_converterpath = autodetect_converter_binary(self.config_binariesdir)
if self.config_kepubifypath is None:
change = True change = True
self.config_kepubifypath = autodetect_kepubify_binary() self.config_kepubifypath = autodetect_kepubify_binary()
if self.config_rarfile_location == None: # pylint: disable=access-member-before-definition if self.config_rarfile_location is None:
change = True change = True
self.config_rarfile_location = autodetect_unrar_binary() self.config_rarfile_location = autodetect_unrar_binary()
if change: if change:
@@ -256,19 +268,19 @@ class ConfigSQL(object):
def list_denied_tags(self): def list_denied_tags(self):
mct = self.config_denied_tags or "" 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): def list_allowed_tags(self):
mct = self.config_allowed_tags or "" 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): def list_denied_column_values(self):
mct = self.config_denied_column_value or "" 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): def list_allowed_column_values(self):
mct = self.config_allowed_column_value or "" 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): def get_log_level(self):
return logger.get_level_name(self.config_log_level) return logger.get_level_name(self.config_log_level)
@@ -340,7 +352,7 @@ class ConfigSQL(object):
db_file = os.path.join(self.config_calibre_dir, 'metadata.db') db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
have_metadata_db = os.path.isfile(db_file) have_metadata_db = os.path.isfile(db_file)
self.db_configured = have_metadata_db self.db_configured = have_metadata_db
constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
from . import cli_param from . import cli_param
if os.environ.get('FLASK_DEBUG'): if os.environ.get('FLASK_DEBUG'):
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG) logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
@@ -389,6 +401,9 @@ class ConfigSQL(object):
self.db_configured = False self.db_configured = False
self.save() self.save()
def get_book_path(self):
return self.config_calibre_split_dir if self.config_calibre_split_dir else self.config_calibre_dir
def store_calibre_uuid(self, calibre_db, Library_table): def store_calibre_uuid(self, calibre_db, Library_table):
try: try:
calibre_uuid = calibre_db.session.query(Library_table).one_or_none() calibre_uuid = calibre_db.session.query(Library_table).one_or_none()
@@ -409,23 +424,16 @@ def _encrypt_fields(session, secret_key):
except OperationalError: except OperationalError:
with session.bind.connect() as conn: with session.bind.connect() as conn:
conn.execute(text("ALTER TABLE settings ADD column 'mail_password_e' String")) conn.execute(text("ALTER TABLE settings ADD column 'mail_password_e' String"))
conn.execute(text("ALTER TABLE settings ADD column 'config_goodreads_api_secret_e' String"))
conn.execute(text("ALTER TABLE settings ADD column 'config_ldap_serv_password_e' String")) conn.execute(text("ALTER TABLE settings ADD column 'config_ldap_serv_password_e' String"))
session.commit() session.commit()
crypter = Fernet(secret_key) crypter = Fernet(secret_key)
settings = session.query(_Settings.mail_password, _Settings.config_goodreads_api_secret, settings = session.query(_Settings.mail_password, _Settings.config_ldap_serv_password).first()
_Settings.config_ldap_serv_password).first()
if settings.mail_password: if settings.mail_password:
session.query(_Settings).update( session.query(_Settings).update(
{_Settings.mail_password_e: crypter.encrypt(settings.mail_password.encode())}) {_Settings.mail_password_e: crypter.encrypt(settings.mail_password.encode())})
if settings.config_goodreads_api_secret:
session.query(_Settings).update(
{_Settings.config_goodreads_api_secret_e:
crypter.encrypt(settings.config_goodreads_api_secret.encode())})
if settings.config_ldap_serv_password: if settings.config_ldap_serv_password:
session.query(_Settings).update( session.query(_Settings).update(
{_Settings.config_ldap_serv_password_e: {_Settings.config_ldap_serv_password_e: crypter.encrypt(settings.config_ldap_serv_password.encode())})
crypter.encrypt(settings.config_ldap_serv_password.encode())})
session.commit() session.commit()
@@ -469,20 +477,38 @@ def _migrate_table(session, orm_class, secret_key=None):
session.rollback() session.rollback()
def autodetect_calibre_binary(): def autodetect_calibre_binaries():
if sys.platform == "win32": if sys.platform == "win32":
calibre_path = ["C:\\program files\\calibre\\ebook-convert.exe", calibre_path = ["C:\\program files\\calibre\\",
"C:\\program files(x86)\\calibre\\ebook-convert.exe", "C:\\program files(x86)\\calibre\\",
"C:\\program files(x86)\\calibre2\\ebook-convert.exe", "C:\\program files(x86)\\calibre2\\",
"C:\\program files\\calibre2\\ebook-convert.exe"] "C:\\program files\\calibre2\\"]
else: else:
calibre_path = ["/opt/calibre/ebook-convert"] calibre_path = ["/opt/calibre/"]
for element in calibre_path: for element in calibre_path:
if os.path.isfile(element) and os.access(element, os.X_OK): supported_binary_paths = [os.path.join(element, binary)
for binary in constants.SUPPORTED_CALIBRE_BINARIES.values()]
if all(os.path.isfile(binary_path) and os.access(binary_path, os.X_OK)
for binary_path in supported_binary_paths):
values = [process_wait([binary_path, "--version"],
pattern=r'\(calibre (.*)\)') for binary_path in supported_binary_paths]
if all(values):
version = values[0].group(1)
log.debug("calibre version %s", version)
return element return element
return "" return ""
def autodetect_converter_binary(calibre_path):
if sys.platform == "win32":
converter_path = os.path.join(calibre_path, "ebook-convert.exe")
else:
converter_path = os.path.join(calibre_path, "ebook-convert")
if calibre_path and os.path.isfile(converter_path) and os.access(converter_path, os.X_OK):
return converter_path
return ""
def autodetect_unrar_binary(): def autodetect_unrar_binary():
if sys.platform == "win32": if sys.platform == "win32":
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe", calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
@@ -523,7 +549,7 @@ def load_configuration(session, secret_key):
def get_flask_session_key(_session): def get_flask_session_key(_session):
flask_settings = _session.query(_Flask_Settings).one_or_none() flask_settings = _session.query(_Flask_Settings).one_or_none()
if flask_settings == None: if flask_settings is None:
flask_settings = _Flask_Settings(os.urandom(32)) flask_settings = _Flask_Settings(os.urandom(32))
_session.add(flask_settings) _session.add(flask_settings)
_session.commit() _session.commit()
@@ -534,6 +560,7 @@ def get_encryption_key(key_path):
key_file = os.path.join(key_path, ".key") key_file = os.path.join(key_path, ".key")
generate = True generate = True
error = "" error = ""
key = None
if os.path.exists(key_file) and os.path.getsize(key_file) > 32: if os.path.exists(key_file) and os.path.getsize(key_file) > 32:
with open(key_file, "rb") as f: with open(key_file, "rb") as f:
key = f.read() key = f.read()
+15 -5
View File
@@ -34,6 +34,8 @@ UPDATER_AVAILABLE = True
# Base dir is parent of current file, necessary if called from different folder # Base dir is parent of current file, necessary if called from different folder
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
# if executable file the files should be placed in the parent dir (parallel to the exe file)
STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static') STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static')
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates') TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations') TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
@@ -49,6 +51,9 @@ if HOME_CONFIG:
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', home_dir) CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', home_dir)
else: else:
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', BASE_DIR) CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', BASE_DIR)
if getattr(sys, 'frozen', False):
CONFIG_DIR = os.path.abspath(os.path.join(CONFIG_DIR, os.pardir))
DEFAULT_SETTINGS_FILE = "app.db" DEFAULT_SETTINGS_FILE = "app.db"
DEFAULT_GDRIVE_FILE = "gdrive.db" DEFAULT_GDRIVE_FILE = "gdrive.db"
@@ -144,17 +149,23 @@ del env_CALIBRE_PORT
EXTENSIONS_AUDIO = {'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'} EXTENSIONS_AUDIO = {'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'}
EXTENSIONS_CONVERT_FROM = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', EXTENSIONS_CONVERT_FROM = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf',
'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr'] 'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr', 'prc']
EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2',
'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt'] 'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt']
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'cb7', 'djvu', 'djv', EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'cb7', 'djvu', 'djv',
'prc', 'doc', 'docx', 'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'mp4', 'ogg', 'prc', 'doc', 'docx', 'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'mp4', 'ogg',
'opus', 'wav', 'flac', 'm4a', 'm4b'} 'opus', 'wav', 'flac', 'm4a', 'm4b'}
_extension = ""
if sys.platform == "win32":
_extension = ".exe"
SUPPORTED_CALIBRE_BINARIES = {binary: binary + _extension for binary in ["ebook-convert", "calibredb"]}
def has_flag(value, bit_flag): def has_flag(value, bit_flag):
return bit_flag == (bit_flag & (value or 0)) return bit_flag == (bit_flag & (value or 0))
def selected_roles(dictionary): def selected_roles(dictionary):
return sum(v for k, v in ALL_ROLES.items() if k in dictionary) return sum(v for k, v in ALL_ROLES.items() if k in dictionary)
@@ -163,13 +174,12 @@ def selected_roles(dictionary):
BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, ' BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, '
'series_id, languages, publisher, pubdate, identifiers') 'series_id, languages, publisher, pubdate, identifiers')
STABLE_VERSION = {'version': '0.6.21'} # python build process likes to have x.y.zbw -> b for beta and w a counting number
STABLE_VERSION = {'version': '0.6.24b'}
NIGHTLY_VERSION = dict() NIGHTLY_VERSION = dict()
NIGHTLY_VERSION[0] = '$Format:%H$' NIGHTLY_VERSION[0] = '$Format:%H$'
NIGHTLY_VERSION[1] = '$Format:%cI$' NIGHTLY_VERSION[1] = '$Format:%cI$'
# NIGHTLY_VERSION[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57'
# NIGHTLY_VERSION[1] = '2018-09-09T10:13:08+02:00'
# CACHE # CACHE
CACHE_TYPE_THUMBNAILS = 'thumbnails' CACHE_TYPE_THUMBNAILS = 'thumbnails'
@@ -183,7 +193,7 @@ THUMBNAIL_TYPE_AUTHOR = 3
COVER_THUMBNAIL_ORIGINAL = 0 COVER_THUMBNAIL_ORIGINAL = 0
COVER_THUMBNAIL_SMALL = 1 COVER_THUMBNAIL_SMALL = 1
COVER_THUMBNAIL_MEDIUM = 2 COVER_THUMBNAIL_MEDIUM = 2
COVER_THUMBNAIL_LARGE = 3 COVER_THUMBNAIL_LARGE = 4
# clean-up the module namespace # clean-up the module namespace
del sys, os, namedtuple del sys, os, namedtuple
+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
+49 -33
View File
@@ -20,9 +20,11 @@
import os import os
import re import re
import json import json
from datetime import datetime from datetime import datetime, timezone
from urllib.parse import quote from urllib.parse import quote
import unidecode import unidecode
from weakref import WeakSet
from uuid import uuid4
from sqlite3 import OperationalError as sqliteOperationalError from sqlite3 import OperationalError as sqliteOperationalError
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -40,16 +42,14 @@ except ImportError:
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from sqlalchemy.sql.expression import and_, true, false, text, func, or_ from sqlalchemy.sql.expression import and_, true, false, text, func, or_
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from flask_login import current_user from .cw_login import current_user
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_babel import get_locale from flask_babel import get_locale
from flask import flash from flask import flash
from . import logger, ub, isoLanguages from . import logger, ub, isoLanguages
from .pagination import Pagination from .pagination import Pagination
from .string_helper import strip_whitespaces
from weakref import WeakSet
log = logger.create() log = logger.create()
@@ -104,6 +104,7 @@ class Identifiers(Base):
book = Column(Integer, ForeignKey('books.id'), nullable=False) book = Column(Integer, ForeignKey('books.id'), nullable=False)
def __init__(self, val, id_type, book): def __init__(self, val, id_type, book):
super().__init__()
self.val = val self.val = val
self.type = id_type self.type = id_type
self.book = book self.book = book
@@ -128,6 +129,8 @@ class Identifiers(Base):
return "Google Books" return "Google Books"
elif format_type == "kobo": elif format_type == "kobo":
return "Kobo" return "Kobo"
elif format_type == "barnesnoble":
return "Barnes & Noble"
elif format_type == "litres": elif format_type == "litres":
return "ЛитРес" return "ЛитРес"
elif format_type == "issn": elif format_type == "issn":
@@ -161,6 +164,8 @@ class Identifiers(Base):
return "https://books.google.com/books?id={0}".format(self.val) return "https://books.google.com/books?id={0}".format(self.val)
elif format_type == "kobo": elif format_type == "kobo":
return "https://www.kobo.com/ebook/{0}".format(self.val) return "https://www.kobo.com/ebook/{0}".format(self.val)
elif format_type == "barnesnoble":
return "https://www.barnesandnoble.com/w/{0}".format(self.val)
elif format_type == "lubimyczytac": elif format_type == "lubimyczytac":
return "https://lubimyczytac.pl/ksiazka/{0}/ksiazka".format(self.val) return "https://lubimyczytac.pl/ksiazka/{0}/ksiazka".format(self.val)
elif format_type == "litres": elif format_type == "litres":
@@ -188,6 +193,7 @@ class Comments(Base):
text = Column(String(collation='NOCASE'), nullable=False) text = Column(String(collation='NOCASE'), nullable=False)
def __init__(self, comment, book): def __init__(self, comment, book):
super().__init__()
self.text = comment self.text = comment
self.book = book self.book = book
@@ -205,6 +211,7 @@ class Tags(Base):
name = Column(String(collation='NOCASE'), unique=True, nullable=False) name = Column(String(collation='NOCASE'), unique=True, nullable=False)
def __init__(self, name): def __init__(self, name):
super().__init__()
self.name = name self.name = name
def get(self): def get(self):
@@ -226,6 +233,7 @@ class Authors(Base):
link = Column(String, nullable=False, default="") link = Column(String, nullable=False, default="")
def __init__(self, name, sort, link=""): def __init__(self, name, sort, link=""):
super().__init__()
self.name = name self.name = name
self.sort = sort self.sort = sort
self.link = link self.link = link
@@ -248,6 +256,7 @@ class Series(Base):
sort = Column(String(collation='NOCASE')) sort = Column(String(collation='NOCASE'))
def __init__(self, name, sort): def __init__(self, name, sort):
super().__init__()
self.name = name self.name = name
self.sort = sort self.sort = sort
@@ -268,6 +277,7 @@ class Ratings(Base):
rating = Column(Integer, CheckConstraint('rating>-1 AND rating<11'), unique=True) rating = Column(Integer, CheckConstraint('rating>-1 AND rating<11'), unique=True)
def __init__(self, rating): def __init__(self, rating):
super().__init__()
self.rating = rating self.rating = rating
def get(self): def get(self):
@@ -287,6 +297,7 @@ class Languages(Base):
lang_code = Column(String(collation='NOCASE'), nullable=False, unique=True) lang_code = Column(String(collation='NOCASE'), nullable=False, unique=True)
def __init__(self, lang_code): def __init__(self, lang_code):
super().__init__()
self.lang_code = lang_code self.lang_code = lang_code
def get(self): def get(self):
@@ -310,6 +321,7 @@ class Publishers(Base):
sort = Column(String(collation='NOCASE')) sort = Column(String(collation='NOCASE'))
def __init__(self, name, sort): def __init__(self, name, sort):
super().__init__()
self.name = name self.name = name
self.sort = sort self.sort = sort
@@ -334,6 +346,7 @@ class Data(Base):
name = Column(String, nullable=False) name = Column(String, nullable=False)
def __init__(self, book, book_format, uncompressed_size, name): def __init__(self, book, book_format, uncompressed_size, name):
super().__init__()
self.book = book self.book = book
self.format = book_format self.format = book_format
self.uncompressed_size = uncompressed_size self.uncompressed_size = uncompressed_size
@@ -353,6 +366,7 @@ class Metadata_Dirtied(Base):
book = Column(Integer, ForeignKey('books.id'), nullable=False, unique=True) book = Column(Integer, ForeignKey('books.id'), nullable=False, unique=True)
def __init__(self, book): def __init__(self, book):
super().__init__()
self.book = book self.book = book
@@ -365,10 +379,10 @@ class Books(Base):
title = Column(String(collation='NOCASE'), nullable=False, default='Unknown') title = Column(String(collation='NOCASE'), nullable=False, default='Unknown')
sort = Column(String(collation='NOCASE')) sort = Column(String(collation='NOCASE'))
author_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) pubdate = Column(TIMESTAMP, default=DEFAULT_PUBDATE)
series_index = Column(String, nullable=False, default="1.0") 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) path = Column(String, default="", nullable=False)
has_cover = Column(Integer, default=0) has_cover = Column(Integer, default=0)
uuid = Column(String) uuid = Column(String)
@@ -387,6 +401,7 @@ class Books(Base):
def __init__(self, title, sort, author_sort, timestamp, pubdate, series_index, last_modified, path, has_cover, def __init__(self, title, sort, author_sort, timestamp, pubdate, series_index, last_modified, path, has_cover,
authors, tags, languages=None): authors, tags, languages=None):
super().__init__()
self.title = title self.title = title
self.sort = sort self.sort = sort
self.author_sort = author_sort self.author_sort = author_sort
@@ -395,7 +410,7 @@ class Books(Base):
self.series_index = series_index self.series_index = series_index
self.last_modified = last_modified self.last_modified = last_modified
self.path = path self.path = path
self.has_cover = (has_cover != None) self.has_cover = (has_cover is not None)
def __repr__(self): def __repr__(self):
return "<Books('{0},{1}{2}{3}{4}{5}{6}{7}{8}')>".format(self.title, self.sort, self.author_sort, return "<Books('{0},{1}{2}{3}{4}{5}{6}{7}{8}')>".format(self.title, self.sort, self.author_sort,
@@ -444,11 +459,13 @@ class CustomColumns(Base):
content['is_editable'] = self.editable content['is_editable'] = self.editable
content['rec_index'] = sequence + 22 # toDo why ?? content['rec_index'] = sequence + 22 # toDo why ??
if isinstance(value, datetime): if isinstance(value, datetime):
content['#value#'] = {"__class__": "datetime.datetime", "__value__": value.strftime("%Y-%m-%dT%H:%M:%S+00:00")} content['#value#'] = {"__class__": "datetime.datetime",
"__value__": value.strftime("%Y-%m-%dT%H:%M:%S+00:00")}
else: else:
content['#value#'] = value content['#value#'] = value
content['#extra#'] = extra content['#extra#'] = extra
content['is_multiple2'] = {} if not self.is_multiple else {"cache_to_list": "|", "ui_to_list": ",", "list_to_ui": ", "} content['is_multiple2'] = {} if not self.is_multiple else {"cache_to_list": "|", "ui_to_list": ",",
"list_to_ui": ", "}
return json.dumps(content, ensure_ascii=False) return json.dumps(content, ensure_ascii=False)
@@ -508,7 +525,6 @@ class CalibreDB:
if init: if init:
self.init_db(expire_on_commit) self.init_db(expire_on_commit)
def init_db(self, expire_on_commit=True): def init_db(self, expire_on_commit=True):
if self._init: if self._init:
self.init_session(expire_on_commit) self.init_session(expire_on_commit)
@@ -518,7 +534,7 @@ class CalibreDB:
def init_session(self, expire_on_commit=True): def init_session(self, expire_on_commit=True):
self.session = self.session_factory() self.session = self.session_factory()
self.session.expire_on_commit = expire_on_commit self.session.expire_on_commit = expire_on_commit
self.update_title_sort(self.config) self.create_functions(self.config)
@classmethod @classmethod
def setup_db_cc_classes(cls, cc): def setup_db_cc_classes(cls, cc):
@@ -839,8 +855,7 @@ class CalibreDB:
entries = list() entries = list()
pagination = list() pagination = list()
try: try:
pagination = Pagination(page, pagesize, pagination = Pagination(page, pagesize, query.count())
len(query.all()))
entries = query.order_by(*order).offset(off).limit(pagesize).all() entries = query.order_by(*order).offset(off).limit(pagesize).all()
except Exception as ex: except Exception as ex:
log.error_or_exception(ex) log.error_or_exception(ex)
@@ -861,10 +876,11 @@ class CalibreDB:
authors_ordered = list() authors_ordered = list()
# error = False # error = False
for auth in sort_authors: 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 # ToDo: How to handle not found author name
if not len(results): 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 # error = True
break break
for r in results: for r in results:
@@ -886,7 +902,8 @@ class CalibreDB:
def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()): def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()):
query = query or '' query = query or ''
self.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). \ entries = self.session.query(database).filter(tag_filter). \
filter(func.lower(database.name).ilike("%" + query + "%")).all() filter(func.lower(database.name).ilike("%" + query + "%")).all()
# json_dumps = json.dumps([dict(name=escape(r.name.replace(*replace))) for r in entries]) # json_dumps = json.dumps([dict(name=escape(r.name.replace(*replace))) for r in entries])
@@ -894,7 +911,8 @@ class CalibreDB:
return json_dumps return json_dumps
def check_exists_book(self, authr, title): 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() q = list()
author_terms = re.split(r'\s*&\s*', authr) author_terms = re.split(r'\s*&\s*', authr)
for author_term in author_terms: for author_term in author_terms:
@@ -904,8 +922,9 @@ class CalibreDB:
.filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first() .filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first()
def search_query(self, term, config, *join): def search_query(self, term, config, *join):
term.strip().lower() strip_whitespaces(term).lower()
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() q = list()
author_terms = re.split("[, ]+", term) author_terms = re.split("[, ]+", term)
for author_term in author_terms: for author_term in author_terms:
@@ -956,7 +975,7 @@ class CalibreDB:
pagination = None pagination = None
result = self.search_query(term, config, *join).order_by(*order).all() result = self.search_query(term, config, *join).order_by(*order).all()
result_count = len(result) result_count = len(result)
if offset != None and limit != None: if offset is not None and limit is not None:
offset = int(offset) offset = int(offset)
limit_all = offset + int(limit) limit_all = offset + int(limit)
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count) pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
@@ -1003,7 +1022,7 @@ class CalibreDB:
lang.name = isoLanguages.get_language_name(get_locale(), lang.lang_code) lang.name = isoLanguages.get_language_name(get_locale(), lang.lang_code)
return sorted(languages, key=lambda x: x.name, reverse=reverse_order) 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.) # user defined sort function for calibre databases (Series, etc.)
def _title_sort(title): def _title_sort(title):
# calibre sort stuff # calibre sort stuff
@@ -1012,16 +1031,19 @@ class CalibreDB:
if match: if match:
prep = match.group(1) prep = match.group(1)
title = title[len(prep):] + ', ' + prep title = title[len(prep):] + ', ' + prep
return title.strip() return strip_whitespaces(title)
try: try:
# sqlalchemy <1.4.24 # sqlalchemy <1.4.24 and sqlalchemy 2.0
conn = conn or self.session.connection().connection.driver_connection conn = self.session.connection().connection.driver_connection
except AttributeError: except AttributeError:
# sqlalchemy >1.4.24 and sqlalchemy 2.0 # sqlalchemy >1.4.24
conn = conn or self.session.connection().connection.connection conn = self.session.connection().connection.connection
try: try:
if config:
conn.create_function("title_sort", 1, _title_sort) conn.create_function("title_sort", 1, _title_sort)
conn.create_function('uuid4', 0, lambda: str(uuid4()))
conn.create_function("lower", 1, lcase)
except sqliteOperationalError: except sqliteOperationalError:
pass pass
@@ -1084,9 +1106,3 @@ class Category:
self.id = cat_id self.id = cat_id
self.rating = rating self.rating = rating
self.count = 1 self.count = 1
'''class Count:
count = None
def __init__(self, count):
self.count = count'''
+8 -3
View File
@@ -26,13 +26,15 @@ from flask_babel.speaklater import LazyString
import os import os
from flask import send_file, __version__ from flask import send_file
import importlib
from . import logger, config from . import logger, config
from .about import collect_stats from .about import collect_stats
log = logger.create() log = logger.create()
class lazyEncoder(json.JSONEncoder): class lazyEncoder(json.JSONEncoder):
def default(self, obj): def default(self, obj):
if isinstance(obj, LazyString): if isinstance(obj, LazyString):
@@ -40,6 +42,7 @@ class lazyEncoder(json.JSONEncoder):
# Let the base class default method raise the TypeError # Let the base class default method raise the TypeError
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, obj)
def assemble_logfiles(file_name): def assemble_logfiles(file_name):
log_list = sorted(glob.glob(file_name + '*'), reverse=True) log_list = sorted(glob.glob(file_name + '*'), reverse=True)
wfd = BytesIO() wfd = BytesIO()
@@ -47,7 +50,8 @@ def assemble_logfiles(file_name):
with open(f, 'rb') as fd: with open(f, 'rb') as fd:
shutil.copyfileobj(fd, wfd) shutil.copyfileobj(fd, wfd)
wfd.seek(0) wfd.seek(0)
if int(__version__.split('.')[0]) < 2: version = importlib.metadata.version("flask")
if int(version.split('.')[0]) < 2:
return send_file(wfd, return send_file(wfd,
as_attachment=True, as_attachment=True,
attachment_filename=os.path.basename(file_name)) attachment_filename=os.path.basename(file_name))
@@ -70,7 +74,8 @@ def send_debug():
for fp in file_list: for fp in file_list:
zf.write(fp, os.path.basename(fp)) zf.write(fp, os.path.basename(fp))
memory_zip.seek(0) 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, return send_file(memory_zip,
as_attachment=True, as_attachment=True,
attachment_filename="Calibre-Web-debug-pack.zip") attachment_filename="Calibre-Web-debug-pack.zip")
+3 -1
View File
@@ -58,10 +58,12 @@ def load_dependencies(optional=False):
def dependency_check(optional=False): def dependency_check(optional=False):
d = list() d = list()
dep_version_int = None
low_check = None
deps = load_dependencies(optional) deps = load_dependencies(optional)
for dep in deps: for dep in deps:
try: try:
dep_version_int = [int(x) for x in dep[0].split('.')] dep_version_int = [int(x) if x.isnumeric() else 0 for x in dep[0].split('.')]
low_check = [int(x) for x in dep[3].split('.')] low_check = [int(x) for x in dep[3].split('.')]
high_check = [int(x) for x in dep[5].split('.')] high_check = [int(x) for x in dep[5].split('.')]
except AttributeError: except AttributeError:
Executable → Regular
+336 -272
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
# -*- 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/>.
from uuid import uuid4
import os
from .file_helper import get_temp_dir
from .subproc_wrapper import process_open
from . import logger, config
from .constants import SUPPORTED_CALIBRE_BINARIES
log = logger.create()
def do_calibre_export(book_id, book_format):
try:
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.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)]
p = process_open(opf_command, quotes, my_env)
_, err = p.communicate()
if err:
log.error('Metadata embedder encountered an error: %s', err)
return tmp_dir, temp_file_name
except OSError as ex:
# ToDo real error handling
log.error_or_exception(ex)
return None, None
def get_calibre_binarypath(binary):
binariesdir = config.config_binariesdir
if binariesdir:
try:
return os.path.join(binariesdir, SUPPORTED_CALIBRE_BINARIES[binary])
except KeyError as ex:
log.error("Binary not supported by Calibre-Web: %s", SUPPORTED_CALIBRE_BINARIES[binary])
pass
return ""
+17 -24
View File
@@ -23,10 +23,13 @@ from lxml import etree
from . import isoLanguages, cover from . import isoLanguages, cover
from . import config, logger from . import config, logger
from .helper import split_authors from .helper import split_authors
from .epub_helper import get_content_opf, default_ns
from .constants import BookMeta from .constants import BookMeta
from .string_helper import strip_whitespaces
log = logger.create() log = logger.create()
def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name): def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
if cover_file is None: if cover_file is None:
return None return None
@@ -43,25 +46,17 @@ def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
cf = zip_file.read(zip_cover_path) cf = zip_file.read(zip_cover_path)
return cover.cover_processing(tmp_file_name, cf, extension) return cover.cover_processing(tmp_file_name, cf, extension)
def get_epub_layout(book, book_data): def get_epub_layout(book, book_data):
ns = { file_path = os.path.normpath(os.path.join(config.get_book_path(),
'n': 'urn:oasis:names:tc:opendocument:xmlns:container', book.path, book_data.name + "." + book_data.format.lower()))
'pkg': 'http://www.idpf.org/2007/opf',
}
file_path = os.path.normpath(os.path.join(config.config_calibre_dir, book.path, book_data.name + "." + book_data.format.lower()))
try: try:
epubZip = zipfile.ZipFile(file_path) tree, __ = get_content_opf(file_path, default_ns)
txt = epubZip.read('META-INF/container.xml') p = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
tree = etree.fromstring(txt)
cfname = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
cf = epubZip.read(cfname)
tree = etree.fromstring(cf) layout = p.xpath('pkg:meta[@property="rendition:layout"]/text()', namespaces=default_ns)
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=ns)[0] except (etree.XMLSyntaxError, KeyError, IndexError, OSError) as e:
layout = p.xpath('pkg:meta[@property="rendition:layout"]/text()', namespaces=ns)
except (etree.XMLSyntaxError, KeyError, IndexError) as e:
log.error("Could not parse epub metadata of book {} during kobo sync: {}".format(book.id, e)) log.error("Could not parse epub metadata of book {} during kobo sync: {}".format(book.id, e))
layout = [] layout = []
@@ -71,20 +66,14 @@ def get_epub_layout(book, book_data):
return layout[0] 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 = { ns = {
'n': 'urn:oasis:names:tc:opendocument:xmlns:container', 'n': 'urn:oasis:names:tc:opendocument:xmlns:container',
'pkg': 'http://www.idpf.org/2007/opf', 'pkg': 'http://www.idpf.org/2007/opf',
'dc': 'http://purl.org/dc/elements/1.1/' 'dc': 'http://purl.org/dc/elements/1.1/'
} }
epub_zip = zipfile.ZipFile(tmp_file_path) tree, cf_name = get_content_opf(tmp_file_path, ns)
txt = epub_zip.read('META-INF/container.xml')
tree = etree.fromstring(txt)
cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
cf = epub_zip.read(cf_name)
tree = etree.fromstring(cf)
cover_path = os.path.dirname(cf_name) cover_path = os.path.dirname(cf_name)
@@ -102,7 +91,7 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
elif s == 'date': elif s == 'date':
epub_metadata[s] = tmp[0][:10] epub_metadata[s] = tmp[0][:10]
else: else:
epub_metadata[s] = tmp[0] epub_metadata[s] = strip_whitespaces(tmp[0])
else: else:
epub_metadata[s] = 'Unknown' epub_metadata[s] = 'Unknown'
@@ -127,7 +116,11 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
epub_metadata = parse_epub_series(ns, tree, epub_metadata) epub_metadata = parse_epub_series(ns, tree, epub_metadata)
epub_zip = zipfile.ZipFile(tmp_file_path)
if not no_cover_processing:
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path) cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path)
else:
cover_file = None
identifiers = [] identifiers = []
for node in p.xpath('dc:identifier', namespaces=ns): for node in p.xpath('dc:identifier', namespaces=ns):
+169
View File
@@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2018 lemmsh, Kennyl, Kyosfonica, matthazinski
#
# 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 zipfile
from lxml import etree
from . import isoLanguages
default_ns = {
'n': 'urn:oasis:names:tc:opendocument:xmlns:container',
'pkg': 'http://www.idpf.org/2007/opf',
}
OPF_NAMESPACE = "http://www.idpf.org/2007/opf"
PURL_NAMESPACE = "http://purl.org/dc/elements/1.1/"
OPF = "{%s}" % OPF_NAMESPACE
PURL = "{%s}" % PURL_NAMESPACE
etree.register_namespace("opf", OPF_NAMESPACE)
etree.register_namespace("dc", PURL_NAMESPACE)
OPF_NS = {None: OPF_NAMESPACE} # the default namespace (no prefix)
NSMAP = {'dc': PURL_NAMESPACE, 'opf': OPF_NAMESPACE}
def updateEpub(src, dest, filename, data, ):
# create a temp copy of the archive without filename
with zipfile.ZipFile(src, 'r') as zin:
with zipfile.ZipFile(dest, 'w') as zout:
zout.comment = zin.comment # preserve the comment
for item in zin.infolist():
if item.filename != filename:
zout.writestr(item, zin.read(item.filename))
# now add filename with its new data
with zipfile.ZipFile(dest, mode='a', compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr(filename, data)
def get_content_opf(file_path, ns=None):
if ns is None:
ns = default_ns
epubZip = zipfile.ZipFile(file_path)
txt = epubZip.read('META-INF/container.xml')
tree = etree.fromstring(txt)
cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
cf = epubZip.read(cf_name)
return etree.fromstring(cf), cf_name
def create_new_metadata_backup(book, custom_columns, export_language, translated_cover_name, lang_type=3):
# generate root package element
package = etree.Element(OPF + "package", nsmap=OPF_NS)
package.set("unique-identifier", "uuid_id")
package.set("version", "2.0")
# generate metadata element and all sub elements of it
metadata = etree.SubElement(package, "metadata", nsmap=NSMAP)
identifier = etree.SubElement(metadata, PURL + "identifier", id="calibre_id", nsmap=NSMAP)
identifier.set(OPF + "scheme", "calibre")
identifier.text = str(book.id)
identifier2 = etree.SubElement(metadata, PURL + "identifier", id="uuid_id", nsmap=NSMAP)
identifier2.set(OPF + "scheme", "uuid")
identifier2.text = book.uuid
for i in book.identifiers:
identifier = etree.SubElement(metadata, PURL + "identifier", nsmap=NSMAP)
identifier.set(OPF + "scheme", i.format_type())
identifier.text = str(i.val)
title = etree.SubElement(metadata, PURL + "title", nsmap=NSMAP)
title.text = book.title
for author in book.authors:
creator = etree.SubElement(metadata, PURL + "creator", nsmap=NSMAP)
creator.text = str(author.name)
creator.set(OPF + "file-as", book.author_sort) # ToDo Check
creator.set(OPF + "role", "aut")
contributor = etree.SubElement(metadata, PURL + "contributor", nsmap=NSMAP)
contributor.text = "calibre (5.7.2) [https://calibre-ebook.com]"
contributor.set(OPF + "file-as", "calibre") # ToDo Check
contributor.set(OPF + "role", "bkp")
date = etree.SubElement(metadata, PURL + "date", nsmap=NSMAP)
date.text = '{d.year:04}-{d.month:02}-{d.day:02}T{d.hour:02}:{d.minute:02}:{d.second:02}'.format(d=book.pubdate)
if book.comments and book.comments[0].text:
for b in book.comments:
description = etree.SubElement(metadata, PURL + "description", nsmap=NSMAP)
description.text = b.text
for b in book.publishers:
publisher = etree.SubElement(metadata, PURL + "publisher", nsmap=NSMAP)
publisher.text = str(b.name)
if not book.languages:
language = etree.SubElement(metadata, PURL + "language", nsmap=NSMAP)
language.text = export_language
else:
for b in book.languages:
language = etree.SubElement(metadata, PURL + "language", nsmap=NSMAP)
language.text = str(b.lang_code) if lang_type == 3 else isoLanguages.get(part3=b.lang_code).part1
for b in book.tags:
subject = etree.SubElement(metadata, PURL + "subject", nsmap=NSMAP)
subject.text = str(b.name)
etree.SubElement(metadata, "meta", name="calibre:author_link_map",
content="{" + ", ".join(['"' + str(a.name) + '": ""' for a in book.authors]) + "}",
nsmap=NSMAP)
for b in book.series:
etree.SubElement(metadata, "meta", name="calibre:series",
content=str(str(b.name)),
nsmap=NSMAP)
if book.series:
etree.SubElement(metadata, "meta", name="calibre:series_index",
content=str(book.series_index),
nsmap=NSMAP)
if len(book.ratings) and book.ratings[0].rating > 0:
etree.SubElement(metadata, "meta", name="calibre:rating",
content=str(book.ratings[0].rating),
nsmap=NSMAP)
etree.SubElement(metadata, "meta", name="calibre:timestamp",
content='{d.year:04}-{d.month:02}-{d.day:02}T{d.hour:02}:{d.minute:02}:{d.second:02}'.format(
d=book.timestamp),
nsmap=NSMAP)
etree.SubElement(metadata, "meta", name="calibre:title_sort",
content=book.sort,
nsmap=NSMAP)
sequence = 0
for cc in custom_columns:
value = None
extra = None
cc_entry = getattr(book, "custom_column_" + str(cc.id))
if cc_entry.__len__():
value = [c.value for c in cc_entry] if cc.is_multiple else cc_entry[0].value
extra = cc_entry[0].extra if hasattr(cc_entry[0], "extra") else None
etree.SubElement(metadata, "meta", name="calibre:user_metadata:#{}".format(cc.label),
content=cc.to_json(value, extra, sequence),
nsmap=NSMAP)
sequence += 1
# generate guide element and all sub elements of it
# Title is translated from default export language
guide = etree.SubElement(package, "guide")
etree.SubElement(guide, "reference", type="cover", title=translated_cover_name, href="cover.jpg")
return package
def replace_metadata(tree, package):
rep_element = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
new_element = package.xpath('//metadata', namespaces=default_ns)[0]
tree.replace(rep_element, new_element)
return etree.tostring(tree,
xml_declaration=True,
encoding='utf-8',
pretty_print=True).decode('utf-8')
+2 -1
View File
@@ -31,6 +31,7 @@ from . import config, app, logger, services
log = logger.create() log = logger.create()
# custom error page # custom error page
def error_http(error): def error_http(error):
return render_template('http_error.html', return render_template('http_error.html',
error_code="Error {0}".format(error.code), error_code="Error {0}".format(error.code),
@@ -52,6 +53,7 @@ def internal_error(error):
instance=config.config_calibre_web_title instance=config.config_calibre_web_title
), 500 ), 500
def init_errorhandler(): def init_errorhandler():
# http error handling # http error handling
for ex in default_exceptions: for ex in default_exceptions:
@@ -60,7 +62,6 @@ def init_errorhandler():
elif ex == 500: elif ex == 500:
app.register_error_handler(ex, internal_error) app.register_error_handler(ex, internal_error)
if services.ldap: if services.ldap:
# Only way of catching the LDAPException upon logging in with LDAP server down # Only way of catching the LDAPException upon logging in with LDAP server down
@app.errorhandler(services.ldap.LDAPException) @app.errorhandler(services.ldap.LDAPException)
+74
View File
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2023 OzzieIsaacs
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from tempfile import gettempdir
import os
import shutil
import zipfile
import mimetypes
from io import BytesIO
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')
if not os.path.isdir(tmp_dir):
os.mkdir(tmp_dir)
return tmp_dir
def del_temp_dir():
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
shutil.rmtree(tmp_dir)
def validate_mime_type(file_buffer, allowed_extensions):
if error:
log.error(error)
return False
mime = magic.Magic(mime=True)
allowed_mimetypes = list()
for x in allowed_extensions:
try:
allowed_mimetypes.append(mimetypes.types_map["." + x])
except KeyError:
log.error("Unkown mimetype for Extension: {}".format(x))
tmp_mime_type = mime.from_buffer(file_buffer.read())
file_buffer.seek(0)
if any(mime_type in tmp_mime_type for mime_type in allowed_mimetypes):
return True
# Some epubs show up as zip mimetypes
elif "zip" in tmp_mime_type:
try:
with zipfile.ZipFile(BytesIO(file_buffer.read()), 'r') as epub:
file_buffer.seek(0)
if "mimetype" in epub.namelist():
return True
except:
file_buffer.seek(0)
log.error("Mimetype '{}' not found in allowed types".format(tmp_mime_type))
return False
+8 -8
View File
@@ -23,17 +23,17 @@
import os import os
import hashlib import hashlib
import json import json
import tempfile
from uuid import uuid4 from uuid import uuid4
from time import time from time import time
from shutil import move, copyfile from shutil import move, copyfile
from flask import Blueprint, flash, request, redirect, url_for, abort from flask import Blueprint, flash, request, redirect, url_for, abort
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import login_required
from . import logger, gdriveutils, config, ub, calibre_db, csrf from . import logger, gdriveutils, config, ub, calibre_db, csrf
from .admin import admin_required 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') gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive')
log = logger.create() log = logger.create()
@@ -49,7 +49,7 @@ gdrive_watch_callback_token = 'target=calibreweb-watch_files' #nosec
@gdrive.route("/authenticate") @gdrive.route("/authenticate")
@login_required @user_login_required
@admin_required @admin_required
def authenticate_google_drive(): def authenticate_google_drive():
try: try:
@@ -76,7 +76,7 @@ def google_drive_callback():
@gdrive.route("/watch/subscribe") @gdrive.route("/watch/subscribe")
@login_required @user_login_required
@admin_required @admin_required
def watch_gdrive(): def watch_gdrive():
if not config.config_google_drive_watch_changes_response: if not config.config_google_drive_watch_changes_response:
@@ -87,6 +87,7 @@ def watch_gdrive():
try: try:
result = gdriveutils.watchChange(gdriveutils.Gdrive.Instance().drive, notification_id, result = gdriveutils.watchChange(gdriveutils.Gdrive.Instance().drive, notification_id,
'web_hook', address, gdrive_watch_callback_token, current_milli_time() + 604800*1000) 'web_hook', address, gdrive_watch_callback_token, current_milli_time() + 604800*1000)
config.config_google_drive_watch_changes_response = result config.config_google_drive_watch_changes_response = result
config.save() config.save()
except HttpError as e: except HttpError as e:
@@ -101,7 +102,7 @@ def watch_gdrive():
@gdrive.route("/watch/revoke") @gdrive.route("/watch/revoke")
@login_required @user_login_required
@admin_required @admin_required
def revoke_watch_gdrive(): def revoke_watch_gdrive():
last_watch_response = config.config_google_drive_watch_changes_response last_watch_response = config.config_google_drive_watch_changes_response
@@ -115,6 +116,7 @@ def revoke_watch_gdrive():
config.save() config.save()
return redirect(url_for('admin.db_configuration')) return redirect(url_for('admin.db_configuration'))
try: try:
@csrf.exempt @csrf.exempt
@gdrive.route("/watch/callback", methods=['GET', 'POST']) @gdrive.route("/watch/callback", methods=['GET', 'POST'])
@@ -139,9 +141,7 @@ try:
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode() dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
if not response['deleted'] and response['file']['title'] == 'metadata.db' \ if not response['deleted'] and response['file']['title'] == 'metadata.db' \
and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec
tmp_dir = os.path.join(tempfile.gettempdir(), 'calibre_web') tmp_dir = get_temp_dir()
if not os.path.isdir(tmp_dir):
os.mkdir(tmp_dir)
log.info('Database file updated') log.info('Database file updated')
copyfile(dbpath, os.path.join(tmp_dir, "metadata.db_" + str(current_milli_time()))) copyfile(dbpath, os.path.join(tmp_dir, "metadata.db_" + str(current_milli_time())))
+50 -23
View File
@@ -21,7 +21,10 @@ import json
import shutil import shutil
import chardet import chardet
import ssl import ssl
import sqlite3
import mimetypes
from werkzeug.datastructures import Headers
from flask import Response, stream_with_context from flask import Response, stream_with_context
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy import Column, UniqueConstraint from sqlalchemy import Column, UniqueConstraint
@@ -34,7 +37,6 @@ except ImportError:
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.exc import OperationalError, InvalidRequestError, IntegrityError from sqlalchemy.exc import OperationalError, InvalidRequestError, IntegrityError
from sqlalchemy.orm.exc import StaleDataError from sqlalchemy.orm.exc import StaleDataError
from sqlalchemy.sql.expression import text
try: try:
from httplib2 import __version__ as httplib2_version from httplib2 import __version__ as httplib2_version
@@ -64,7 +66,7 @@ except ImportError as err:
importError = err importError = err
gdrive_support = False gdrive_support = False
from . import logger, cli_param, config from . import logger, cli_param, config, db
from .constants import CONFIG_DIR as _CONFIG_DIR from .constants import CONFIG_DIR as _CONFIG_DIR
@@ -208,6 +210,7 @@ def getDrive(drive=None, gauth=None):
log.error("Google Drive error: {}".format(e)) log.error("Google Drive error: {}".format(e))
return drive return drive
def listRootFolders(): def listRootFolders():
try: try:
drive = getDrive(Gdrive.Instance().drive) drive = getDrive(Gdrive.Instance().drive)
@@ -236,6 +239,7 @@ def getFolderInFolder(parentId, folderName, drive):
else: else:
return fileList[0] return fileList[0]
# Search for id of root folder in gdrive database, if not found request from gdrive and store in internal database # Search for id of root folder in gdrive database, if not found request from gdrive and store in internal database
def getEbooksFolderId(drive=None): def getEbooksFolderId(drive=None):
storedPathName = session.query(GdriveId).filter(GdriveId.path == '/').first() storedPathName = session.query(GdriveId).filter(GdriveId.path == '/').first()
@@ -257,17 +261,20 @@ def getEbooksFolderId(drive=None):
return gDriveId.gdrive_id return gDriveId.gdrive_id
def getFile(pathId, fileName, drive): def getFile(pathId, fileName, drive, nocase):
metaDataFile = "'%s' in parents and trashed = false and title = '%s'" % (pathId, fileName.replace("'", r"\'")) metaDataFile = "'%s' in parents and trashed = false and title contains '%s'" % (pathId, fileName.replace("'", r"\'"))
fileList = drive.ListFile({'q': metaDataFile}).GetList() fileList = drive.ListFile({'q': metaDataFile}).GetList()
if fileList.__len__() == 0: if fileList.__len__() == 0:
return None return None
else: if nocase:
return fileList[0] return fileList[0] if db.lcase(fileList[0]['title']) == db.lcase(fileName) else None
for f in fileList:
if f['title'] == fileName:
return f
return None
def getFolderId(path, drive): def getFolderId(path, drive):
# drive = getDrive(drive)
currentFolderId = None currentFolderId = None
try: try:
currentFolderId = getEbooksFolderId(drive) currentFolderId = getEbooksFolderId(drive)
@@ -301,7 +308,7 @@ def getFolderId(path, drive):
session.commit() session.commit()
else: else:
currentFolderId = storedPathName.gdrive_id currentFolderId = storedPathName.gdrive_id
except (OperationalError, IntegrityError, StaleDataError) as ex: except (OperationalError, IntegrityError, StaleDataError, sqlite3.IntegrityError) as ex:
log.error_or_exception('Database error: {}'.format(ex)) log.error_or_exception('Database error: {}'.format(ex))
session.rollback() session.rollback()
except ApiRequestError as ex: except ApiRequestError as ex:
@@ -312,7 +319,7 @@ def getFolderId(path, drive):
return currentFolderId return currentFolderId
def getFileFromEbooksFolder(path, fileName): def getFileFromEbooksFolder(path, fileName, nocase=False):
drive = getDrive(Gdrive.Instance().drive) drive = getDrive(Gdrive.Instance().drive)
if path: if path:
# sqlCheckPath=path if path[-1] =='/' else path + '/' # sqlCheckPath=path if path[-1] =='/' else path + '/'
@@ -320,7 +327,7 @@ def getFileFromEbooksFolder(path, fileName):
else: else:
folderId = getEbooksFolderId(drive) folderId = getEbooksFolderId(drive)
if folderId: if folderId:
return getFile(folderId, fileName, drive) return getFile(folderId, fileName, drive, nocase)
else: else:
return None return None
@@ -336,12 +343,19 @@ def downloadFile(path, filename, output):
f.GetContentFile(output) f.GetContentFile(output)
def moveGdriveFolderRemote(origin_file, target_folder): def moveGdriveFolderRemote(origin_file, target_folder, single_book=False):
drive = getDrive(Gdrive.Instance().drive) drive = getDrive(Gdrive.Instance().drive)
previous_parents = ",".join([parent["id"] for parent in origin_file.get('parents')]) previous_parents = ",".join([parent["id"] for parent in origin_file.get('parents')])
children = drive.auth.service.children().list(folderId=previous_parents).execute() children = drive.auth.service.children().list(folderId=previous_parents).execute()
gFileTargetDir = getFileFromEbooksFolder(None, target_folder) if single_book:
if not gFileTargetDir: gFileTargetDir = getFileFromEbooksFolder(None, target_folder, nocase=True)
if gFileTargetDir:
# Move the file to the new folder
drive.auth.service.files().update(fileId=origin_file['id'],
addParents=gFileTargetDir['id'],
removeParents=previous_parents,
fields='id, parents').execute()
else:
gFileTargetDir = drive.CreateFile( gFileTargetDir = drive.CreateFile(
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}], {'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
"mimeType": "application/vnd.google-apps.folder"}) "mimeType": "application/vnd.google-apps.folder"})
@@ -351,18 +365,15 @@ def moveGdriveFolderRemote(origin_file, target_folder):
addParents=gFileTargetDir['id'], addParents=gFileTargetDir['id'],
removeParents=previous_parents, removeParents=previous_parents,
fields='id, parents').execute() fields='id, parents').execute()
elif origin_file['title'] != target_folder:
elif gFileTargetDir['title'] != target_folder: #gFileTargetDir = getFileFromEbooksFolder(None, target_folder, nocase=True)
#if gFileTargetDir:
deleteDatabasePath(origin_file['title'])
# Folder is not existing, create, and move folder # Folder is not existing, create, and move folder
drive.auth.service.files().patch(fileId=origin_file['id'], drive.auth.service.files().patch(fileId=origin_file['id'],
body={'title': target_folder}, body={'title': target_folder},
fields='title').execute() fields='title').execute()
else:
# Move the file to the new folder
drive.auth.service.files().update(fileId=origin_file['id'],
addParents=gFileTargetDir['id'],
removeParents=previous_parents,
fields='id, parents').execute()
# if previous_parents has no children anymore, delete original fileparent # if previous_parents has no children anymore, delete original fileparent
if len(children['items']) == 1: if len(children['items']) == 1:
deleteDatabaseEntry(previous_parents) deleteDatabaseEntry(previous_parents)
@@ -539,6 +550,7 @@ def updateGdriveCalibreFromLocal():
if os.path.isdir(os.path.join(config.config_calibre_dir, x)): if os.path.isdir(os.path.join(config.config_calibre_dir, x)):
shutil.rmtree(os.path.join(config.config_calibre_dir, x)) shutil.rmtree(os.path.join(config.config_calibre_dir, x))
# update gdrive.db on edit of books title # update gdrive.db on edit of books title
def updateDatabaseOnEdit(ID, newPath): def updateDatabaseOnEdit(ID, newPath):
sqlCheckPath = newPath if newPath[-1] == '/' else newPath + '/' sqlCheckPath = newPath if newPath[-1] == '/' else newPath + '/'
@@ -561,6 +573,14 @@ def deleteDatabaseEntry(ID):
log.error_or_exception('Database error: {}'.format(ex)) log.error_or_exception('Database error: {}'.format(ex))
session.rollback() session.rollback()
def deleteDatabasePath(Pathname):
session.query(GdriveId).filter(GdriveId.path.contains(Pathname)).delete()
try:
session.commit()
except OperationalError as ex:
log.error_or_exception('Database error: {}'.format(ex))
session.rollback()
# Gets cover file from gdrive # Gets cover file from gdrive
# ToDo: Check is this right everyone get read permissions on cover files? # ToDo: Check is this right everyone get read permissions on cover files?
@@ -579,13 +599,17 @@ def get_cover_via_gdrive(cover_path):
session.add(permissionAdded) session.add(permissionAdded)
try: try:
session.commit() session.commit()
except OperationalError as ex: except (OperationalError, IntegrityError) as ex:
log.error_or_exception('Database error: {}'.format(ex)) log.error_or_exception('Database error: {}'.format(ex))
session.rollback() session.rollback()
return df.metadata.get('webContentLink') headers = Headers()
headers["Content-Type"] = 'image/jpeg'
resp, content = df.auth.Get_Http_Object().request(df.metadata.get('downloadUrl'), headers=headers)
return content
else: else:
return None return None
# Gets cover file from gdrive # Gets cover file from gdrive
def get_metadata_backup_via_gdrive(metadata_path): def get_metadata_backup_via_gdrive(metadata_path):
df = getFileFromEbooksFolder(metadata_path, 'metadata.opf') df = getFileFromEbooksFolder(metadata_path, 'metadata.opf')
@@ -609,6 +633,7 @@ def get_metadata_backup_via_gdrive(metadata_path):
else: else:
return None return None
# Creates chunks for downloading big files # Creates chunks for downloading big files
def partial(total_byte_len, part_size_limit): def partial(total_byte_len, part_size_limit):
s = [] s = []
@@ -617,6 +642,7 @@ def partial(total_byte_len, part_size_limit):
s.append([p, last]) s.append([p, last])
return s return s
# downloads files in chunks from gdrive # downloads files in chunks from gdrive
def do_gdrive_download(df, headers, convert_encoding=False): def do_gdrive_download(df, headers, convert_encoding=False):
total_size = int(df.metadata.get('fileSize')) total_size = int(df.metadata.get('fileSize'))
@@ -656,6 +682,7 @@ oauth_scope:
- https://www.googleapis.com/auth/drive - https://www.googleapis.com/auth/drive
""" """
def update_settings(client_id, client_secret, redirect_uri): def update_settings(client_id, client_secret, redirect_uri):
if redirect_uri.endswith('/'): if redirect_uri.endswith('/'):
redirect_uri = redirect_uri[:-1] redirect_uri = redirect_uri[:-1]
+24 -1
View File
@@ -16,9 +16,10 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import datetime
from gevent.pywsgi import WSGIHandler from gevent.pywsgi import WSGIHandler
class MyWSGIHandler(WSGIHandler): class MyWSGIHandler(WSGIHandler):
def get_environ(self): def get_environ(self):
env = super().get_environ() env = super().get_environ()
@@ -26,4 +27,26 @@ class MyWSGIHandler(WSGIHandler):
env['RAW_URI'] = path env['RAW_URI'] = path
return env 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)
+239 -168
View File
@@ -22,18 +22,19 @@ import random
import io import io
import mimetypes import mimetypes
import re import re
import regex
import shutil import shutil
import socket import socket
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from tempfile import gettempdir
import requests import requests
import unidecode import unidecode
from uuid import uuid4
from flask import send_from_directory, make_response, redirect, abort, url_for from flask import send_from_directory, make_response, abort, url_for, Response
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_babel import lazy_gettext as N_ from flask_babel import lazy_gettext as N_
from flask_babel import get_locale from flask_babel import get_locale
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.sql.expression import true, false, and_, or_, text, func
from sqlalchemy.exc import InvalidRequestError, OperationalError from sqlalchemy.exc import InvalidRequestError, OperationalError
from werkzeug.datastructures import Headers from werkzeug.datastructures import Headers
@@ -51,15 +52,20 @@ except ImportError:
UnacceptableAddressException = MissingSchema = BaseException UnacceptableAddressException = MissingSchema = BaseException
from . import calibre_db, cli_param from . import calibre_db, cli_param
from .string_helper import strip_whitespaces
from .tasks.convert import TaskConvert from .tasks.convert import TaskConvert
from . import logger, config, db, ub, fs from . import logger, config, db, ub, fs
from . import gdriveutils as gd from . import gdriveutils as gd
from .constants import STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES from .constants import (STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES,
SUPPORTED_CALIBRE_BINARIES)
from .subproc_wrapper import process_wait from .subproc_wrapper import process_wait
from .services.worker import WorkerThread from .services.worker import WorkerThread
from .tasks.mail import TaskEmail from .tasks.mail import TaskEmail
from .tasks.thumbnail import TaskClearCoverThumbnailCache, TaskGenerateCoverThumbnails from .tasks.thumbnail import TaskClearCoverThumbnailCache, TaskGenerateCoverThumbnails
from .tasks.metadata_backup import TaskBackupMetadata from .tasks.metadata_backup import TaskBackupMetadata
from .file_helper import get_temp_dir
from .epub_helper import get_content_opf, create_new_metadata_backup, updateEpub, replace_metadata
from .embed_helper import do_calibre_export
log = logger.create() log = logger.create()
@@ -112,8 +118,10 @@ 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 # Texts are not lazy translated as they are supposed to get send out as is
def send_test_mail(ereader_mail, user_name): def send_test_mail(ereader_mail, user_name):
for email in ereader_mail.split(','):
email = strip_whitespaces(email)
WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None, WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None,
config.get_mail_settings(), ereader_mail, N_("Test Email"), config.get_mail_settings(), email, N_("Test Email"),
_('This Email has been sent via Calibre-Web.'))) _('This Email has been sent via Calibre-Web.')))
return return
@@ -220,9 +228,11 @@ def send_mail(book_id, book_format, convert, ereader_mail, calibrepath, user_id)
converted_file_name = entry.name + '.' + book_format.lower() 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)) 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) email_text = N_("%(book)s send to eReader", book=link)
for email in ereader_mail.split(','):
email = strip_whitespaces(email)
WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name, WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
config.get_mail_settings(), ereader_mail, config.get_mail_settings(), email,
email_text, _('This Email has been sent via Calibre-Web.'))) email_text, _('This Email has been sent via Calibre-Web.'), book.id))
return return
return _("The requested file could not be read. Maybe wrong permissions?") return _("The requested file could not be read. Maybe wrong permissions?")
@@ -243,7 +253,7 @@ def get_valid_filename(value, replace_whitespace=True, chars=128):
# pipe has to be replaced with comma # pipe has to be replaced with comma
value = re.sub(r'[|]+', ',', value, flags=re.U) 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: if not value:
raise ValueError("Filename cannot be empty") raise ValueError("Filename cannot be empty")
@@ -258,11 +268,11 @@ def split_authors(values):
commas = author.count(',') commas = author.count(',')
if commas == 1: if commas == 1:
author_split = author.split(',') 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: 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: else:
authors_list.append(author.strip()) authors_list.append(strip_whitespaces(author))
return authors_list return authors_list
@@ -318,7 +328,7 @@ def edit_book_read_status(book_id, read_status=None):
ub.session_commit("Book {} readbit toggled".format(book_id)) ub.session_commit("Book {} readbit toggled".format(book_id))
else: else:
try: try:
calibre_db.update_title_sort(config) calibre_db.create_functions(config)
book = calibre_db.get_filtered_book(book_id) book = calibre_db.get_filtered_book(book_id)
book_read_status = getattr(book, 'custom_column_' + str(config.config_read_column)) book_read_status = getattr(book, 'custom_column_' + str(config.config_read_column))
if len(book_read_status): if len(book_read_status):
@@ -383,75 +393,56 @@ def delete_book_file(book, calibrepath, book_format=None):
id=book.id, id=book.id,
path=book.path) path=book.path)
def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdrive=False):
def clean_author_database(renamed_author, calibre_path="", local_book=None, gdrive=None): for file_format in one_book.data:
valid_filename_authors = [get_valid_filename(r, chars=96) for r in renamed_author]
for r in renamed_author:
if local_book:
all_books = [local_book]
else:
all_books = calibre_db.session.query(db.Books) \
.filter(db.Books.authors.any(db.Authors.name == r)).all()
for book in all_books:
book_author_path = book.path.split('/')[0]
if book_author_path in valid_filename_authors or local_book:
new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == r).first()
all_new_authordir = get_valid_filename(new_author.name, chars=96)
all_titledir = book.path.split('/')[1]
all_new_path = os.path.join(calibre_path, all_new_authordir, all_titledir)
all_new_name = get_valid_filename(book.title, chars=42) + ' - ' \
+ get_valid_filename(new_author.name, chars=42)
# change location in database to new author/title path
book.path = os.path.join(all_new_authordir, all_titledir).replace('\\', '/')
for file_format in book.data:
if not gdrive: if not gdrive:
shutil.move(os.path.normcase(os.path.join(all_new_path, if not os.path.exists(new_path):
file_format.name + '.' + file_format.format.lower())), os.makedirs(new_path)
os.path.normcase(os.path.join(all_new_path, shutil.move(os.path.join(old_path, file_format.name + '.' + file_format.format.lower()),
all_new_name + '.' + file_format.format.lower()))) os.path.join(new_path, all_new_name + '.' + file_format.format.lower()))
else: else:
g_file = gd.getFileFromEbooksFolder(all_new_path, g_file = gd.getFileFromEbooksFolder(old_path,
file_format.name + '.' + file_format.format.lower()) file_format.name + '.' + file_format.format.lower())
if g_file: if g_file:
gd.moveGdriveFileRemote(g_file, all_new_name + '.' + file_format.format.lower()) gd.moveGdriveFileRemote(g_file, all_new_name + '.' + file_format.format.lower())
gd.updateDatabaseOnEdit(g_file['id'], all_new_name + '.' + file_format.format.lower()) gd.updateDatabaseOnEdit(g_file['id'], all_new_name + '.' + file_format.format.lower())
else: else:
log.error("File {} not found on gdrive" log.error("File {} not found on gdrive"
.format(all_new_path, file_format.name + '.' + file_format.format.lower())) .format(old_path, file_format.name + '.' + file_format.format.lower()))
# change name in Database
file_format.name = all_new_name file_format.name = all_new_name
def rename_all_authors(first_author, renamed_author, calibre_path="", localbook=None, gdrive=False): 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_author_dir from parameter or from database
# Create new title_dir from database and add id # Create new title_dir from database and add id
if first_author:
new_authordir = get_valid_filename(first_author, chars=96) 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 == renamed_author).first()
new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == r).first() # old_author_dir = get_valid_filename(old_author_name, chars=96)
old_author_dir = get_valid_filename(r, chars=96) new_author_rename_dir = get_valid_filename(renamed_author, chars=96)
new_author_rename_dir = get_valid_filename(new_author.name, chars=96)
if gdrive: if gdrive:
g_file = gd.getFileFromEbooksFolder(None, old_author_dir) g_file = gd.getFileFromEbooksFolder(None, old_author_dir)
if g_file: if g_file:
gd.moveGdriveFolderRemote(g_file, new_author_rename_dir) gd.moveGdriveFolderRemote(g_file, new_author_rename_dir)
else: else:
if os.path.isdir(os.path.join(calibre_path, old_author_dir)): if os.path.isdir(os.path.join(calibre_path, old_author_dir)):
try:
old_author_path = 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) new_author_path = os.path.join(calibre_path, new_author_rename_dir)
shutil.move(os.path.normcase(old_author_path), os.path.normcase(new_author_path)) try:
os.rename(old_author_path, new_author_path)
except OSError:
try:
shutil.move(old_author_path, new_author_path)
except OSError as ex: except OSError as ex:
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex) log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
log.debug(ex, exc_info=True) log.error_or_exception(ex)
return _("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s", 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)) 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 return new_authordir
# Moves files in file storage during author/title rename, or from temp dir to file storage # Moves files in file storage during author/title rename, or from temp dir to file storage
def update_dir_structure_file(book_id, calibre_path, first_author, original_filepath, db_filename, renamed_author): def update_dir_structure_file(book_id, calibre_path, original_filepath, new_author, db_filename):
# get book database entry from id, if original path overwrite source with original_filepath # get book database entry from id, if original path overwrite source with original_filepath
local_book = calibre_db.get_book(book_id) local_book = calibre_db.get_book(book_id)
if original_filepath: if original_filepath:
@@ -463,15 +454,11 @@ def update_dir_structure_file(book_id, calibre_path, first_author, original_file
author_dir = local_book.path.split('/')[0] author_dir = local_book.path.split('/')[0]
title_dir = local_book.path.split('/')[1] title_dir = local_book.path.split('/')[1]
# Create new_author_dir from parameter or from database
# Create new title_dir from database and add id
new_author_dir = rename_all_authors(first_author, renamed_author, calibre_path, local_book)
if first_author:
if first_author.lower() in [r.lower() for r in renamed_author]:
if os.path.isdir(os.path.join(calibre_path, new_author_dir)):
path = os.path.join(calibre_path, new_author_dir, title_dir)
new_title_dir = get_valid_filename(local_book.title, chars=96) + " (" + str(book_id) + ")" new_title_dir = get_valid_filename(local_book.title, chars=96) + " (" + str(book_id) + ")"
if new_author:
new_author_dir = get_valid_filename(new_author, chars=96)
else:
new_author = new_author_dir = author_dir
if title_dir != new_title_dir or author_dir != new_author_dir or original_filepath: if title_dir != new_title_dir or author_dir != new_author_dir or original_filepath:
error = move_files_on_change(calibre_path, error = move_files_on_change(calibre_path,
@@ -481,36 +468,42 @@ def update_dir_structure_file(book_id, calibre_path, first_author, original_file
db_filename, db_filename,
original_filepath, original_filepath,
path) 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)
# Book folder already moved, only files need to be renamed
rename_all_files_on_change(local_book, new_path, new_path, all_new_name)
if error: if error:
return error return error
# Rename all files from old names to new names # Rename all files from old names to new names
return rename_files_on_change(first_author, renamed_author, local_book, original_filepath, path, calibre_path) return False
def upload_new_file_gdrive(book_id, first_author, renamed_author, title, title_dir, original_filepath, filename_ext): def upload_new_file_gdrive(book_id, first_author, title, title_dir, original_filepath, filename_ext):
book = calibre_db.get_book(book_id) book = calibre_db.get_book(book_id)
file_name = get_valid_filename(title, chars=42) + ' - ' + \ file_name = get_valid_filename(title, chars=42) + ' - ' + \
get_valid_filename(first_author, chars=42) + filename_ext get_valid_filename(first_author, chars=42) + filename_ext
rename_all_authors(first_author, renamed_author, gdrive=True)
gdrive_path = os.path.join(get_valid_filename(first_author, chars=96), gdrive_path = os.path.join(get_valid_filename(first_author, chars=96),
title_dir + " (" + str(book_id) + ")") title_dir + " (" + str(book_id) + ")")
book.path = gdrive_path.replace("\\", "/") book.path = gdrive_path.replace("\\", "/")
gd.uploadFileToEbooksFolder(os.path.join(gdrive_path, file_name).replace("\\", "/"), original_filepath) gd.uploadFileToEbooksFolder(os.path.join(gdrive_path, file_name).replace("\\", "/"), original_filepath)
return rename_files_on_change(first_author, renamed_author, local_book=book, gdrive=True) return False # rename_files_on_change(first_author, renamed_author, local_book=book, gdrive=True)
def update_dir_structure_gdrive(book_id, first_author):
def update_dir_structure_gdrive(book_id, first_author, renamed_author):
book = calibre_db.get_book(book_id) book = calibre_db.get_book(book_id)
authordir = book.path.split('/')[0] authordir = book.path.split('/')[0]
titledir = book.path.split('/')[1] titledir = book.path.split('/')[1]
new_authordir = rename_all_authors(first_author, renamed_author, gdrive=True) # new_authordir = rename_all_authors(first_author, renamed_author, gdrive=True)
new_authordir = get_valid_filename(first_author, chars=96)
new_titledir = get_valid_filename(book.title, chars=96) + " (" + str(book_id) + ")" new_titledir = get_valid_filename(book.title, chars=96) + " (" + str(book_id) + ")"
if titledir != new_titledir: if titledir != new_titledir:
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), titledir) g_file = gd.getFileFromEbooksFolder(authordir, titledir)
if g_file: if g_file:
gd.moveGdriveFileRemote(g_file, new_titledir) gd.moveGdriveFileRemote(g_file, new_titledir)
book.path = book.path.split('/')[0] + '/' + new_titledir book.path = book.path.split('/')[0] + '/' + new_titledir
@@ -518,44 +511,51 @@ def update_dir_structure_gdrive(book_id, first_author, renamed_author):
else: else:
return _('File %(file)s not found on Google Drive', file=book.path) # file not found return _('File %(file)s not found on Google Drive', file=book.path) # file not found
if authordir != new_authordir and authordir not in renamed_author: if authordir != new_authordir:
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), new_titledir) g_file = gd.getFileFromEbooksFolder(authordir, new_titledir)
if g_file: if g_file:
gd.moveGdriveFolderRemote(g_file, new_authordir) gd.moveGdriveFolderRemote(g_file, new_authordir, single_book=True)
book.path = new_authordir + '/' + book.path.split('/')[1] book.path = new_authordir + '/' + book.path.split('/')[1]
gd.updateDatabaseOnEdit(g_file['id'], book.path) gd.updateDatabaseOnEdit(g_file['id'], book.path)
else: else:
return _('File %(file)s not found on Google Drive', file=authordir) # file not found return _('File %(file)s not found on Google Drive', file=authordir) # file not found'''
if titledir != new_titledir or authordir != new_authordir :
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 # change location in database to new author/title path
book.path = os.path.join(new_authordir, new_titledir).replace('\\', '/') # book.path = os.path.join(authordir, new_titledir).replace('\\', '/')
return rename_files_on_change(first_author, renamed_author, book, gdrive=True) return False
def move_files_on_change(calibre_path, new_authordir, new_titledir, localbook, db_filename, original_filepath, path): 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_authordir, new_titledir) new_path = os.path.join(calibre_path, new_author_dir, new_titledir)
new_name = get_valid_filename(localbook.title, chars=96) + ' - ' + new_authordir # new_name = get_valid_filename(localbook.title, chars=96) + ' - ' + new_author_dir
try: try:
if original_filepath: if original_filepath:
if not os.path.isdir(new_path): if not os.path.isdir(new_path):
os.makedirs(new_path) os.makedirs(new_path)
shutil.move(os.path.normcase(original_filepath), os.path.normcase(os.path.join(new_path, db_filename))) shutil.move(original_filepath, os.path.join(new_path, db_filename))
log.debug("Moving title: %s to %s/%s", original_filepath, new_path, new_name) log.debug("Moving title: %s to %s", original_filepath, new_path)
else: else:
# Check new path is not valid path # Check new path is not valid path
if not os.path.exists(new_path): if not os.path.exists(new_path):
# move original path to new path # move original path to new path
log.debug("Moving title: %s to %s", path, new_path) log.debug("Moving title: %s to %s", path, new_path)
shutil.move(os.path.normcase(path), os.path.normcase(new_path)) shutil.move(path, new_path)
else: # path is valid copy only files to new location (merge) else: # path is valid copy only files to new location (merge)
log.info("Moving title: %s into existing: %s", path, new_path) log.info("Moving title: %s into existing: %s", path, new_path)
# Take all files and subfolder from old path (strange command) # Take all files and subfolder from old path (strange command)
for dir_name, __, file_list in os.walk(path): for dir_name, __, file_list in os.walk(path):
for file in file_list: for file in file_list:
shutil.move(os.path.normcase(os.path.join(dir_name, file)), shutil.move(os.path.join(dir_name, file), os.path.join(new_path + dir_name[len(path):], file))
os.path.normcase(os.path.join(new_path + dir_name[len(path):], file))) if not os.listdir(os.path.split(path)[0]):
try:
shutil.rmtree(os.path.split(path)[0])
except (IOError, OSError) as ex:
log.error("Deleting authorpath for book %s failed: %s", localbook.id, ex)
# change location in database to new author/title path # change location in database to new author/title path
localbook.path = os.path.join(new_authordir, new_titledir).replace('\\', '/') localbook.path = os.path.join(new_author_dir, new_titledir).replace('\\', '/')
except OSError as ex: except OSError as ex:
log.error_or_exception("Rename title from {} to {} failed with error: {}".format(path, new_path, ex)) log.error_or_exception("Rename title from {} to {} failed with error: {}".format(path, new_path, ex))
return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s", return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
@@ -571,15 +571,15 @@ def rename_files_on_change(first_author,
calibre_path="", calibre_path="",
gdrive=False): gdrive=False):
# Rename all files from old names to new names # Rename all files from old names to new names
try: #try:
clean_author_database(renamed_author, calibre_path, gdrive=gdrive) #clean_author_database(renamed_author, calibre_path, gdrive=gdrive)
if first_author and first_author not in renamed_author: #if first_author and first_author not in renamed_author:
clean_author_database([first_author], calibre_path, local_book, gdrive) # clean_author_database([first_author], calibre_path, local_book, gdrive)
if not gdrive and not renamed_author and not original_filepath and len(os.listdir(os.path.dirname(path))) == 0: #if not gdrive and not renamed_author and not original_filepath and len(os.listdir(os.path.dirname(path))) == 0:
shutil.rmtree(os.path.dirname(path)) # shutil.rmtree(os.path.dirname(path))
except (OSError, FileNotFoundError) as ex: #except (OSError, FileNotFoundError) as ex:
log.error_or_exception("Error in rename file in path {}".format(ex)) # log.error_or_exception("Error in rename file in path {}".format(ex))
return _("Error in rename file in path: {}".format(str(ex))) # return _("Error in rename file in path: {}".format(str(ex)))
return False return False
@@ -590,7 +590,7 @@ def delete_book_gdrive(book, book_format):
for entry in book.data: for entry in book.data:
if entry.format.upper() == book_format: if entry.format.upper() == book_format:
name = entry.name + '.' + book_format name = entry.name + '.' + book_format
g_file = gd.getFileFromEbooksFolder(book.path, name) g_file = gd.getFileFromEbooksFolder(book.path, name, nocase=True)
else: else:
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), book.path.split('/')[1]) g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), book.path.split('/')[1])
if g_file: if g_file:
@@ -618,6 +618,7 @@ def reset_password(user_id):
ub.session.rollback() ub.session.rollback()
return 0, None return 0, None
def generate_random_password(min_length): def generate_random_password(min_length):
min_length = max(8, min_length) - 4 min_length = max(8, min_length) - 4
random_source = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?" random_source = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
@@ -643,12 +644,6 @@ def generate_random_password(min_length):
return ''.join(password) return ''.join(password)
'''def generate_random_password(min_length):
s = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
passlen = min_length
return "".join(s[c % len(s)] for c in os.urandom(passlen))'''
def uniq(inpt): def uniq(inpt):
output = [] output = []
inpt = [" ".join(inp.split()) for inp in inpt] inpt = [" ".join(inp.split()) for inp in inpt]
@@ -667,38 +662,44 @@ def check_email(email):
def check_username(username): 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(): if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).scalar():
log.error("This username is already taken") log.error("This username is already taken")
raise Exception(_("This username is already taken")) raise Exception(_("This username is already taken"))
return username return username
def valid_email(email): def valid_email(emails):
email = email.strip() valid_emails = []
for email in emails.split(','):
email = strip_whitespaces(email)
# if email is not deleted # if email is not deleted
if email: if email:
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation # 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])?)*$", if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
email): email):
log.error("Invalid Email address format") log.error("Invalid Email address format for {}".format(email))
raise Exception(_("Invalid Email address format")) raise Exception(_("Invalid Email address format"))
return email valid_emails.append(email)
return ",".join(valid_emails)
def valid_password(check_password): def valid_password(check_password):
if config.config_password_policy: if config.config_password_policy:
verify = "" verify = ""
if config.config_password_min_length > 0: if config.config_password_min_length > 0:
verify += "^(?=.{" + str(config.config_password_min_length) + ",}$)" verify += r"^(?=.{" + str(config.config_password_min_length) + ",}$)"
if config.config_password_number: if config.config_password_number:
verify += "(?=.*?\d)" verify += r"(?=.*?\d)"
if config.config_password_lower: if config.config_password_lower:
verify += "(?=.*?[a-z])" verify += r"(?=.*?[\p{Ll}])"
if config.config_password_upper: if config.config_password_upper:
verify += "(?=.*?[A-Z])" verify += r"(?=.*?[\p{Lu}])"
if config.config_password_character:
verify += r"(?=.*?[\p{Letter}])"
if config.config_password_special: if config.config_password_special:
verify += "(?=.*?[^A-Za-z\s0-9])" verify += r"(?=.*?[^\p{Letter}\s0-9])"
match = re.match(verify, check_password) match = regex.match(verify, check_password)
if not match: if not match:
raise Exception(_("Password doesn't comply with password validation rules")) raise Exception(_("Password doesn't comply with password validation rules"))
return check_password return check_password
@@ -709,22 +710,20 @@ def update_dir_structure(book_id,
calibre_path, calibre_path,
first_author=None, # change author of book to this author first_author=None, # change author of book to this author
original_filepath=None, original_filepath=None,
db_filename=None, db_filename=None):
renamed_author=None):
renamed_author = renamed_author or []
if config.config_use_google_drive: if config.config_use_google_drive:
return update_dir_structure_gdrive(book_id, first_author, renamed_author) return update_dir_structure_gdrive(book_id, first_author)
else: else:
return update_dir_structure_file(book_id, return update_dir_structure_file(book_id,
calibre_path, calibre_path,
first_author,
original_filepath, original_filepath,
db_filename, renamed_author) first_author,
db_filename)
def delete_book(book, calibrepath, book_format): def delete_book(book, calibrepath, book_format):
if not book_format: if not book_format:
clear_cover_thumbnail_cache(book.id) ## here it breaks clear_cover_thumbnail_cache(book.id) # here it breaks
calibre_db.delete_dirty_metadata(book.id) calibre_db.delete_dirty_metadata(book.id)
if config.config_use_google_drive: if config.config_use_google_drive:
return delete_book_gdrive(book, book_format) return delete_book_gdrive(book, book_format)
@@ -769,9 +768,9 @@ def get_book_cover_internal(book, resolution=None):
try: try:
if not gd.is_gdrive_ready(): if not gd.is_gdrive_ready():
return get_cover_on_failure() return get_cover_on_failure()
path = gd.get_cover_via_gdrive(book.path) cover_file = gd.get_cover_via_gdrive(book.path)
if path: if cover_file:
return redirect(path) return Response(cover_file, mimetype='image/jpeg')
else: else:
log.error('{}/cover.jpg not found on Google Drive'.format(book.path)) log.error('{}/cover.jpg not found on Google Drive'.format(book.path))
return get_cover_on_failure() return get_cover_on_failure()
@@ -781,7 +780,7 @@ def get_book_cover_internal(book, resolution=None):
# Send the book cover from the Calibre directory # Send the book cover from the Calibre directory
else: else:
cover_file_path = os.path.join(config.config_calibre_dir, book.path) cover_file_path = os.path.join(config.get_book_path(), book.path)
if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")): if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")):
return send_from_directory(cover_file_path, "cover.jpg") return send_from_directory(cover_file_path, "cover.jpg")
else: else:
@@ -792,24 +791,23 @@ def get_book_cover_internal(book, resolution=None):
def get_book_cover_thumbnail(book, resolution): def get_book_cover_thumbnail(book, resolution):
if book and book.has_cover: if book and book.has_cover:
return ub.session \ return (ub.session
.query(ub.Thumbnail) \ .query(ub.Thumbnail)
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER) \ .filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER)
.filter(ub.Thumbnail.entity_id == book.id) \ .filter(ub.Thumbnail.entity_id == book.id)
.filter(ub.Thumbnail.resolution == resolution) \ .filter(ub.Thumbnail.resolution == resolution)
.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)))
.first() .first())
def get_series_thumbnail_on_failure(series_id, resolution): def get_series_thumbnail_on_failure(series_id, resolution):
book = calibre_db.session \ book = (calibre_db.session
.query(db.Books) \ .query(db.Books)
.join(db.books_series_link) \ .join(db.books_series_link)
.join(db.Series) \ .join(db.Series)
.filter(db.Series.id == series_id) \ .filter(db.Series.id == series_id)
.filter(db.Books.has_cover == 1) \ .filter(db.Books.has_cover == 1)
.first() .first())
return get_book_cover_internal(book, resolution=resolution) return get_book_cover_internal(book, resolution=resolution)
@@ -831,13 +829,13 @@ def get_series_cover_internal(series_id, resolution=None):
def get_series_thumbnail(series_id, resolution): def get_series_thumbnail(series_id, resolution):
return ub.session \ return (ub.session
.query(ub.Thumbnail) \ .query(ub.Thumbnail)
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES) \ .filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES)
.filter(ub.Thumbnail.entity_id == series_id) \ .filter(ub.Thumbnail.entity_id == series_id)
.filter(ub.Thumbnail.resolution == resolution) \ .filter(ub.Thumbnail.resolution == resolution)
.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)))
.first() .first())
# saves book cover from url # saves book cover from url
@@ -877,7 +875,7 @@ def save_cover_from_filestorage(filepath, saved_filename, img):
log.error("Failed to create path for cover") log.error("Failed to create path for cover")
return False, _("Failed to create path for cover") return False, _("Failed to create path for cover")
try: try:
# upload of jgp file without wand # upload of jpg file without wand
if isinstance(img, requests.Response): if isinstance(img, requests.Response):
with open(os.path.join(filepath, saved_filename), 'wb') as f: with open(os.path.join(filepath, saved_filename), 'wb') as f:
f.write(img.content) f.write(img.content)
@@ -921,10 +919,7 @@ def save_cover(img, book_path):
return False, _("Only jpg/jpeg files are supported as coverfile") return False, _("Only jpg/jpeg files are supported as coverfile")
if config.config_use_google_drive: if config.config_use_google_drive:
tmp_dir = os.path.join(gettempdir(), 'calibre_web') tmp_dir = get_temp_dir()
if not os.path.isdir(tmp_dir):
os.mkdir(tmp_dir)
ret, message = save_cover_from_filestorage(tmp_dir, "uploaded_cover.jpg", img) ret, message = save_cover_from_filestorage(tmp_dir, "uploaded_cover.jpg", img)
if ret is True: if ret is True:
gd.uploadFileToEbooksFolder(os.path.join(book_path, 'cover.jpg').replace("\\", "/"), gd.uploadFileToEbooksFolder(os.path.join(book_path, 'cover.jpg').replace("\\", "/"),
@@ -934,34 +929,74 @@ def save_cover(img, book_path):
else: else:
return False, message return False, message
else: else:
return save_cover_from_filestorage(os.path.join(config.config_calibre_dir, book_path), "cover.jpg", img) return save_cover_from_filestorage(os.path.join(config.get_book_path(), book_path), "cover.jpg", img)
def do_download_file(book, book_format, client, data, headers): def do_download_file(book, book_format, client, data, headers):
book_name = data.name
download_name = filename = None
if config.config_use_google_drive: if config.config_use_google_drive:
# startTime = time.time() # startTime = time.time()
df = gd.getFileFromEbooksFolder(book.path, data.name + "." + book_format) df = gd.getFileFromEbooksFolder(book.path, data.name + "." + book_format)
# log.debug('%s', time.time() - startTime) # log.debug('%s', time.time() - startTime)
if df: if df:
if config.config_embed_metadata and (
(book_format == "kepub" and config.config_kepubifypath) or
(book_format != "kepub" and config.config_binariesdir)):
output_path = os.path.join(config.config_calibre_dir, book.path)
if not os.path.exists(output_path):
os.makedirs(output_path)
output = os.path.join(config.config_calibre_dir, book.path, book_name + "." + book_format)
gd.downloadFile(book.path, book_name + "." + book_format, output)
if book_format == "kepub" and config.config_kepubifypath:
filename, download_name = do_kepubify_metadata_replace(book, output)
elif book_format != "kepub" and config.config_binariesdir:
filename, download_name = do_calibre_export(book.id, book_format)
else:
return gd.do_gdrive_download(df, headers) return gd.do_gdrive_download(df, headers)
else: else:
abort(404) abort(404)
else: else:
filename = os.path.join(config.config_calibre_dir, book.path) filename = os.path.join(config.get_book_path(), book.path)
if not os.path.isfile(os.path.join(filename, data.name + "." + book_format)): if not os.path.isfile(os.path.join(filename, book_name + "." + book_format)):
# ToDo: improve error handling # ToDo: improve error handling
log.error('File not found: %s', os.path.join(filename, data.name + "." + book_format)) log.error('File not found: %s', os.path.join(filename, book_name + "." + book_format))
if client == "kobo" and book_format == "kepub": if client == "kobo" and book_format == "kepub":
headers["Content-Disposition"] = headers["Content-Disposition"].replace(".kepub", ".kepub.epub") headers["Content-Disposition"] = headers["Content-Disposition"].replace(".kepub", ".kepub.epub")
response = make_response(send_from_directory(filename, data.name + "." + book_format)) if book_format == "kepub" and config.config_kepubifypath and config.config_embed_metadata:
filename, download_name = do_kepubify_metadata_replace(book, os.path.join(filename,
book_name + "." + book_format))
elif book_format != "kepub" and config.config_binariesdir and config.config_embed_metadata:
filename, download_name = do_calibre_export(book.id, book_format)
else:
download_name = book_name
response = make_response(send_from_directory(filename, download_name + "." + book_format))
# ToDo Check headers parameter # ToDo Check headers parameter
for element in headers: for element in headers:
response.headers[element[0]] = element[1] response.headers[element[0]] = element[1]
log.info('Downloading file: {}'.format(os.path.join(filename, data.name + "." + book_format))) log.info('Downloading file: {}'.format(os.path.join(filename, book_name + "." + book_format)))
return response return response
def do_kepubify_metadata_replace(book, file_path):
custom_columns = (calibre_db.session.query(db.CustomColumns)
.filter(db.CustomColumns.mark_for_delete == 0)
.filter(db.CustomColumns.datatype.notin_(db.cc_exceptions))
.order_by(db.CustomColumns.label).all())
tree, cf_name = get_content_opf(file_path)
package = create_new_metadata_backup(book, custom_columns, current_user.locale, _("Cover"), lang_type=2)
content = replace_metadata(tree, package)
tmp_dir = get_temp_dir()
temp_file_name = str(uuid4())
# open zipfile and replace metadata block in content.opf
updateEpub(file_path, os.path.join(tmp_dir, temp_file_name + ".kepub"), cf_name, content)
return tmp_dir, temp_file_name
################################## ##################################
@@ -970,20 +1005,61 @@ def check_unrar(unrar_location):
return return
if not os.path.exists(unrar_location): if not os.path.exists(unrar_location):
return _('Unrar binary file not found') return _('UnRar binary file not found')
try: try:
unrar_location = [unrar_location] unrar_location = [unrar_location]
value = process_wait(unrar_location, pattern='UNRAR (.*) freeware') value = process_wait(unrar_location, pattern='UNRAR (.*) freeware')
if value: if value:
version = value.group(1) version = value.group(1)
log.debug("unrar version %s", version) log.debug("UnRar version %s", version)
except (OSError, UnicodeDecodeError) as err: except (OSError, UnicodeDecodeError) as err:
log.error_or_exception(err) log.error_or_exception(err)
return _('Error executing UnRar') return _('Error executing UnRar')
def check_calibre(calibre_location):
if not calibre_location:
return
if not os.path.exists(calibre_location):
return _('Could not find the specified directory')
if not os.path.isdir(calibre_location):
return _('Please specify a directory, not a file')
try:
supported_binary_paths = [os.path.join(calibre_location, binary)
for binary in SUPPORTED_CALIBRE_BINARIES.values()]
binaries_available = [os.path.isfile(binary_path) for binary_path in supported_binary_paths]
binaries_executable = [os.access(binary_path, os.X_OK) for binary_path in supported_binary_paths]
if all(binaries_available) and all(binaries_executable):
values = [process_wait([binary_path, "--version"], pattern=r'\(calibre (.*)\)')
for binary_path in supported_binary_paths]
if all(values):
version = values[0].group(1)
log.debug("calibre version %s", version)
else:
return _('Calibre binaries not viable')
else:
ret_val = []
missing_binaries = [path for path, available in
zip(SUPPORTED_CALIBRE_BINARIES.values(), binaries_available) if not available]
missing_perms = [path for path, available in
zip(SUPPORTED_CALIBRE_BINARIES.values(), binaries_executable) if not available]
if missing_binaries:
ret_val.append(_('Missing calibre binaries: %(missing)s', missing=", ".join(missing_binaries)))
if missing_perms:
ret_val.append(_('Missing executable permissions: %(missing)s', missing=", ".join(missing_perms)))
return ", ".join(ret_val)
except (OSError, UnicodeDecodeError) as err:
log.error_or_exception(err)
return _('Error executing Calibre')
def json_serial(obj): def json_serial(obj):
"""JSON serializer for objects not serializable by default json code""" """JSON serializer for objects not serializable by default json code"""
@@ -1013,23 +1089,17 @@ def tags_filters():
# in all calls the email address is checked for validity # in all calls the email address is checked for validity
def check_valid_domain(domain_text): def check_valid_domain(domain_text):
sql = "SELECT * FROM registration WHERE (:domain LIKE domain and allow = 1);" sql = "SELECT * FROM registration WHERE (:domain LIKE domain and allow = 1);"
result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all() if not len(ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all()):
if not len(result):
return False return False
sql = "SELECT * FROM registration WHERE (:domain LIKE domain and allow = 0);" sql = "SELECT * FROM registration WHERE (:domain LIKE domain and allow = 0);"
result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all() return not len(ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all())
return not len(result)
def get_download_link(book_id, book_format, client): def get_download_link(book_id, book_format, client):
book_format = book_format.split(".")[0] book_format = book_format.split(".")[0]
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
data1= ""
if book: if book:
data1 = calibre_db.get_book_format(book.id, book_format.upper()) data1 = calibre_db.get_book_format(book.id, book_format.upper())
else:
log.error("Book id {} not found for downloading".format(book_id))
abort(404)
if data1: if data1:
# collect downloaded books only for registered user and not for anonymous user # collect downloaded books only for registered user and not for anonymous user
if current_user.is_authenticated: if current_user.is_authenticated:
@@ -1044,6 +1114,7 @@ def get_download_link(book_id, book_format, client):
quote(file_name), book_format, quote(file_name), book_format) quote(file_name), book_format, quote(file_name), book_format)
return do_download_file(book, book_format, client, data1, headers) return do_download_file(book, book_format, client, data1, headers)
else: else:
log.error("Book id {} not found for downloading".format(book_id))
abort(404) abort(404)
+36 -22
View File
@@ -15,24 +15,30 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
from .iso_language_names import LANGUAGE_NAMES as _LANGUAGE_NAMES from .iso_language_names import LANGUAGE_NAMES as _LANGUAGE_NAMES
from . import logger from . import logger
from .string_helper import strip_whitespaces
log = logger.create() log = logger.create()
try: try:
from iso639 import languages, __version__ from iso639 import languages
# iso_version = importlib.metadata.version("iso639")
get = languages.get get = languages.get
except ImportError:
from pycountry import languages as pyc_languages
try: try:
if sys.version_info >= (3, 12):
import pkg_resources import pkg_resources
__version__ = pkg_resources.get_distribution('pycountry').version + ' (PyCountry)' except ImportError:
del pkg_resources print("Python 3.12 isn't compatible with iso-639. Please install pycountry.")
except (ImportError, Exception): except ImportError as ex:
__version__ = "? (PyCountry)" from pycountry import languages as pyc_languages
#try:
# iso_version = importlib.metadata.version("pycountry") + ' (PyCountry)'
#except (ImportError, Exception):
# iso_version = "?" + ' (PyCountry)'
def _copy_fields(l): def _copy_fields(l):
l.part1 = getattr(l, 'alpha_2', None) l.part1 = getattr(l, 'alpha_2', None)
@@ -49,32 +55,40 @@ except ImportError:
def get_language_names(locale): def get_language_names(locale):
return _LANGUAGE_NAMES.get(str(locale)) names = _LANGUAGE_NAMES.get(str(locale))
if names is None:
names = _LANGUAGE_NAMES.get(locale.language)
return names
def get_language_name(locale, lang_code): def get_language_name(locale, lang_code):
try: UNKNOWN_TRANSLATION = "Unknown"
return get_language_names(locale)[lang_code] names = get_language_names(locale)
except KeyError: if names is None:
log.error('Missing translation for language name: {}'.format(lang_code)) log.error(f"Missing language names for locale: {str(locale)}/{locale.language}")
return "Unknown" return UNKNOWN_TRANSLATION
name = names.get(lang_code, UNKNOWN_TRANSLATION)
if name == UNKNOWN_TRANSLATION:
log.error("Missing translation for language name: {}".format(lang_code))
return name
def get_language_codes(locale, language_names, remainder=None): def get_language_code_from_name(locale, language_names, remainder=None):
language_names = set(x.strip().lower() for x in language_names if x) language_names = set(strip_whitespaces(x).lower() for x in language_names if x)
lang = list() lang = list()
for k, v in get_language_names(locale).items(): for key, val in get_language_names(locale).items():
v = v.lower() val = val.lower()
if v in language_names: if val in language_names:
lang.append(k) lang.append(key)
language_names.remove(v) language_names.remove(val)
if remainder is not None and language_names: if remainder is not None and language_names:
remainder.extend(language_names) remainder.extend(language_names)
return lang return lang
def get_valid_language_codes_from_code(locale, language_names, remainder=None):
def get_valid_language_codes(locale, language_names, remainder=None):
lang = list() lang = list()
if "" in language_names: if "" in language_names:
language_names.remove("") language_names.remove("")
+378
View File
@@ -7760,6 +7760,384 @@ LANGUAGE_NAMES = {
"zxx": "Нет языкового содержимого", "zxx": "Нет языкового содержимого",
"zza": "Зазаки" "zza": "Зазаки"
}, },
"sk": {
"abk": "Abkhazian",
"ace": "Achinese",
"ach": "Acoli",
"ada": "Adangme",
"ady": "Adyghe",
"aar": "Afar",
"afh": "Afrihili",
"afr": "Afrikánsky",
"ain": "Ainu (Japan)",
"aka": "Akan",
"akk": "Akkadian",
"sqi": "Albanian",
"ale": "Aleut",
"amh": "Amharic",
"anp": "Angika",
"ara": "Arabská",
"arg": "Aragonese",
"arp": "Arapaho",
"arw": "Arawak",
"hye": "Arménčina",
"asm": "Assamese",
"ast": "Asturian",
"ava": "Avaric",
"ave": "Avestan",
"awa": "Awadhi",
"aym": "Aymara",
"aze": "Ázerbajdžánsky",
"ban": "Balinese",
"bal": "Baluchi",
"bam": "Bambara",
"bas": "Basa (Cameroon)",
"bak": "Bashkir",
"eus": "Baskitský",
"bej": "Beja",
"bel": "Belarusian",
"bem": "Bemba (Zambia)",
"ben": "Bengali",
"bit": "Berinomo",
"bho": "Bhojpuri",
"bik": "Bikol",
"byn": "Bilin",
"bin": "Bini",
"bis": "Bislama",
"zbl": "Blissymbols",
"bos": "Bosnian",
"bra": "Braj",
"bre": "Bretónsky",
"bug": "Buginese",
"bul": "Bulharský",
"bua": "Buriat",
"mya": "Burmese",
"cad": "Caddo",
"cat": "Katalánsky",
"ceb": "Cebuano",
"chg": "Chagatai",
"cha": "Chamorro",
"che": "Chechen",
"chr": "Cherokee",
"chy": "Cheyenne",
"chb": "Chibcha",
"zho": "Čínsky",
"chn": "Chinook jargon",
"chp": "Chipewyan",
"cho": "Choctaw",
"cht": "Cholón",
"chk": "Chuukese",
"chv": "Chuvash",
"cop": "Coptic",
"cor": "Cornish",
"cos": "Corsican",
"cre": "Cree",
"mus": "Creek",
"hrv": "Chorvátsky",
"ces": "Český",
"dak": "Dakota",
"dan": "Dánsky",
"dar": "Dargwa",
"del": "Delaware",
"div": "Dhivehi",
"din": "Dinka",
"doi": "Dogri (macrolanguage)",
"dgr": "Dogrib",
"dua": "Duala",
"nld": "Holandský",
"dse": "Dutch Sign Language",
"dyu": "Dyula",
"dzo": "Dzongkha",
"efi": "Efik",
"egy": "Egyptian (Ancient)",
"eka": "Ekajuk",
"elx": "Elamite",
"eng": "Angličtina",
"enu": "Enu",
"myv": "Erzya",
"epo": "Esperanto",
"est": "Estónsky",
"ewe": "Ewe",
"ewo": "Ewondo",
"fan": "Fang (Equatorial Guinea)",
"fat": "Fanti",
"fao": "Faroese",
"fij": "Fijian",
"fil": "Filipino",
"fin": "Fínsky",
"fon": "Fon",
"fra": "Francúzsky",
"fur": "Friulian",
"ful": "Fulah",
"gaa": "Ga",
"glg": "Galician",
"lug": "Ganda",
"gay": "Gayo",
"gba": "Gbaya (Central African Republic)",
"hmj": "Ge",
"gez": "Geez",
"kat": "Georgian",
"deu": "Nemecký",
"gil": "Gilbertese",
"gon": "Gondi",
"gor": "Gorontalo",
"got": "Gothic",
"grb": "Grebo",
"grn": "Guarani",
"guj": "Gujarati",
"gwi": "Gwichʼin",
"hai": "Haida",
"hau": "Hausa",
"haw": "Hawaiian",
"heb": "Hebrejský",
"her": "Herero",
"hil": "Hiligaynon",
"hin": "Hindi",
"hmo": "Hiri Motu",
"hit": "Hittite",
"hmn": "Hmong",
"hun": "Maďarský",
"hup": "Hupa",
"iba": "Iban",
"isl": "Islandský",
"ido": "Ido",
"ibo": "Igbo",
"ilo": "Iloko",
"ind": "Indonézsky",
"inh": "Ingush",
"ina": "Interlingua (International Auxiliary Language Association)",
"ile": "Interlingue",
"iku": "Inuktitut",
"ipk": "Inupiaq",
"gle": "Írsky",
"ita": "Taliansky",
"jpn": "Japonský",
"jav": "Javanese",
"jrb": "Judeo-Arabic",
"jpr": "Judeo-Persian",
"kbd": "Kabardian",
"kab": "Kabyle",
"kac": "Kachin",
"kal": "Kalaallisut",
"xal": "Kalmyk",
"kam": "Kamba (Kenya)",
"kan": "Kannada",
"kau": "Kanuri",
"kaa": "Kara-Kalpak",
"krc": "Karachay-Balkar",
"krl": "Karelian",
"kas": "Kashmiri",
"csb": "Kashubian",
"kaw": "Kawi",
"kaz": "Kazakh",
"kha": "Khasi",
"kho": "Khotanese",
"kik": "Kikuyu",
"kmb": "Kimbundu",
"kin": "Kinyarwanda",
"kir": "Kirghiz",
"tlh": "Klingon",
"kom": "Komi",
"kon": "Kongo",
"kok": "Konkani (macrolanguage)",
"kor": "Kórejský",
"kos": "Kosraean",
"kpe": "Kpelle",
"kua": "Kuanyama",
"kum": "Kumyk",
"kur": "Kurdský",
"kru": "Kurukh",
"kut": "Kutenai",
"lad": "Ladino",
"lah": "Lahnda",
"lam": "Lamba",
"lao": "Lao",
"lat": "Latin",
"lav": "Latvian",
"lez": "Lezghian",
"lim": "Limburgan",
"lin": "Lingala",
"lit": "Lotyšský",
"jbo": "Lojban",
"loz": "Lozi",
"lub": "Luba-Katanga",
"lua": "Luba-Lulua",
"lui": "Luiseno",
"smj": "Lule Sami",
"lun": "Lunda",
"luo": "Luo (Kenya and Tanzania)",
"lus": "Lushai",
"ltz": "Luxembourgish",
"mkd": "Macedónsky",
"mad": "Madurese",
"mag": "Magahi",
"mai": "Maithili",
"mak": "Makasar",
"mlg": "Malagasy",
"msa": "Malay (macrolanguage)",
"mal": "Malayalam",
"mlt": "Maltézsky",
"mnc": "Manchu",
"mdr": "Mandar",
"man": "Mandingo",
"mni": "Manipuri",
"glv": "Manx",
"mri": "Maori",
"arn": "Mapudungun",
"mar": "Marathi",
"chm": "Mari (Russia)",
"mah": "Marshallese",
"mwr": "Marwari",
"mas": "Masai",
"men": "Mende (Sierra Leone)",
"mic": "Mi'kmaq",
"min": "Minangkabau",
"mwl": "Mirandese",
"moh": "Mohawk",
"mdf": "Moksha",
"lol": "Mongo",
"mon": "Mongolian",
"mos": "Mossi",
"mul": "Multiple languages",
"nqo": "N'Ko",
"nau": "Nauru",
"nav": "Navajo",
"ndo": "Ndonga",
"nap": "Neapolitan",
"nia": "Nias",
"niu": "Niuean",
"zxx": "No linguistic content",
"nog": "Nogai",
"nor": "Norwegian",
"nob": "Norwegian Bokmål",
"nno": "Norwegian 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": "Palauan",
"pli": "Pali",
"pam": "Pampanga",
"pag": "Pangasinan",
"pan": "Panjabi",
"pap": "Papiamento",
"fas": "Persian",
"phn": "Phoenician",
"pon": "Pohnpeian",
"pol": "Poľský",
"por": "Portugalský",
"pus": "Pashto",
"que": "Quechua",
"raj": "Rajasthani",
"rap": "Rapanui",
"ron": "Rumunský",
"roh": "Romansh",
"rom": "Romany",
"run": "Rundi",
"rus": "Ruský",
"smo": "Samoan",
"sad": "Sandawe",
"sag": "Sango",
"san": "Sanskrit",
"sat": "Santali",
"srd": "Sardinian",
"sas": "Sasak",
"sco": "Scots",
"sel": "Selkup",
"srp": "Srbský",
"srr": "Serer",
"shn": "Shan",
"sna": "Shona",
"scn": "Sicilian",
"sid": "Sidamo",
"bla": "Siksika",
"snd": "Sindhi",
"sin": "Sinhala",
"den": "Slave (Athapascan)",
"slk": "Slovenský",
"slv": "Slovinský",
"sog": "Sogdian",
"som": "Somali",
"snk": "Soninke",
"spa": "Španielsky",
"srn": "Sranan Tongo",
"suk": "Sukuma",
"sux": "Sumerian",
"sun": "Sundanese",
"sus": "Susu",
"swa": "Swahili (macrolanguage)",
"ssw": "Swati",
"swe": "Švédsky",
"syr": "Syriac",
"tgl": "Tagalog",
"tah": "Tahitian",
"tgk": "Tajik",
"tmh": "Tamashek",
"tam": "Tamilský",
"tat": "Tatar",
"tel": "Telugu",
"ter": "Tereno",
"tet": "Tetum",
"tha": "Thajský",
"bod": "Tibetan",
"tig": "Tigre",
"tir": "Tigrinya",
"tem": "Timne",
"tiv": "Tiv",
"tli": "Tlingit",
"tpi": "Tok Pisin",
"tkl": "Tokelau",
"tog": "Tonga (Nyasa)",
"ton": "Tonga (Tonga Islands)",
"tsi": "Tsimshian",
"tso": "Tsonga",
"tsn": "Tswana",
"tum": "Tumbuka",
"tur": "Turecký",
"tuk": "Turkmen",
"tvl": "Tuvalu",
"tyv": "Tuvinian",
"twi": "Twi",
"udm": "Udmurt",
"uga": "Ugaritic",
"uig": "Uighur",
"ukr": "Ukrainian",
"umb": "Umbundu",
"mis": "Uncoded languages",
"und": "Undetermined",
"urd": "Urdu",
"uzb": "Uzbek",
"vai": "Vai",
"ven": "Venda",
"vie": "Vietnamský",
"vol": "Volapük",
"vot": "Votic",
"wln": "Vallónsky",
"war": "Waray (Philippines)",
"was": "Washo",
"cym": "Welšský",
"wal": "Wolaytta",
"wol": "Wolof",
"xho": "Xhosa",
"sah": "Yakut",
"yao": "Yao",
"yap": "Yapese",
"yid": "Yiddish",
"yor": "Yoruba",
"zap": "Zapotec",
"zza": "Zaza",
"zen": "Zenaga",
"zha": "Zhuang",
"zul": "Zulu",
"zun": "Zuni"
},
"sv": { "sv": {
"aar": "Afar", "aar": "Afar",
"abk": "Abchaziska", "abk": "Abchaziska",
+18 -5
View File
@@ -27,10 +27,9 @@ import datetime
import mimetypes import mimetypes
from uuid import uuid4 from uuid import uuid4
# from babel.dates import format_date from flask import Blueprint, request, url_for, g
from flask import Blueprint, request, url_for
from flask_babel import format_date from flask_babel import format_date
from flask_login import current_user from .cw_login import current_user
from . import constants, logger from . import constants, logger
@@ -44,6 +43,8 @@ def url_for_other_page(page):
args = request.view_args.copy() args = request.view_args.copy()
args['page'] = page args['page'] = page
for get, val in request.args.items(): for get, val in request.args.items():
if get == "page":
continue
args[get] = val args[get] = val
return url_for(request.endpoint, **args) return url_for(request.endpoint, **args)
@@ -113,7 +114,10 @@ def yesno(value, yes, no):
@jinjia.app_template_filter('formatfloat') @jinjia.app_template_filter('formatfloat')
def formatfloat(value, decimals=1): def formatfloat(value, decimals=1):
value = 0 if not value else value value = 0 if not value else value
return ('{0:.' + str(decimals) + 'f}').format(value).rstrip('0').rstrip('.') 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')
@@ -124,7 +128,7 @@ def formatseriesindex_filter(series_index):
return int(series_index) return int(series_index)
else: else:
return series_index return series_index
except ValueError: except (ValueError, TypeError):
return series_index return series_index
return 0 return 0
@@ -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()) url = url_for('web.get_series_cover', series_id=series.id, resolution=shortname, c=cache_timestamp())
srcset.append(f'{url} {resolution}x') srcset.append(f'{url} {resolution}x')
return ', '.join(srcset) 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
+29 -19
View File
@@ -18,7 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64 import base64
import datetime from datetime import datetime, timezone
import os import os
import uuid import uuid
import zipfile import zipfile
@@ -36,7 +36,7 @@ from flask import (
redirect, redirect,
abort abort
) )
from flask_login import current_user from .cw_login import current_user
from werkzeug.datastructures import Headers from werkzeug.datastructures import Headers
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.sql.expression import and_, or_ from sqlalchemy.sql.expression import and_, or_
@@ -44,11 +44,10 @@ from sqlalchemy.exc import StatementError
from sqlalchemy.sql import select from sqlalchemy.sql import select
import requests import requests
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
from . import isoLanguages from . import isoLanguages
from .epub import get_epub_layout from .epub import get_epub_layout
from .constants import COVER_THUMBNAIL_SMALL #, sqlalchemy_version2 from .constants import COVER_THUMBNAIL_SMALL, COVER_THUMBNAIL_MEDIUM, COVER_THUMBNAIL_LARGE
from .helper import get_download_link from .helper import get_download_link
from .services import SyncToken as SyncToken from .services import SyncToken as SyncToken
from .web import download_required from .web import download_required
@@ -56,7 +55,7 @@ from .kobo_auth import requires_kobo_auth, get_auth_token
KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB3", "EPUB"]} KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB3", "EPUB"]}
KOBO_STOREAPI_URL = "https://storeapi.kobo.com" KOBO_STOREAPI_URL = "https://storeapi.kobo.com"
KOBO_IMAGEHOST_URL = "https://kbimages1-a.akamaihd.net" KOBO_IMAGEHOST_URL = "https://cdn.kobo.com/book-images"
SYNC_ITEM_LIMIT = 100 SYNC_ITEM_LIMIT = 100
@@ -132,15 +131,18 @@ def convert_to_kobo_timestamp_string(timestamp):
return timestamp.strftime("%Y-%m-%dT%H:%M:%SZ") return timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
except AttributeError as exc: except AttributeError as exc:
log.debug("Timestamp not valid: {}".format(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") @kobo.route("/v1/library/sync")
@requires_kobo_auth @requires_kobo_auth
@download_required # @download_required
def HandleSyncRequest(): def HandleSyncRequest():
if not current_user.role_download():
log.info("Users need download permissions for syncing library to Kobo reader")
return abort(403)
sync_token = SyncToken.SyncToken.from_headers(request.headers) sync_token = SyncToken.SyncToken.from_headers(request.headers)
log.info("Kobo library sync request received.") log.info("Kobo library sync request received")
log.debug("SyncToken: {}".format(sync_token)) log.debug("SyncToken: {}".format(sync_token))
log.debug("Download link format {}".format(get_download_url_for_book('[bookid]', '[bookformat]'))) log.debug("Download link format {}".format(get_download_url_for_book('[bookid]', '[bookformat]')))
if not current_app.wsgi_app.is_proxied: if not current_app.wsgi_app.is_proxied:
@@ -148,15 +150,15 @@ def HandleSyncRequest():
# if no books synced don't respect sync_token # 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(): 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_modified = datetime.min
sync_token.books_last_created = datetime.datetime.min sync_token.books_last_created = datetime.min
sync_token.reading_state_last_modified = datetime.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_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_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_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 = [] sync_results = []
# We reload the book database so that the user gets a fresh view of the library # We reload the book database so that the user gets a fresh view of the library
@@ -205,7 +207,7 @@ def HandleSyncRequest():
for book in books: for book in books:
formats = [data.format for data in book.Books.data] formats = [data.format for data in book.Books.data]
if 'KEPUB' not in formats and config.config_kepubifypath and 'EPUB' in formats: if 'KEPUB' not in formats and config.config_kepubifypath and 'EPUB' in formats:
helper.convert_book_format(book.Books.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name) helper.convert_book_format(book.Books.id, config.get_book_path(), 'EPUB', 'KEPUB', current_user.name)
kobo_reading_state = get_or_create_reading_state(book.Books.id) kobo_reading_state = get_or_create_reading_state(book.Books.id)
entitlement = { entitlement = {
@@ -315,7 +317,7 @@ def generate_sync_response(sync_token, sync_results, set_cont=False):
extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads") extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads")
except Exception as ex: except Exception as ex:
log.error("Failed to receive or parse response from Kobo's sync endpoint: {}".format(ex)) log.error_or_exception("Failed to receive or parse response from Kobo's sync endpoint: {}".format(ex))
if set_cont: if set_cont:
extra_headers["x-kobo-sync"] = "continue" extra_headers["x-kobo-sync"] = "continue"
sync_token.to_headers(extra_headers) sync_token.to_headers(extra_headers)
@@ -373,7 +375,7 @@ def create_book_entitlement(book, archived):
book_uuid = str(book.uuid) book_uuid = str(book.uuid)
return { return {
"Accessibility": "Full", "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), "Created": convert_to_kobo_timestamp_string(book.timestamp),
"CrossRevisionId": book_uuid, "CrossRevisionId": book_uuid,
"Id": book_uuid, "Id": book_uuid,
@@ -793,7 +795,7 @@ def HandleStateRequest(book_uuid):
if new_book_read_status == ub.ReadBook.STATUS_IN_PROGRESS \ if new_book_read_status == ub.ReadBook.STATUS_IN_PROGRESS \
and new_book_read_status != book_read.read_status: and new_book_read_status != book_read.read_status:
book_read.times_started_reading += 1 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 book_read.read_status = new_book_read_status
update_results_response["StatusInfoResult"] = {"Result": "Success"} update_results_response["StatusInfoResult"] = {"Result": "Success"}
except (KeyError, TypeError, ValueError, StatementError): except (KeyError, TypeError, ValueError, StatementError):
@@ -901,7 +903,12 @@ def get_current_bookmark_response(current_bookmark):
@requires_kobo_auth @requires_kobo_auth
def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale): def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
try: 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: except ValueError:
log.error("Requested height %s of book %s is invalid" % (book_uuid, height)) log.error("Requested height %s of book %s is invalid" % (book_uuid, height))
resolution = COVER_THUMBNAIL_SMALL resolution = COVER_THUMBNAIL_SMALL
@@ -948,7 +955,8 @@ def HandleBookDeletionRequest(book_uuid):
@csrf.exempt @csrf.exempt
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET"]) @kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET"])
def HandleUnimplementedRequest(dummy=None): def HandleUnimplementedRequest(dummy=None):
log.debug("Unimplemented Library Request received: %s (request is forwarded to kobo if configured)", request.base_url) log.debug("Unimplemented Library Request received: %s (request is forwarded to kobo if configured)",
request.base_url)
return redirect_or_proxy_request() return redirect_or_proxy_request()
@@ -959,6 +967,7 @@ def HandleUnimplementedRequest(dummy=None):
@kobo.route("/v1/user/wishlist", methods=["GET", "POST"]) @kobo.route("/v1/user/wishlist", methods=["GET", "POST"])
@kobo.route("/v1/user/recommendations", methods=["GET", "POST"]) @kobo.route("/v1/user/recommendations", methods=["GET", "POST"])
@kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"]) @kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"])
@kobo.route("/v1/assets", methods=["GET"])
def HandleUserRequest(dummy=None): def HandleUserRequest(dummy=None):
log.debug("Unimplemented User Request received: %s (request is forwarded to kobo if configured)", request.base_url) log.debug("Unimplemented User Request received: %s (request is forwarded to kobo if configured)", request.base_url)
return redirect_or_proxy_request() return redirect_or_proxy_request()
@@ -1000,7 +1009,8 @@ def handle_getests():
@kobo.route("/v1/affiliate", methods=["GET", "POST"]) @kobo.route("/v1/affiliate", methods=["GET", "POST"])
@kobo.route("/v1/deals", methods=["GET", "POST"]) @kobo.route("/v1/deals", methods=["GET", "POST"])
def HandleProductsRequest(dummy=None): def HandleProductsRequest(dummy=None):
log.debug("Unimplemented Products Request received: %s (request is forwarded to kobo if configured)", request.base_url) log.debug("Unimplemented Products Request received: %s (request is forwarded to kobo if configured)",
request.base_url)
return redirect_or_proxy_request() return redirect_or_proxy_request()
+8 -3
View File
@@ -65,12 +65,14 @@ from os import urandom
from functools import wraps from functools import wraps
from flask import g, Blueprint, abort, request 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_babel import gettext as _
from flask_limiter import RateLimitExceeded from flask_limiter import RateLimitExceeded
from . import logger, config, calibre_db, db, helper, ub, lm, limiter from . import logger, config, calibre_db, db, helper, ub, lm, limiter
from .render_template import render_title_template from .render_template import render_title_template
from .usermanagement import user_login_required
log = logger.create() 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>") @kobo_auth.route("/generate_auth_token/<int:user_id>")
@login_required @user_login_required
def generate_auth_token(user_id): def generate_auth_token(user_id):
warning = False warning = False
host_list = request.host.rsplit(':') host_list = request.host.rsplit(':')
@@ -120,7 +122,7 @@ def generate_auth_token(user_id):
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"]) @kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
@login_required @user_login_required
def delete_auth_token(user_id): def delete_auth_token(user_id):
# Invalidate any previously generated Kobo Auth token for this user # Invalidate any previously generated Kobo Auth token for this user
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\ ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
@@ -156,6 +158,9 @@ def requires_kobo_auth(f):
limiter.check() limiter.check()
except RateLimitExceeded: except RateLimitExceeded:
return abort(429) return abort(429)
except (ConnectionError, Exception) as e:
log.error("Connection error to limiter backend: %s", e)
return abort(429)
user = ( user = (
ub.session.query(ub.User) ub.session.query(ub.User)
.join(ub.RemoteAuthToken) .join(ub.RemoteAuthToken)
+5 -5
View File
@@ -17,11 +17,12 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from flask_login import current_user from .cw_login import current_user
from . import ub from . import ub
import datetime from datetime import datetime, timezone
from sqlalchemy.sql.expression import or_, and_, true 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, # Add the current book id to kobo_synced_books table for current user, if entry is already present,
# do nothing (safety precaution) # do nothing (safety precaution)
@@ -50,7 +51,6 @@ def remove_synced_book(book_id, all=False, session=None):
ub.session_commit(_session=session) ub.session_commit(_session=session)
def change_archived_books(book_id, state=None, message=None): def change_archived_books(book_id, state=None, message=None):
archived_book = ub.session.query(ub.ArchivedBook).filter(and_(ub.ArchivedBook.user_id == int(current_user.id), archived_book = ub.session.query(ub.ArchivedBook).filter(and_(ub.ArchivedBook.user_id == int(current_user.id),
ub.ArchivedBook.book_id == book_id)).first() ub.ArchivedBook.book_id == book_id)).first()
@@ -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 = ub.ArchivedBook(user_id=current_user.id, book_id=book_id)
archived_book.is_archived = state if state else not archived_book.is_archived archived_book.is_archived = state if state 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.merge(archived_book)
ub.session_commit(message) ub.session_commit(message)
+5 -4
View File
@@ -43,13 +43,14 @@ logging.addLevelName(logging.CRITICAL, "CRIT")
class _Logger(logging.Logger): class _Logger(logging.Logger):
def error_or_exception(self, message, stacklevel=2, *args, **kwargs): def error_or_exception(self, message, stacklevel=2, *args, **kwargs):
is_debug = self.getEffectiveLevel() <= logging.DEBUG
if sys.version_info > (3, 7): if sys.version_info > (3, 7):
if is_debug_enabled(): if is_debug:
self.exception(message, stacklevel=stacklevel, *args, **kwargs) self.exception(message, stacklevel=stacklevel, *args, **kwargs)
else: else:
self.error(message, stacklevel=stacklevel, *args, **kwargs) self.error(message, stacklevel=stacklevel, *args, **kwargs)
else: else:
if is_debug_enabled(): if is_debug:
self.exception(message, stack_info=True, *args, **kwargs) self.exception(message, stack_info=True, *args, **kwargs)
else: else:
self.error(message, *args, **kwargs) self.error(message, *args, **kwargs)
@@ -150,7 +151,7 @@ def setup(log_file, log_level=None):
else: else:
try: try:
file_handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=2, encoding='utf-8') file_handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=2, encoding='utf-8')
except IOError: except (IOError, PermissionError):
if log_file == DEFAULT_LOG_FILE: if log_file == DEFAULT_LOG_FILE:
raise raise
file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=100000, backupCount=2, encoding='utf-8') file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=100000, backupCount=2, encoding='utf-8')
@@ -177,7 +178,7 @@ def create_access_log(log_file, log_name, formatter):
access_log.setLevel(logging.INFO) access_log.setLevel(logging.INFO)
try: try:
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2, encoding='utf-8') file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2, encoding='utf-8')
except IOError: except (IOError, PermissionError):
if log_file == DEFAULT_ACCESS_LOG: if log_file == DEFAULT_ACCESS_LOG:
raise raise
file_handler = RotatingFileHandler(DEFAULT_ACCESS_LOG, maxBytes=50000, backupCount=2, encoding='utf-8') file_handler = RotatingFileHandler(DEFAULT_ACCESS_LOG, maxBytes=50000, backupCount=2, encoding='utf-8')
+6 -1
View File
@@ -20,17 +20,18 @@ import sys
from . import create_app, limiter from . import create_app, limiter
from .jinjia import jinjia from .jinjia import jinjia
from .remotelogin import remotelogin
from flask import request from flask import request
def request_username(): def request_username():
return request.authorization.username return request.authorization.username
def main(): def main():
app = create_app() app = create_app()
from .web import web from .web import web
from .basic import basic
from .opds import opds from .opds import opds
from .admin import admi from .admin import admi
from .gdrive import gdrive from .gdrive import gdrive
@@ -41,6 +42,7 @@ def main():
from .shelf import shelf from .shelf import shelf
from .tasks_status import tasks from .tasks_status import tasks
from .error_handler import init_errorhandler from .error_handler import init_errorhandler
from .remotelogin import remotelogin
try: try:
from .kobo import kobo, get_kobo_activated from .kobo import kobo, get_kobo_activated
from .kobo_auth import kobo_auth from .kobo_auth import kobo_auth
@@ -48,12 +50,14 @@ def main():
kobo_available = get_kobo_activated() kobo_available = get_kobo_activated()
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator) except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
kobo_available = False kobo_available = False
kobo = kobo_auth = get_remote_address = None
try: try:
from .oauth_bb import oauth from .oauth_bb import oauth
oauth_available = True oauth_available = True
except ImportError: except ImportError:
oauth_available = False oauth_available = False
oauth = None
from . import web_server from . import web_server
init_errorhandler() init_errorhandler()
@@ -61,6 +65,7 @@ def main():
app.register_blueprint(search) app.register_blueprint(search)
app.register_blueprint(tasks) app.register_blueprint(tasks)
app.register_blueprint(web) app.register_blueprint(web)
app.register_blueprint(basic)
app.register_blueprint(opds) app.register_blueprint(opds)
limiter.limit("3/minute", key_func=request_username)(opds) limiter.limit("3/minute", key_func=request_username)(opds)
app.register_blueprint(jinjia) app.register_blueprint(jinjia)
+5 -8
View File
@@ -25,7 +25,7 @@ try:
import cchardet #optional for better speed import cchardet #optional for better speed
except ImportError: except ImportError:
pass pass
from cps import logger
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
import cps.logger as logger import cps.logger as logger
@@ -33,8 +33,6 @@ import cps.logger as logger
from operator import itemgetter from operator import itemgetter
log = logger.create() log = logger.create()
log = logger.create()
class Amazon(Metadata): class Amazon(Metadata):
__name__ = "Amazon" __name__ = "Amazon"
@@ -55,7 +53,6 @@ class Amazon(Metadata):
def search( def search(
self, query: str, generic_cover: str = "", locale: str = "en" self, query: str, generic_cover: str = "", locale: str = "en"
) -> Optional[List[MetaRecord]]: ) -> Optional[List[MetaRecord]]:
#timer=time()
def inner(link, index) -> [dict, int]: def inner(link, index) -> [dict, int]:
with self.session as session: with self.session as session:
try: try:
@@ -63,11 +60,11 @@ class Amazon(Metadata):
r.raise_for_status() r.raise_for_status()
except Exception as ex: except Exception as ex:
log.warning(ex) log.warning(ex)
return None return []
long_soup = BS(r.text, "lxml") #~4sec :/ 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-books-ppd_csm_instrumentation_wrapper"})
if soup2 is None: if soup2 is None:
return None return []
try: try:
match = MetaRecord( match = MetaRecord(
title = "", title = "",
@@ -90,7 +87,7 @@ class Amazon(Metadata):
soup2.find("div", attrs={"data-feature-name": "bookDescription"}).stripped_strings)\ soup2.find("div", attrs={"data-feature-name": "bookDescription"}).stripped_strings)\
.replace("\xa0"," ")[:-9].strip().strip("\n") .replace("\xa0"," ")[:-9].strip().strip("\n")
except (AttributeError, TypeError): 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: try:
match.title = soup2.find("span", attrs={"id": "productTitle"}).text match.title = soup2.find("span", attrs={"id": "productTitle"}).text
except (AttributeError, TypeError): except (AttributeError, TypeError):
@@ -115,7 +112,7 @@ class Amazon(Metadata):
return match, index return match, index
except Exception as e: except Exception as e:
log.error_or_exception(e) log.error_or_exception(e)
return None return []
val = list() val = list()
if self.active: if self.active:
+5 -3
View File
@@ -169,7 +169,8 @@ class Douban(Metadata):
), ),
) )
html = etree.HTML(r.content.decode("utf8")) decode_content = r.content.decode("utf8")
html = etree.HTML(decode_content)
match.title = html.xpath(self.TITTLE_XPATH)[0].text match.title = html.xpath(self.TITTLE_XPATH)[0].text
match.cover = html.xpath( match.cover = html.xpath(
@@ -184,7 +185,7 @@ class Douban(Metadata):
if len(tag_elements): if len(tag_elements):
match.tags = [tag_element.text for tag_element in tag_elements] match.tags = [tag_element.text for tag_element in tag_elements]
else: else:
match.tags = self._get_tags(html.text) match.tags = self._get_tags(decode_content)
description_element = html.xpath(self.DESCRIPTION_XPATH) description_element = html.xpath(self.DESCRIPTION_XPATH)
if len(description_element): if len(description_element):
@@ -216,7 +217,8 @@ class Douban(Metadata):
return match return match
def _clean_date(self, date: str) -> str: @staticmethod
def _clean_date(date: str) -> str:
""" """
Clean up the date string to be in the format YYYY-MM-DD Clean up the date string to be in the format YYYY-MM-DD
+1 -1
View File
@@ -54,7 +54,7 @@ class Google(Metadata):
results.raise_for_status() results.raise_for_status()
except Exception as e: except Exception as e:
log.warning(e) log.warning(e)
return None return []
for result in results.json().get("items", []): for result in results.json().get("items", []):
val.append( val.append(
self._parse_search_result( self._parse_search_result(
+11 -2
View File
@@ -97,12 +97,14 @@ class LubimyCzytac(Metadata):
LANGUAGES = f"{CONTAINER}//dt[contains(text(),'Język:')]{SIBLINGS}/text()" LANGUAGES = f"{CONTAINER}//dt[contains(text(),'Język:')]{SIBLINGS}/text()"
DESCRIPTION = f"{CONTAINER}//div[@class='collapse-content']" DESCRIPTION = f"{CONTAINER}//div[@class='collapse-content']"
SERIES = f"{CONTAINER}//span/a[contains(@href,'/cykl/')]/text()" SERIES = f"{CONTAINER}//span/a[contains(@href,'/cykl/')]/text()"
TRANSLATOR = f"{CONTAINER}//dt[contains(text(),'Tłumacz:')]{SIBLINGS}/a/text()"
DETAILS = "//div[@id='book-details']" DETAILS = "//div[@id='book-details']"
PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania" PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania"
FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()" FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()"
FIRST_PUBLISH_DATE_PL = f"{DETAILS}{PUBLISH_DATE} polskiego')]{SIBLINGS}[1]/text()" FIRST_PUBLISH_DATE_PL = f"{DETAILS}{PUBLISH_DATE} polskiego')]{SIBLINGS}[1]/text()"
TAGS = "//nav[@aria-label='breadcrumbs']//a[contains(@href,'/ksiazki/k/')]/span/text()" TAGS = "//a[contains(@href,'/ksiazki/t/')]/text()" # "//nav[@aria-label='breadcrumbs']//a[contains(@href,'/ksiazki/k/')]/span/text()"
RATING = "//meta[@property='books:rating:value']/@content" RATING = "//meta[@property='books:rating:value']/@content"
COVER = "//meta[@property='og:image']/@content" COVER = "//meta[@property='og:image']/@content"
@@ -135,7 +137,7 @@ class LubimyCzytac(Metadata):
def _prepare_query(self, title: str) -> str: def _prepare_query(self, title: str) -> str:
query = "" query = ""
characters_to_remove = "\?()\/" characters_to_remove = r"\?()\/"
pattern = "[" + characters_to_remove + "]" pattern = "[" + characters_to_remove + "]"
title = re.sub(pattern, "", title) title = re.sub(pattern, "", title)
title = title.replace("_", " ") title = title.replace("_", " ")
@@ -158,6 +160,7 @@ class LubimyCzytac(Metadata):
class LubimyCzytacParser: class LubimyCzytacParser:
PAGES_TEMPLATE = "<p id='strony'>Książka ma {0} stron(y).</p>" PAGES_TEMPLATE = "<p id='strony'>Książka ma {0} stron(y).</p>"
TRANSLATOR_TEMPLATE = "<p id='translator'>Tłumacz: {0}</p>"
PUBLISH_DATE_TEMPLATE = "<p id='pierwsze_wydanie'>Data pierwszego wydania: {0}</p>" PUBLISH_DATE_TEMPLATE = "<p id='pierwsze_wydanie'>Data pierwszego wydania: {0}</p>"
PUBLISH_DATE_PL_TEMPLATE = ( PUBLISH_DATE_PL_TEMPLATE = (
"<p id='pierwsze_wydanie'>Data pierwszego wydania w Polsce: {0}</p>" "<p id='pierwsze_wydanie'>Data pierwszego wydania w Polsce: {0}</p>"
@@ -282,11 +285,13 @@ class LubimyCzytacParser:
def _parse_tags(self) -> List[str]: def _parse_tags(self) -> List[str]:
tags = self._parse_xpath_node(xpath=LubimyCzytac.TAGS, take_first=False) tags = self._parse_xpath_node(xpath=LubimyCzytac.TAGS, take_first=False)
if tags:
return [ return [
strip_accents(w.replace(", itd.", " itd.")) strip_accents(w.replace(", itd.", " itd."))
for w in tags for w in tags
if isinstance(w, str) if isinstance(w, str)
] ]
return None
def _parse_from_summary(self, attribute_name: str) -> Optional[str]: def _parse_from_summary(self, attribute_name: str) -> Optional[str]:
value = None value = None
@@ -346,5 +351,9 @@ class LubimyCzytacParser:
description += LubimyCzytacParser.PUBLISH_DATE_PL_TEMPLATE.format( description += LubimyCzytacParser.PUBLISH_DATE_PL_TEMPLATE.format(
first_publish_date_pl.strftime("%d.%m.%Y") first_publish_date_pl.strftime("%d.%m.%Y")
) )
translator = self._parse_xpath_node(xpath=LubimyCzytac.TRANSLATOR)
if translator:
description += LubimyCzytacParser.TRANSLATOR_TEMPLATE.format(translator)
return description return description
+6 -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.github import make_github_blueprint, github
from flask_dance.contrib.google import make_google_blueprint, google from flask_dance.contrib.google import make_google_blueprint, google
from oauthlib.oauth2 import TokenExpiredError, InvalidGrantError from 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 sqlalchemy.orm.exc import NoResultFound
from .usermanagement import user_login_required
from . import constants, logger, config, app, ub from . import constants, logger, config, app, ub
@@ -205,6 +206,7 @@ def unlink_oauth(provider):
flash(_("Not Linked to %(oauth)s", oauth=provider), category="error") flash(_("Not Linked to %(oauth)s", oauth=provider), category="error")
return redirect(url_for('web.profile')) return redirect(url_for('web.profile'))
def generate_oauth_blueprints(): def generate_oauth_blueprints():
if not ub.session.query(ub.OAuthProvider).count(): if not ub.session.query(ub.OAuthProvider).count():
for provider in ("github", "google"): for provider in ("github", "google"):
@@ -291,6 +293,7 @@ if ub.oauth_support:
return oauth_update_token(str(oauthblueprints[1]['id']), token, google_user_id) return oauth_update_token(str(oauthblueprints[1]['id']), token, google_user_id)
# notify on OAuth provider error # notify on OAuth provider error
@oauth_error.connect_via(oauthblueprints[0]['blueprint']) @oauth_error.connect_via(oauthblueprints[0]['blueprint'])
def github_error(blueprint, error, error_description=None, error_uri=None): def github_error(blueprint, error, error_description=None, error_uri=None):
@@ -338,7 +341,7 @@ def github_login():
@oauth.route('/unlink/github', methods=["GET"]) @oauth.route('/unlink/github', methods=["GET"])
@login_required @user_login_required
def github_login_unlink(): def github_login_unlink():
return unlink_oauth(oauthblueprints[0]['id']) return unlink_oauth(oauthblueprints[0]['id'])
@@ -362,6 +365,6 @@ def google_login():
@oauth.route('/unlink/google', methods=["GET"]) @oauth.route('/unlink/google', methods=["GET"])
@login_required @user_login_required
def google_login_unlink(): def google_login_unlink():
return unlink_oauth(oauthblueprints[1]['id']) return unlink_oauth(oauthblueprints[1]['id'])
+50 -13
View File
@@ -24,15 +24,16 @@ import datetime
import json import json
from urllib.parse import unquote_plus from urllib.parse import unquote_plus
from flask import Blueprint, request, render_template, make_response, abort, Response 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 get_locale
from flask_babel import gettext as _ from flask_babel import gettext as _
from sqlalchemy.sql.expression import func, text, or_, and_, true from sqlalchemy.sql.expression import func, text, or_, and_, true
from sqlalchemy.exc import InvalidRequestError, OperationalError from sqlalchemy.exc import InvalidRequestError, OperationalError
from . import logger, config, db, calibre_db, ub, isoLanguages 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 .helper import get_download_link, get_book_cover
from .pagination import Pagination from .pagination import Pagination
from .web import render_read_books from .web import render_read_books
@@ -94,6 +95,8 @@ def feed_letter_books(book_id):
@opds.route("/opds/new") @opds.route("/opds/new")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_new(): def feed_new():
if not auth.current_user().check_visibility(constants.SIDEBAR_RECENT):
abort(404)
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0, entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
db.Books, True, [db.Books.timestamp.desc()], db.Books, True, [db.Books.timestamp.desc()],
@@ -104,6 +107,8 @@ def feed_new():
@opds.route("/opds/discover") @opds.route("/opds/discover")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_discover(): def feed_discover():
if not auth.current_user().check_visibility(constants.SIDEBAR_RANDOM):
abort(404)
query = calibre_db.generate_linked_query(config.config_read_column, db.Books) query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page) entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page)
pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page)) pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page))
@@ -113,6 +118,8 @@ def feed_discover():
@opds.route("/opds/rated") @opds.route("/opds/rated")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_best_rated(): def feed_best_rated():
if not auth.current_user().check_visibility(constants.SIDEBAR_BEST_RATED):
abort(404)
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0, entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
db.Books, db.Books.ratings.any(db.Ratings.rating > 9), db.Books, db.Books.ratings.any(db.Ratings.rating > 9),
@@ -124,6 +131,8 @@ def feed_best_rated():
@opds.route("/opds/hot") @opds.route("/opds/hot")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_hot(): def feed_hot():
if not auth.current_user().check_visibility(constants.SIDEBAR_HOT):
abort(404)
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
all_books = ub.session.query(ub.Downloads, func.count(ub.Downloads.book_id)).order_by( all_books = ub.session.query(ub.Downloads, func.count(ub.Downloads.book_id)).order_by(
func.count(ub.Downloads.book_id).desc()).group_by(ub.Downloads.book_id) func.count(ub.Downloads.book_id).desc()).group_by(ub.Downloads.book_id)
@@ -146,12 +155,16 @@ def feed_hot():
@opds.route("/opds/author") @opds.route("/opds/author")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_authorindex(): def feed_authorindex():
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') return render_element_index(db.Authors.sort, db.books_authors_link, 'opds.feed_letter_author')
@opds.route("/opds/author/letter/<book_id>") @opds.route("/opds/author/letter/<book_id>")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_letter_author(book_id): def feed_letter_author(book_id):
if not auth.current_user().check_visibility(constants.SIDEBAR_AUTHOR):
abort(404)
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id) letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id)
entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\ entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\
@@ -173,6 +186,8 @@ def feed_author(book_id):
@opds.route("/opds/publisher") @opds.route("/opds/publisher")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_publisherindex(): def feed_publisherindex():
if not auth.current_user().check_visibility(constants.SIDEBAR_PUBLISHER):
abort(404)
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
entries = calibre_db.session.query(db.Publishers)\ entries = calibre_db.session.query(db.Publishers)\
.join(db.books_publishers_link)\ .join(db.books_publishers_link)\
@@ -194,12 +209,16 @@ def feed_publisher(book_id):
@opds.route("/opds/category") @opds.route("/opds/category")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_categoryindex(): def feed_categoryindex():
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') return render_element_index(db.Tags.name, db.books_tags_link, 'opds.feed_letter_category')
@opds.route("/opds/category/letter/<book_id>") @opds.route("/opds/category/letter/<book_id>")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_letter_category(book_id): def feed_letter_category(book_id):
if not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY):
abort(404)
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id) letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
entries = calibre_db.session.query(db.Tags)\ entries = calibre_db.session.query(db.Tags)\
@@ -223,12 +242,16 @@ def feed_category(book_id):
@opds.route("/opds/series") @opds.route("/opds/series")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_seriesindex(): def feed_seriesindex():
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') return render_element_index(db.Series.sort, db.books_series_link, 'opds.feed_letter_series')
@opds.route("/opds/series/letter/<book_id>") @opds.route("/opds/series/letter/<book_id>")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_letter_series(book_id): def feed_letter_series(book_id):
if not auth.current_user().check_visibility(constants.SIDEBAR_SERIES):
abort(404)
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id) letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
entries = calibre_db.session.query(db.Series)\ entries = calibre_db.session.query(db.Series)\
@@ -258,6 +281,8 @@ def feed_series(book_id):
@opds.route("/opds/ratings") @opds.route("/opds/ratings")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_ratingindex(): def feed_ratingindex():
if not auth.current_user().check_visibility(constants.SIDEBAR_RATING):
abort(404)
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'), entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
(db.Ratings.rating / 2).label('name')) \ (db.Ratings.rating / 2).label('name')) \
@@ -284,6 +309,8 @@ def feed_ratings(book_id):
@opds.route("/opds/formats") @opds.route("/opds/formats")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_formatindex(): def feed_formatindex():
if not auth.current_user().check_visibility(constants.SIDEBAR_FORMAT):
abort(404)
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
entries = calibre_db.session.query(db.Data).join(db.Books)\ entries = calibre_db.session.query(db.Data).join(db.Books)\
.filter(calibre_db.common_filters()) \ .filter(calibre_db.common_filters()) \
@@ -291,7 +318,6 @@ def feed_formatindex():
.order_by(db.Data.format).all() .order_by(db.Data.format).all()
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(entries)) len(entries))
element = list() element = list()
for entry in entries: for entry in entries:
element.append(FeedObject(entry.format, entry.format)) element.append(FeedObject(entry.format, entry.format))
@@ -314,12 +340,14 @@ def feed_format(book_id):
@opds.route("/opds/language/") @opds.route("/opds/language/")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_languagesindex(): def feed_languagesindex():
if not auth.current_user().check_visibility(constants.SIDEBAR_LANGUAGE):
abort(404)
off = request.args.get("offset") or 0 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() languages = calibre_db.speaking_language()
else: else:
languages = calibre_db.session.query(db.Languages).filter( 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) languages[0].name = isoLanguages.get_language_name(get_locale(), languages[0].lang_code)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(languages)) len(languages))
@@ -341,9 +369,11 @@ def feed_languages(book_id):
@opds.route("/opds/shelfindex") @opds.route("/opds/shelfindex")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_shelfindex(): def feed_shelfindex():
if not (auth.current_user().is_authenticated or g.allow_anonymous):
abort(404)
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
shelf = ub.session.query(ub.Shelf).filter( 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) number = len(shelf)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
number) number)
@@ -353,16 +383,19 @@ def feed_shelfindex():
@opds.route("/opds/shelf/<int:book_id>") @opds.route("/opds/shelf/<int:book_id>")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_shelf(book_id): def feed_shelf(book_id):
if not (auth.current_user().is_authenticated or g.allow_anonymous):
abort(404)
off = request.args.get("offset") or 0 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, shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1,
ub.Shelf.id == book_id).first() ub.Shelf.id == book_id).first()
else: 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), ub.Shelf.id == book_id),
and_(ub.Shelf.is_public == 1, and_(ub.Shelf.is_public == 1,
ub.Shelf.id == book_id))).first() ub.Shelf.id == book_id))).first()
result = list() result = list()
pagination = list()
# user is allowed to access shelf # user is allowed to access shelf
if shelf: if shelf:
result, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), result, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
@@ -390,7 +423,7 @@ def feed_shelf(book_id):
@opds.route("/opds/download/<book_id>/<book_format>/") @opds.route("/opds/download/<book_id>/<book_format>/")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def opds_download_link(book_id, book_format): def opds_download_link(book_id, book_format):
if not current_user.role_download(): if not auth.current_user().role_download():
return abort(403) return abort(403)
if "Kobo" in request.headers.get('User-Agent'): if "Kobo" in request.headers.get('User-Agent'):
client = "kobo" client = "kobo"
@@ -436,6 +469,8 @@ def feed_get_cover(book_id):
@opds.route("/opds/readbooks") @opds.route("/opds/readbooks")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_read_books(): def feed_read_books():
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 off = request.args.get("offset") or 0
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True) result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
return render_xml_template('feed.xml', entries=result, pagination=pagination) return render_xml_template('feed.xml', entries=result, pagination=pagination)
@@ -444,6 +479,8 @@ def feed_read_books():
@opds.route("/opds/unreadbooks") @opds.route("/opds/unreadbooks")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_unread_books(): def feed_unread_books():
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 off = request.args.get("offset") or 0
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True) result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)
return render_xml_template('feed.xml', entries=result, pagination=pagination) return render_xml_template('feed.xml', entries=result, pagination=pagination)
@@ -477,7 +514,7 @@ def feed_search(term):
def render_xml_template(*args, **kwargs): def render_xml_template(*args, **kwargs):
# ToDo: return time in current timezone similar to %z # ToDo: return time in current timezone similar to %z
currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00") currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00")
xml = render_template(current_time=currtime, instance=config.config_calibre_web_title, *args, **kwargs) xml = render_template(current_time=currtime, instance=config.config_calibre_web_title, constants=constants.sidebar_settings, *args, **kwargs)
response = make_response(xml) response = make_response(xml)
response.headers["Content-Type"] = "application/atom+xml; charset=utf-8" response.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
return response return response
@@ -502,7 +539,7 @@ def render_element_index(database_column, linked_table, folder):
entries = entries.join(linked_table).join(db.Books) entries = entries.join(linked_table).join(db.Books)
entries = entries.filter(calibre_db.common_filters()).group_by(func.upper(func.substr(database_column, 1, 1))).all() entries = entries.filter(calibre_db.common_filters()).group_by(func.upper(func.substr(database_column, 1, 1))).all()
elements = [] elements = []
if off == 0: if off == 0 and entries:
elements.append({'id': "00", 'name': _("All")}) elements.append({'id': "00", 'name': _("All")})
shift = 1 shift = 1
for entry in entries[ for entry in entries[
+10 -11
View File
@@ -29,7 +29,7 @@
from urllib.parse import urlparse, urljoin from urllib.parse import urlparse, urljoin
from flask import request, url_for, redirect from flask import request, url_for, redirect, current_app
def is_safe_url(target): def is_safe_url(target):
@@ -38,16 +38,15 @@ def is_safe_url(target):
return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
def get_redirect_target(): def remove_prefix(text, prefix):
for target in request.values.get('next'), request.referrer: if text.startswith(prefix):
if not target: return text[len(prefix):]
continue return ""
if is_safe_url(target):
return target
def redirect_back(endpoint, **values): def get_redirect_location(next, endpoint, **values):
target = request.form['next'] target = next or url_for(endpoint, **values)
if not target or not is_safe_url(target): adapter = current_app.url_map.bind(urlparse(request.host_url).netloc)
if not len(adapter.allowed_methods(remove_prefix(target, request.environ.get('HTTP_X_SCRIPT_NAME',"")))):
target = url_for(endpoint, **values) target = url_for(endpoint, **values)
return redirect(target) return target
+3 -2
View File
@@ -25,12 +25,13 @@ from datetime import datetime
from functools import wraps from functools import wraps
from flask import Blueprint, request, make_response, abort, url_for, flash, redirect 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 flask_babel import gettext as _
from sqlalchemy.sql.expression import true from sqlalchemy.sql.expression import true
from . import config, logger, ub from . import config, logger, ub
from .render_template import render_title_template from .render_template import render_title_template
from .usermanagement import user_login_required
remotelogin = Blueprint('remotelogin', __name__) remotelogin = Blueprint('remotelogin', __name__)
@@ -65,7 +66,7 @@ def remote_login():
@remotelogin.route('/verify/<token>') @remotelogin.route('/verify/<token>')
@remote_login_required @remote_login_required
@login_required @user_login_required
def verify_token(token): def verify_token(token):
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first() auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()
+2 -3
View File
@@ -19,14 +19,13 @@
from flask import render_template, g, abort, request from flask import render_template, g, abort, request
from flask_babel import gettext as _ from flask_babel import gettext as _
from werkzeug.local import LocalProxy from werkzeug.local import LocalProxy
from flask_login import current_user from .cw_login import current_user
from sqlalchemy.sql.expression import or_ from sqlalchemy.sql.expression import or_
from . import config, constants, logger, ub from . import config, constants, logger, ub
from .ub import User from .ub import User
log = logger.create() log = logger.create()
def get_sidebar_config(kwargs=None): def get_sidebar_config(kwargs=None):
@@ -112,7 +111,7 @@ def render_title_template(*args, **kwargs):
sidebar, simple = get_sidebar_config(kwargs) sidebar, simple = get_sidebar_config(kwargs)
try: try:
return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, simple=simple, return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, simple=simple,
accept=constants.EXTENSIONS_UPLOAD, accept=config.config_upload_formats.split(','),
*args, **kwargs) *args, **kwargs)
except PermissionError: except PermissionError:
log.error("No permission to access {} file.".format(args[0])) log.error("No permission to access {} file.".format(args[0]))
+11 -2
View File
@@ -21,6 +21,7 @@ import datetime
from . import config, constants from . import config, constants
from .services.background_scheduler import BackgroundScheduler, CronTrigger, use_APScheduler from .services.background_scheduler import BackgroundScheduler, CronTrigger, use_APScheduler
from .tasks.database import TaskReconnectDatabase from .tasks.database import TaskReconnectDatabase
from .tasks.clean import TaskClean
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
from .services.worker import WorkerThread from .services.worker import WorkerThread
from .tasks.metadata_backup import TaskBackupMetadata from .tasks.metadata_backup import TaskBackupMetadata
@@ -31,6 +32,9 @@ def get_scheduled_tasks(reconnect=True):
if reconnect: if reconnect:
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False]) tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
# Delete temp folder
tasks.append([lambda: TaskClean(), 'delete temp', True])
# Generate metadata.opf file for each changed book # Generate metadata.opf file for each changed book
if config.schedule_metadata_backup: if config.schedule_metadata_backup:
tasks.append([lambda: TaskBackupMetadata("en"), 'backup metadata', False]) tasks.append([lambda: TaskBackupMetadata("en"), 'backup metadata', False])
@@ -65,9 +69,12 @@ def register_scheduled_tasks(reconnect=True):
duration = config.schedule_duration duration = config.schedule_duration
# Register scheduled tasks # Register scheduled tasks
scheduler.schedule_tasks(tasks=get_scheduled_tasks(reconnect), trigger=CronTrigger(hour=start)) timezone_info = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
scheduler.schedule_tasks(tasks=get_scheduled_tasks(reconnect), trigger=CronTrigger(hour=start,
timezone=timezone_info))
end_time = calclulate_end_time(start, duration) end_time = calclulate_end_time(start, duration)
scheduler.schedule(func=end_scheduled_tasks, trigger=CronTrigger(hour=end_time.hour, minute=end_time.minute), scheduler.schedule(func=end_scheduled_tasks, trigger=CronTrigger(hour=end_time.hour, minute=end_time.minute,
timezone=timezone_info),
name="end scheduled task") name="end scheduled task")
# Kick-off tasks, if they should currently be running # Kick-off tasks, if they should currently be running
@@ -86,6 +93,8 @@ def register_startup_tasks():
# Ignore tasks that should currently be running, as these will be added when registering scheduled tasks # Ignore tasks that should currently be running, as these will be added when registering scheduled tasks
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration): if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration):
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False)) scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
else:
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskClean(), 'delete temp', True]])
def should_task_be_running(start, duration): def should_task_be_running(start, duration):
+47 -19
View File
@@ -19,17 +19,19 @@ from datetime import datetime
from flask import Blueprint, request, redirect, url_for, flash from flask import Blueprint, request, redirect, url_for, flash
from flask import session as flask_session 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 format_date
from flask_babel import gettext as _ from flask_babel import gettext as _
from sqlalchemy.sql.expression import func, not_, and_, or_, text, true from sqlalchemy.sql.expression import func, not_, and_, or_, text, true
from sqlalchemy.sql.functions import coalesce from sqlalchemy.sql.functions import coalesce
from . import logger, db, calibre_db, config, ub from . import logger, db, calibre_db, config, ub
from .string_helper import strip_whitespaces
from .usermanagement import login_required_if_no_ano from .usermanagement import login_required_if_no_ano
from .render_template import render_title_template from .render_template import render_title_template
from .pagination import Pagination from .pagination import Pagination
search = Blueprint('search', __name__) search = Blueprint('search', __name__)
log = logger.create() log = logger.create()
@@ -80,16 +82,27 @@ def adv_search_custom_columns(cc, term, q):
if custom_end: if custom_end:
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
func.datetime(db.cc_classes[c.id].value) <= func.datetime(custom_end))) 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: else:
custom_query = term.get('custom_column_' + str(c.id)) custom_query = term.get('custom_column_' + str(c.id))
if custom_query != '' and custom_query is not None:
if c.datatype == 'bool': 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( q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
db.cc_classes[c.id].value == (custom_query == "True"))) db.cc_classes[c.id].value == bool(custom_query == "True")))
elif c.datatype == 'int' or c.datatype == 'float': elif custom_query != '' and custom_query is not None:
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( if c.datatype == 'rating':
db.cc_classes[c.id].value == custom_query))
elif c.datatype == 'rating':
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
db.cc_classes[c.id].value == int(float(custom_query) * 2))) db.cc_classes[c.id].value == int(float(custom_query) * 2)))
else: else:
@@ -128,10 +141,10 @@ def adv_search_read_status(read_status):
db_filter = coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED db_filter = coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED
else: else:
try: try:
if read_status == "True": if read_status == "":
db_filter = db.cc_classes[config.config_read_column].value == True db_filter = coalesce(db.cc_classes[config.config_read_column].value, 2) == 2
else: 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): except (KeyError, AttributeError, IndexError):
log.error("Custom Column No.{} does not exist in calibre database".format(config.config_read_column)) 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", flash(_("Custom Column No.%(column)d does not exist in calibre database",
@@ -217,8 +230,8 @@ def extend_search_term(searchterm,
searchterm.extend([_("Rating <= %(rating)s", rating=rating_high)]) searchterm.extend([_("Rating <= %(rating)s", rating=rating_high)])
if rating_low: if rating_low:
searchterm.extend([_("Rating >= %(rating)s", rating=rating_low)]) searchterm.extend([_("Rating >= %(rating)s", rating=rating_low)])
if read_status: if read_status != "Any":
searchterm.extend([_("Read Status = %(status)s", status=read_status)]) searchterm.extend([_("Read Status = '%(status)s'", status=read_status)])
searchterm.extend(ext for ext in tags['include_extension']) searchterm.extend(ext for ext in tags['include_extension'])
searchterm.extend(ext for ext in tags['exclude_extension']) searchterm.extend(ext for ext in tags['exclude_extension'])
# handle custom columns # handle custom columns
@@ -231,7 +244,8 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
pagination = None pagination = None
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
calibre_db.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) query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\ q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\
.outerjoin(db.Series)\ .outerjoin(db.Series)\
@@ -244,8 +258,8 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
tags['include_' + element] = term.get('include_' + element) tags['include_' + element] = term.get('include_' + element)
tags['exclude_' + element] = term.get('exclude_' + element) tags['exclude_' + element] = term.get('exclude_' + element)
author_name = term.get("author_name") author_name = term.get("authors")
book_title = term.get("book_title") book_title = term.get("title")
publisher = term.get("publisher") publisher = term.get("publisher")
pub_start = term.get("publishstart") pub_start = term.get("publishstart")
pub_end = term.get("publishend") pub_end = term.get("publishend")
@@ -254,11 +268,11 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
description = term.get("comment") description = term.get("comment")
read_status = term.get("read_status") read_status = term.get("read_status")
if author_name: if author_name:
author_name = author_name.strip().lower().replace(',', '|') author_name = strip_whitespaces(author_name).lower().replace(',', '|')
if book_title: if book_title:
book_title = book_title.strip().lower() book_title = strip_whitespaces(book_title).lower()
if publisher: if publisher:
publisher = publisher.strip().lower() publisher = strip_whitespaces(publisher).lower()
search_term = [] search_term = []
cc_present = False cc_present = False
@@ -278,12 +292,25 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
format='medium') format='medium')
)]) )])
cc_present = True 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)): elif term.get('custom_column_' + str(c.id)):
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))]) search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
cc_present = True cc_present = True
if any(tags.values()) or author_name or book_title or publisher or pub_start or pub_end or rating_low \ if any(tags.values()) or author_name or book_title or publisher or pub_start or pub_end or rating_low \
or rating_high or description or cc_present or read_status: or rating_high or description or cc_present or read_status != "Any":
search_term, pub_start, pub_end = extend_search_term(search_term, search_term, pub_start, pub_end = extend_search_term(search_term,
author_name, author_name,
book_title, book_title,
@@ -302,6 +329,7 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
q = q.filter(func.datetime(db.Books.pubdate) > func.datetime(pub_start)) q = q.filter(func.datetime(db.Books.pubdate) > func.datetime(pub_start))
if pub_end: if pub_end:
q = q.filter(func.datetime(db.Books.pubdate) < func.datetime(pub_end)) q = q.filter(func.datetime(db.Books.pubdate) < func.datetime(pub_end))
if read_status != "Any":
q = q.filter(adv_search_read_status(read_status)) q = q.filter(adv_search_read_status(read_status))
if publisher: if publisher:
q = q.filter(db.Books.publishers.any(func.lower(db.Publishers.name).ilike("%" + publisher + "%"))) q = q.filter(db.Books.publishers.any(func.lower(db.Publishers.name).ilike("%" + publisher + "%")))
+5 -5
View File
@@ -24,14 +24,14 @@ import os
import sys import sys
from flask import Blueprint, Response, request, url_for from flask import Blueprint, Response, request, url_for
from flask_login import current_user from .cw_login import current_user
from flask_login import login_required
from flask_babel import get_locale from flask_babel import get_locale
from sqlalchemy.exc import InvalidRequestError, OperationalError from sqlalchemy.exc import InvalidRequestError, OperationalError
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from cps.services.Metadata import Metadata from cps.services.Metadata import Metadata
from . import constants, logger, ub, web_server from . import constants, logger, ub, web_server
from .usermanagement import user_login_required
# current_milli_time = lambda: int(round(time() * 1000)) # current_milli_time = lambda: int(round(time() * 1000))
@@ -81,7 +81,7 @@ cl = list_classes(new_list)
@meta.route("/metadata/provider") @meta.route("/metadata/provider")
@login_required @user_login_required
def metadata_provider(): def metadata_provider():
active = current_user.view_settings.get("metadata", {}) active = current_user.view_settings.get("metadata", {})
provider = list() provider = list()
@@ -95,7 +95,7 @@ def metadata_provider():
@meta.route("/metadata/provider", methods=["POST"]) @meta.route("/metadata/provider", methods=["POST"])
@meta.route("/metadata/provider/<prov_name>", methods=["POST"]) @meta.route("/metadata/provider/<prov_name>", methods=["POST"])
@login_required @user_login_required
def metadata_change_active_provider(prov_name): def metadata_change_active_provider(prov_name):
new_state = request.get_json() new_state = request.get_json()
active = current_user.view_settings.get("metadata", {}) active = current_user.view_settings.get("metadata", {})
@@ -122,7 +122,7 @@ def metadata_change_active_provider(prov_name):
@meta.route("/metadata/search", methods=["POST"]) @meta.route("/metadata/search", methods=["POST"])
@login_required @user_login_required
def metadata_search(): def metadata_search():
query = request.form.to_dict().get("query") query = request.form.to_dict().get("query")
data = list() data = list()
+58 -22
View File
@@ -21,12 +21,12 @@ import os
import errno import errno
import signal import signal
import socket import socket
import subprocess # nosec
try: try:
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
from .gevent_wsgi import MyWSGIHandler from .gevent_wsgi import MyWSGIHandler
from gevent.pool import Pool from gevent.pool import Pool
from gevent.socket import socket as GeventSocket
from gevent import __version__ as _version from gevent import __version__ as _version
from greenlet import GreenletExit from greenlet import GreenletExit
import ssl import ssl
@@ -36,6 +36,7 @@ except ImportError:
from .tornado_wsgi import MyWSGIContainer from .tornado_wsgi import MyWSGIContainer
from tornado.httpserver import HTTPServer from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop from tornado.ioloop import IOLoop
from tornado import netutil
from tornado import version as _version from tornado import version as _version
VERSION = 'Tornado ' + _version VERSION = 'Tornado ' + _version
_GEVENT = False _GEVENT = False
@@ -95,7 +96,13 @@ class WebServer(object):
log.warning('Cert path: %s', certfile_path) log.warning('Cert path: %s', certfile_path)
log.warning('Key path: %s', keyfile_path) log.warning('Key path: %s', keyfile_path)
def _make_gevent_unix_socket(self, socket_file): @staticmethod
def _make_gevent_socket_activated():
# Reuse an already open socket on fd=SD_LISTEN_FDS_START
SD_LISTEN_FDS_START = 3
return GeventSocket(fileno=SD_LISTEN_FDS_START)
def _prepare_unix_socket(self, socket_file):
# the socket file must not exist prior to bind() # the socket file must not exist prior to bind()
if os.path.exists(socket_file): if os.path.exists(socket_file):
# avoid nuking regular files and symbolic links (could be a mistype or security issue) # avoid nuking regular files and symbolic links (could be a mistype or security issue)
@@ -103,35 +110,41 @@ class WebServer(object):
raise OSError(errno.EEXIST, os.strerror(errno.EEXIST), socket_file) raise OSError(errno.EEXIST, os.strerror(errno.EEXIST), socket_file)
os.remove(socket_file) os.remove(socket_file)
unix_sock = WSGIServer.get_listener(socket_file, family=socket.AF_UNIX)
self.unix_socket_file = socket_file self.unix_socket_file = socket_file
def _make_gevent_listener(self):
if os.name != 'nt':
socket_activated = os.environ.get("LISTEN_FDS")
if socket_activated:
sock = self._make_gevent_socket_activated()
sock_info = sock.getsockname()
return sock, "systemd-socket:" + _readable_listen_address(sock_info[0], sock_info[1])
unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET")
if unix_socket_file:
self._prepare_unix_socket(unix_socket_file)
unix_sock = WSGIServer.get_listener(unix_socket_file, family=socket.AF_UNIX)
# ensure current user and group have r/w permissions, no permissions for other users # ensure current user and group have r/w permissions, no permissions for other users
# this way the socket can be shared in a semi-secure manner # this way the socket can be shared in a semi-secure manner
# between the user running calibre-web and the user running the fronting webserver # between the user running calibre-web and the user running the fronting webserver
os.chmod(socket_file, 0o660) os.chmod(unix_socket_file, 0o660)
return unix_sock return unix_sock, "unix:" + unix_socket_file
def _make_gevent_socket(self):
if os.name != 'nt':
unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET")
if unix_socket_file:
return self._make_gevent_unix_socket(unix_socket_file), "unix:" + unix_socket_file
if self.listen_address: if self.listen_address:
return (self.listen_address, self.listen_port), None return ((self.listen_address, self.listen_port),
_readable_listen_address(self.listen_address, self.listen_port))
if os.name == 'nt': if os.name == 'nt':
self.listen_address = '0.0.0.0' self.listen_address = '0.0.0.0'
return (self.listen_address, self.listen_port), None return ((self.listen_address, self.listen_port),
_readable_listen_address(self.listen_address, self.listen_port))
try:
address = ('::', self.listen_port) address = ('::', self.listen_port)
try:
sock = WSGIServer.get_listener(address, family=socket.AF_INET6) sock = WSGIServer.get_listener(address, family=socket.AF_INET6)
except socket.error as ex: except socket.error as ex:
log.error('%s', ex) log.error('%s', ex)
log.warning('Unable to listen on "", trying on IPv4 only...') log.warning('Unable to listen on {}, trying on IPv4 only...'.format(address))
address = ('', self.listen_port) address = ('', self.listen_port)
sock = WSGIServer.get_listener(address, family=socket.AF_INET) sock = WSGIServer.get_listener(address, family=socket.AF_INET)
@@ -201,9 +214,7 @@ class WebServer(object):
ssl_args = self.ssl_args or {} ssl_args = self.ssl_args or {}
try: try:
sock, output = self._make_gevent_socket() sock, output = self._make_gevent_listener()
if output is None:
output = _readable_listen_address(self.listen_address, self.listen_port)
log.info('Starting Gevent server on %s', output) log.info('Starting Gevent server on %s', output)
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, handler_class=MyWSGIHandler, self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, handler_class=MyWSGIHandler,
error_log=log, error_log=log,
@@ -228,17 +239,42 @@ class WebServer(object):
if os.name == 'nt' and sys.version_info > (3, 7): if os.name == 'nt' and sys.version_info > (3, 7):
import asyncio import asyncio
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
log.info('Starting Tornado server on %s', _readable_listen_address(self.listen_address, self.listen_port)) try:
# Max Buffersize set to 200MB # Max Buffersize set to 200MB
http_server = HTTPServer(MyWSGIContainer(self.app), http_server = HTTPServer(MyWSGIContainer(self.app),
max_buffer_size=209700000, max_buffer_size=209700000,
ssl_options=self.ssl_args) ssl_options=self.ssl_args)
unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET")
if os.environ.get("LISTEN_FDS") and os.name != 'nt':
SD_LISTEN_FDS_START = 3
sock = socket.socket(fileno=SD_LISTEN_FDS_START)
http_server.add_socket(sock)
sock.setblocking(0)
socket_name =sock.getsockname()
output = "systemd-socket:" + _readable_listen_address(socket_name[0], socket_name[1])
elif unix_socket_file and os.name != 'nt':
self._prepare_unix_socket(unix_socket_file)
output = "unix:" + unix_socket_file
unix_socket = netutil.bind_unix_socket(self.unix_socket_file)
http_server.add_socket(unix_socket)
# ensure current user and group have r/w permissions, no permissions for other users
# this way the socket can be shared in a semi-secure manner
# between the user running calibre-web and the user running the fronting webserver
os.chmod(self.unix_socket_file, 0o660)
else:
output = _readable_listen_address(self.listen_address, self.listen_port)
http_server.listen(self.listen_port, self.listen_address) http_server.listen(self.listen_port, self.listen_address)
log.info('Starting Tornado server on %s', output)
self.wsgiserver = IOLoop.current() self.wsgiserver = IOLoop.current()
self.wsgiserver.start() self.wsgiserver.start()
# wait for stop signal # wait for stop signal
self.wsgiserver.close(True) self.wsgiserver.close(True)
finally:
if self.unix_socket_file:
os.remove(self.unix_socket_file)
self.unix_socket_file = None
def start(self): def start(self):
try: try:
@@ -265,7 +301,6 @@ class WebServer(object):
log.info("Performing restart of Calibre-Web") log.info("Performing restart of Calibre-Web")
args = self._get_args_for_reloading() args = self._get_args_for_reloading()
os.execv(args[0].lstrip('"').rstrip('"'), args) os.execv(args[0].lstrip('"').rstrip('"'), args)
return True
@staticmethod @staticmethod
def shutdown_scheduler(): def shutdown_scheduler():
@@ -291,4 +326,5 @@ class WebServer(object):
if restart: if restart:
self.wsgiserver.call_later(1.0, self.wsgiserver.stop) self.wsgiserver.call_later(1.0, self.wsgiserver.stop)
else: else:
self.wsgiserver.add_callback_from_signal(self.wsgiserver.stop) self.wsgiserver.asyncio_loop.call_soon_threadsafe(self.wsgiserver.stop)
+3 -3
View File
@@ -30,10 +30,10 @@ log = logger.create()
def b64encode_json(json_data): def b64encode_json(json_data):
return b64encode(json.dumps(json_data).encode()) return b64encode(json.dumps(json_data).encode()).decode("utf-8")
# Python3 has a timestamp() method we could be calling, however it's not avaiable in python2. # Python3 has a timestamp() method we could be calling, however it's not available in python2.
def to_epoch_timestamp(datetime_object): def to_epoch_timestamp(datetime_object):
return (datetime_object - datetime(1970, 1, 1)).total_seconds() return (datetime_object - datetime(1970, 1, 1)).total_seconds()
@@ -47,7 +47,7 @@ def get_datetime_from_json(json_object, field_name):
class SyncToken: class SyncToken:
""" The SyncToken is used to persist state accross requests. """ The SyncToken is used to persist state across requests.
When serialized over the response headers, the Kobo device will propagate the token onto following When serialized over the response headers, the Kobo device will propagate the token onto following
requests to the service. As an example use-case, the SyncToken is used to detect books that have been added requests to the service. As an example use-case, the SyncToken is used to detect books that have been added
to the library since the last time the device synced to the server. to the library since the last time the device synced to the server.
+1
View File
@@ -36,6 +36,7 @@ SCOPES = ['openid', 'https://www.googleapis.com/auth/gmail.send', 'https://www.g
def setup_gmail(token): def setup_gmail(token):
# If there are no (valid) credentials available, let the user log in. # If there are no (valid) credentials available, let the user log in.
creds = None creds = None
user_info = None
if "token" in token: if "token" in token:
creds = Credentials( creds = Credentials(
token=token['token'], token=token['token'],
+47 -10
View File
@@ -18,16 +18,51 @@
import time import time
from functools import reduce from functools import reduce
import requests
from goodreads.client import GoodreadsClient
from goodreads.request import GoodreadsRequest
import xmltodict
try: try:
from goodreads.client import GoodreadsClient import Levenshtein
except ImportError: except ImportError:
from betterreads.client import GoodreadsClient Levenshtein = False
try: import Levenshtein
except ImportError: Levenshtein = False
from .. import logger from .. import logger
from ..clean_html import clean_string
class my_GoodreadsClient(GoodreadsClient):
def request(self, *args, **kwargs):
"""Create a GoodreadsRequest object and make that request"""
req = my_GoodreadsRequest(self, *args, **kwargs)
return req.request()
class GoodreadsRequestException(Exception):
def __init__(self, error_msg, url):
self.error_msg = error_msg
self.url = url
def __str__(self):
return self.url, ':', self.error_msg
class my_GoodreadsRequest(GoodreadsRequest):
def request(self):
resp = requests.get(self.host+self.path, params=self.params,
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) "
"Gecko/20100101 Firefox/125.0"})
if resp.status_code != 200:
raise GoodreadsRequestException(resp.reason, self.path)
if self.req_format == 'xml':
data_dict = xmltodict.parse(resp.content)
return data_dict['GoodreadsResponse']
else:
raise Exception("Invalid format")
log = logger.create() log = logger.create()
@@ -38,20 +73,20 @@ _CACHE_TIMEOUT = 23 * 60 * 60 # 23 hours (in seconds)
_AUTHORS_CACHE = {} _AUTHORS_CACHE = {}
def connect(key=None, secret=None, enabled=True): def connect(key=None, enabled=True):
global _client global _client
if not enabled or not key or not secret: if not enabled or not key:
_client = None _client = None
return return
if _client: if _client:
# make sure the configuration has not changed since last we used the client # make sure the configuration has not changed since last we used the client
if _client.client_key != key or _client.client_secret != secret: if _client.client_key != key:
_client = None _client = None
if not _client: if not _client:
_client = GoodreadsClient(key, secret) _client = my_GoodreadsClient(key, None)
def get_author_info(author_name): def get_author_info(author_name):
@@ -76,6 +111,7 @@ def get_author_info(author_name):
if author_info: if author_info:
author_info._timestamp = now author_info._timestamp = now
author_info.safe_about = clean_string(author_info.about)
_AUTHORS_CACHE[author_name] = author_info _AUTHORS_CACHE[author_name] = author_info
return author_info return author_info
@@ -91,7 +127,8 @@ def get_other_books(author_info, library_books=None):
identifiers = [] identifiers = []
library_titles = [] library_titles = []
if library_books: if library_books:
identifiers = list(reduce(lambda acc, book: acc + [i.val for i in book.identifiers if i.val], library_books, [])) identifiers = list(
reduce(lambda acc, book: acc + [i.val for i in book.identifiers if i.val], library_books, []))
library_titles = [book.title for book in library_books] library_titles = [book.title for book in library_books]
for book in author_info.books: for book in author_info.books:
+4 -1
View File
@@ -30,9 +30,11 @@ except ImportError:
log = logger.create() log = logger.create()
class LDAPLogger(object): class LDAPLogger(object):
def write(self, message): @staticmethod
def write(message):
try: try:
log.debug(message.strip("\n").replace("\n", "")) log.debug(message.strip("\n").replace("\n", ""))
except Exception: except Exception:
@@ -71,6 +73,7 @@ class mySimpleLDap(LDAP):
_ldap = mySimpleLDap() _ldap = mySimpleLDap()
def init_app(app, config): def init_app(app, config):
if config.config_login_type != constants.LOGIN_LDAP: if config.config_login_type != constants.LOGIN_LDAP:
return return
+3
View File
@@ -266,3 +266,6 @@ class CalibreTask:
def _handleSuccess(self): def _handleSuccess(self):
self.stat = STAT_FINISH_SUCCESS self.stat = STAT_FINISH_SUCCESS
self.progress = 1 self.progress = 1
def __str__(self):
return self.name
+37 -19
View File
@@ -21,17 +21,17 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys import sys
from datetime import datetime from datetime import datetime, timezone
from flask import Blueprint, flash, redirect, request, url_for, abort from flask import Blueprint, flash, redirect, request, url_for, abort
from flask_babel import gettext as _ 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.exc import InvalidRequestError, OperationalError
from sqlalchemy.sql.expression import func, true from sqlalchemy.sql.expression import func, true
from . import calibre_db, config, db, logger, ub from . import calibre_db, config, db, logger, ub
from .render_template import render_title_template 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() log = logger.create()
@@ -39,7 +39,7 @@ shelf = Blueprint('shelf', __name__)
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"]) @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): def add_to_shelf(shelf_id, book_id):
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest' xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
@@ -71,8 +71,16 @@ def add_to_shelf(shelf_id, book_id):
else: else:
maxOrder = maxOrder[0] maxOrder = maxOrder[0]
if not calibre_db.session.query(db.Books).filter(db.Books.id == book_id).one_or_none():
log.error("Invalid Book Id: %s. Could not be added to shelf %s", book_id, shelf.name)
if not xhr:
flash(_("%(book_id)s is a invalid Book Id. Could not be added to Shelf", book_id=book_id),
category="error")
return redirect(url_for('web.index'))
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.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: try:
ub.session.merge(shelf) ub.session.merge(shelf)
ub.session.commit() ub.session.commit()
@@ -95,7 +103,7 @@ def add_to_shelf(shelf_id, book_id):
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"]) @shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
@login_required @user_login_required
def search_to_shelf(shelf_id): def search_to_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf is None: if shelf is None:
@@ -131,7 +139,7 @@ def search_to_shelf(shelf_id):
for book in books_for_shelf: for book in books_for_shelf:
maxOrder += 1 maxOrder += 1
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder)) 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: try:
ub.session.merge(shelf) ub.session.merge(shelf)
ub.session.commit() ub.session.commit()
@@ -147,7 +155,7 @@ def search_to_shelf(shelf_id):
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>", methods=["POST"]) @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): def remove_from_shelf(shelf_id, book_id):
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest' xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
@@ -177,7 +185,7 @@ def remove_from_shelf(shelf_id, book_id):
try: try:
ub.session.delete(book_shelf) ub.session.delete(book_shelf)
shelf.last_modified = datetime.utcnow() shelf.last_modified = datetime.now(timezone.utc)
ub.session.commit() ub.session.commit()
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
ub.session.rollback() ub.session.rollback()
@@ -204,14 +212,14 @@ def remove_from_shelf(shelf_id, book_id):
@shelf.route("/shelf/create", methods=["GET", "POST"]) @shelf.route("/shelf/create", methods=["GET", "POST"])
@login_required @user_login_required
def create_shelf(): def create_shelf():
shelf = ub.Shelf() shelf = ub.Shelf()
return create_edit_shelf(shelf, page_title=_("Create a Shelf"), page="shelfcreate") return create_edit_shelf(shelf, page_title=_("Create a Shelf"), page="shelfcreate")
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"]) @shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
@login_required @user_login_required
def edit_shelf(shelf_id): def edit_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if not check_shelf_edit_permissions(shelf): if not check_shelf_edit_permissions(shelf):
@@ -221,7 +229,7 @@ def edit_shelf(shelf_id):
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"]) @shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
@login_required @user_login_required
def delete_shelf(shelf_id): def delete_shelf(shelf_id):
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
try: try:
@@ -242,7 +250,7 @@ def show_simpleshelf(shelf_id):
return render_show_shelf(2, shelf_id, 1, None) 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>", defaults={'page': 1})
@shelf.route("/shelf/<int:shelf_id>/<sort_param>/<int:page>") @shelf.route("/shelf/<int:shelf_id>/<sort_param>/<int:page>")
@login_required_if_no_ano @login_required_if_no_ano
@@ -251,7 +259,7 @@ def show_shelf(shelf_id, sort_param, page):
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"]) @shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
@login_required @user_login_required
def order_shelf(shelf_id): def order_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf and check_shelf_view_permissions(shelf): if shelf and check_shelf_view_permissions(shelf):
@@ -263,7 +271,7 @@ def order_shelf(shelf_id):
for book in books_in_shelf: for book in books_in_shelf:
setattr(book, 'order', to_save[str(book.book_id)]) setattr(book, 'order', to_save[str(book.book_id)])
counter += 1 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: try:
ub.session.commit() ub.session.commit()
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
@@ -410,15 +418,23 @@ def change_shelf_order(shelf_id, order):
def render_show_shelf(shelf_type, shelf_id, page_no, sort_param): 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() 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 # check user is allowed to access shelf
if shelf and check_shelf_view_permissions(shelf): if shelf and check_shelf_view_permissions(shelf):
if shelf_type == 1: if shelf_type == 1:
# order = [ub.BookShelf.order.asc()] 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': if sort_param == 'pubnew':
change_shelf_order(shelf_id, [db.Books.pubdate.desc()]) change_shelf_order(shelf_id, [db.Books.pubdate.desc()])
if sort_param == 'pubold': if sort_param == 'pubold':
change_shelf_order(shelf_id, [db.Books.pubdate]) 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': if sort_param == 'abc':
change_shelf_order(shelf_id, [db.Books.sort]) change_shelf_order(shelf_id, [db.Books.sort])
if sort_param == 'zyx': if sort_param == 'zyx':
@@ -445,7 +461,7 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
[ub.BookShelf.order.asc()], [ub.BookShelf.order.asc()],
True, config.config_read_column, True, config.config_read_column,
ub.BookShelf, ub.BookShelf.book_id == db.Books.id) 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) \ wrong_entries = calibre_db.session.query(ub.BookShelf) \
.join(db.Books, ub.BookShelf.book_id == db.Books.id, isouter=True) \ .join(db.Books, ub.BookShelf.book_id == db.Books.id, isouter=True) \
.filter(db.Books.id == None).all() .filter(db.Books.id == None).all()
@@ -464,7 +480,9 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
pagination=pagination, pagination=pagination,
title=_("Shelf: '%(name)s'", name=shelf.name), title=_("Shelf: '%(name)s'", name=shelf.name),
shelf=shelf, shelf=shelf,
page="shelf") page="shelf",
status=status,
order=sort_param)
else: else:
flash(_("Error opening shelf. Shelf does not exist or is not accessible"), category="error") flash(_("Error opening shelf. Shelf does not exist or is not accessible"), category="error")
return redirect(url_for("web.index")) return redirect(url_for("web.index"))
+11 -4
View File
@@ -3296,6 +3296,7 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] .dropd
left: 0 !important; left: 0 !important;
} }
#add-to-shelves { #add-to-shelves {
min-height: 48px;
max-height: calc(100% - 120px); max-height: calc(100% - 120px);
overflow-y: auto; overflow-y: auto;
} }
@@ -4812,8 +4813,14 @@ body.advsearch:not(.blur) > div.container-fluid > div.row-fluid > div.col-sm-10
z-index: 999999999999999999999999999999999999 z-index: 999999999999999999999999999999999999
} }
.search #shelf-actions, body.login .home-btn { body.search #shelf-actions button#add-to-shelf {
display: none height: 40px;
}
@media screen and (max-width: 767px) {
body.search .discover, body.advsearch .discover {
display: flex;
flex-direction: column;
}
} }
body.read:not(.blur) a[href*=readbooks] { body.read:not(.blur) a[href*=readbooks] {
@@ -5134,7 +5141,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
right: 5px right: 5px
} }
#shelf-actions > .btn-group.open, .downloadBtn.open, .profileDrop[aria-expanded=true] { body:not(.search) #shelf-actions > .btn-group.open, .downloadBtn.open, .profileDrop[aria-expanded=true] {
pointer-events: none pointer-events: none
} }
@@ -5151,7 +5158,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
color: var(--color-primary) color: var(--color-primary)
} }
#shelf-actions, #shelf-actions > .btn-group, #shelf-actions > .btn-group > .empty-ul { body:not(.search) #shelf-actions, body:not(.search) #shelf-actions > .btn-group, body:not(.search) #shelf-actions > .btn-group > .empty-ul {
pointer-events: none pointer-events: none
} }
@@ -0,0 +1,3 @@
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.375 7.625V11.875C5.375 12.0408 5.44085 12.1997 5.55806 12.3169C5.67527 12.4342 5.83424 12.5 6 12.5C6.16576 12.5 6.32473 12.4342 6.44194 12.3169C6.55915 12.1997 6.625 12.0408 6.625 11.875V7.625L7.125 7.125H11.375C11.5408 7.125 11.6997 7.05915 11.8169 6.94194C11.9342 6.82473 12 6.66576 12 6.5C12 6.33424 11.9342 6.17527 11.8169 6.05806C11.6997 5.94085 11.5408 5.875 11.375 5.875H7.125L6.625 5.375V1.125C6.625 0.95924 6.55915 0.800269 6.44194 0.683058C6.32473 0.565848 6.16576 0.5 6 0.5C5.83424 0.5 5.67527 0.565848 5.55806 0.683058C5.44085 0.800269 5.375 0.95924 5.375 1.125V5.375L4.875 5.875H0.625C0.45924 5.875 0.300269 5.94085 0.183058 6.05806C0.065848 6.17527 0 6.33424 0 6.5C0 6.66576 0.065848 6.82473 0.183058 6.94194C0.300269 7.05915 0.45924 7.125 0.625 7.125H4.762L5.375 7.625Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 920 B

@@ -0,0 +1,3 @@
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 0.5C5.21207 0.5 4.43185 0.655195 3.7039 0.956723C2.97595 1.25825 2.31451 1.70021 1.75736 2.25736C1.20021 2.81451 0.758251 3.47595 0.456723 4.2039C0.155195 4.93185 0 5.71207 0 6.5C0 7.28793 0.155195 8.06815 0.456723 8.7961C0.758251 9.52405 1.20021 10.1855 1.75736 10.7426C2.31451 11.2998 2.97595 11.7417 3.7039 12.0433C4.43185 12.3448 5.21207 12.5 6 12.5C7.5913 12.5 9.11742 11.8679 10.2426 10.7426C11.3679 9.61742 12 8.0913 12 6.5C12 4.9087 11.3679 3.38258 10.2426 2.25736C9.11742 1.13214 7.5913 0.5 6 0.5ZM5.06 8.9L2.9464 6.7856C2.85273 6.69171 2.80018 6.56446 2.80033 6.43183C2.80048 6.29921 2.85331 6.17207 2.9472 6.0784C3.04109 5.98473 3.16834 5.93218 3.30097 5.93233C3.43359 5.93248 3.56073 5.98531 3.6544 6.0792L5.3112 7.7368L8.3464 4.7008C8.44109 4.6109 8.56715 4.56153 8.69771 4.56322C8.82827 4.56492 8.95301 4.61754 9.04534 4.70986C9.13766 4.80219 9.19028 4.92693 9.19198 5.05749C9.19367 5.18805 9.1443 5.31411 9.0544 5.4088L5.5624 8.9H5.06Z" fill="#FBFBFE"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,6 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
<path d="M9 3.5a1.5 1.5 0 0 0-3-.001v7.95C6 12.83 7.12 14 8.5 14s2.5-1.17 2.5-2.55V5.5a.5.5 0 0 1 1 0v6.03C11.955 13.427 10.405 15 8.5 15S5.044 13.426 5 11.53V3.5a2.5 2.5 0 0 1 5 0v7.003a1.5 1.5 0 0 1-3-.003v-5a.5.5 0 0 1 1 0v5a.5.5 0 0 0 1 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

@@ -0,0 +1,7 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
<path d="M8.156 12.5a.99.99 0 0 0 .707-.294l.523-2.574L10.5 8.499l1.058-1.04 2.65-.601a.996.996 0 0 0 0-1.414l-3.657-3.658a.996.996 0 0 0-1.414 0l-.523 2.576L7.5 5.499 6.442 6.535l-2.65.6a.996.996 0 0 0 0 1.413l3.657 3.658a.999.999 0 0 0 .707.295z"/>
<path d="M9.842.996c-.386 0-.77.146-1.06.44a.5.5 0 0 0-.136.251l-.492 2.43-1.008 1.03-.953.933-2.511.566a.5.5 0 0 0-.243.133 1.505 1.505 0 0 0-.002 2.123l1.477 1.477-2.768 2.767a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l2.767-2.767 1.475 1.474a1.494 1.494 0 0 0 2.123-.002.5.5 0 0 0 .135-.254l.492-2.427 1.008-1.024.953-.937 2.511-.57a.5.5 0 0 0 .243-.132c.586-.58.583-1.543.002-2.125l-3.659-3.656A1.501 1.501 0 0 0 9.842.996Zm.05 1.025a.394.394 0 0 1 .305.12l3.658 3.657c.18.18.141.432.002.627l-2.41.545a.5.5 0 0 0-.24.131L10.15 8.142a.5.5 0 0 0-.007.006L9.029 9.283a.5.5 0 0 0-.133.25l-.48 2.36c-.082.053-.165.109-.26.109a.492.492 0 0 1-.353-.149L4.145 8.195c-.18-.18-.141-.432-.002-.627l2.41-.545a.5.5 0 0 0 .238-.13L7.85 5.857a.5.5 0 0 0 .007-.008l1.114-1.138a.5.5 0 0 0 .133-.25l.472-2.323a.619.619 0 0 1 .317-.117Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,6 @@
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 3.09C12.28 3.01 12.43 3 12.43 3C12.48 3 12.58 3.02 12.66 3.1L14.45 4.89C14.58 5.02 14.58 5.22 14.45 5.35L11.7713 8.02872L9.51628 5.77372L12.2 3.09ZM13.2658 5.12L11.7713 6.6145L10.9305 5.77372L12.425 4.27921L13.2658 5.12Z" fill="#FBFBFE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.98 9.32L8.23 11.57L10.7106 9.08938L8.45562 6.83438L5.98 9.31V9.32ZM8.23 10.1558L9.29641 9.08938L8.45562 8.24859L7.38921 9.315L8.23 10.1558Z" fill="#FBFBFE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1526 13.1816L16.2125 7.1217C16.7576 6.58919 17.05 5.8707 17.05 5.12C17.05 4.36931 16.7576 3.65084 16.2126 3.11834L14.4317 1.33747C13.8992 0.79242 13.1807 0.5 12.43 0.5C11.6643 0.5 10.9529 0.812929 10.4329 1.33289L3.68289 8.08289C3.04127 8.72452 3.00459 9.75075 3.57288 10.4363L1.29187 12.7239C1.09186 12.9245 0.990263 13.1957 1.0007 13.4685L1 14.5C0.447715 14.5 0 14.9477 0 15.5V17.5C0 18.0523 0.447715 18.5 1 18.5H16C16.5523 18.5 17 18.0523 17 17.5V15.5C17 14.9477 16.5523 14.5 16 14.5H10.2325C9.83594 14.5 9.39953 13.9347 10.1526 13.1816ZM4.39 9.85L4.9807 10.4407L2.39762 13.0312H6.63877L7.10501 12.565L7.57125 13.0312H8.88875L15.51 6.41C15.86 6.07 16.05 5.61 16.05 5.12C16.05 4.63 15.86 4.17 15.51 3.83L13.72 2.04C13.38 1.69 12.92 1.5 12.43 1.5C11.94 1.5 11.48 1.7 11.14 2.04L4.39 8.79C4.1 9.08 4.1 9.56 4.39 9.85ZM16 17.5V15.5H1V17.5H16Z" fill="#FBFBFE"/>
<path d="M15.1616 6.05136L15.1616 6.05132L15.1564 6.05645L8.40645 12.8064C8.35915 12.8537 8.29589 12.88 8.23 12.88C8.16411 12.88 8.10085 12.8537 8.05355 12.8064L7.45857 12.2115L7.10501 11.8579L6.75146 12.2115L6.03289 12.93H3.20465L5.33477 10.7937L5.6873 10.4402L5.33426 10.0871L4.74355 9.49645C4.64882 9.40171 4.64882 9.23829 4.74355 9.14355L11.4936 2.39355C11.7436 2.14354 12.0779 2 12.43 2C12.7883 2 13.1179 2.13776 13.3614 2.38839L13.3613 2.38843L13.3664 2.39355L15.1564 4.18355L15.1564 4.18359L15.1616 4.18864C15.4122 4.43211 15.55 4.76166 15.55 5.12C15.55 5.47834 15.4122 5.80789 15.1616 6.05136ZM7.87645 11.9236L8.23 12.2771L8.58355 11.9236L11.0642 9.44293L11.4177 9.08938L11.0642 8.73582L8.80918 6.48082L8.45562 6.12727L8.10207 6.48082L5.62645 8.95645L5.48 9.10289V9.31V9.32V9.52711L5.62645 9.67355L7.87645 11.9236ZM11.4177 8.38227L11.7713 8.73582L12.1248 8.38227L14.8036 5.70355C15.1288 5.37829 15.1288 4.86171 14.8036 4.53645L13.0136 2.74645C12.8186 2.55146 12.5792 2.5 12.43 2.5H12.4134L12.3967 2.50111L12.43 3C12.3967 2.50111 12.3966 2.50112 12.3965 2.50112L12.3963 2.50114L12.3957 2.50117L12.3947 2.50125L12.3924 2.50142L12.387 2.50184L12.3732 2.50311C12.3628 2.50416 12.3498 2.50567 12.3346 2.50784C12.3049 2.51208 12.2642 2.51925 12.2178 2.53146C12.1396 2.55202 11.9797 2.60317 11.8464 2.73645L9.16273 5.42016L8.80918 5.77372L9.16273 6.12727L11.4177 8.38227ZM1.5 16H15.5V17H1.5V16Z" stroke="#15141A"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.75H12.5V2.25V1V0.5H12H10.358C9.91165 0.5 9.47731 0.625661 9.09989 0.860442L9.09886 0.861087L8 1.54837L6.89997 0.860979L6.89911 0.860443C6.5218 0.625734 6.08748 0.5 5.642 0.5H4H3.5V1V2.25V2.75H4H5.642C5.66478 2.75 5.6885 2.75641 5.71008 2.76968C5.71023 2.76977 5.71038 2.76986 5.71053 2.76995L6.817 3.461C6.81704 3.46103 6.81709 3.46105 6.81713 3.46108C6.81713 3.46108 6.81713 3.46108 6.81714 3.46109C6.8552 3.48494 6.876 3.52285 6.876 3.567V8V12.433C6.876 12.4771 6.85523 12.515 6.81722 12.5389C6.81715 12.5389 6.81707 12.539 6.817 12.539L5.70953 13.23C5.70941 13.2301 5.70929 13.2302 5.70917 13.2303C5.68723 13.2438 5.6644 13.25 5.641 13.25H4H3.5V13.75V15V15.5H4H5.642C6.08835 15.5 6.52269 15.3743 6.90011 15.1396L6.90086 15.1391L8 14.4526L9.10003 15.14L9.10089 15.1406C9.47831 15.3753 9.91265 15.501 10.359 15.501H12H12.5V15.001V13.751V13.251H12H10.358C10.3352 13.251 10.3115 13.2446 10.2899 13.2313C10.2897 13.2312 10.2896 13.2311 10.2895 13.231L9.183 12.54C9.18298 12.54 9.18295 12.54 9.18293 12.54C9.18291 12.5399 9.18288 12.5399 9.18286 12.5399C9.14615 12.5169 9.125 12.4797 9.125 12.434V8V3.567C9.125 3.52266 9.14603 3.48441 9.18364 3.4606C9.18377 3.46052 9.1839 3.46043 9.18404 3.46035L10.2895 2.76995C10.2896 2.76985 10.2898 2.76975 10.2899 2.76966C10.3119 2.75619 10.3346 2.75 10.358 2.75H12Z" fill="black" stroke="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.0189877 13.6645L0.612989 10.4635C0.687989 10.0545 0.884989 9.6805 1.18099 9.3825L9.98199 0.5805C10.756 -0.1925 12.015 -0.1945 12.792 0.5805L14.42 2.2085C15.194 2.9835 15.194 4.2435 14.42 5.0185L5.61599 13.8215C5.31999 14.1165 4.94599 14.3125 4.53799 14.3875L1.33599 14.9815C1.26599 14.9935 1.19799 15.0005 1.12999 15.0005C0.832989 15.0005 0.544988 14.8835 0.330988 14.6695C0.0679874 14.4055 -0.0490122 14.0305 0.0189877 13.6645Z" fill="white"/>
<path d="M0.0189877 13.6645L0.612989 10.4635C0.687989 10.0545 0.884989 9.6805 1.18099 9.3825L9.98199 0.5805C10.756 -0.1925 12.015 -0.1945 12.792 0.5805L14.42 2.2085C15.194 2.9835 15.194 4.2435 14.42 5.0185L5.61599 13.8215C5.31999 14.1165 4.94599 14.3125 4.53799 14.3875L1.33599 14.9815C1.26599 14.9935 1.19799 15.0005 1.12999 15.0005C0.832989 15.0005 0.544988 14.8835 0.330988 14.6695C0.0679874 14.4055 -0.0490122 14.0305 0.0189877 13.6645ZM12.472 5.1965L13.632 4.0365L13.631 3.1885L11.811 1.3675L10.963 1.3685L9.80299 2.5285L12.472 5.1965ZM4.31099 13.1585C4.47099 13.1285 4.61799 13.0515 4.73399 12.9345L11.587 6.0815L8.91899 3.4135L2.06599 10.2655C1.94899 10.3835 1.87199 10.5305 1.84099 10.6915L1.36699 13.2485L1.75199 13.6335L4.31099 13.1585Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,8 @@
<svg width="29" height="32" viewBox="0 0 29 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28 16.75C28.2761 16.75 28.5 16.5261 28.5 16.25V15C28.5 14.7239 28.2761 14.5 28 14.5H26.358C25.9117 14.5 25.4773 14.6257 25.0999 14.8604L25.0989 14.8611L24 15.5484L22.9 14.861L22.8991 14.8604C22.5218 14.6257 22.0875 14.5 21.642 14.5H20C19.7239 14.5 19.5 14.7239 19.5 15V16.25C19.5 16.5261 19.7239 16.75 20 16.75H21.642C21.6648 16.75 21.6885 16.7564 21.7101 16.7697C21.7102 16.7698 21.7104 16.7699 21.7105 16.77L22.817 17.461C22.817 17.461 22.8171 17.4611 22.8171 17.4611C22.8171 17.4611 22.8171 17.4611 22.8171 17.4611C22.8552 17.4849 22.876 17.5229 22.876 17.567V22.625V27.683C22.876 27.7271 22.8552 27.765 22.8172 27.7889C22.8171 27.7889 22.8171 27.789 22.817 27.789L21.7095 28.48C21.7094 28.4801 21.7093 28.4802 21.7092 28.4803C21.6872 28.4938 21.6644 28.5 21.641 28.5H20C19.7239 28.5 19.5 28.7239 19.5 29V30.25C19.5 30.5261 19.7239 30.75 20 30.75H21.642C22.0883 30.75 22.5227 30.6243 22.9001 30.3896L22.9009 30.3891L24 29.7026L25.1 30.39L25.1009 30.3906C25.4783 30.6253 25.9127 30.751 26.359 30.751H28C28.2761 30.751 28.5 30.5271 28.5 30.251V29.001C28.5 28.7249 28.2761 28.501 28 28.501H26.358C26.3352 28.501 26.3115 28.4946 26.2899 28.4813C26.2897 28.4812 26.2896 28.4811 26.2895 28.481L25.183 27.79C25.183 27.79 25.183 27.79 25.1829 27.79C25.1829 27.7899 25.1829 27.7899 25.1829 27.7899C25.1462 27.7669 25.125 27.7297 25.125 27.684V22.625V17.567C25.125 17.5227 25.146 17.4844 25.1836 17.4606C25.1838 17.4605 25.1839 17.4604 25.184 17.4603L26.2895 16.77C26.2896 16.7699 26.2898 16.7698 26.2899 16.7697C26.3119 16.7562 26.3346 16.75 26.358 16.75H28Z" fill="black" stroke="#FBFBFE" stroke-linejoin="round"/>
<path d="M24.625 17.567C24.625 17.35 24.735 17.152 24.918 17.037L26.026 16.345C26.126 16.283 26.24 16.25 26.358 16.25H28V15H26.358C26.006 15 25.663 15.099 25.364 15.285L24.256 15.978C24.161 16.037 24.081 16.113 24 16.187C23.918 16.113 23.839 16.037 23.744 15.978L22.635 15.285C22.336 15.099 21.993 15 21.642 15H20V16.25H21.642C21.759 16.25 21.874 16.283 21.974 16.345L23.082 17.037C23.266 17.152 23.376 17.35 23.376 17.567V22.625V27.683C23.376 27.9 23.266 28.098 23.082 28.213L21.973 28.905C21.873 28.967 21.759 29 21.641 29H20V30.25H21.642C21.994 30.25 22.337 30.151 22.636 29.965L23.744 29.273C23.84 29.213 23.919 29.137 24 29.064C24.081 29.137 24.161 29.213 24.256 29.273L25.365 29.966C25.664 30.152 26.007 30.251 26.359 30.251H28V29.001H26.358C26.241 29.001 26.126 28.968 26.026 28.906L24.918 28.214C24.734 28.099 24.625 27.901 24.625 27.684V22.625V17.567Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 2.59C12.28 2.51 12.43 2.5 12.43 2.5C12.48 2.5 12.58 2.52 12.66 2.6L14.45 4.39C14.58 4.52 14.58 4.72 14.45 4.85L11.7713 7.52872L9.51628 5.27372L12.2 2.59ZM13.2658 4.62L11.7713 6.1145L10.9305 5.27372L12.425 3.77921L13.2658 4.62Z" fill="#FBFBFE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.98 8.82L8.23 11.07L10.7106 8.58938L8.45562 6.33438L5.98 8.81V8.82ZM8.23 9.65579L9.29641 8.58938L8.45562 7.74859L7.38921 8.815L8.23 9.65579Z" fill="#FBFBFE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1526 12.6816L16.2125 6.6217C16.7576 6.08919 17.05 5.3707 17.05 4.62C17.05 3.86931 16.7576 3.15084 16.2126 2.61834L14.4317 0.837474C13.8992 0.29242 13.1807 0 12.43 0C11.6643 0 10.9529 0.312929 10.4329 0.832893L3.68289 7.58289C3.04127 8.22452 3.00459 9.25075 3.57288 9.93634L1.29187 12.2239C1.09186 12.4245 0.990263 12.6957 1.0007 12.9685L1 14C0.447715 14 0 14.4477 0 15V17C0 17.5523 0.447715 18 1 18H16C16.5523 18 17 17.5523 17 17V15C17 14.4477 16.5523 14 16 14H10.2325C9.83594 14 9.39953 13.4347 10.1526 12.6816ZM4.39 9.35L4.9807 9.9407L2.39762 12.5312H6.63877L7.10501 12.065L7.57125 12.5312H8.88875L15.51 5.91C15.86 5.57 16.05 5.11 16.05 4.62C16.05 4.13 15.86 3.67 15.51 3.33L13.72 1.54C13.38 1.19 12.92 1 12.43 1C11.94 1 11.48 1.2 11.14 1.54L4.39 8.29C4.1 8.58 4.1 9.06 4.39 9.35ZM16 17V15H1V17H16Z" fill="#FBFBFE"/>
<path d="M15.1616 5.55136L15.1616 5.55132L15.1564 5.55645L8.40645 12.3064C8.35915 12.3537 8.29589 12.38 8.23 12.38C8.16411 12.38 8.10085 12.3537 8.05355 12.3064L7.45857 11.7115L7.10501 11.3579L6.75146 11.7115L6.03289 12.43H3.20465L5.33477 10.2937L5.6873 9.94019L5.33426 9.58715L4.74355 8.99645C4.64882 8.90171 4.64882 8.73829 4.74355 8.64355L11.4936 1.89355C11.7436 1.64354 12.0779 1.5 12.43 1.5C12.7883 1.5 13.1179 1.63776 13.3614 1.88839L13.3613 1.88843L13.3664 1.89355L15.1564 3.68355L15.1564 3.68359L15.1616 3.68864C15.4122 3.93211 15.55 4.26166 15.55 4.62C15.55 4.97834 15.4122 5.30789 15.1616 5.55136ZM5.48 8.82V9.02711L5.62645 9.17355L7.87645 11.4236L8.23 11.7771L8.58355 11.4236L11.0642 8.94293L11.4177 8.58938L11.0642 8.23582L8.80918 5.98082L8.45562 5.62727L8.10207 5.98082L5.62645 8.45645L5.48 8.60289V8.81V8.82ZM11.4177 7.88227L11.7713 8.23582L12.1248 7.88227L14.8036 5.20355C15.1288 4.87829 15.1288 4.36171 14.8036 4.03645L13.0136 2.24645C12.8186 2.05146 12.5792 2 12.43 2H12.4134L12.3967 2.00111L12.43 2.5C12.3967 2.00111 12.3966 2.00112 12.3965 2.00112L12.3963 2.00114L12.3957 2.00117L12.3947 2.00125L12.3924 2.00142L12.387 2.00184L12.3732 2.00311C12.3628 2.00416 12.3498 2.00567 12.3346 2.00784C12.3049 2.01208 12.2642 2.01925 12.2178 2.03146C12.1396 2.05202 11.9797 2.10317 11.8464 2.23645L9.16273 4.92016L8.80918 5.27372L9.16273 5.62727L11.4177 7.88227ZM1.5 16.5V15.5H15.5V16.5H1.5Z" stroke="#15141A"/>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11 3H13.6C14 3 14.3 3.3 14.3 3.6C14.3 3.9 14 4.2 13.7 4.2H13.3V14C13.3 15.1 12.4 16 11.3 16H4.80005C3.70005 16 2.80005 15.1 2.80005 14V4.2H2.40005C2.00005 4.2 1.80005 4 1.80005 3.6C1.80005 3.2 2.00005 3 2.40005 3H5.00005V2C5.00005 0.9 5.90005 0 7.00005 0H9.00005C10.1 0 11 0.9 11 2V3ZM6.90005 1.2L6.30005 1.8V3H9.80005V1.8L9.20005 1.2H6.90005ZM11.4 14.7L12 14.1V4.2H4.00005V14.1L4.60005 14.7H11.4ZM7.00005 12.4C7.00005 12.7 6.70005 13 6.40005 13C6.10005 13 5.80005 12.7 5.80005 12.4V7.6C5.70005 7.3 6.00005 7 6.40005 7C6.80005 7 7.00005 7.3 7.00005 7.6V12.4ZM10.2001 12.4C10.2001 12.7 9.90006 13 9.60006 13C9.30006 13 9.00006 12.7 9.00006 12.4V7.6C9.00006 7.3 9.30006 7 9.60006 7C9.90006 7 10.2001 7.3 10.2001 7.6V12.4Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 909 B

@@ -1,4 +1,3 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- License, v. 2.0. If a copy of the MPL was not distributed with this <path d="M10.999 8.352L5.534 13.818C5.41551 13.9303 5.25786 13.9918 5.09466 13.9895C4.93146 13.9872 4.77561 13.9212 4.66033 13.8057C4.54505 13.6902 4.47945 13.5342 4.47752 13.3709C4.47559 13.2077 4.53748 13.0502 4.65 12.932L9.585 7.998L4.651 3.067C4.53862 2.94864 4.47691 2.79106 4.47903 2.62786C4.48114 2.46466 4.54692 2.30874 4.66233 2.19333C4.77774 2.07792 4.93366 2.01215 5.09686 2.01003C5.26006 2.00792 5.41763 2.06962 5.536 2.182L11 7.647L10.999 8.352Z" fill="black"/>
- file, You can obtain one at http://mozilla.org/MPL/2.0/. --> </svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8 9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"></path></svg>

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 578 B

@@ -1,4 +1,3 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- License, v. 2.0. If a copy of the MPL was not distributed with this <path d="M5.001 8.352L10.466 13.818C10.5845 13.9303 10.7421 13.9918 10.9053 13.9895C11.0685 13.9872 11.2244 13.9212 11.3397 13.8057C11.4549 13.6902 11.5205 13.5342 11.5225 13.3709C11.5244 13.2077 11.4625 13.0502 11.35 12.932L6.416 7.999L11.349 3.067C11.4614 2.94864 11.5231 2.79106 11.521 2.62786C11.5189 2.46466 11.4531 2.30874 11.3377 2.19333C11.2223 2.07792 11.0663 2.01215 10.9031 2.01003C10.7399 2.00792 10.5824 2.06962 10.464 2.182L5 7.647L5.001 8.352Z" fill="black"/>
- file, You can obtain one at http://mozilla.org/MPL/2.0/. --> </svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M13 11a1 1 0 0 1-.707-.293L8 6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 11z"></path></svg>

Before

Width:  |  Height:  |  Size: 431 B

After

Width:  |  Height:  |  Size: 578 B

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.969 10.059C17.262 9.766 17.737 9.766 18.03 10.059C18.323 10.352 18.323 10.827 18.03 11.12L12.15 17H11.35L5.46896 11.12C5.17596 10.827 5.17596 10.352 5.46896 10.059C5.76196 9.766 6.23696 9.766 6.52996 10.059L11 14.529V2.75C11 2.336 11.336 2 11.75 2C12.164 2 12.5 2.336 12.499 2.75V14.529L16.969 10.059ZM4.98193 19.7L5.78193 20.5H17.7169L18.5169 19.7V17.75C18.5169 17.336 18.8529 17 19.2669 17C19.6809 17 20.0169 17.336 20.0169 17.75V19.5C20.0169 20.881 18.8979 22 17.5169 22H5.98193C4.60093 22 3.48193 20.881 3.48193 19.5V17.75C3.48193 17.336 3.81793 17 4.23193 17C4.64593 17 4.98193 17.336 4.98193 17.75V19.7Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 782 B

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,15 +1,3 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- License, v. 2.0. If a copy of the MPL was not distributed with this <path d="M8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5ZM0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8ZM8.75 4V5.5H7.25V4H8.75ZM8.75 12V7H7.25V12H8.75Z" fill="black"/>
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16
16">
<path
d="M8 16a8 8 0 1 1 8-8 8.009 8.009 0 0 1-8 8zM8 2a6 6 0 1 0 6 6 6.006 6.006 0 0 0-6-6z">
</path>
<path
d="M8 7a1 1 0 0 0-1 1v3a1 1 0 0 0 2 0V8a1 1 0 0 0-1-1z">
</path>
<circle
cx="8" cy="5" r="1.188">
</circle>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 530 B

After

Width:  |  Height:  |  Size: 417 B

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M13 13c-.3 0-.5-.1-.7-.3L8 8.4l-4.3 4.3c-.9.9-2.3-.5-1.4-1.4l5-5c.4-.4 1-.4 1.4 0l5 5c.6.6.2 1.7-.7 1.7zm0-11H3C1.7 2 1.7 4 3 4h10c1.3 0 1.3-2 0-2z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 3.5H2V5H14V3.5ZM8 8.811L12.939 13.75L14.001 12.689L8.531 7.219C8.238 6.926 7.763 6.926 7.47 7.219L2 12.689L3.061 13.75L8 8.811Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 228 B

After

Width:  |  Height:  |  Size: 260 B

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M15 3.7V13c0 1.5-1.53 3-3 3H7.13c-.72 0-1.63-.5-2.13-1l-5-5s.84-1 .87-1c.13-.1.33-.2.53-.2.1 0 .3.1.4.2L4 10.6V2.7c0-.6.4-1 1-1s1 .4 1 1v4.6h1V1c0-.6.4-1 1-1s1 .4 1 1v6.3h1V1.7c0-.6.4-1 1-1s1 .4 1 1v5.7h1V3.7c0-.6.4-1 1-1s1 .4 1 1z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.75 2.125C7.75 1.78021 8.03021 1.5 8.375 1.5C8.71979 1.5 9 1.78021 9 2.125V3.125V8H10.5V3.125C10.5 2.78021 10.7802 2.5 11.125 2.5C11.4698 2.5 11.75 2.78021 11.75 3.125V4.625V8H13.25V4.625C13.25 4.28021 13.5302 4 13.875 4C14.2198 4 14.5 4.28021 14.5 4.625V12.0188L13.3802 13.6628C13.2954 13.7872 13.25 13.9344 13.25 14.085V16H14.75V14.3162L15.8698 12.6722C15.9546 12.5478 16 12.4006 16 12.25V4.625C16 3.45179 15.0482 2.5 13.875 2.5C13.6346 2.5 13.4035 2.53996 13.188 2.6136C12.959 1.68724 12.1219 1 11.125 1C10.8235 1 10.5366 1.06286 10.2768 1.17618C9.9281 0.478968 9.20726 0 8.375 0C7.54274 0 6.8219 0.478968 6.47323 1.17618C6.21337 1.06286 5.9265 1 5.625 1C4.45179 1 3.5 1.95179 3.5 3.125V7.25317C2.66504 6.54282 1.41035 6.58199 0.621672 7.37067C-0.208221 8.20056 -0.208221 9.54644 0.621672 10.3763L0.62188 10.3765L5.499 15.2498V16H6.999V14.939C6.999 14.74 6.9199 14.5491 6.77912 14.4085L1.68233 9.31567C1.43823 9.07156 1.43823 8.67544 1.68233 8.43133C1.92644 8.18722 2.32257 8.18722 2.56667 8.43133L3.71967 9.58433C3.93417 9.79883 4.25676 9.863 4.53701 9.74691C4.81727 9.63082 5 9.35735 5 9.054V3.125C5 2.78021 5.28022 2.5 5.625 2.5C5.96921 2.5 6.24906 2.77927 6.25 3.12326V8H7.75L7.75 3.125L7.75 3.12178V2.125Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 10c-.3 0-.5-.1-.7-.3l-5-5c-.9-.9.5-2.3 1.4-1.4L8 7.6l4.3-4.3c.9-.9 2.3.5 1.4 1.4l-5 5c-.2.2-.4.3-.7.3zm5 2H3c-1.3 0-1.3 2 0 2h10c1.3 0 1.3-2 0-2z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 8.189L12.939 3.25L14 4.311L8.531 9.781C8.238 10.074 7.763 10.074 7.47 9.781L2 4.311L3.061 3.25L8 8.189ZM14 13.5V12H2V13.5H14Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 257 B

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M1 1a1 1 0 011 1v2.4A7 7 0 118 15a7 7 0 01-4.9-2 1 1 0 011.4-1.5 5 5 0 10-1-5.5H6a1 1 0 010 2H1a1 1 0 01-1-1V2a1 1 0 011-1z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.4105 4.83612L4.77001 6.19601C5.06701 6.49201 4.85701 7.00001 4.43701 7.00001H0.862006C0.602006 7.00001 0.391006 6.78901 0.391006 6.52901V2.95401C0.391006 2.53401 0.899006 2.32401 1.19601 2.62101L2.32796 3.75328C3.67958 1.78973 5.9401 0.5 8.5 0.5C12.636 0.5 16 3.864 16 8C16 12.136 12.636 15.5 8.5 15.5C4.704 15.5 1.566 12.663 1.075 9H2.59C3.068 11.833 5.532 14 8.5 14C11.809 14 14.5 11.309 14.5 8C14.5 4.691 11.809 2 8.5 2C6.35262 2 4.46893 3.13503 3.4105 4.83612Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 204 B

After

Width:  |  Height:  |  Size: 596 B

@@ -1,4 +1,3 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- License, v. 2.0. If a copy of the MPL was not distributed with this <path d="M12.5895 4.83613L11.23 6.19601C10.933 6.49201 11.143 7.00001 11.563 7.00001H15.138C15.398 7.00001 15.609 6.78901 15.609 6.52901V2.95401C15.609 2.53401 15.101 2.32401 14.804 2.62101L13.672 3.75328C12.3204 1.78973 10.0599 0.5 7.5 0.5C3.364 0.5 0 3.864 0 8C0 12.136 3.364 15.5 7.5 15.5C11.296 15.5 14.434 12.663 14.925 9H13.41C12.932 11.833 10.468 14 7.5 14C4.191 14 1.5 11.309 1.5 8C1.5 4.691 4.191 2 7.5 2C9.64738 2 11.5311 3.13503 12.5895 4.83613Z" fill="black"/>
- file, You can obtain one at http://mozilla.org/MPL/2.0/. --> </svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M15 1a1 1 0 0 0-1 1v2.418A6.995 6.995 0 1 0 8 15a6.954 6.954 0 0 0 4.95-2.05 1 1 0 0 0-1.414-1.414A5.019 5.019 0 1 1 12.549 6H10a1 1 0 0 0 0 2h5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"></path></svg>

Before

Width:  |  Height:  |  Size: 494 B

After

Width:  |  Height:  |  Size: 576 B

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M0 4h1.5c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5H0zM9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM16 4h-1.5c-1 0-1.5.5-1.5 1.5v5c0 1 .5 1.5 1.5 1.5H16z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3.78C3 2.7621 2.13279 2.11834 1.25 2.01476V2H1V3.5C1.18133 3.5 1.32279 3.5609 1.40708 3.63029C1.48961 3.69823 1.5 3.75458 1.5 3.78V11.72C1.5 11.7454 1.48961 11.8018 1.40708 11.8697C1.32279 11.9391 1.18133 12 1 12V13.5H1.25V13.4852C2.13279 13.3817 3 12.7379 3 11.72V3.78ZM10.5 4C10.5 3.72386 10.2761 3.5 10 3.5H6.5C6.22386 3.5 6 3.72386 6 4V11.5C6 11.7761 6.22386 12 6.5 12H10C10.2761 12 10.5 11.7761 10.5 11.5V4ZM10 2C11.1046 2 12 2.89543 12 4V11.5C12 12.6046 11.1046 13.5 10 13.5H6.5C5.39543 13.5 4.5 12.6046 4.5 11.5V4C4.5 2.89543 5.39543 2 6.5 2H10ZM15.5 2H15.25V2.01476C14.3672 2.11834 13.5 2.7621 13.5 3.78V11.72C13.5 12.7379 14.3672 13.3817 15.25 13.4852V13.5H15.5V12C15.3187 12 15.1772 11.9391 15.0929 11.8697C15.0104 11.8018 15 11.7454 15 11.72V3.78C15 3.75458 15.0104 3.69823 15.0929 3.63029C15.1772 3.5609 15.3187 3.5 15.5 3.5V2Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 275 B

After

Width:  |  Height:  |  Size: 971 B

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 2C3.5 1.72421 3.72421 1.5 4 1.5H12C12.2758 1.5 12.5 1.72421 12.5 2V14C12.5 14.2758 12.2758 14.5 12 14.5H4C3.72421 14.5 3.5 14.2758 3.5 14V2ZM4 0C2.89579 0 2 0.895786 2 2V14C2 15.1042 2.89579 16 4 16H12C13.1042 16 14 15.1042 14 14V2C14 0.895786 13.1042 0 12 0H4ZM5.89301 6H7.25V10H5.89301C5.54301 10 5.36801 10.423 5.61501 10.67L7.72101 12.776C7.87401 12.929 8.12301 12.929 8.27601 12.776L10.383 10.669C10.63 10.422 10.455 9.99902 10.105 9.99902H8.75V6H10.106C10.456 6 10.632 5.577 10.383 5.331L8.27601 3.224C8.12301 3.071 7.87401 3.071 7.72101 3.224L5.61501 5.33C5.36801 5.577 5.54301 6 5.89301 6Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 171 B

After

Width:  |  Height:  |  Size: 731 B

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM11 0v.5c0 1-.5 1.5-1.5 1.5h-3C5.5 2 5 1.5 5 .5V0h6zM11 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 1V1.25H2.01476C2.11834 2.13279 2.7621 3 3.78 3H11.72C12.7379 3 13.3817 2.13279 13.4852 1.25H13.5V1H12C12 1.18133 11.9391 1.32279 11.8697 1.40708C11.8018 1.48961 11.7454 1.5 11.72 1.5H3.78C3.75458 1.5 3.69823 1.48961 3.63029 1.40708C3.5609 1.32279 3.5 1.18133 3.5 1H2ZM4 6C3.72386 6 3.5 6.22386 3.5 6.5V10C3.5 10.2761 3.72386 10.5 4 10.5H11.5C11.7761 10.5 12 10.2761 12 10V6.5C12 6.22386 11.7761 6 11.5 6H4ZM2 6.5C2 5.39543 2.89543 4.5 4 4.5H11.5C12.6046 4.5 13.5 5.39543 13.5 6.5V10C13.5 11.1046 12.6046 12 11.5 12H4C2.89543 12 2 11.1046 2 10V6.5ZM3.78 13.5C2.7621 13.5 2.11834 14.3672 2.01476 15.25H2V15.5H3.5C3.5 15.3187 3.5609 15.1772 3.63029 15.0929C3.69823 15.0104 3.75458 15 3.78 15H11.72C11.7454 15 11.8018 15.0104 11.8697 15.0929C11.9391 15.1772 12 15.3187 12 15.5H13.5V15.25H13.4852C13.3817 14.3672 12.7379 13.5 11.72 13.5H3.78Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 969 B

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M5.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C1 4.5 1.5 4 2.5 4zM7 0v.5C7 1.5 6.5 2 5.5 2h-3C1.5 2 1 1.5 1 .5V0h6zM7 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6zM13.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5c0-1 .5-1.5 1.5-1.5zM15 0v.5c0 1-.5 1.5-1.5 1.5h-3C9.5 2 9 1.5 9 .5V0h6zM15 16v-.507c0-1-.5-1.5-1.5-1.5h-3C9.5 14 9 14.5 9 15.5v.5h6z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 1C2.5 1.27579 2.72421 1.5 3 1.5H5C5.27579 1.5 5.5 1.27579 5.5 1H7C7 2.10421 6.10421 3 5 3H3C1.89579 3 1 2.10421 1 1H2.5ZM2.5 6C2.5 5.72421 2.72421 5.5 3 5.5H5C5.27579 5.5 5.5 5.72421 5.5 6V10C5.5 10.2758 5.27579 10.5 5 10.5H3C2.72421 10.5 2.5 10.2758 2.5 10V6ZM3 4C1.89579 4 1 4.89579 1 6V10C1 11.1042 1.89579 12 3 12H5C6.10421 12 7 11.1042 7 10V6C7 4.89579 6.10421 4 5 4H3ZM10 6C10 5.72421 10.2242 5.5 10.5 5.5H12.5C12.7758 5.5 13 5.72421 13 6V10C13 10.2758 12.7758 10.5 12.5 10.5H10.5C10.2242 10.5 10 10.2758 10 10V6ZM10.5 4C9.39579 4 8.5 4.89579 8.5 6V10C8.5 11.1042 9.39579 12 10.5 12H12.5C13.6042 12 14.5 11.1042 14.5 10V6C14.5 4.89579 13.6042 4 12.5 4H10.5ZM3 14.5C2.72421 14.5 2.5 14.7242 2.5 15H1C1 13.8958 1.89579 13 3 13H5C6.10421 13 7 13.8958 7 15H5.5C5.5 14.7242 5.27579 14.5 5 14.5H3ZM10 15C10 14.7242 10.2242 14.5 10.5 14.5H12.5C12.7758 14.5 13 14.7242 13 15H14.5C14.5 13.8958 13.6042 13 12.5 13H10.5C9.39579 13 8.5 13.8958 8.5 15H10ZM10.5 1.5C10.2242 1.5 10 1.27579 10 1H8.5C8.5 2.10421 9.39579 3 10.5 3H12.5C13.6042 3 14.5 2.10421 14.5 1H13C13 1.27579 12.7758 1.5 12.5 1.5H10.5Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 482 B

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,4 +1,3 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- License, v. 2.0. If a copy of the MPL was not distributed with this <path d="M0.371588 2.93131C-0.203366 1.33422 1.3342 -0.20335 2.93129 0.371603L2.93263 0.372085L12.0716 3.68171C12.0718 3.68178 12.0714 3.68163 12.0716 3.68171C13.4459 4.17758 13.8478 5.9374 12.8076 6.9776L11.8079 7.97727L14.6876 10.8569C15.4705 11.6398 15.4705 12.9047 14.6876 13.6876L13.6476 14.7276C12.8647 15.5105 11.5998 15.5105 10.8169 14.7276L7.93725 11.8479L6.97758 12.8076C5.93739 13.8478 4.17779 13.4465 3.68192 12.0722C3.68184 12.072 3.682 12.0724 3.68192 12.0722L0.371588 2.93131ZM1.78292 2.42323C1.78298 2.4234 1.78286 2.42305 1.78292 2.42323L5.09281 11.5629C5.21725 11.9082 5.65728 12.0066 5.91692 11.7469L7.93725 9.72661L11.8776 13.6669C12.0747 13.864 12.3898 13.864 12.5869 13.6669L13.6269 12.6269C13.824 12.4298 13.824 12.1147 13.6269 11.9176L9.68659 7.97727L11.7469 5.91694C12.0066 5.65729 11.9081 5.21727 11.5629 5.09283L11.5619 5.09245L2.42321 1.78293C2.42304 1.78287 2.42339 1.783 2.42321 1.78293C2.02067 1.63847 1.63846 2.02069 1.78292 2.42323Z" fill="black"/>
- file, You can obtain one at http://mozilla.org/MPL/2.0/. --> </svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M12.408 8.217l-8.083-6.7A.2.2 0 0 0 4 1.672V12.3a.2.2 0 0 0 .333.146l2.56-2.372 1.857 3.9A1.125 1.125 0 1 0 10.782 13L8.913 9.075l3.4-.51a.2.2 0 0 0 .095-.348z"></path></svg>

Before

Width:  |  Height:  |  Size: 478 B

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M1.5 3.5C.5 3.5 0 4 0 5v6.5c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm2 1.2c.8 0 1.4.2 1.8.6.5.4.7 1 .7 1.7 0 .5-.2 1-.5 1.4-.2.3-.5.7-1 1l-.6.4c-.4.3-.6.4-.75.56-.15.14-.25.24-.35.44H6v1.3H1c0-.6.1-1.1.3-1.5.3-.6.7-1 1.5-1.6.7-.4 1.1-.8 1.28-1 .32-.3.42-.6.42-1 0-.3-.1-.6-.23-.8-.17-.2-.37-.3-.77-.3s-.7.1-.9.5c-.04.2-.1.5-.1.9H1.1c0-.6.1-1.1.3-1.5.4-.7 1.1-1.1 2.1-1.1zM10.54 3.54C9.5 3.54 9 4 9 5v6.5c0 1 .5 1.5 1.54 1.5h4c.96 0 1.46-.5 1.46-1.5V5c0-1-.5-1.46-1.5-1.46zm1.9.95c.7 0 1.3.2 1.7.5.4.4.6.8.6 1.4 0 .4-.1.8-.4 1.1-.2.2-.3.3-.5.4.1 0 .3.1.6.3.4.3.5.8.5 1.4 0 .6-.2 1.2-.6 1.6-.4.5-1.1.7-1.9.7-1 0-1.8-.3-2.2-1-.14-.29-.24-.69-.24-1.29h1.4c0 .3 0 .5.1.7.2.4.5.5 1 .5.3 0 .5-.1.7-.3.2-.2.3-.5.3-.8 0-.5-.2-.8-.6-.95-.2-.05-.5-.15-1-.15v-1c.5 0 .8-.1 1-.14.3-.1.5-.4.5-.9 0-.3-.1-.5-.2-.7-.2-.2-.4-.3-.7-.3-.3 0-.6.1-.75.3-.2.2-.2.5-.2.86h-1.34c0-.4.1-.7.19-1.1 0-.12.2-.32.4-.62.2-.2.4-.3.7-.4.3-.1.6-.1 1-.1z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M2 3.5C1.72421 3.5 1.5 3.72421 1.5 4V12.5C1.5 12.7758 1.72421 13 2 13H7.25V3.5H2ZM14 13H8.75V3.5H14C14.2758 3.5 14.5 3.72421 14.5 4V12.5C14.5 12.7758 14.2758 13 14 13ZM0 4C0 2.89579 0.895786 2 2 2H14C15.1042 2 16 2.89579 16 4V12.5C16 13.6042 15.1042 14.5 14 14.5H2C0.895786 14.5 0 13.6042 0 12.5V4ZM10 6.5H11.5V7.5H10V9H11.5V10H10V11.5H12.25C12.6642 11.5 13 11.1642 13 10.75V5.75C13 5.33579 12.6642 5 12.25 5H10V6.5ZM4.5 6.5H3V5H5.25C5.66421 5 6 5.33579 6 5.75V7.75C6 8.03408 5.8395 8.29378 5.58541 8.42082L4.5 8.96353V10H6V11.5H3.75C3.33579 11.5 3 11.1642 3 10.75V8.5C3 8.21592 3.1605 7.95622 3.41459 7.82918L4.5 7.28647V6.5Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1022 B

After

Width:  |  Height:  |  Size: 775 B

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M6 3c-1 0-1.5.5-1.5 1.5v7c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5v-7c0-1-.5-1.5-1.5-1.5z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 1.5C3.72421 1.5 3.5 1.72421 3.5 2V14C3.5 14.2758 3.72421 14.5 4 14.5H12C12.2758 14.5 12.5 14.2758 12.5 14V2C12.5 1.72421 12.2758 1.5 12 1.5H4ZM2 2C2 0.895786 2.89579 0 4 0H12C13.1042 0 14 0.895786 14 2V14C14 15.1042 13.1042 16 12 16H4C2.89579 16 2 15.1042 2 14V2Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 169 B

After

Width:  |  Height:  |  Size: 395 B

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M10.56 3.5C9.56 3.5 9 4 9 5v6.5c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm1.93 1.2c.8 0 1.4.2 1.8.64.5.4.7 1 .7 1.7 0 .5-.2 1-.5 1.44-.2.3-.6.6-1 .93l-.6.4c-.4.3-.6.4-.7.55-.1.1-.2.2-.3.4h3.2v1.27h-5c0-.5.1-1 .3-1.43.2-.49.7-1 1.5-1.54.7-.5 1.1-.8 1.3-1.02.3-.3.4-.7.4-1.05 0-.3-.1-.6-.3-.77-.2-.2-.4-.3-.7-.3-.4 0-.7.2-.9.5-.1.2-.1.5-.2.9h-1.4c0-.6.2-1.1.3-1.5.4-.7 1.1-1.1 2-1.1zM1.54 3.5C.54 3.5 0 4 0 5v6.5c0 1 .5 1.5 1.54 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm1.8 1.125H4.5V12H3V6.9H1.3v-1c.5 0 .8 0 .97-.03.33-.07.53-.17.73-.37.1-.2.2-.3.25-.5.05-.2.05-.3.05-.3z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.5 4C1.5 3.72421 1.72421 3.5 2 3.5H7.25V13H2C1.72421 13 1.5 12.7758 1.5 12.5V4ZM8.75 13V3.5H14C14.2758 3.5 14.5 3.72421 14.5 4V12.5C14.5 12.7758 14.2758 13 14 13H8.75ZM2 2C0.895786 2 0 2.89579 0 4V12.5C0 13.6042 0.895786 14.5 2 14.5H14C15.1042 14.5 16 13.6042 16 12.5V4C16 2.89579 15.1042 2 14 2H2ZM4.75 5H3V6.5H4V11.5H5.5V5.75C5.5 5.33579 5.16421 5 4.75 5ZM10 6.5H11.5V7.28647L10.4146 7.82918C10.1605 7.95622 10 8.21592 10 8.5V10.75C10 11.1642 10.3358 11.5 10.75 11.5H13V10H11.5V8.96353L12.5854 8.42082C12.8395 8.29378 13 8.03408 13 7.75V5.75C13 5.33579 12.6642 5 12.25 5H10V6.5Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 678 B

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 B

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M4 16V2s0-1 1-1h6s1 0 1 1v14l-4-5z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 3.5C1.72421 3.5 1.5 3.72421 1.5 4V12C1.5 12.2758 1.72421 12.5 2 12.5H14C14.2758 12.5 14.5 12.2758 14.5 12V4C14.5 3.72421 14.2758 3.5 14 3.5H2ZM0 4C0 2.89579 0.895786 2 2 2H14C15.1042 2 16 2.89579 16 4V12C16 13.1042 15.1042 14 14 14H2C0.895786 14 0 13.1042 0 12V4ZM8.75 8.75H7.25V7.25H8.75V8.75ZM8.00001 4.625C5.91142 4.625 4.14736 5.94291 3.45159 7.77847L3.36761 8L3.45159 8.22153C4.14736 10.0571 5.91142 11.375 8.00001 11.375C10.0886 11.375 11.8527 10.0571 12.5484 8.22153L12.6324 8L12.5484 7.77847C11.8527 5.94291 10.0886 4.625 8.00001 4.625ZM8.00001 10.125C6.53912 10.125 5.28508 9.25455 4.71282 8C5.28508 6.74545 6.53912 5.875 8.00001 5.875C9.4609 5.875 10.7149 6.74545 11.2872 8C10.7149 9.25455 9.4609 10.125 8.00001 10.125Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 115 B

After

Width:  |  Height:  |  Size: 861 B

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="m14 9h-6c-1.3 0-1.3 2 0 2h6c1.3 0 1.3-2 0-2zm-5.2-8h-3.8c-1.3 0-1.3 2 0 2h1.7zm-6.8 0c-1 0-1.3 1-0.7 1.7 0.7 0.6 1.7 0.3 1.7-0.7 0-0.5-0.4-1-1-1zm3 8c-1 0-1.3 1-0.7 1.7 0.6 0.6 1.7 0.2 1.7-0.7 0-0.5-0.4-1-1-1zm0.3-4h-0.3c-1.4 0-1.4 2 0 2h2.3zm-3.3 0c-0.9 0-1.4 1-0.7 1.7 0.7 0.6 1.7 0.2 1.7-0.7 0-0.6-0.5-1-1-1zm12 8h-9c-1.3 0-1.3 2 0 2h9c1.3 0 1.3-2 0-2zm-12 0c-1 0-1.3 1-0.7 1.7 0.7 0.6 1.7 0.2 1.7-0.712 0-0.5-0.4-1-1-1z"/><path d="m7.37 4.838 3.93-3.911v2.138h3.629v3.546h-3.629v2.138l-3.93-3.911"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.803 4.74998V6.02436C10.803 6.39302 10.3571 6.57793 10.0967 6.31753L7.87716 4.098C7.71566 3.93649 7.71566 3.67434 7.87716 3.51283L10.0967 1.29329C10.3571 1.0329 10.8036 1.21722 10.8036 1.58588V3.24998H15V4.74998H10.803ZM8 1.24998H3V2.74998H6.5L8 1.24998ZM6.5 5.24998H3V6.74998H8L6.5 5.24998ZM3 13.25H15V14.75H3V13.25ZM6 9.24998H15V10.75H6V9.24998ZM1.5 5.24998H0V6.74998H1.5V5.24998ZM0 13.25H1.5V14.75H0V13.25ZM1.5 1.24998H0V2.74998H1.5V1.24998ZM3 9.24998H4.5V10.75H3V9.24998Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 581 B

After

Width:  |  Height:  |  Size: 607 B

@@ -1,4 +1,4 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
- License, v. 2.0. If a copy of the MPL was not distributed with this <path d="M9.79407 7.31811H7.86307C7.41807 7.31811 7.19407 7.85711 7.50907 8.17211L10.1911 10.8541C10.3861 11.0491 10.7031 11.0491 10.8981 10.8541L13.5801 8.17211C13.8951 7.85711 13.6721 7.31811 13.2261 7.31811H11.2941V4.38211H11.2961V3.13211H11.2941V2.30811H9.79407V3.13211H9.79107V4.38211H9.79507V7.31811H9.79407Z" fill="black"/>
- file, You can obtain one at http://mozilla.org/MPL/2.0/. --> <path d="M14 3.13208H12.796V4.38208H14C14.345 4.38208 14.625 4.66208 14.625 5.00708V13.0071C14.625 13.3521 14.345 13.6321 14 13.6321H2C1.655 13.6321 1.375 13.3521 1.375 13.0071V3.00708C1.375 2.66208 1.655 2.38208 2 2.38208H5.643C5.82 2.38208 5.989 2.45808 6.108 2.58908L7.536 4.17508C7.654 4.30708 7.823 4.38208 8 4.38208H8.291V3.13208H8.278L7.036 1.75208C6.681 1.35808 6.173 1.13208 5.642 1.13208H2C0.966 1.13208 0.125 1.97308 0.125 3.00708V13.0071C0.125 14.0411 0.966 14.8821 2 14.8821H14C15.034 14.8821 15.875 14.0411 15.875 13.0071V5.00708C15.875 3.97308 15.034 3.13208 14 3.13208Z" fill="black"/>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M14 3h-2v2h2v8H2V5h7V3h-.849L6.584 1.538A2 2 0 0 0 5.219 1H2a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zM2 3h3.219l1.072 1H2z"></path><path d="M8.146 6.146a.5.5 0 0 0 0 .707l2 2a.5.5 0 0 0 .707 0l2-2a.5.5 0 1 0-.707-.707L11 7.293V.5a.5.5 0 0 0-1 0v6.793L8.854 6.146a.5.5 0 0 0-.708 0z"></path></svg> </svg>

Before

Width:  |  Height:  |  Size: 624 B

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,24 +1,5 @@
<?xml version="1.0" encoding="iso-8859-1"?> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- copied from https://www.svgrepo.com/svg/255881/text --> <path fill-rule="evenodd" clip-rule="evenodd" d="M3 2.5C2.72421 2.5 2.5 2.72421 2.5 3V4.75H1V3C1 1.89579 1.89579 1 3 1H13C14.1042 1 15 1.89579 15 3V4.75H13.5V3C13.5 2.72421 13.2758 2.5 13 2.5H3Z" fill="black"/>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <path fill-rule="evenodd" clip-rule="evenodd" d="M11 15H5V13.5H11V15Z" fill="black"/>
viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;" xml:space="preserve"> <path fill-rule="evenodd" clip-rule="evenodd" d="M8.75 2.25V14.25H7.25V2.25H8.75Z" fill="black"/>
<g>
<g transform="scale(0.03125)">
<path d="M405.787,43.574H8.17c-4.513,0-8.17,3.658-8.17,8.17v119.83c0,4.512,3.657,8.17,8.17,8.17h32.681
c4.513,0,8.17-3.658,8.17-8.17v-24.511h95.319v119.83c0,4.512,3.657,8.17,8.17,8.17c4.513,0,8.17-3.658,8.17-8.17v-128
c0-4.512-3.657-8.17-8.17-8.17H40.851c-4.513,0-8.17,3.658-8.17,8.17v24.511H16.34V59.915h381.277v103.489h-16.34v-24.511
c0-4.512-3.657-8.17-8.17-8.17h-111.66c-4.513,0-8.17,3.658-8.17,8.17v288.681c0,4.512,3.657,8.17,8.17,8.17h57.191v16.34H95.319
v-16.34h57.191c4.513,0,8.17-3.658,8.17-8.17v-128c0-4.512-3.657-8.17-8.17-8.17c-4.513,0-8.17,3.658-8.17,8.17v119.83H87.149
c-4.513,0-8.17,3.658-8.17,8.17v32.681c0,4.512,3.657,8.17,8.17,8.17h239.66c4.513,0,8.17-3.658,8.17-8.17v-32.681
c0-4.512-3.657-8.17-8.17-8.17h-57.192v-272.34h95.319v24.511c0,4.512,3.657,8.17,8.17,8.17h32.681c4.513,0,8.17-3.658,8.17-8.17
V51.745C413.957,47.233,410.3,43.574,405.787,43.574z"/>
</g>
</g>
<g>
<g transform="scale(0.03125)">
<path d="M503.83,452.085h-24.511V59.915h24.511c4.513,0,8.17-3.658,8.17-8.17s-3.657-8.17-8.17-8.17h-65.362
c-4.513,0-8.17,3.658-8.17,8.17s3.657,8.17,8.17,8.17h24.511v392.17h-24.511c-4.513,0-8.17,3.658-8.17,8.17s3.657,8.17,8.17,8.17
h65.362c4.513,0,8.17-3.658,8.17-8.17S508.343,452.085,503.83,452.085z"/>
</g>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 498 B

@@ -0,0 +1,6 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.10918 11.66C7.24918 11.8 7.43918 11.88 7.63918 11.88C7.83918 11.88 8.02918 11.8 8.16918 11.66L14.9192 4.91C15.2692 4.57 15.4592 4.11 15.4592 3.62C15.4592 3.13 15.2692 2.67 14.9192 2.33L13.1292 0.54C12.7892 0.19 12.3292 0 11.8392 0C11.3492 0 10.8892 0.2 10.5492 0.54L3.79918 7.29C3.50918 7.58 3.50918 8.06 3.79918 8.35L4.38988 8.9407L1.40918 11.93H5.64918L6.51419 11.065L7.10918 11.66ZM7.63918 10.07L5.38918 7.82V7.81L7.8648 5.33438L10.1198 7.58938L7.63918 10.07ZM11.1805 6.52872L13.8592 3.85C13.9892 3.72 13.9892 3.52 13.8592 3.39L12.0692 1.6C11.9892 1.52 11.8892 1.5 11.8392 1.5C11.8392 1.5 11.6892 1.51 11.6092 1.59L8.92546 4.27372L11.1805 6.52872Z" fill="#000"/>
<path d="M0.40918 14H15.4092V16H0.40918V14Z" fill="#000"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 910 B

@@ -1,9 +1,4 @@
<?xml version='1.0' encoding='utf-8'?> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'> <path d="M2.49913 12.6251C2.61913 12.6251 2.73913 12.6051 2.85713 12.5661L6.29013 11.4201L13.2891 4.4221C14.0191 3.6911 14.0191 2.5011 13.2891 1.7701L12.2291 0.710098C11.4971 -0.0199023 10.3091 -0.0199023 9.57713 0.710098L2.57813 7.7091L1.43313 11.1451C1.29813 11.5511 1.40213 11.9931 1.70513 12.2951C1.92113 12.5101 2.20613 12.6251 2.49913 12.6251ZM10.4611 1.5951C10.7031 1.3511 11.1021 1.3511 11.3441 1.5951L12.4051 2.6561C12.6491 2.8991 12.6491 3.2961 12.4051 3.5391L11.3401 4.6051L9.39513 2.6601L10.4611 1.5951ZM3.67013 8.3851L8.51013 3.5451L10.4541 5.4891L5.61413 10.3301L2.69713 11.3031L3.67013 8.3851Z" fill="black"/>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" xmlns:xlink="http://www.w3.org/1999/xlink" enable-background="new 0 0 16 16"> <path d="M14.8169 13.314L13.0229 13.862C12.3309 14.073 11.5909 14.111 10.8859 13.968L8.80391 13.551C7.58491 13.308 6.29791 13.48 5.18491 14.036C3.95291 14.652 2.46691 14.412 1.49191 13.436L1.44091 13.385L0.60791 14.321C1.46291 15.175 2.59991 15.625 3.75291 15.625C4.42891 15.625 5.10991 15.471 5.74391 15.153C6.60891 14.721 7.60891 14.586 8.55891 14.777L10.6409 15.194C11.5509 15.376 12.5009 15.327 13.3879 15.056L15.1819 14.508L14.8169 13.314Z" fill="black"/>
<g>
<g transform="scale(0.03125)">
<path d="m455.1,137.9l-32.4,32.4-81-81.1 32.4-32.4c6.6-6.6 18.1-6.6 24.7,0l56.3,56.4c6.8,6.8 6.8,17.9 0,24.7zm-270.7,271l-81-81.1 209.4-209.7 81,81.1-209.4,209.7zm-99.7-42l60.6,60.7-84.4,23.8 23.8-84.5zm399.3-282.6l-56.3-56.4c-11-11-50.7-31.8-82.4,0l-285.3,285.5c-2.5,2.5-4.3,5.5-5.2,8.9l-43,153.1c-2,7.1 0.1,14.7 5.2,20 5.2,5.3 15.6,6.2 20,5.2l153-43.1c3.4-0.9 6.4-2.7 8.9-5.2l285.1-285.5c22.7-22.7 22.7-59.7 0-82.5z"/>
</g>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 804 B

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,8 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="black">
<path d="M3 1a2 2 0 0 0-2 2l0 10a2 2 0 0 0 2 2l10 0a2 2 0 0 0 2-2l0-10a2 2 0 0 0-2-2L3 1zm10.75 12.15-.6.6-10.3 0-.6-.6 0-10.3.6-.6 10.3 0 .6.6 0 10.3z"/>
<path d="m11 12-6 0a1 1 0 0 1-1-1l0-1.321a.75.75 0 0 1 .218-.529L6.35 7.005a.75.75 0 0 1 1.061-.003l2.112 2.102.612-.577a.75.75 0 0 1 1.047.017l.6.605a.75.75 0 0 1 .218.529L12 11a1 1 0 0 1-1 1z"/>
<path d="m11.6 5-1.2 0-.4.4 0 1.2.4.4 1.2 0 .4-.4 0-1.2z"/>
</svg>

After

Width:  |  Height:  |  Size: 734 B

@@ -1 +1,3 @@
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg"><path d="M8 11a1 1 0 01-.707-.293l-2.99-2.99c-.91-.942.471-2.324 1.414-1.414L8 8.586l2.283-2.283c.943-.91 2.324.472 1.414 1.414l-2.99 2.99A1 1 0 018 11z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.23336 10.4664L11.8474 6.85339C11.894 6.8071 11.931 6.75203 11.9563 6.69136C11.9816 6.63069 11.9946 6.56562 11.9946 6.49989C11.9946 6.43417 11.9816 6.3691 11.9563 6.30843C11.931 6.24776 11.894 6.19269 11.8474 6.14639C11.7536 6.05266 11.6264 6 11.4939 6C11.3613 6 11.2341 6.05266 11.1404 6.14639L7.99236 9.29339L4.84736 6.14739C4.75305 6.05631 4.62675 6.00592 4.49566 6.00706C4.36456 6.0082 4.23915 6.06078 4.14645 6.15348C4.05374 6.24619 4.00116 6.37159 4.00002 6.50269C3.99888 6.63379 4.04928 6.76009 4.14036 6.85439L7.75236 10.4674L8.23336 10.4664Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 681 B

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