Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0af52f2053 | ||
|
|
2fff002699 | ||
|
|
5a87c1c4b3 | ||
|
|
f0b770224a | ||
|
|
4c3fc90830 | ||
|
|
d6798fb286 | ||
|
|
6315f40800 | ||
|
|
1b73d8a7e4 | ||
|
|
54a0ddaab5 | ||
|
|
a84c316e3d | ||
|
|
0a53719394 | ||
|
|
3cc89d4ca0 | ||
|
|
2eee5fbdd8 | ||
|
|
e0b2734d66 | ||
|
|
9926e03009 | ||
|
|
3aa0c82795 | ||
|
|
bf89bbc8bc | ||
|
|
deb1ff37b2 | ||
|
|
2927b5d544 | ||
|
|
bf9313ddeb | ||
|
|
c2ffa940cd | ||
|
|
b94e1df518 | ||
|
|
2eb4f77a23 | ||
|
|
d5059979d5 | ||
|
|
4fa7520598 | ||
|
|
e8cb84136b | ||
|
|
8a3409e911 | ||
|
|
abedb13473 | ||
|
|
cd01aaa69e | ||
|
|
d4502713ff | ||
|
|
c523463591 | ||
|
|
cca8158bdf | ||
|
|
b9085129a7 | ||
|
|
cb073daca3 | ||
|
|
c40c8a2255 | ||
|
|
e42e11e2a4 | ||
|
|
07bf9047b0 | ||
|
|
230573dc7a | ||
|
|
94df767c28 | ||
|
|
1eb9cd44dc | ||
|
|
eb6563480f | ||
|
|
2f39e51e1c | ||
|
|
24252e09b4 | ||
|
|
0e7eed4d3c | ||
|
|
884f7666fa | ||
|
|
c54e9fe588 | ||
|
|
d82944d08c | ||
|
|
97764acbcd | ||
|
|
53f0647c5d | ||
|
|
b5ee07eba8 | ||
|
|
5fe13f53bf | ||
|
|
13dc0b9c8e | ||
|
|
78d45f0208 | ||
|
|
d0e166140f | ||
|
|
2617fa8af4 | ||
|
|
89a1dcfbf6 | ||
|
|
9d619ae5c7 | ||
|
|
10d0542e05 | ||
|
|
111c93c391 | ||
|
|
1d9471b252 | ||
|
|
88cb58c285 | ||
|
|
eea01b0ff5 | ||
|
|
d755878bd2 | ||
|
|
2d7cf1e6d3 | ||
|
|
b8a770a470 | ||
|
|
481237dc77 | ||
|
|
5e9dfc0724 | ||
|
|
ce720f40fe | ||
|
|
c92115faf3 | ||
|
|
a1f9f03fc1 | ||
|
|
6a504673e5 | ||
|
|
839118e4f1 | ||
|
|
fd730f6609 | ||
|
|
1be0ff9620 | ||
|
|
6717683ac3 | ||
|
|
0f9b5d08e9 | ||
|
|
1eb77c9cd4 | ||
|
|
ca92dbb168 | ||
|
|
8bc1f03164 | ||
|
|
d8d9d405e9 | ||
|
|
8f2f6f5c91 | ||
|
|
4c01af340f | ||
|
|
88783257b4 | ||
|
|
c2474aa91d | ||
|
|
d2860fc374 | ||
|
|
87a1971d74 | ||
|
|
a9359bc1db | ||
|
|
c7f0293568 | ||
|
|
22affedafc | ||
|
|
4d79c593f7 | ||
|
|
87b82424d5 | ||
|
|
5f81084e66 | ||
|
|
095074f977 | ||
|
|
acd3c8d103 | ||
|
|
3733fd442f | ||
|
|
4eb9845ff7 | ||
|
|
ec6efafbed | ||
|
|
10668571e8 | ||
|
|
878fd85c63 | ||
|
|
3827d46d66 | ||
|
|
7feff3b55c | ||
|
|
4523a12708 | ||
|
|
7a8d054b28 | ||
|
|
a56d1c80ae | ||
|
|
074aed6997 | ||
|
|
2f8db1f7f0 | ||
|
|
b3d878bae8 | ||
|
|
9b12852544 | ||
|
|
a7d58386eb | ||
|
|
68531a44d8 | ||
|
|
d3233b4b9c | ||
|
|
bdac7a3257 | ||
|
|
0d359e9e57 | ||
|
|
c4fad3cd20 | ||
|
|
9b520b1941 | ||
|
|
bcf4a479ac | ||
|
|
abc923ade4 | ||
|
|
3aa72c8afb | ||
|
|
249017a4fa | ||
|
|
84f432830c | ||
|
|
e6ccd42f89 | ||
|
|
12d46d33d9 | ||
|
|
8d9f301aea | ||
|
|
0ff3f0f426 | ||
|
|
b2f5853b12 | ||
|
|
f8adcbb691 | ||
|
|
a0728b07d0 | ||
|
|
e3b895755d | ||
|
|
e33e4ac4ac | ||
|
|
2f515b725b | ||
|
|
a1d7dc5c28 | ||
|
|
1c5d1e24f7 | ||
|
|
8df45d150d | ||
|
|
a59d20bd0f | ||
|
|
e3be7595e2 | ||
|
|
e33a7a3f08 | ||
|
|
8c02ffb1cb | ||
|
|
7367e96d2b | ||
|
|
2d80187faa | ||
|
|
1d3a768dfe | ||
|
|
c09e1ed203 | ||
|
|
99e50bafc4 | ||
|
|
155cf04536 | ||
|
|
ebe7cd7ba4 | ||
|
|
2d470e0ce1 | ||
|
|
87c0b15f5f | ||
|
|
02d2c2bbf8 | ||
|
|
6996e813b5 | ||
|
|
840f4d2010 | ||
|
|
3cda0a0f32 | ||
|
|
a6931b4936 | ||
|
|
9536b2c322 | ||
|
|
099dbf95e7 | ||
|
|
23d5463213 | ||
|
|
a2ceeb678f | ||
|
|
092329c9e6 | ||
|
|
de989783e8 | ||
|
|
1d7dcce98d | ||
|
|
551828f8cf | ||
|
|
d26964ab08 | ||
|
|
0fe12fe598 | ||
|
|
b0e8024f08 | ||
|
|
bc1c097271 | ||
|
|
f98cb3d8fb | ||
|
|
488005962f | ||
|
|
6d6f8c1b1f | ||
|
|
c4a38087d9 | ||
|
|
f468b9e6e3 | ||
|
|
53a16c5a6f | ||
|
|
108caa0898 | ||
|
|
a0b776cbd1 | ||
|
|
4719797aff | ||
|
|
60cc0c9395 | ||
|
|
ded4cb7a19 | ||
|
|
f5f16a3dd2 | ||
|
|
c5f2323c8a | ||
|
|
ea89fe07d8 | ||
|
|
5d86bb0ba0 | ||
|
|
05032aed3c | ||
|
|
7e46f9c0b1 | ||
|
|
5e15cac598 | ||
|
|
d0551274dc | ||
|
|
99c05650a1 | ||
|
|
617ee3d781 | ||
|
|
47aa3254c7 | ||
|
|
0717b9493c | ||
|
|
824ebc4d76 | ||
|
|
cbaa61bbb7 | ||
|
|
dbac685985 | ||
|
|
b25c024b66 | ||
|
|
bc65180c32 | ||
|
|
10c66b6e63 | ||
|
|
c29b1696f7 | ||
|
|
c974cd048e | ||
|
|
24e9bf2728 | ||
|
|
ff46af9c49 | ||
|
|
0a7345186d | ||
|
|
cb0d9ec95c |
@@ -1 +1,3 @@
|
|||||||
graft src/calibreweb
|
graft src/calibreweb
|
||||||
|
global-exclude __pycache__
|
||||||
|
global-exclude *.pyc
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
# Short Notice from the maintainer
|
|
||||||
|
|
||||||
After 6 years of more or less intensive programming on Calibre-Web, I need a break.
|
|
||||||
The last few months, maintaining Calibre-Web has felt more like work than a hobby. I felt pressured and teased by people to solve "their" problems and merge PRs for "their" Calibre-Web.
|
|
||||||
I have turned off all notifications from Github/Discord and will now concentrate undisturbed on the development of “my” Calibre-Web over the next few weeks/months.
|
|
||||||
I will look into the issues and maybe also the PRs from time to time, but don't expect a quick response from me.
|
|
||||||
|
|
||||||
# Calibre-Web
|
# Calibre-Web
|
||||||
|
|
||||||
Calibre-Web is a web app that offers a clean and intuitive interface for browsing, reading, and downloading eBooks using a valid [Calibre](https://calibre-ebook.com) database.
|
Calibre-Web is a web app that offers a clean and intuitive interface for browsing, reading, and downloading eBooks using a valid [Calibre](https://calibre-ebook.com) database.
|
||||||
@@ -26,13 +19,13 @@ Calibre-Web is a web app that offers a clean and intuitive interface for browsin
|
|||||||
- [Quick start](#quick-start)
|
- [Quick start](#quick-start)
|
||||||
- [Requirements](#requirements)
|
- [Requirements](#requirements)
|
||||||
4. [Docker Images](#docker-images)
|
4. [Docker Images](#docker-images)
|
||||||
5. [Contributor Recognition](#contributor-recognition)
|
5. [Troubleshooting](#troubleshooting)
|
||||||
6. [Contact](#contact)
|
6. [Contributor Recognition](#contributor-recognition)
|
||||||
7. [Contributing to Calibre-Web](#contributing-to-calibre-web)
|
7. [Contact](#contact)
|
||||||
|
8. [Contributing to Calibre-Web](#contributing-to-calibre-web)
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
*This software is a fork of [library](https://github.com/mutschler/calibreserver) and licensed under the GPL v3 License.*
|
*This software is a fork of [library](https://github.com/mutschler/calibreserver) and licensed under the GPL v3 License.*
|
||||||
|
|
||||||

|

|
||||||
@@ -64,51 +57,103 @@ Calibre-Web is a web app that offers a clean and intuitive interface for browsin
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
#### Installation via pip (recommended)
|
### Installation via pip (recommended)
|
||||||
1. Create a virtual environment for Calibre-Web to avoid conflicts with existing Python dependencies
|
|
||||||
2. Install Calibre-Web via pip: `pip install calibreweb` (or `pip3` depending on your OS/distro)
|
|
||||||
3. Install optional features via pip as needed, see [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-and-Windows) for details
|
|
||||||
4. Start Calibre-Web by typing `cps`
|
|
||||||
|
|
||||||
*Note: Raspberry Pi OS users may encounter issues during installation. If so, please update pip (`./venv/bin/python3 -m pip install --upgrade pip`) and/or install cargo (`sudo apt install cargo`) before retrying the installation.*
|
1. **Create a virtual environment**: It’s essential to isolate your Calibre-Web installation to avoid dependency conflicts. You can create a virtual environment by running:
|
||||||
|
```
|
||||||
|
python3 -m venv calibre-web-env
|
||||||
|
```
|
||||||
|
2. **Activate the virtual environment**:
|
||||||
|
```
|
||||||
|
source calibre-web-env/bin/activate
|
||||||
|
```
|
||||||
|
3. **Install Calibre-Web**: Use pip to install the application:
|
||||||
|
```
|
||||||
|
pip install calibreweb
|
||||||
|
```
|
||||||
|
4. **Install optional features**: For additional functionality, you may need to install optional features. Refer to [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-and-Windows) for details on what can be installed.
|
||||||
|
5. **Start Calibre-Web**: After installation, you can start the application with:
|
||||||
|
```
|
||||||
|
cps
|
||||||
|
```
|
||||||
|
|
||||||
Refer to the Wiki for additional installation examples: [manual installation](https://github.com/janeczku/calibre-web/wiki/Manual-installation), [Linux Mint](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-in-Linux-Mint-19-or-20), [Cloud Provider](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-on-a-Cloud-Provider).
|
*Note: Users of Raspberry Pi OS may encounter installation issues. If you do, try upgrading pip and/or installing cargo as follows:*
|
||||||
|
```
|
||||||
|
./venv/bin/python3 -m pip install --upgrade pip
|
||||||
|
sudo apt install cargo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Important Links
|
||||||
|
- For additional installation examples, check the following:
|
||||||
|
- [Manual installation](https://github.com/janeczku/calibre-web/wiki/Manual-installation)
|
||||||
|
- [Linux Mint installation](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-in-Linux-Mint-19-or-20)
|
||||||
|
- [Cloud Provider setup](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-on-a-Cloud-Provider)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
1. Open your browser and navigate to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
|
1. **Access Calibre-Web**: Open your browser and navigate to:
|
||||||
2. Log in with the default admin credentials
|
```
|
||||||
3. If you don't have a Calibre database, you can use [this database](https://github.com/janeczku/calibre-web/raw/master/library/metadata.db) (move it out of the Calibre-Web folder to prevent overwriting during updates)
|
http://localhost:8083
|
||||||
4. Set `Location of Calibre database` to the path of the folder containing your Calibre library (metadata.db) and click "Save"
|
```
|
||||||
5. Optionally, use Google Drive to host your Calibre library by following the [Google Drive integration guide](https://github.com/janeczku/calibre-web/wiki/G-Drive-Setup#using-google-drive-integration)
|
or for the OPDS catalog:
|
||||||
6. Configure your Calibre-Web instance via the admin page, referring to the [Basic Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#basic-configuration) and [UI Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#ui-configuration) guides
|
```
|
||||||
|
http://localhost:8083/opds
|
||||||
#### Default Admin Login:
|
```
|
||||||
- **Username:** admin
|
2. **Log in**: Use the default admin credentials:
|
||||||
- **Password:** admin123
|
- **Username:** admin
|
||||||
|
- **Password:** admin123
|
||||||
|
3. **Database Setup**: If you do not have a Calibre database, download a sample from:
|
||||||
|
```
|
||||||
|
https://github.com/janeczku/calibre-web/raw/master/library/metadata.db
|
||||||
|
```
|
||||||
|
Move it out of the Calibre-Web folder to avoid overwriting during updates.
|
||||||
|
4. **Configure Calibre Database**: In the admin interface, set the `Location of Calibre database` to the path of the folder containing your Calibre library (where `metadata.db` is located) and click "Save".
|
||||||
|
5. **Google Drive Integration**: For hosting your Calibre library on Google Drive, refer to the [Google Drive integration guide](https://github.com/janeczku/calibre-web/wiki/G-Drive-Setup#using-google-drive-integration).
|
||||||
|
6. **Admin Configuration**: Configure your instance via the admin page, referring to the [Basic Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#basic-configuration) and [UI Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#ui-configuration) guides.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.5+
|
- **Python Version**: Ensure you have Python 3.7 or newer.
|
||||||
- [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**: Required for cover extraction from EPUBs. Windows users may also need to install [Ghostscript](https://ghostscript.com/releases/gsdnld.html) for PDF cover extraction.
|
||||||
- Optional: [Calibre desktop program](https://calibre-ebook.com/download) for on-the-fly conversion and metadata editing (set "calibre's converter tool" path on the setup page)
|
- **Windows Users**: Install [libmagic](https://gnuwin32.sourceforge.net/downlinks/file.php) for 32bit python or [libmagic for 64bit python](https://github.com/nscaife/file-windows/releases/tag/20170108), depending on your Python version. Make sure these files are included in your path.
|
||||||
- Optional: [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 Tools**:
|
||||||
|
- **Calibre desktop program**: Recommended for on-the-fly conversion and metadata editing. Set the path to Calibre’s converter tool on the setup page.
|
||||||
|
- **Kepubify tool**: Needed for Kobo device support. Download the tool and place the binary in `/opt/kepubify` on Linux or `C:\Program Files\kepubify` on Windows.
|
||||||
|
|
||||||
## Docker Images
|
## Docker Images
|
||||||
|
|
||||||
Pre-built Docker images are available in the following Docker Hub repositories (maintained by the LinuxServer team):
|
Pre-built Docker images are available:
|
||||||
|
|
||||||
#### **LinuxServer - x64, aarch64**
|
### **LinuxServer - x64, aarch64**
|
||||||
- [Docker Hub](https://hub.docker.com/r/linuxserver/calibre-web)
|
- **Docker Hub**: [linuxserver/calibre-web](https://hub.docker.com/r/linuxserver/calibre-web)
|
||||||
- [GitHub](https://github.com/linuxserver/docker-calibre-web)
|
- **GitHub**: [linuxserver/docker-calibre-web](https://github.com/linuxserver/docker-calibre-web)
|
||||||
- [GitHub - Optional Calibre layer](https://github.com/linuxserver/docker-mods/tree/universal-calibre)
|
- **Optional Calibre layer**: [linuxserver/docker-mods](https://github.com/linuxserver/docker-mods/tree/universal-calibre)
|
||||||
|
|
||||||
Include the environment variable `DOCKER_MODS=linuxserver/mods:universal-calibre` in your Docker run/compose file to add the Calibre `ebook-convert` binary (x64 only). Omit this variable for a lightweight image.
|
To include the Calibre `ebook-convert` binary (x64 only), add the environment variable:
|
||||||
|
```
|
||||||
|
DOCKER_MODS=linuxserver/mods:universal-calibre
|
||||||
|
```
|
||||||
|
in your Docker run/compose file. Omit this variable for a lightweight image.
|
||||||
|
|
||||||
Both the Calibre-Web and Calibre-Mod images are automatically rebuilt on new releases and updates.
|
- **Paths Configuration**:
|
||||||
|
- Set **Path to Calibre Binaries** to `/usr/bin`.
|
||||||
|
- Set **Path to Unrar** to `/usr/bin/unrar`.
|
||||||
|
|
||||||
- Set "path to convertertool" to `/usr/bin/ebook-convert`
|
## Troubleshooting
|
||||||
- Set "path to unrar" to `/usr/bin/unrar`
|
|
||||||
|
- **Common Issues**:
|
||||||
|
- If you experience issues starting the application, check the log files located in the `logs` directory for error messages.
|
||||||
|
- If eBooks fail to load, verify that the `Location of Calibre database` is correctly set and that the database file is accessible.
|
||||||
|
|
||||||
|
- **Configuration Errors**: Ensure that your Calibre database is compatible and properly formatted. Refer to the Calibre documentation for guidance on maintaining the database.
|
||||||
|
|
||||||
|
- **Performance Problems**:
|
||||||
|
- If the application is slow, consider increasing the allocated resources (CPU/RAM) to your server or optimizing the Calibre database by removing duplicates and unnecessary entries.
|
||||||
|
- Regularly clear the cache in your web browser to improve loading times.
|
||||||
|
|
||||||
|
- **User Management Issues**: If users are unable to log in or register, check the user permission settings in the admin interface. Ensure that registration is enabled and that users are being assigned appropriate roles.
|
||||||
|
|
||||||
|
- **Support Resources**: For additional help, consider visiting the [FAQ section](https://github.com/janeczku/calibre-web/wiki/FAQ) of the wiki or posting your questions in the [Discord community](https://discord.gg/h2VsJ2NEfB).
|
||||||
|
|
||||||
## Contributor Recognition
|
## Contributor Recognition
|
||||||
|
|
||||||
@@ -122,4 +167,21 @@ For more information, How To's, and FAQs, please visit the [Wiki](https://github
|
|||||||
|
|
||||||
## Contributing to Calibre-Web
|
## Contributing to Calibre-Web
|
||||||
|
|
||||||
Check out our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
|
To contribute, please check our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md). We welcome issues, feature requests, and pull requests from the community.
|
||||||
|
|
||||||
|
### Reporting Bugs
|
||||||
|
|
||||||
|
If you encounter bugs or issues, please report them in the [issues section](https://github.com/janeczku/calibre-web/issues) of the repository. Be sure to include detailed information about your setup and the problem encountered.
|
||||||
|
|
||||||
|
### Feature Requests
|
||||||
|
|
||||||
|
We welcome suggestions for new features. Please create a new issue in the repository to discuss your ideas.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- **Documentation**: Comprehensive documentation is available on the [Calibre-Web wiki](https://github.com/janeczku/calibre-web/wiki).
|
||||||
|
- **Community Contributions**: Explore the [community contributions](https://github.com/janeczku/calibre-web/pulls) to see ongoing work and how you can get involved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Thank you for using Calibre-Web! We hope you enjoy managing your eBook library with our tool.
|
||||||
|
|||||||
+40
-35
@@ -10,41 +10,46 @@ 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|
|
||||||
| V 0.6.13 | Name of Shelves are only visible to users who can access the corresponding shelf Thanks to @ibarrionuevo ||
|
| V 0.6.13 | Name of Shelves are only visible to users who can access the corresponding shelf Thanks to @ibarrionuevo ||
|
||||||
| V 0.6.13 | JavaScript could get executed in the description field. Thanks to @ranjit-git and Hagai Wechsler (WhiteSource) ||
|
| V 0.6.13 | JavaScript could get executed in the description field. Thanks to @ranjit-git and Hagai Wechsler (WhiteSource) ||
|
||||||
| V 0.6.13 | JavaScript could get executed in a custom column of type "comment" field ||
|
| V 0.6.13 | JavaScript could get executed in a custom column of type "comment" field ||
|
||||||
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a title containing javascript code ||
|
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a title containing javascript code ||
|
||||||
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a username containing javascript code ||
|
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a username containing javascript code ||
|
||||||
| V 0.6.13 | JavaScript could get executed in the description series, categories or publishers title ||
|
| V 0.6.13 | JavaScript could get executed in the description series, categories or publishers title ||
|
||||||
| V 0.6.13 | JavaScript could get executed in the shelf title ||
|
| V 0.6.13 | JavaScript could get executed in the shelf title ||
|
||||||
| V 0.6.13 | Login with the old session cookie after logout. Thanks to @ibarrionuevo ||
|
| V 0.6.13 | Login with the old session cookie after logout. Thanks to @ibarrionuevo ||
|
||||||
| V 0.6.14 | CSRF was possible. Thanks to @mik317 and Hagai Wechsler (WhiteSource) |CVE-2021-25965|
|
| V 0.6.14 | CSRF was possible. Thanks to @mik317 and Hagai Wechsler (WhiteSource) |CVE-2021-25965|
|
||||||
| V 0.6.14 | Migrated some routes to POST-requests (CSRF protection). Thanks to @scara31 |CVE-2021-4164|
|
| V 0.6.14 | Migrated some routes to POST-requests (CSRF protection). Thanks to @scara31 |CVE-2021-4164|
|
||||||
| V 0.6.15 | Fix for "javascript:" script links in identifier. Thanks to @scara31 |CVE-2021-4170|
|
| V 0.6.15 | Fix for "javascript:" script links in identifier. Thanks to @scara31 |CVE-2021-4170|
|
||||||
| V 0.6.15 | Cross-Site Scripting vulnerability on uploaded cover file names. Thanks to @ibarrionuevo ||
|
| V 0.6.15 | Cross-Site Scripting vulnerability on uploaded cover file names. Thanks to @ibarrionuevo ||
|
||||||
| V 0.6.15 | Creating public shelfs is now denied if user is missing the edit public shelf right. Thanks to @ibarrionuevo ||
|
| V 0.6.15 | Creating public shelfs is now denied if user is missing the edit public shelf right. Thanks to @ibarrionuevo ||
|
||||||
| V 0.6.15 | Changed error message in case of trying to delete a shelf unauthorized. Thanks to @ibarrionuevo ||
|
| V 0.6.15 | Changed error message in case of trying to delete a shelf unauthorized. Thanks to @ibarrionuevo ||
|
||||||
| V 0.6.16 | JavaScript could get executed on authors page. Thanks to @alicaz |CVE-2022-0352|
|
| V 0.6.16 | JavaScript could get executed on authors page. Thanks to @alicaz |CVE-2022-0352|
|
||||||
| V 0.6.16 | Localhost can no longer be used to upload covers. Thanks to @scara31 |CVE-2022-0339|
|
| V 0.6.16 | Localhost can no longer be used to upload covers. Thanks to @scara31 |CVE-2022-0339|
|
||||||
| V 0.6.16 | Another case where public shelfs could be created without permission is prevented. Thanks to @nhiephon |CVE-2022-0273|
|
| V 0.6.16 | Another case where public shelfs could be created without permission is prevented. Thanks to @nhiephon |CVE-2022-0273|
|
||||||
| V 0.6.16 | It's prevented to get the name of a private shelfs. Thanks to @nhiephon |CVE-2022-0405|
|
| V 0.6.16 | It's prevented to get the name of a private shelfs. Thanks to @nhiephon |CVE-2022-0405|
|
||||||
| V 0.6.17 | The SSRF Protection can no longer be bypassed via an HTTP redirect. Thanks to @416e6e61 |CVE-2022-0767|
|
| V 0.6.17 | The SSRF Protection can no longer be bypassed via an HTTP redirect. Thanks to @416e6e61 |CVE-2022-0767|
|
||||||
| V 0.6.17 | The SSRF Protection can no longer be bypassed via 0.0.0.0 and it's ipv6 equivalent. Thanks to @r0hanSH |CVE-2022-0766|
|
| V 0.6.17 | The SSRF Protection can no longer be bypassed via 0.0.0.0 and it's ipv6 equivalent. Thanks to @r0hanSH |CVE-2022-0766|
|
||||||
| V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) |CVE-2022-30765|
|
| V 0.6.18 | 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 | Credentials for emails are now stored encrypted ||
|
||||||
| V 0.6.20 | Login is rate limited ||
|
| V 0.6.20 | Login is rate limited ||
|
||||||
| V 0.6.20 | Passwordstrength can be forced ||
|
| 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 | 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) 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 | 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.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)
|
||||||
|
|||||||
@@ -30,19 +30,15 @@ from cps.main import main
|
|||||||
|
|
||||||
def hide_console_windows():
|
def hide_console_windows():
|
||||||
import ctypes
|
import ctypes
|
||||||
import os
|
|
||||||
|
|
||||||
hwnd = ctypes.windll.kernel32.GetConsoleWindow()
|
kernel32 = ctypes.WinDLL('kernel32')
|
||||||
if hwnd != 0:
|
user32 = ctypes.WinDLL('user32')
|
||||||
try:
|
|
||||||
import win32process
|
SW_HIDE = 0
|
||||||
except ImportError:
|
|
||||||
print("To hide console window install 'pywin32' using 'pip install pywin32'")
|
hWnd = kernel32.GetConsoleWindow()
|
||||||
return
|
if hWnd:
|
||||||
ctypes.windll.user32.ShowWindow(hwnd, 0)
|
user32.ShowWindow(hWnd, SW_HIDE)
|
||||||
ctypes.windll.kernel32.CloseHandle(hwnd)
|
|
||||||
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
|
||||||
os.system('taskkill /PID ' + str(pid) + ' /f')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
+4
-20
@@ -20,11 +20,8 @@
|
|||||||
# 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_login import LoginManager, confirm_login
|
from flask import session
|
||||||
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):
|
||||||
@@ -36,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
|
|
||||||
|
|||||||
Executable → Regular
+10
-6
@@ -31,12 +31,10 @@ from flask_principal import Principal
|
|||||||
|
|
||||||
from . import logger
|
from . import logger
|
||||||
from .cli import CliParameter
|
from .cli import CliParameter
|
||||||
from .constants import CONFIG_DIR
|
|
||||||
from .reverseproxy import ReverseProxied
|
from .reverseproxy import ReverseProxied
|
||||||
from .server import WebServer
|
from .server import WebServer
|
||||||
from .dep_check import dependency_check
|
from .dep_check import dependency_check
|
||||||
from .updater import Updater
|
from .updater import Updater
|
||||||
from .babel import babel, get_locale
|
|
||||||
from . import config_sql
|
from . import config_sql
|
||||||
from . import cache_buster
|
from . import cache_buster
|
||||||
from . import ub, db
|
from . import ub, db
|
||||||
@@ -70,8 +68,11 @@ mimetypes.add_type('image/vnd.djv', '.djv')
|
|||||||
mimetypes.add_type('image/vnd.djv', '.djvu')
|
mimetypes.add_type('image/vnd.djv', '.djvu')
|
||||||
mimetypes.add_type('application/mpeg', '.mpeg')
|
mimetypes.add_type('application/mpeg', '.mpeg')
|
||||||
mimetypes.add_type('audio/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('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('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')
|
||||||
@@ -84,8 +85,10 @@ app = Flask(__name__)
|
|||||||
app.config.update(
|
app.config.update(
|
||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SAMESITE='Lax',
|
SESSION_COOKIE_SAMESITE='Lax',
|
||||||
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()
|
||||||
@@ -178,6 +181,7 @@ def create_app():
|
|||||||
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
|
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
|
||||||
|
|
||||||
web_server.init_app(app, config)
|
web_server.init_app(app, config)
|
||||||
|
from .cw_babel import babel, get_locale
|
||||||
if hasattr(babel, "localeselector"):
|
if hasattr(babel, "localeselector"):
|
||||||
babel.init_app(app)
|
babel.init_app(app)
|
||||||
babel.localeselector(get_locale)
|
babel.localeselector(get_locale)
|
||||||
|
|||||||
+8
-7
@@ -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,18 @@ 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")
|
||||||
modules['pySqlite'] = sqlite3.version
|
if sys.version_info < (3, 12):
|
||||||
|
modules['pySqlite'] = sqlite3.version
|
||||||
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'].replace("b", " Beta")
|
calibre_web_version = constants.STABLE_VERSION.replace("b", " Beta")
|
||||||
else:
|
else:
|
||||||
calibre_web_version = (constants.STABLE_VERSION['version'].replace("b", " Beta") + ' - '
|
calibre_web_version = (constants.STABLE_VERSION.replace("b", " Beta") + ' - '
|
||||||
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
|
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
|
||||||
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'))
|
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'))
|
||||||
|
|
||||||
@@ -74,7 +75,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()
|
||||||
|
|||||||
Executable → Regular
+108
-81
@@ -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
|
||||||
@@ -52,8 +51,10 @@ 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 .babel import get_available_translations, get_available_locale, get_user_locale_language
|
from .usermanagement import user_login_required
|
||||||
|
from .cw_babel import get_available_translations, get_available_locale, get_user_locale_language
|
||||||
from . import debug_info
|
from . import debug_info
|
||||||
|
from .string_helper import strip_whitespaces
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
@@ -103,13 +104,13 @@ def admin_required(f):
|
|||||||
|
|
||||||
@admi.before_app_request
|
@admi.before_app_request
|
||||||
def before_request():
|
def before_request():
|
||||||
try:
|
#try:
|
||||||
if not ub.check_user_session(current_user.id,
|
#if not ub.check_user_session(current_user.id,
|
||||||
flask_session.get('_id')) and 'opds' not in request.path \
|
# flask_session.get('_id')) and 'opds' not in request.path \
|
||||||
and config.config_session == 1:
|
# and config.config_session == 1:
|
||||||
logout_user()
|
# logout_user()
|
||||||
except AttributeError:
|
#except AttributeError:
|
||||||
pass # ? fails on requesting /ajax/emailstat during restart ?
|
# 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
|
||||||
@@ -129,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)
|
||||||
@@ -165,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 = {}
|
||||||
@@ -189,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']:
|
||||||
@@ -199,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()
|
||||||
@@ -233,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":
|
||||||
@@ -242,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",
|
||||||
@@ -253,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) \
|
||||||
@@ -291,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', {})
|
||||||
@@ -305,7 +306,15 @@ def edit_user_table():
|
|||||||
.group_by(text('books_tags_link.tag')) \
|
.group_by(text('books_tags_link.tag')) \
|
||||||
.order_by(db.Tags.name).all()
|
.order_by(db.Tags.name).all()
|
||||||
if config.config_restricted_column:
|
if config.config_restricted_column:
|
||||||
custom_values = calibre_db.session.query(db.cc_classes[config.config_restricted_column]).all()
|
try:
|
||||||
|
custom_values = calibre_db.session.query(db.cc_classes[config.config_restricted_column]).all()
|
||||||
|
except (KeyError, AttributeError, IndexError):
|
||||||
|
custom_values = []
|
||||||
|
log.error("Custom Column No.{} does not exist in calibre database".format(
|
||||||
|
config.config_restricted_column))
|
||||||
|
flash(_("Custom Column No.%(column)d does not exist in calibre database",
|
||||||
|
column=config.config_restricted_column),
|
||||||
|
category="error")
|
||||||
else:
|
else:
|
||||||
custom_values = []
|
custom_values = []
|
||||||
if not config.config_anonbrowse:
|
if not config.config_anonbrowse:
|
||||||
@@ -326,7 +335,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)
|
||||||
@@ -377,7 +386,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)
|
||||||
@@ -412,7 +421,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()
|
||||||
@@ -424,7 +433,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()
|
||||||
@@ -436,7 +445,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)
|
||||||
@@ -463,9 +472,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"))
|
||||||
@@ -541,7 +550,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)
|
||||||
@@ -558,7 +567,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()
|
||||||
@@ -566,7 +575,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")
|
||||||
@@ -603,7 +612,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":
|
||||||
@@ -639,7 +648,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
|
||||||
@@ -653,7 +662,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()
|
||||||
@@ -667,7 +676,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:
|
||||||
@@ -685,12 +694,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
|
||||||
@@ -698,7 +707,7 @@ def list_domain(allow):
|
|||||||
|
|
||||||
@admi.route("/ajax/editrestriction/<int:res_type>", defaults={"user_id": 0}, methods=['POST'])
|
@admi.route("/ajax/editrestriction/<int:res_type>", 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()
|
||||||
@@ -764,14 +773,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()
|
||||||
@@ -817,14 +826,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()
|
||||||
@@ -872,7 +881,7 @@ def delete_restriction(res_type, user_id):
|
|||||||
|
|
||||||
@admi.route("/ajax/listrestriction/<int:res_type>", defaults={"user_id": 0})
|
@admi.route("/ajax/listrestriction/<int:res_type>", 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
|
||||||
@@ -916,20 +925,20 @@ 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_self_fullsync():
|
def ajax_self_fullsync():
|
||||||
return do_full_kobo_sync(current_user.id)
|
return do_full_kobo_sync(current_user.id)
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/fullsync/<int:userid>", methods=["POST"])
|
@admi.route("/ajax/fullsync/<int:userid>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def ajax_fullsync(userid):
|
def ajax_fullsync(userid):
|
||||||
return do_full_kobo_sync(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()
|
||||||
@@ -981,8 +990,14 @@ def prepare_tags(user, action, tags_name, id_list):
|
|||||||
raise Exception(_("Tag not found"))
|
raise Exception(_("Tag not found"))
|
||||||
new_tags_list = [x.name for x in tags]
|
new_tags_list = [x.name for x in tags]
|
||||||
else:
|
else:
|
||||||
tags = calibre_db.session.query(db.cc_classes[config.config_restricted_column]) \
|
try:
|
||||||
.filter(db.cc_classes[config.config_restricted_column].id.in_(id_list)).all()
|
tags = calibre_db.session.query(db.cc_classes[config.config_restricted_column]) \
|
||||||
|
.filter(db.cc_classes[config.config_restricted_column].id.in_(id_list)).all()
|
||||||
|
except (KeyError, AttributeError, IndexError):
|
||||||
|
log.error("Custom Column No.{} does not exist in calibre database".format(
|
||||||
|
config.config_restricted_column))
|
||||||
|
raise Exception(_("Custom Column No.%(column)d does not exist in calibre database",
|
||||||
|
column=config.config_restricted_column))
|
||||||
new_tags_list = [x.value for x in tags]
|
new_tags_list = [x.value for x in tags]
|
||||||
saved_tags_list = user.__dict__[tags_name].split(",") if len(user.__dict__[tags_name]) else []
|
saved_tags_list = user.__dict__[tags_name].split(",") if len(user.__dict__[tags_name]) else []
|
||||||
if action == "remove":
|
if action == "remove":
|
||||||
@@ -1100,7 +1115,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):
|
||||||
@@ -1246,7 +1261,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()
|
||||||
@@ -1254,7 +1269,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()
|
||||||
@@ -1276,7 +1291,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()
|
||||||
@@ -1285,7 +1300,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()
|
||||||
@@ -1311,9 +1326,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:
|
||||||
@@ -1342,7 +1357,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()
|
||||||
@@ -1363,7 +1378,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
|
||||||
@@ -1406,7 +1421,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
|
||||||
@@ -1435,7 +1450,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:
|
||||||
@@ -1453,7 +1468,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),
|
||||||
@@ -1467,7 +1482,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:
|
||||||
@@ -1483,7 +1498,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:
|
||||||
@@ -1498,14 +1513,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']:
|
||||||
@@ -1516,7 +1531,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 = {}
|
||||||
@@ -1611,7 +1626,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 = {}
|
||||||
@@ -1666,7 +1681,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)
|
||||||
@@ -1678,10 +1693,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)
|
||||||
@@ -1715,6 +1730,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"]:
|
||||||
@@ -1736,12 +1758,18 @@ def _db_configuration_update_helper():
|
|||||||
ub.session.query(ub.KoboSyncedBooks).delete()
|
ub.session.query(ub.KoboSyncedBooks).delete()
|
||||||
helper.delete_thumbnail_cache()
|
helper.delete_thumbnail_cache()
|
||||||
ub.session_commit()
|
ub.session_commit()
|
||||||
|
# deleted visibilities based on custom column and tags
|
||||||
|
config.config_restricted_column = 0
|
||||||
|
config.config_denied_tags = ""
|
||||||
|
config.config_allowed_tags = ""
|
||||||
|
config.config_columns_to_ignore = ""
|
||||||
|
config.config_denied_column_value = ""
|
||||||
|
config.config_allowed_column_value = ""
|
||||||
|
config.config_read_column = 0
|
||||||
_config_string(to_save, "config_calibre_dir")
|
_config_string(to_save, "config_calibre_dir")
|
||||||
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")
|
||||||
_config_string(to_save, "config_calibre_split_dir")
|
|
||||||
config.config_calibre_split = to_save.get('config_calibre_split', 0) == "on"
|
|
||||||
calibre_db.update_config(config)
|
calibre_db.update_config(config)
|
||||||
config.save()
|
config.save()
|
||||||
return _db_configuration_result(None, gdrive_error)
|
return _db_configuration_result(None, gdrive_error)
|
||||||
@@ -1775,9 +1803,8 @@ 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_binariesdir")
|
_config_string(to_save, "config_binariesdir")
|
||||||
@@ -2076,7 +2103,7 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
|
|||||||
|
|
||||||
|
|
||||||
def extract_user_data_from_field(user, field):
|
def extract_user_data_from_field(user, field):
|
||||||
match = re.search(field + r"=([@\.\d\s\w-]+)", user, re.IGNORECASE | re.UNICODE)
|
match = re.search(field + r"=(.*?)($|(?<!\\),)", user, re.IGNORECASE | re.UNICODE)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
else:
|
else:
|
||||||
@@ -2084,7 +2111,7 @@ def extract_user_data_from_field(user, field):
|
|||||||
|
|
||||||
|
|
||||||
def extract_dynamic_field_from_filter(user, filtr):
|
def extract_dynamic_field_from_filter(user, filtr):
|
||||||
match = re.search("([a-zA-Z0-9-]+)=%s", filtr, re.IGNORECASE | re.UNICODE)
|
match = re.search(r"([a-zA-Z0-9-]+)=%s", filtr, re.IGNORECASE | re.UNICODE)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
else:
|
else:
|
||||||
|
|||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2024 Ozzieisaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import mutagen
|
||||||
|
import base64
|
||||||
|
from . import cover, logger
|
||||||
|
|
||||||
|
from cps.constants import BookMeta
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
def get_audio_file_info(tmp_file_path, original_file_extension, original_file_name, no_cover_processing):
|
||||||
|
tmp_cover_name = None
|
||||||
|
audio_file = mutagen.File(tmp_file_path)
|
||||||
|
comments = None
|
||||||
|
if original_file_extension in [".mp3", ".wav", ".aiff"]:
|
||||||
|
cover_data = list()
|
||||||
|
for key, val in audio_file.tags.items():
|
||||||
|
if key.startswith("APIC:"):
|
||||||
|
cover_data.append(val)
|
||||||
|
if key.startswith("COMM:"):
|
||||||
|
comments = val.text[0]
|
||||||
|
title = audio_file.tags.get('TIT2').text[0] if "TIT2" in audio_file.tags else None
|
||||||
|
author = audio_file.tags.get('TPE1').text[0] if "TPE1" in audio_file.tags else None
|
||||||
|
if author is None:
|
||||||
|
author = audio_file.tags.get('TPE2').text[0] if "TPE2" in audio_file.tags else None
|
||||||
|
tags = audio_file.tags.get('TCON').text[0] if "TCON" in audio_file.tags else None # Genre
|
||||||
|
series = audio_file.tags.get('TALB').text[0] if "TALB" in audio_file.tags else None# Album
|
||||||
|
series_id = audio_file.tags.get('TRCK').text[0] if "TRCK" in audio_file.tags else None # track no.
|
||||||
|
publisher = audio_file.tags.get('TPUB').text[0] if "TPUB" in audio_file.tags else None
|
||||||
|
pubdate = str(audio_file.tags.get('TDRL').text[0]) if "TDRL" in audio_file.tags else None
|
||||||
|
if not pubdate:
|
||||||
|
pubdate = str(audio_file.tags.get('TDRC').text[0]) if "TDRC" in audio_file.tags else None
|
||||||
|
if not pubdate:
|
||||||
|
pubdate = str(audio_file.tags.get('TDOR').text[0]) if "TDOR" in audio_file.tags else None
|
||||||
|
if cover_data and not no_cover_processing:
|
||||||
|
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||||
|
cover_info = cover_data[0]
|
||||||
|
for dat in cover_data:
|
||||||
|
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
|
||||||
|
cover_info = dat
|
||||||
|
break
|
||||||
|
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
||||||
|
elif original_file_extension in [".ogg", ".flac", ".opus", ".ogv"]:
|
||||||
|
title = audio_file.tags.get('TITLE')[0] if "TITLE" in audio_file else None
|
||||||
|
author = audio_file.tags.get('ARTIST')[0] if "ARTIST" in audio_file else None
|
||||||
|
comments = audio_file.tags.get('COMMENTS')[0] if "COMMENTS" in audio_file else None
|
||||||
|
tags = audio_file.tags.get('GENRE')[0] if "GENRE" in audio_file else None # Genre
|
||||||
|
series = audio_file.tags.get('ALBUM')[0] if "ALBUM" in audio_file else None
|
||||||
|
series_id = audio_file.tags.get('TRACKNUMBER')[0] if "TRACKNUMBER" in audio_file else None
|
||||||
|
publisher = audio_file.tags.get('LABEL')[0] if "LABEL" in audio_file else None
|
||||||
|
pubdate = audio_file.tags.get('DATE')[0] if "DATE" in audio_file else None
|
||||||
|
cover_data = audio_file.tags.get('METADATA_BLOCK_PICTURE')
|
||||||
|
if not no_cover_processing:
|
||||||
|
if cover_data:
|
||||||
|
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||||
|
cover_info = mutagen.flac.Picture(base64.b64decode(cover_data[0]))
|
||||||
|
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
||||||
|
if hasattr(audio_file, "pictures"):
|
||||||
|
cover_info = audio_file.pictures[0]
|
||||||
|
for dat in audio_file.pictures:
|
||||||
|
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
|
||||||
|
cover_info = dat
|
||||||
|
break
|
||||||
|
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||||
|
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
||||||
|
elif original_file_extension in [".aac"]:
|
||||||
|
title = audio_file.tags.get('Title').value if "Title" in audio_file else None
|
||||||
|
author = audio_file.tags.get('Artist').value if "Artist" in audio_file else None
|
||||||
|
comments = audio_file.tags.get('Comment').value if "Comment" in audio_file else None
|
||||||
|
tags = audio_file.tags.get('Genre').value if "Genre" in audio_file else None
|
||||||
|
series = audio_file.tags.get('Album').value if "Album" in audio_file else None
|
||||||
|
series_id = audio_file.tags.get('Track').value if "Track" in audio_file else None
|
||||||
|
publisher = audio_file.tags.get('Label').value if "Label" in audio_file else None
|
||||||
|
pubdate = audio_file.tags.get('Year').value if "Year" in audio_file else None
|
||||||
|
cover_data = audio_file.tags['Cover Art (Front)']
|
||||||
|
if cover_data and not no_cover_processing:
|
||||||
|
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||||
|
with open(tmp_cover_name, "wb") as cover_file:
|
||||||
|
cover_file.write(cover_data.value.split(b"\x00",1)[1])
|
||||||
|
elif original_file_extension in [".asf"]:
|
||||||
|
title = audio_file.tags.get('Title')[0].value if "Title" in audio_file else None
|
||||||
|
author = audio_file.tags.get('Artist')[0].value if "Artist" in audio_file else None
|
||||||
|
comments = audio_file.tags.get('Comments')[0].value if "Comments" in audio_file else None
|
||||||
|
tags = audio_file.tags.get('Genre')[0].value if "Genre" in audio_file else None
|
||||||
|
series = audio_file.tags.get('Album')[0].value if "Album" in audio_file else None
|
||||||
|
series_id = audio_file.tags.get('Track')[0].value if "Track" in audio_file else None
|
||||||
|
publisher = audio_file.tags.get('Label')[0].value if "Label" in audio_file else None
|
||||||
|
pubdate = audio_file.tags.get('Year')[0].value if "Year" in audio_file else None
|
||||||
|
cover_data = audio_file.tags.get('WM/Picture', None)
|
||||||
|
if cover_data and not no_cover_processing:
|
||||||
|
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||||
|
with open(tmp_cover_name, "wb") as cover_file:
|
||||||
|
cover_file.write(cover_data[0].value)
|
||||||
|
elif original_file_extension in [".mp4", ".m4a", ".m4b"]:
|
||||||
|
title = audio_file.tags.get('©nam')[0] if "©nam" in audio_file.tags else None
|
||||||
|
author = audio_file.tags.get('©ART')[0] if "©ART" in audio_file.tags else None
|
||||||
|
comments = audio_file.tags.get('©cmt')[0] if "©cmt" in audio_file.tags else None
|
||||||
|
tags = audio_file.tags.get('©gen')[0] if "©gen" in audio_file.tags else None
|
||||||
|
series = audio_file.tags.get('©alb')[0] if "©alb" in audio_file.tags else None
|
||||||
|
series_id = str(audio_file.tags.get('trkn')[0][0]) if "trkn" in audio_file.tags else None
|
||||||
|
publisher = ""
|
||||||
|
pubdate = audio_file.tags.get('©day')[0] if "©day" in audio_file.tags else None
|
||||||
|
cover_data = audio_file.tags.get('covr', None)
|
||||||
|
if cover_data and not no_cover_processing:
|
||||||
|
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||||
|
cover_type = None
|
||||||
|
for c in cover_data:
|
||||||
|
if c.imageformat == mutagen.mp4.AtomDataType.JPEG:
|
||||||
|
cover_type =".jpg"
|
||||||
|
cover_bin = c
|
||||||
|
break
|
||||||
|
elif c.imageformat == mutagen.mp4.AtomDataType.PNG:
|
||||||
|
cover_type = ".png"
|
||||||
|
cover_bin = c
|
||||||
|
break
|
||||||
|
if cover_type:
|
||||||
|
cover.cover_processing(tmp_file_path, cover_bin, cover_type)
|
||||||
|
else:
|
||||||
|
logger.error("Unknown covertype in file {} ".format(original_file_name))
|
||||||
|
|
||||||
|
return BookMeta(
|
||||||
|
file_path=tmp_file_path,
|
||||||
|
extension=original_file_extension,
|
||||||
|
title=title or original_file_name ,
|
||||||
|
author="Unknown" if author is None else author,
|
||||||
|
cover=tmp_cover_name,
|
||||||
|
description="" if comments is None else comments,
|
||||||
|
tags="" if tags is None else tags,
|
||||||
|
series="" if series is None else series,
|
||||||
|
series_id="1" if series_id is None else series_id.split("/")[0],
|
||||||
|
languages="",
|
||||||
|
publisher= "" if publisher is None else publisher,
|
||||||
|
pubdate="" if pubdate is None else pubdate,
|
||||||
|
identifiers=[],
|
||||||
|
)
|
||||||
+1
-1
@@ -35,7 +35,7 @@ def clean_string(unsafe_text, book_id=0):
|
|||||||
try:
|
try:
|
||||||
if bleach:
|
if bleach:
|
||||||
allowed_tags = list(ALLOWED_TAGS)
|
allowed_tags = list(ALLOWED_TAGS)
|
||||||
allowed_tags.extend(['p', 'span', 'div', 'pre'])
|
allowed_tags.extend(["p", "span", "div", "pre", "br", "h1", "h2", "h3", "h4", "h5", "h6"])
|
||||||
safe_text = clean_html(unsafe_text, tags=set(allowed_tags))
|
safe_text = clean_html(unsafe_text, tags=set(allowed_tags))
|
||||||
else:
|
else:
|
||||||
safe_text = clean_html(unsafe_text)
|
safe_text = clean_html(unsafe_text)
|
||||||
|
|||||||
+2
-2
@@ -29,8 +29,8 @@ 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'].replace("b", " Beta")
|
return "Calibre-Web version: %s - unknown git-clone" % _STABLE_VERSION.replace("b", " Beta")
|
||||||
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'].replace("b", " Beta"), _NIGHTLY_VERSION[1])
|
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION.replace("b", " Beta"), _NIGHTLY_VERSION[1])
|
||||||
|
|
||||||
|
|
||||||
class CliParameter(object):
|
class CliParameter(object):
|
||||||
|
|||||||
+11
-4
@@ -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="",
|
||||||
|
|||||||
+6
-26
@@ -35,7 +35,7 @@ except ImportError:
|
|||||||
|
|
||||||
from . import constants, logger
|
from . import constants, logger
|
||||||
from .subproc_wrapper import process_wait
|
from .subproc_wrapper import process_wait
|
||||||
|
from .string_helper import strip_whitespaces
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
_Base = declarative_base()
|
_Base = declarative_base()
|
||||||
@@ -182,26 +182,6 @@ class _Settings(_Base):
|
|||||||
class ConfigSQL(object):
|
class ConfigSQL(object):
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
'''self.config_calibre_uuid = None
|
|
||||||
self.config_calibre_split_dir = None
|
|
||||||
self.dirty = None
|
|
||||||
self.config_logfile = None
|
|
||||||
self.config_upload_formats = None
|
|
||||||
self.mail_gmail_token = None
|
|
||||||
self.mail_server_type = None
|
|
||||||
self.mail_server = None
|
|
||||||
self.config_log_level = None
|
|
||||||
self.config_allowed_column_value = None
|
|
||||||
self.config_denied_column_value = None
|
|
||||||
self.config_allowed_tags = None
|
|
||||||
self.config_denied_tags = None
|
|
||||||
self.config_default_show = None
|
|
||||||
self.config_default_role = None
|
|
||||||
self.config_keyfile = None
|
|
||||||
self.config_certfile = None
|
|
||||||
self.config_rarfile_location = None
|
|
||||||
self.config_kepubifypath = None
|
|
||||||
self.config_binariesdir = None'''
|
|
||||||
self.__dict__["dirty"] = list()
|
self.__dict__["dirty"] = list()
|
||||||
|
|
||||||
def init_config(self, session, secret_key, cli):
|
def init_config(self, session, secret_key, cli):
|
||||||
@@ -288,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)
|
||||||
@@ -372,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)
|
||||||
|
|||||||
+2
-5
@@ -19,9 +19,6 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from sqlalchemy import __version__ as sql_version
|
|
||||||
|
|
||||||
sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2, 0, 0])
|
|
||||||
|
|
||||||
# APP_MODE - production, development, or test
|
# APP_MODE - production, development, or test
|
||||||
APP_MODE = os.environ.get('APP_MODE', 'production')
|
APP_MODE = os.environ.get('APP_MODE', 'production')
|
||||||
@@ -175,7 +172,7 @@ BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, d
|
|||||||
'series_id, languages, publisher, pubdate, identifiers')
|
'series_id, languages, publisher, pubdate, identifiers')
|
||||||
|
|
||||||
# python build process likes to have x.y.zbw -> b for beta and w a counting number
|
# python build process likes to have x.y.zbw -> b for beta and w a counting number
|
||||||
STABLE_VERSION = {'version': '0.6.22'}
|
STABLE_VERSION = '0.6.24'
|
||||||
|
|
||||||
NIGHTLY_VERSION = dict()
|
NIGHTLY_VERSION = dict()
|
||||||
NIGHTLY_VERSION[0] = '$Format:%H$'
|
NIGHTLY_VERSION[0] = '$Format:%H$'
|
||||||
@@ -193,7 +190,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
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2015 Jordan Milne
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# Source: https://github.com/JordanMilne/Advocate
|
||||||
|
|
||||||
|
|
||||||
|
from .adapters import ValidatingHTTPAdapter
|
||||||
|
from .api import *
|
||||||
|
from .addrvalidator import AddrValidator
|
||||||
|
from .exceptions import UnacceptableAddressException
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2015 Jordan Milne
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# Source: https://github.com/JordanMilne/Advocate
|
||||||
|
|
||||||
|
from requests.adapters import HTTPAdapter, DEFAULT_POOLBLOCK
|
||||||
|
|
||||||
|
from .addrvalidator import AddrValidator
|
||||||
|
from .exceptions import ProxyDisabledException
|
||||||
|
from .poolmanager import ValidatingPoolManager
|
||||||
|
|
||||||
|
|
||||||
|
class ValidatingHTTPAdapter(HTTPAdapter):
|
||||||
|
__attrs__ = HTTPAdapter.__attrs__ + ['_validator']
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self._validator = kwargs.pop('validator', None)
|
||||||
|
if not self._validator:
|
||||||
|
self._validator = AddrValidator()
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def init_poolmanager(self, connections, maxsize, block=DEFAULT_POOLBLOCK,
|
||||||
|
**pool_kwargs):
|
||||||
|
self._pool_connections = connections
|
||||||
|
self._pool_maxsize = maxsize
|
||||||
|
self._pool_block = block
|
||||||
|
self.poolmanager = ValidatingPoolManager(
|
||||||
|
num_pools=connections,
|
||||||
|
maxsize=maxsize,
|
||||||
|
block=block,
|
||||||
|
validator=self._validator,
|
||||||
|
**pool_kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def proxy_manager_for(self, proxy, **proxy_kwargs):
|
||||||
|
raise ProxyDisabledException("Proxies cannot be used with Advocate")
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2015 Jordan Milne
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# Source: https://github.com/JordanMilne/Advocate
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import fnmatch
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
|
||||||
|
try:
|
||||||
|
import netifaces
|
||||||
|
HAVE_NETIFACES = True
|
||||||
|
except ImportError:
|
||||||
|
netifaces = None
|
||||||
|
HAVE_NETIFACES = False
|
||||||
|
|
||||||
|
from .exceptions import NameserverException, ConfigException
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_hostname(hostname):
|
||||||
|
"""Lowercase and punycodify a hostname"""
|
||||||
|
# We do the lowercasing after IDNA encoding because we only want to
|
||||||
|
# lowercase the *ASCII* chars.
|
||||||
|
# TODO: The differences between IDNA2003 and IDNA2008 might be relevant
|
||||||
|
# to us, but both specs are damn confusing.
|
||||||
|
return str(hostname.encode("idna").lower(), 'utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def determine_local_addresses():
|
||||||
|
"""Get all IPs that refer to this machine according to netifaces"""
|
||||||
|
if not HAVE_NETIFACES:
|
||||||
|
raise ConfigException("Tried to determine local addresses, "
|
||||||
|
"but netifaces module was not importable")
|
||||||
|
ips = []
|
||||||
|
for interface in netifaces.interfaces():
|
||||||
|
if_families = netifaces.ifaddresses(interface)
|
||||||
|
for family_kind in {netifaces.AF_INET, netifaces.AF_INET6}:
|
||||||
|
addrs = if_families.get(family_kind, [])
|
||||||
|
for addr in (x.get("addr", "") for x in addrs):
|
||||||
|
if family_kind == netifaces.AF_INET6:
|
||||||
|
# We can't do anything sensible with the scope here
|
||||||
|
addr = addr.split("%")[0]
|
||||||
|
ips.append(ipaddress.ip_network(addr))
|
||||||
|
return ips
|
||||||
|
|
||||||
|
|
||||||
|
def add_local_address_arg(func):
|
||||||
|
"""Add the "_local_addresses" kwarg if it's missing
|
||||||
|
|
||||||
|
IMO this information shouldn't be cached between calls (what if one of the
|
||||||
|
adapters got a new IP at runtime?,) and we don't want each function to
|
||||||
|
recalculate it. Just recalculate it if the caller didn't provide it for us.
|
||||||
|
"""
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(self, *args, **kwargs):
|
||||||
|
if "_local_addresses" not in kwargs:
|
||||||
|
if self.autodetect_local_addresses:
|
||||||
|
kwargs["_local_addresses"] = determine_local_addresses()
|
||||||
|
else:
|
||||||
|
kwargs["_local_addresses"] = []
|
||||||
|
return func(self, *args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class AddrValidator:
|
||||||
|
_6TO4_RELAY_NET = ipaddress.ip_network("192.88.99.0/24")
|
||||||
|
# Just the well known prefix, DNS64 servers can set their own
|
||||||
|
# prefix, but in practice most probably don't.
|
||||||
|
_DNS64_WK_PREFIX = ipaddress.ip_network("64:ff9b::/96")
|
||||||
|
DEFAULT_PORT_WHITELIST = {80, 8080, 443, 8443, 8000}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ip_blacklist=None,
|
||||||
|
ip_whitelist=None,
|
||||||
|
port_whitelist=None,
|
||||||
|
port_blacklist=None,
|
||||||
|
hostname_blacklist=None,
|
||||||
|
allow_ipv6=False,
|
||||||
|
allow_teredo=False,
|
||||||
|
allow_6to4=False,
|
||||||
|
allow_dns64=False,
|
||||||
|
# Must be explicitly set to "False" if you don't want to try
|
||||||
|
# detecting local interface addresses with netifaces.
|
||||||
|
autodetect_local_addresses=True,
|
||||||
|
):
|
||||||
|
if not port_blacklist and not port_whitelist:
|
||||||
|
# An assortment of common HTTPS? ports.
|
||||||
|
port_whitelist = self.DEFAULT_PORT_WHITELIST.copy()
|
||||||
|
self.ip_blacklist = ip_blacklist or set()
|
||||||
|
self.ip_whitelist = ip_whitelist or set()
|
||||||
|
self.port_blacklist = port_blacklist or set()
|
||||||
|
self.port_whitelist = port_whitelist or set()
|
||||||
|
# TODO: ATM this can contain either regexes or globs that are converted
|
||||||
|
# to regexes upon every check. Create a collection that automagically
|
||||||
|
# converts them to regexes on insert?
|
||||||
|
self.hostname_blacklist = hostname_blacklist or set()
|
||||||
|
self.allow_ipv6 = allow_ipv6
|
||||||
|
self.allow_teredo = allow_teredo
|
||||||
|
self.allow_6to4 = allow_6to4
|
||||||
|
self.allow_dns64 = allow_dns64
|
||||||
|
self.autodetect_local_addresses = autodetect_local_addresses
|
||||||
|
|
||||||
|
@add_local_address_arg
|
||||||
|
def is_ip_allowed(self, addr_ip, _local_addresses=None):
|
||||||
|
if not isinstance(addr_ip,
|
||||||
|
(ipaddress.IPv4Address, ipaddress.IPv6Address)):
|
||||||
|
addr_ip = ipaddress.ip_address(addr_ip)
|
||||||
|
|
||||||
|
# The whitelist should take precedence over the blacklist so we can
|
||||||
|
# punch holes in blacklisted ranges
|
||||||
|
if any(addr_ip in net for net in self.ip_whitelist):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if any(addr_ip in net for net in self.ip_blacklist):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if any(addr_ip in net for net in _local_addresses):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if addr_ip.version == 4:
|
||||||
|
if not addr_ip.is_private:
|
||||||
|
# IPs for carrier-grade NAT. Seems weird that it doesn't set
|
||||||
|
# `is_private`, but we need to check `not is_global`
|
||||||
|
if not ipaddress.ip_network(addr_ip).is_global:
|
||||||
|
return False
|
||||||
|
elif addr_ip.version == 6:
|
||||||
|
# You'd better have a good reason for enabling IPv6
|
||||||
|
# because Advocate's techniques don't work well without NAT.
|
||||||
|
if not self.allow_ipv6:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# v6 addresses can also map to IPv4 addresses! Tricky!
|
||||||
|
v4_nested = []
|
||||||
|
if addr_ip.ipv4_mapped:
|
||||||
|
v4_nested.append(addr_ip.ipv4_mapped)
|
||||||
|
# WTF IPv6? Why you gotta have a billion tunneling mechanisms?
|
||||||
|
# XXX: Do we even really care about these? If we're tunneling
|
||||||
|
# through public servers we shouldn't be able to access
|
||||||
|
# addresses on our private network, right?
|
||||||
|
if addr_ip.sixtofour:
|
||||||
|
if not self.allow_6to4:
|
||||||
|
return False
|
||||||
|
v4_nested.append(addr_ip.sixtofour)
|
||||||
|
if addr_ip.teredo:
|
||||||
|
if not self.allow_teredo:
|
||||||
|
return False
|
||||||
|
# Check both the client *and* server IPs
|
||||||
|
v4_nested.extend(addr_ip.teredo)
|
||||||
|
if addr_ip in self._DNS64_WK_PREFIX:
|
||||||
|
if not self.allow_dns64:
|
||||||
|
return False
|
||||||
|
# When using the well-known prefix the last 4 bytes
|
||||||
|
# are the IPv4 addr
|
||||||
|
v4_nested.append(ipaddress.ip_address(addr_ip.packed[-4:]))
|
||||||
|
|
||||||
|
if not all(self.is_ip_allowed(addr_v4) for addr_v4 in v4_nested):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# fec0::*, apparently deprecated?
|
||||||
|
if addr_ip.is_site_local:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
raise ValueError("Unsupported IP version(?): %r" % addr_ip)
|
||||||
|
|
||||||
|
# 169.254.XXX.XXX, AWS uses these for autoconfiguration
|
||||||
|
if addr_ip.is_link_local:
|
||||||
|
return False
|
||||||
|
# 127.0.0.1, ::1, etc.
|
||||||
|
if addr_ip.is_loopback:
|
||||||
|
return False
|
||||||
|
if addr_ip.is_multicast:
|
||||||
|
return False
|
||||||
|
# 192.168.XXX.XXX, 10.XXX.XXX.XXX
|
||||||
|
if addr_ip.is_private:
|
||||||
|
return False
|
||||||
|
# 255.255.255.255, ::ffff:XXXX:XXXX (v6->v4) mapping
|
||||||
|
if addr_ip.is_reserved:
|
||||||
|
return False
|
||||||
|
# There's no reason to connect directly to a 6to4 relay
|
||||||
|
if addr_ip in self._6TO4_RELAY_NET:
|
||||||
|
return False
|
||||||
|
# 0.0.0.0
|
||||||
|
if addr_ip.is_unspecified:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# It doesn't look bad, so... it's must be ok!
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _hostname_matches_pattern(self, hostname, pattern):
|
||||||
|
# If they specified a string, just assume they only want basic globbing.
|
||||||
|
# This stops people from not realizing they're dealing in REs and
|
||||||
|
# not escaping their periods unless they specifically pass in an RE.
|
||||||
|
# This has the added benefit of letting us sanely handle globbed
|
||||||
|
# IDNs by default.
|
||||||
|
if isinstance(pattern, str):
|
||||||
|
# convert the glob to a punycode glob, then a regex
|
||||||
|
pattern = fnmatch.translate(canonicalize_hostname(pattern))
|
||||||
|
|
||||||
|
hostname = canonicalize_hostname(hostname)
|
||||||
|
# Down the line the hostname may get treated as a null-terminated string
|
||||||
|
# (as with `socket.getaddrinfo`.) Try to account for that.
|
||||||
|
#
|
||||||
|
# >>> socket.getaddrinfo("example.com\x00aaaa", 80)
|
||||||
|
# [(2, 1, 6, '', ('93.184.216.34', 80)), [...]
|
||||||
|
no_null_hostname = hostname.split("\x00")[0]
|
||||||
|
|
||||||
|
return any(re.match(pattern, x.strip(".")) for x
|
||||||
|
in (no_null_hostname, hostname))
|
||||||
|
|
||||||
|
def is_hostname_allowed(self, hostname):
|
||||||
|
# Sometimes (like with "external" services that your IP has privileged
|
||||||
|
# access to) you might not always know the IP range to blacklist access
|
||||||
|
# to, or the `A` record might change without you noticing.
|
||||||
|
# For e.x.: `foocorp.external.org`.
|
||||||
|
#
|
||||||
|
# Another option is doing something like:
|
||||||
|
#
|
||||||
|
# for addrinfo in socket.getaddrinfo("foocorp.external.org", 80):
|
||||||
|
# global_validator.ip_blacklist.add(ip_address(addrinfo[4][0]))
|
||||||
|
#
|
||||||
|
# but that's not always a good idea if they're behind a third-party lb.
|
||||||
|
for pattern in self.hostname_blacklist:
|
||||||
|
if self._hostname_matches_pattern(hostname, pattern):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@add_local_address_arg
|
||||||
|
def is_addrinfo_allowed(self, addrinfo, _local_addresses=None):
|
||||||
|
assert(len(addrinfo) == 5)
|
||||||
|
# XXX: Do we care about any of the other elements? Guessing not.
|
||||||
|
family, socktype, proto, canonname, sockaddr = addrinfo
|
||||||
|
|
||||||
|
# The 4th elem inaddrinfo may either be a touple of two or four items,
|
||||||
|
# depending on whether we're dealing with IPv4 or v6
|
||||||
|
if len(sockaddr) == 2:
|
||||||
|
# v4
|
||||||
|
ip, port = sockaddr
|
||||||
|
elif len(sockaddr) == 4:
|
||||||
|
# v6
|
||||||
|
# XXX: what *are* `flow_info` and `scope_id`? Anything useful?
|
||||||
|
# Seems like we can figure out all we need about the scope from
|
||||||
|
# the `is_<x>` properties.
|
||||||
|
ip, port, flow_info, scope_id = sockaddr
|
||||||
|
else:
|
||||||
|
raise ValueError("Unexpected addrinfo format %r" % sockaddr)
|
||||||
|
|
||||||
|
# Probably won't help protect against SSRF, but might prevent our being
|
||||||
|
# used to attack others' non-HTTP services. See
|
||||||
|
# http://www.remote.org/jochen/sec/hfpa/
|
||||||
|
if self.port_whitelist and port not in self.port_whitelist:
|
||||||
|
return False
|
||||||
|
if port in self.port_blacklist:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.hostname_blacklist:
|
||||||
|
if not canonname:
|
||||||
|
raise NameserverException(
|
||||||
|
"addrinfo must contain the canon name to do blacklisting "
|
||||||
|
"based on hostname. Make sure you use the "
|
||||||
|
"`socket.AI_CANONNAME` flag, and that each record contains "
|
||||||
|
"the canon name. Your DNS server might also be garbage."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.is_hostname_allowed(canonname):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.is_ip_allowed(ip, _local_addresses=_local_addresses)
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2015 Jordan Milne
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# Source: https://github.com/JordanMilne/Advocate
|
||||||
|
|
||||||
|
"""
|
||||||
|
advocate.api
|
||||||
|
~~~~~~~~~~~~
|
||||||
|
|
||||||
|
This module implements the Requests API, largely a copy/paste from `requests`
|
||||||
|
itself.
|
||||||
|
|
||||||
|
:copyright: (c) 2015 by Jordan Milne.
|
||||||
|
:license: Apache2, see LICENSE for more details.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections import OrderedDict
|
||||||
|
import hashlib
|
||||||
|
import pickle
|
||||||
|
|
||||||
|
from requests import Session as RequestsSession
|
||||||
|
|
||||||
|
# import cw_advocate
|
||||||
|
from .adapters import ValidatingHTTPAdapter
|
||||||
|
from .exceptions import MountDisabledException
|
||||||
|
|
||||||
|
|
||||||
|
class Session(RequestsSession):
|
||||||
|
"""Convenience wrapper around `requests.Session` set up for `advocate`ing"""
|
||||||
|
|
||||||
|
__attrs__ = RequestsSession.__attrs__ + ["validator"]
|
||||||
|
DEFAULT_VALIDATOR = None
|
||||||
|
"""
|
||||||
|
User-replaceable default validator to use for all Advocate sessions,
|
||||||
|
includes sessions created by advocate.get()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.validator = kwargs.pop("validator", None) or self.DEFAULT_VALIDATOR
|
||||||
|
adapter_kwargs = kwargs.pop("_adapter_kwargs", {})
|
||||||
|
|
||||||
|
# `Session.__init__()` calls `mount()` internally, so we need to allow
|
||||||
|
# it temporarily
|
||||||
|
self.__mount_allowed = True
|
||||||
|
RequestsSession.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
|
# Drop any existing adapters
|
||||||
|
self.adapters = OrderedDict()
|
||||||
|
|
||||||
|
self.mount("http://", ValidatingHTTPAdapter(validator=self.validator, **adapter_kwargs))
|
||||||
|
self.mount("https://", ValidatingHTTPAdapter(validator=self.validator, **adapter_kwargs))
|
||||||
|
self.__mount_allowed = False
|
||||||
|
|
||||||
|
def mount(self, *args, **kwargs):
|
||||||
|
"""Wrapper around `mount()` to prevent a protection bypass"""
|
||||||
|
if self.__mount_allowed:
|
||||||
|
super().mount(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
raise MountDisabledException(
|
||||||
|
"mount() is disabled to prevent protection bypasses"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def session(*args, **kwargs):
|
||||||
|
return Session(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, url, **kwargs):
|
||||||
|
"""Constructs and sends a :class:`Request <Request>`.
|
||||||
|
|
||||||
|
:param method: method for the new :class:`Request` object.
|
||||||
|
:param url: URL for the new :class:`Request` object.
|
||||||
|
:param params: (optional) Dictionary or bytes to be sent in the query string for the :class:`Request`.
|
||||||
|
:param data: (optional) Dictionary, bytes, or file-like object to send in the body of the :class:`Request`.
|
||||||
|
:param json: (optional) json data to send in the body of the :class:`Request`.
|
||||||
|
:param headers: (optional) Dictionary of HTTP Headers to send with the :class:`Request`.
|
||||||
|
:param cookies: (optional) Dict or CookieJar object to send with the :class:`Request`.
|
||||||
|
:param files: (optional) Dictionary of ``'name': file-like-objects`` (or ``{'name': ('filename', fileobj)}``) for multipart encoding upload.
|
||||||
|
:param auth: (optional) Auth tuple to enable Basic/Digest/Custom HTTP Auth.
|
||||||
|
:param timeout: (optional) How long to wait for the server to send data
|
||||||
|
before giving up, as a float, or a (`connect timeout, read timeout
|
||||||
|
<user/advanced.html#timeouts>`_) tuple.
|
||||||
|
:type timeout: float or tuple
|
||||||
|
:param allow_redirects: (optional) Boolean. Set to True if POST/PUT/DELETE redirect following is allowed.
|
||||||
|
:type allow_redirects: bool
|
||||||
|
:param proxies: (optional) Dictionary mapping protocol to the URL of the proxy.
|
||||||
|
:param verify: (optional) if ``True``, the SSL cert will be verified. A CA_BUNDLE path can also be provided.
|
||||||
|
:param stream: (optional) if ``False``, the response content will be immediately downloaded.
|
||||||
|
:param cert: (optional) if String, path to ssl client cert file (.pem). If Tuple, ('cert', 'key') pair.
|
||||||
|
:return: :class:`Response <Response>` object
|
||||||
|
:rtype: requests.Response
|
||||||
|
"""
|
||||||
|
|
||||||
|
validator = kwargs.pop("validator", None)
|
||||||
|
with Session(validator=validator) as sess:
|
||||||
|
response = sess.request(method=method, url=url, **kwargs)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def get(url, **kwargs):
|
||||||
|
"""Sends a GET request.
|
||||||
|
|
||||||
|
:param url: URL for the new :class:`Request` object.
|
||||||
|
:param **kwargs: Optional arguments that ``request`` takes.
|
||||||
|
:return: :class:`Response <Response>` object
|
||||||
|
:rtype: requests.Response
|
||||||
|
"""
|
||||||
|
|
||||||
|
kwargs.setdefault('allow_redirects', True)
|
||||||
|
return request('get', url, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestsAPIWrapper:
|
||||||
|
"""Provides a `requests.api`-like interface with a specific validator"""
|
||||||
|
|
||||||
|
# Due to how the classes are dynamically constructed pickling may not work
|
||||||
|
# correctly unless loaded within the same interpreter instance.
|
||||||
|
# Enable at your peril.
|
||||||
|
SUPPORT_WRAPPER_PICKLING = False
|
||||||
|
|
||||||
|
def __init__(self, validator):
|
||||||
|
# Do this here to avoid circular import issues
|
||||||
|
try:
|
||||||
|
from .futures import FuturesSession
|
||||||
|
have_requests_futures = True
|
||||||
|
except ImportError as e:
|
||||||
|
have_requests_futures = False
|
||||||
|
|
||||||
|
self.validator = validator
|
||||||
|
outer_self = self
|
||||||
|
|
||||||
|
class _WrappedSession(Session):
|
||||||
|
"""An `advocate.Session` that uses the wrapper's blacklist
|
||||||
|
|
||||||
|
the wrapper is meant to be a transparent replacement for `requests`,
|
||||||
|
so people should be able to subclass `wrapper.Session` and still
|
||||||
|
get the desired validation behaviour
|
||||||
|
"""
|
||||||
|
DEFAULT_VALIDATOR = outer_self.validator
|
||||||
|
|
||||||
|
self._make_wrapper_cls_global(_WrappedSession)
|
||||||
|
|
||||||
|
if have_requests_futures:
|
||||||
|
|
||||||
|
class _WrappedFuturesSession(FuturesSession):
|
||||||
|
"""Like _WrappedSession, but for `FuturesSession`s"""
|
||||||
|
DEFAULT_VALIDATOR = outer_self.validator
|
||||||
|
self._make_wrapper_cls_global(_WrappedFuturesSession)
|
||||||
|
|
||||||
|
self.FuturesSession = _WrappedFuturesSession
|
||||||
|
|
||||||
|
self.request = self._default_arg_wrapper(request)
|
||||||
|
self.get = self._default_arg_wrapper(get)
|
||||||
|
self.Session = _WrappedSession
|
||||||
|
|
||||||
|
def __getattr__(self, item):
|
||||||
|
# This class is meant to mimic the requests base module, so if we don't
|
||||||
|
# have this attribute, it might be on the base module (like the Request
|
||||||
|
# class, etc.)
|
||||||
|
try:
|
||||||
|
return object.__getattribute__(self, item)
|
||||||
|
except AttributeError:
|
||||||
|
from . import cw_advocate
|
||||||
|
return getattr(cw_advocate, item)
|
||||||
|
|
||||||
|
def _default_arg_wrapper(self, fun):
|
||||||
|
def wrapped_func(*args, **kwargs):
|
||||||
|
kwargs.setdefault("validator", self.validator)
|
||||||
|
return fun(*args, **kwargs)
|
||||||
|
return wrapped_func
|
||||||
|
|
||||||
|
def _make_wrapper_cls_global(self, cls):
|
||||||
|
if not self.SUPPORT_WRAPPER_PICKLING:
|
||||||
|
return
|
||||||
|
# Gnarly, but necessary to give pickle a consistent module-level
|
||||||
|
# reference for each wrapper.
|
||||||
|
wrapper_hash = hashlib.sha256(pickle.dumps(self)).hexdigest()
|
||||||
|
cls.__name__ = "_".join((cls.__name__, wrapper_hash))
|
||||||
|
cls.__qualname__ = ".".join((__name__, cls.__name__))
|
||||||
|
if not globals().get(cls.__name__):
|
||||||
|
globals()[cls.__name__] = cls
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"get",
|
||||||
|
"request",
|
||||||
|
"session",
|
||||||
|
"Session",
|
||||||
|
"RequestsAPIWrapper",
|
||||||
|
)
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2015 Jordan Milne
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# Source: https://github.com/JordanMilne/Advocate
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
from socket import timeout as SocketTimeout
|
||||||
|
|
||||||
|
from urllib3.connection import HTTPSConnection, HTTPConnection
|
||||||
|
from urllib3.exceptions import ConnectTimeoutError
|
||||||
|
from urllib3.util.connection import _set_socket_options
|
||||||
|
from urllib3.util.connection import create_connection as old_create_connection
|
||||||
|
|
||||||
|
from . import addrvalidator
|
||||||
|
from .exceptions import UnacceptableAddressException
|
||||||
|
|
||||||
|
|
||||||
|
def advocate_getaddrinfo(host, port, get_canonname=False):
|
||||||
|
addrinfo = socket.getaddrinfo(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
0,
|
||||||
|
socket.SOCK_STREAM,
|
||||||
|
0,
|
||||||
|
# We need what the DNS client sees the hostname as, correctly handles
|
||||||
|
# IDNs and tricky things like `private.foocorp.org\x00.google.com`.
|
||||||
|
# All IDNs will be converted to punycode.
|
||||||
|
socket.AI_CANONNAME if get_canonname else 0,
|
||||||
|
)
|
||||||
|
return fix_addrinfo(addrinfo)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_addrinfo(records):
|
||||||
|
"""
|
||||||
|
Propagate the canonname across records and parse IPs
|
||||||
|
|
||||||
|
I'm not sure if this is just the behaviour of `getaddrinfo` on Linux, but
|
||||||
|
it seems like only the first record in the set has the canonname field
|
||||||
|
populated.
|
||||||
|
"""
|
||||||
|
def fix_record(record, canonname):
|
||||||
|
sa = record[4]
|
||||||
|
sa = (ipaddress.ip_address(sa[0]),) + sa[1:]
|
||||||
|
return record[0], record[1], record[2], canonname, sa
|
||||||
|
|
||||||
|
canonname = None
|
||||||
|
if records:
|
||||||
|
# Apparently the canonical name is only included in the first record?
|
||||||
|
# Add it to all of them.
|
||||||
|
assert(len(records[0]) == 5)
|
||||||
|
canonname = records[0][3]
|
||||||
|
return tuple(fix_record(x, canonname) for x in records)
|
||||||
|
|
||||||
|
|
||||||
|
# Lifted from requests' urllib3, which in turn lifted it from `socket.py`. Oy!
|
||||||
|
def validating_create_connection(address,
|
||||||
|
timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
|
||||||
|
source_address=None, socket_options=None,
|
||||||
|
validator=None):
|
||||||
|
"""Connect to *address* and return the socket object.
|
||||||
|
|
||||||
|
Convenience function. Connect to *address* (a 2-tuple ``(host,
|
||||||
|
port)``) and return the socket object. Passing the optional
|
||||||
|
*timeout* parameter will set the timeout on the socket instance
|
||||||
|
before attempting to connect. If no *timeout* is supplied, the
|
||||||
|
global default timeout setting returned by :func:`getdefaulttimeout`
|
||||||
|
is used. If *source_address* is set it must be a tuple of (host, port)
|
||||||
|
for the socket to bind as a source address before making the connection.
|
||||||
|
An host of '' or port 0 tells the OS to use the default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
host, port = address
|
||||||
|
# We can skip asking for the canon name if we're not doing hostname-based
|
||||||
|
# blacklisting.
|
||||||
|
need_canonname = False
|
||||||
|
if validator.hostname_blacklist:
|
||||||
|
need_canonname = True
|
||||||
|
# We check both the non-canonical and canonical hostnames so we can
|
||||||
|
# catch both of these:
|
||||||
|
# CNAME from nonblacklisted.com -> blacklisted.com
|
||||||
|
# CNAME from blacklisted.com -> nonblacklisted.com
|
||||||
|
if not validator.is_hostname_allowed(host):
|
||||||
|
raise UnacceptableAddressException(host)
|
||||||
|
|
||||||
|
err = None
|
||||||
|
addrinfo = advocate_getaddrinfo(host, port, get_canonname=need_canonname)
|
||||||
|
if addrinfo:
|
||||||
|
if validator.autodetect_local_addresses:
|
||||||
|
local_addresses = addrvalidator.determine_local_addresses()
|
||||||
|
else:
|
||||||
|
local_addresses = []
|
||||||
|
for res in addrinfo:
|
||||||
|
# Are we allowed to connect with this result?
|
||||||
|
if not validator.is_addrinfo_allowed(
|
||||||
|
res,
|
||||||
|
_local_addresses=local_addresses,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
af, socktype, proto, canonname, sa = res
|
||||||
|
# Unparse the validated IP
|
||||||
|
sa = (sa[0].exploded,) + sa[1:]
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = socket.socket(af, socktype, proto)
|
||||||
|
|
||||||
|
# If provided, set socket level options before connecting.
|
||||||
|
# This is the only addition urllib3 makes to this function.
|
||||||
|
_set_socket_options(sock, socket_options)
|
||||||
|
|
||||||
|
if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT:
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
if source_address:
|
||||||
|
sock.bind(source_address)
|
||||||
|
sock.connect(sa)
|
||||||
|
return sock
|
||||||
|
|
||||||
|
except socket.error as _:
|
||||||
|
err = _
|
||||||
|
if sock is not None:
|
||||||
|
sock.close()
|
||||||
|
sock = None
|
||||||
|
|
||||||
|
if err is None:
|
||||||
|
# If we got here, none of the results were acceptable
|
||||||
|
err = UnacceptableAddressException(address)
|
||||||
|
if err is not None:
|
||||||
|
raise err
|
||||||
|
else:
|
||||||
|
raise socket.error("getaddrinfo returns an empty list")
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Is there a better way to add this to multiple classes with different
|
||||||
|
# base classes? I tried a mixin, but it used the base method instead.
|
||||||
|
def _validating_new_conn(self):
|
||||||
|
""" Establish a socket connection and set nodelay settings on it.
|
||||||
|
|
||||||
|
:return: New socket connection.
|
||||||
|
"""
|
||||||
|
extra_kw = {}
|
||||||
|
if self.source_address:
|
||||||
|
extra_kw['source_address'] = self.source_address
|
||||||
|
|
||||||
|
if self.socket_options:
|
||||||
|
extra_kw['socket_options'] = self.socket_options
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Hack around HTTPretty's patched sockets
|
||||||
|
# TODO: some better method of hacking around it that checks if we
|
||||||
|
# _would have_ connected to a private addr?
|
||||||
|
conn_func = validating_create_connection
|
||||||
|
if socket.getaddrinfo.__module__.startswith("httpretty"):
|
||||||
|
conn_func = old_create_connection
|
||||||
|
else:
|
||||||
|
extra_kw["validator"] = self._validator
|
||||||
|
|
||||||
|
conn = conn_func(
|
||||||
|
(self.host, self.port),
|
||||||
|
self.timeout,
|
||||||
|
**extra_kw
|
||||||
|
)
|
||||||
|
|
||||||
|
except SocketTimeout:
|
||||||
|
raise ConnectTimeoutError(
|
||||||
|
self, "Connection to %s timed out. (connect timeout=%s)" %
|
||||||
|
(self.host, self.timeout))
|
||||||
|
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
# Don't silently break if the private API changes across urllib3 versions
|
||||||
|
assert(hasattr(HTTPConnection, '_new_conn'))
|
||||||
|
assert(hasattr(HTTPSConnection, '_new_conn'))
|
||||||
|
|
||||||
|
|
||||||
|
class ValidatingHTTPConnection(HTTPConnection):
|
||||||
|
_new_conn = _validating_new_conn
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self._validator = kwargs.pop("validator")
|
||||||
|
HTTPConnection.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidatingHTTPSConnection(HTTPSConnection):
|
||||||
|
_new_conn = _validating_new_conn
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self._validator = kwargs.pop("validator")
|
||||||
|
HTTPSConnection.__init__(self, *args, **kwargs)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2015 Jordan Milne
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# Source: https://github.com/JordanMilne/Advocate
|
||||||
|
|
||||||
|
from urllib3 import HTTPConnectionPool, HTTPSConnectionPool
|
||||||
|
|
||||||
|
from .connection import (
|
||||||
|
ValidatingHTTPConnection,
|
||||||
|
ValidatingHTTPSConnection,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Don't silently break if the private API changes across urllib3 versions
|
||||||
|
assert(hasattr(HTTPConnectionPool, 'ConnectionCls'))
|
||||||
|
assert(hasattr(HTTPSConnectionPool, 'ConnectionCls'))
|
||||||
|
assert(hasattr(HTTPConnectionPool, 'scheme'))
|
||||||
|
assert(hasattr(HTTPSConnectionPool, 'scheme'))
|
||||||
|
|
||||||
|
|
||||||
|
class ValidatingHTTPConnectionPool(HTTPConnectionPool):
|
||||||
|
scheme = 'http'
|
||||||
|
ConnectionCls = ValidatingHTTPConnection
|
||||||
|
|
||||||
|
|
||||||
|
class ValidatingHTTPSConnectionPool(HTTPSConnectionPool):
|
||||||
|
scheme = 'https'
|
||||||
|
ConnectionCls = ValidatingHTTPSConnection
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2015 Jordan Milne
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# Source: https://github.com/JordanMilne/Advocate
|
||||||
|
|
||||||
|
class AdvocateException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnacceptableAddressException(AdvocateException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NameserverException(AdvocateException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MountDisabledException(AdvocateException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyDisabledException(NotImplementedError, AdvocateException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigException(AdvocateException):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2015 Jordan Milne
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# Source: https://github.com/JordanMilne/Advocate
|
||||||
|
|
||||||
|
import collections
|
||||||
|
import functools
|
||||||
|
|
||||||
|
from urllib3 import PoolManager
|
||||||
|
from urllib3.poolmanager import _default_key_normalizer, PoolKey
|
||||||
|
|
||||||
|
from .connectionpool import (
|
||||||
|
ValidatingHTTPSConnectionPool,
|
||||||
|
ValidatingHTTPConnectionPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
pool_classes_by_scheme = {
|
||||||
|
"http": ValidatingHTTPConnectionPool,
|
||||||
|
"https": ValidatingHTTPSConnectionPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
AdvocatePoolKey = collections.namedtuple('AdvocatePoolKey',
|
||||||
|
PoolKey._fields + ('key_validator',))
|
||||||
|
|
||||||
|
|
||||||
|
def key_normalizer(key_class, request_context):
|
||||||
|
request_context = request_context.copy()
|
||||||
|
# TODO: add ability to serialize validator rules to dict,
|
||||||
|
# allowing pool to be shared between sessions with the same
|
||||||
|
# rules.
|
||||||
|
request_context["validator"] = id(request_context["validator"])
|
||||||
|
return _default_key_normalizer(key_class, request_context)
|
||||||
|
|
||||||
|
|
||||||
|
key_fn_by_scheme = {
|
||||||
|
'http': functools.partial(key_normalizer, AdvocatePoolKey),
|
||||||
|
'https': functools.partial(key_normalizer, AdvocatePoolKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ValidatingPoolManager(PoolManager):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Make sure the API hasn't changed
|
||||||
|
assert (hasattr(self, 'pool_classes_by_scheme'))
|
||||||
|
|
||||||
|
self.pool_classes_by_scheme = pool_classes_by_scheme
|
||||||
|
self.key_fn_by_scheme = key_fn_by_scheme.copy()
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ class Identifiers(Base):
|
|||||||
if format_type == 'amazon':
|
if format_type == 'amazon':
|
||||||
return "Amazon"
|
return "Amazon"
|
||||||
elif format_type.startswith("amazon_"):
|
elif format_type.startswith("amazon_"):
|
||||||
return "Amazon.{0}".format(format_type[7:])
|
return "Amazon.{0}".format(format_type[7:].lower().replace("uk","co.uk"))
|
||||||
elif format_type == "isbn":
|
elif format_type == "isbn":
|
||||||
return "ISBN"
|
return "ISBN"
|
||||||
elif format_type == "doi":
|
elif format_type == "doi":
|
||||||
@@ -149,7 +149,7 @@ class Identifiers(Base):
|
|||||||
if format_type == "amazon" or format_type == "asin":
|
if format_type == "amazon" or format_type == "asin":
|
||||||
return "https://amazon.com/dp/{0}".format(self.val)
|
return "https://amazon.com/dp/{0}".format(self.val)
|
||||||
elif format_type.startswith('amazon_'):
|
elif format_type.startswith('amazon_'):
|
||||||
return "https://amazon.{0}/dp/{1}".format(format_type[7:], self.val)
|
return "https://amazon.{0}/dp/{1}".format(format_type[7:].lower().replace("uk","co.uk"), self.val)
|
||||||
elif format_type == "isbn":
|
elif format_type == "isbn":
|
||||||
return "https://www.worldcat.org/isbn/{0}".format(self.val)
|
return "https://www.worldcat.org/isbn/{0}".format(self.val)
|
||||||
elif format_type == "doi":
|
elif format_type == "doi":
|
||||||
@@ -379,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)
|
||||||
@@ -534,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):
|
||||||
@@ -876,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:
|
||||||
@@ -901,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])
|
||||||
@@ -909,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:
|
||||||
@@ -919,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:
|
||||||
@@ -1018,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
|
||||||
@@ -1027,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:
|
||||||
conn.create_function("title_sort", 1, _title_sort)
|
if config:
|
||||||
|
conn.create_function("title_sort", 1, _title_sort)
|
||||||
|
conn.create_function('uuid4', 0, lambda: str(uuid4()))
|
||||||
|
conn.create_function("lower", 1, lcase)
|
||||||
except sqliteOperationalError:
|
except sqliteOperationalError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -26,7 +26,8 @@ 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
|
||||||
@@ -49,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))
|
||||||
@@ -72,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")
|
||||||
|
|||||||
+18
-2
@@ -39,11 +39,27 @@ def load_dependencies(optional=False):
|
|||||||
with open(req_path, 'r') as f:
|
with open(req_path, 'r') as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
if not line.startswith('#') and not line == '\n' and not line.startswith('git'):
|
if not line.startswith('#') and not line == '\n' and not line.startswith('git'):
|
||||||
res = re.match(r'(.*?)([<=>\s]+)([\d\.]+),?\s?([<=>\s]+)?([\d\.]+)?', line.strip())
|
res = re.match(r'(.*?)([<=>\s]+)([\d\.]+),?\s?([<=>\s]+)?([\d\.]+)?(?:\s?;\s?'
|
||||||
|
r'(?:(python_version)\s?([<=>]+)\s?\'([\d\.]+)\'|'
|
||||||
|
r'(sys_platform)\s?([\!=]+)\s?\'([\w]+)\'))?', line.strip())
|
||||||
try:
|
try:
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
dep_version = exe_deps[res.group(1).lower().replace('_', '-')]
|
dep_version = exe_deps[res.group(1).lower().replace('_', '-')]
|
||||||
else:
|
else:
|
||||||
|
if res.group(7) and res.group(8):
|
||||||
|
val = res.group(8).split(".")
|
||||||
|
if not eval(str(sys.version_info[0]) + "." + "{:02d}".format(sys.version_info[1]) +
|
||||||
|
res.group(7) + val[0] + "." + "{:02d}".format(int(val[1]))):
|
||||||
|
continue
|
||||||
|
elif res.group(10) and res.group(11):
|
||||||
|
# only installed if platform is eqal, don't check if platform is not equal
|
||||||
|
if res.group(10) == "==":
|
||||||
|
if sys.platform != res.group(11):
|
||||||
|
continue
|
||||||
|
# installed if platform is not eqal, don't check if platform is equal
|
||||||
|
elif res.group(10) == "!=":
|
||||||
|
if sys.platform == res.group(11):
|
||||||
|
continue
|
||||||
if importlib:
|
if importlib:
|
||||||
dep_version = version(res.group(1))
|
dep_version = version(res.group(1))
|
||||||
else:
|
else:
|
||||||
@@ -63,7 +79,7 @@ def dependency_check(optional=False):
|
|||||||
deps = load_dependencies(optional)
|
deps = load_dependencies(optional)
|
||||||
for dep in deps:
|
for dep in deps:
|
||||||
try:
|
try:
|
||||||
dep_version_int = [int(x) if x.isnumeric() else 0 for x in dep[0].split('.')]
|
dep_version_int = [int(x) if x.isnumeric() else 0 for x in dep[0].split('.')[:3]]
|
||||||
low_check = [int(x) for x in dep[3].split('.')]
|
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:
|
||||||
|
|||||||
+328
-297
@@ -21,10 +21,10 @@
|
|||||||
# 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 os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
import json
|
import json
|
||||||
from shutil import copyfile, move
|
from shutil import copyfile
|
||||||
from uuid import uuid4
|
|
||||||
from markupsafe import escape, Markup # dependency of flask
|
from markupsafe import escape, Markup # dependency of flask
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ from flask import Blueprint, request, flash, redirect, url_for, abort, Response
|
|||||||
from flask_babel import gettext as _
|
from flask_babel import 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, login_required
|
from .cw_login import current_user, login_required
|
||||||
from sqlalchemy.exc import OperationalError, IntegrityError, InterfaceError
|
from sqlalchemy.exc import OperationalError, IntegrityError, InterfaceError
|
||||||
from sqlalchemy.orm.exc import StaleDataError
|
from sqlalchemy.orm.exc import StaleDataError
|
||||||
from sqlalchemy.sql.expression import func
|
from sqlalchemy.sql.expression import func
|
||||||
@@ -43,10 +43,11 @@ from . import config, ub, db, calibre_db
|
|||||||
from .services.worker import WorkerThread
|
from .services.worker import WorkerThread
|
||||||
from .tasks.upload import TaskUpload
|
from .tasks.upload import TaskUpload
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
from .usermanagement import login_required_if_no_ano
|
|
||||||
from .kobo_sync_status import change_archived_books
|
from .kobo_sync_status import change_archived_books
|
||||||
from .redirect import get_redirect_location
|
from .redirect import get_redirect_location
|
||||||
from .file_helper import validate_mime_type
|
from .file_helper import validate_mime_type
|
||||||
|
from .usermanagement import user_login_required, login_required_if_no_ano
|
||||||
|
from .string_helper import strip_whitespaces
|
||||||
|
|
||||||
editbook = Blueprint('edit-book', __name__)
|
editbook = Blueprint('edit-book', __name__)
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -73,14 +74,14 @@ def edit_required(f):
|
|||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"])
|
@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def delete_book_from_details(book_id):
|
def delete_book_from_details(book_id):
|
||||||
return Response(delete_book_from_table(book_id, "", True), mimetype='application/json')
|
return Response(delete_book_from_table(book_id, "", True), mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
|
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
|
||||||
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
|
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
|
||||||
@login_required
|
@user_login_required
|
||||||
def delete_book_ajax(book_id, book_format):
|
def delete_book_ajax(book_id, book_format):
|
||||||
return delete_book_from_table(book_id, book_format, False, request.form.to_dict().get('location', ""))
|
return delete_book_from_table(book_id, book_format, False, request.form.to_dict().get('location', ""))
|
||||||
|
|
||||||
@@ -96,156 +97,22 @@ def show_edit_book(book_id):
|
|||||||
@login_required_if_no_ano
|
@login_required_if_no_ano
|
||||||
@edit_required
|
@edit_required
|
||||||
def edit_book(book_id):
|
def edit_book(book_id):
|
||||||
modify_date = False
|
return do_edit_book(book_id)
|
||||||
edit_error = False
|
|
||||||
|
|
||||||
# create the function for sorting...
|
|
||||||
calibre_db.update_title_sort(config)
|
|
||||||
|
|
||||||
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
|
||||||
# Book not found
|
|
||||||
if not book:
|
|
||||||
flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
|
|
||||||
category="error")
|
|
||||||
return redirect(url_for("web.index"))
|
|
||||||
|
|
||||||
to_save = request.form.to_dict()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Update folder of book on local disk
|
|
||||||
edited_books_id = None
|
|
||||||
title_author_error = None
|
|
||||||
# handle book title change
|
|
||||||
title_change = handle_title_on_edit(book, to_save["book_title"])
|
|
||||||
# handle book author change
|
|
||||||
input_authors, author_change = handle_author_on_edit(book, to_save["author_name"])
|
|
||||||
if author_change or title_change:
|
|
||||||
edited_books_id = book.id
|
|
||||||
modify_date = True
|
|
||||||
title_author_error = helper.update_dir_structure(edited_books_id,
|
|
||||||
config.get_book_path(),
|
|
||||||
input_authors[0])
|
|
||||||
if title_author_error:
|
|
||||||
flash(title_author_error, category="error")
|
|
||||||
calibre_db.session.rollback()
|
|
||||||
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
|
||||||
|
|
||||||
# handle upload other formats from local disk
|
|
||||||
meta = upload_single_file(request, book, book_id)
|
|
||||||
# only merge metadata if file was uploaded and no error occurred (meta equals not false or none)
|
|
||||||
if meta:
|
|
||||||
merge_metadata(to_save, meta)
|
|
||||||
# handle upload covers from local disk
|
|
||||||
cover_upload_success = upload_cover(request, book)
|
|
||||||
if cover_upload_success:
|
|
||||||
book.has_cover = 1
|
|
||||||
modify_date = True
|
|
||||||
|
|
||||||
# upload new covers or new file formats to google drive
|
|
||||||
if config.config_use_google_drive:
|
|
||||||
gdriveutils.updateGdriveCalibreFromLocal()
|
|
||||||
|
|
||||||
if to_save.get("cover_url", None):
|
|
||||||
if not current_user.role_upload():
|
|
||||||
edit_error = True
|
|
||||||
flash(_("User has no rights to upload cover"), category="error")
|
|
||||||
if to_save["cover_url"].endswith('/static/generic_cover.jpg'):
|
|
||||||
book.has_cover = 0
|
|
||||||
else:
|
|
||||||
result, error = helper.save_cover_from_url(to_save["cover_url"].strip(), book.path)
|
|
||||||
if result is True:
|
|
||||||
book.has_cover = 1
|
|
||||||
modify_date = True
|
|
||||||
helper.replace_cover_thumbnail_cache(book.id)
|
|
||||||
else:
|
|
||||||
flash(error, category="error")
|
|
||||||
|
|
||||||
# Add default series_index to book
|
|
||||||
modify_date |= edit_book_series_index(to_save["series_index"], book)
|
|
||||||
# Handle book comments/description
|
|
||||||
modify_date |= edit_book_comments(Markup(to_save['description']).unescape(), book)
|
|
||||||
# Handle identifiers
|
|
||||||
input_identifiers = identifier_list(to_save, book)
|
|
||||||
modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session)
|
|
||||||
if warning:
|
|
||||||
flash(_("Identifiers are not Case Sensitive, Overwriting Old Identifier"), category="warning")
|
|
||||||
modify_date |= modification
|
|
||||||
# Handle book tags
|
|
||||||
modify_date |= edit_book_tags(to_save['tags'], book)
|
|
||||||
# Handle book series
|
|
||||||
modify_date |= edit_book_series(to_save["series"], book)
|
|
||||||
# handle book publisher
|
|
||||||
modify_date |= edit_book_publisher(to_save['publisher'], book)
|
|
||||||
# handle book languages
|
|
||||||
try:
|
|
||||||
modify_date |= edit_book_languages(to_save['languages'], book)
|
|
||||||
except ValueError as e:
|
|
||||||
flash(str(e), category="error")
|
|
||||||
edit_error = True
|
|
||||||
# handle book ratings
|
|
||||||
modify_date |= edit_book_ratings(to_save, book)
|
|
||||||
# handle cc data
|
|
||||||
modify_date |= edit_all_cc_data(book_id, book, to_save)
|
|
||||||
|
|
||||||
if to_save.get("pubdate", None):
|
|
||||||
try:
|
|
||||||
book.pubdate = datetime.strptime(to_save["pubdate"], "%Y-%m-%d")
|
|
||||||
except ValueError as e:
|
|
||||||
book.pubdate = db.Books.DEFAULT_PUBDATE
|
|
||||||
flash(str(e), category="error")
|
|
||||||
edit_error = True
|
|
||||||
else:
|
|
||||||
book.pubdate = db.Books.DEFAULT_PUBDATE
|
|
||||||
|
|
||||||
if modify_date:
|
|
||||||
book.last_modified = datetime.utcnow()
|
|
||||||
kobo_sync_status.remove_synced_book(edited_books_id, all=True)
|
|
||||||
calibre_db.set_metadata_dirty(book.id)
|
|
||||||
|
|
||||||
calibre_db.session.merge(book)
|
|
||||||
calibre_db.session.commit()
|
|
||||||
if config.config_use_google_drive:
|
|
||||||
gdriveutils.updateGdriveCalibreFromLocal()
|
|
||||||
if meta is not False \
|
|
||||||
and edit_error is not True \
|
|
||||||
and title_author_error is not True \
|
|
||||||
and cover_upload_success is not False:
|
|
||||||
flash(_("Metadata successfully updated"), category="success")
|
|
||||||
if "detail_view" in to_save:
|
|
||||||
return redirect(url_for('web.show_book', book_id=book.id))
|
|
||||||
else:
|
|
||||||
return render_edit_book(book_id)
|
|
||||||
except ValueError as e:
|
|
||||||
log.error_or_exception("Error: {}".format(e))
|
|
||||||
calibre_db.session.rollback()
|
|
||||||
flash(str(e), category="error")
|
|
||||||
return redirect(url_for('web.show_book', book_id=book.id))
|
|
||||||
except (OperationalError, IntegrityError, StaleDataError, InterfaceError) as e:
|
|
||||||
log.error_or_exception("Database error: {}".format(e))
|
|
||||||
calibre_db.session.rollback()
|
|
||||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e), category="error")
|
|
||||||
return redirect(url_for('web.show_book', book_id=book.id))
|
|
||||||
except Exception as ex:
|
|
||||||
log.error_or_exception(ex)
|
|
||||||
calibre_db.session.rollback()
|
|
||||||
flash(_("Error editing book: {}".format(ex)), category="error")
|
|
||||||
return redirect(url_for('web.show_book', book_id=book.id))
|
|
||||||
|
|
||||||
|
|
||||||
@editbook.route("/upload", methods=["POST"])
|
@editbook.route("/upload", methods=["POST"])
|
||||||
@login_required_if_no_ano
|
@login_required_if_no_ano
|
||||||
@upload_required
|
@upload_required
|
||||||
def upload():
|
def upload():
|
||||||
if not config.config_uploading:
|
if len(request.files.getlist("btn-upload-format")):
|
||||||
abort(404)
|
book_id = request.form.get('book_id', -1)
|
||||||
if request.method == 'POST' and 'btn-upload' in request.files:
|
return do_edit_book(book_id, request.files.getlist("btn-upload-format"))
|
||||||
|
elif len(request.files.getlist("btn-upload")):
|
||||||
for requested_file in request.files.getlist("btn-upload"):
|
for requested_file in request.files.getlist("btn-upload"):
|
||||||
try:
|
try:
|
||||||
modify_date = False
|
modify_date = False
|
||||||
# create the function for sorting...
|
# create the function for sorting...
|
||||||
calibre_db.update_title_sort(config)
|
calibre_db.create_functions(config)
|
||||||
calibre_db.session.connection().connection.connection.create_function('uuid4', 0, lambda: str(uuid4()))
|
|
||||||
|
|
||||||
meta, error = file_handling_on_upload(requested_file)
|
meta, error = file_handling_on_upload(requested_file)
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
@@ -273,9 +140,7 @@ def upload():
|
|||||||
input_authors[0],
|
input_authors[0],
|
||||||
meta.file_path,
|
meta.file_path,
|
||||||
title_dir + meta.extension.lower())
|
title_dir + meta.extension.lower())
|
||||||
|
|
||||||
move_coverfile(meta, db_book)
|
move_coverfile(meta, db_book)
|
||||||
|
|
||||||
if modify_date:
|
if modify_date:
|
||||||
calibre_db.set_metadata_dirty(book_id)
|
calibre_db.set_metadata_dirty(book_id)
|
||||||
# save data to database, reread data
|
# save data to database, reread data
|
||||||
@@ -303,6 +168,7 @@ def upload():
|
|||||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e),
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e),
|
||||||
category="error")
|
category="error")
|
||||||
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
@editbook.route("/admin/book/convert/<int:book_id>", methods=['POST'])
|
@editbook.route("/admin/book/convert/<int:book_id>", methods=['POST'])
|
||||||
@@ -331,7 +197,7 @@ def convert_bookformat(book_id):
|
|||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/getcustomenum/<int:c_id>")
|
@editbook.route("/ajax/getcustomenum/<int:c_id>")
|
||||||
@login_required
|
@user_login_required
|
||||||
def table_get_custom_enum(c_id):
|
def table_get_custom_enum(c_id):
|
||||||
ret = list()
|
ret = list()
|
||||||
cc = (calibre_db.session.query(db.CustomColumns)
|
cc = (calibre_db.session.query(db.CustomColumns)
|
||||||
@@ -438,7 +304,7 @@ def edit_list_book(param):
|
|||||||
mimetype='application/json')
|
mimetype='application/json')
|
||||||
else:
|
else:
|
||||||
return _("Parameter not found"), 400
|
return _("Parameter not found"), 400
|
||||||
book.last_modified = datetime.utcnow()
|
book.last_modified = datetime.now(timezone.utc)
|
||||||
|
|
||||||
calibre_db.session.commit()
|
calibre_db.session.commit()
|
||||||
# revert change for sort if automatic fields link is deactivated
|
# revert change for sort if automatic fields link is deactivated
|
||||||
@@ -455,7 +321,7 @@ def edit_list_book(param):
|
|||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/sort_value/<field>/<int:bookid>")
|
@editbook.route("/ajax/sort_value/<field>/<int:bookid>")
|
||||||
@login_required
|
@user_login_required
|
||||||
def get_sorted_entry(field, bookid):
|
def get_sorted_entry(field, bookid):
|
||||||
if field in ['title', 'authors', 'sort', 'author_sort']:
|
if field in ['title', 'authors', 'sort', 'author_sort']:
|
||||||
book = calibre_db.get_filtered_book(bookid)
|
book = calibre_db.get_filtered_book(bookid)
|
||||||
@@ -472,7 +338,7 @@ def get_sorted_entry(field, bookid):
|
|||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/simulatemerge", methods=['POST'])
|
@editbook.route("/ajax/simulatemerge", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@edit_required
|
@edit_required
|
||||||
def simulate_merge_list_book():
|
def simulate_merge_list_book():
|
||||||
vals = request.get_json().get('Merge_books')
|
vals = request.get_json().get('Merge_books')
|
||||||
@@ -488,7 +354,7 @@ def simulate_merge_list_book():
|
|||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/mergebooks", methods=['POST'])
|
@editbook.route("/ajax/mergebooks", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@edit_required
|
@edit_required
|
||||||
def merge_list_book():
|
def merge_list_book():
|
||||||
vals = request.get_json().get('Merge_books')
|
vals = request.get_json().get('Merge_books')
|
||||||
@@ -526,7 +392,7 @@ def merge_list_book():
|
|||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/xchange", methods=['POST'])
|
@editbook.route("/ajax/xchange", methods=['POST'])
|
||||||
@login_required
|
@user_login_required
|
||||||
@edit_required
|
@edit_required
|
||||||
def table_xchange_author_title():
|
def table_xchange_author_title():
|
||||||
vals = request.get_json().get('xchange')
|
vals = request.get_json().get('xchange')
|
||||||
@@ -554,7 +420,7 @@ def table_xchange_author_title():
|
|||||||
# toDo: Handle error
|
# toDo: Handle error
|
||||||
edit_error = helper.update_dir_structure(edited_books_id, config.get_book_path(), input_authors[0])
|
edit_error = helper.update_dir_structure(edited_books_id, config.get_book_path(), input_authors[0])
|
||||||
if modify_date:
|
if modify_date:
|
||||||
book.last_modified = datetime.utcnow()
|
book.last_modified = datetime.now(timezone.utc)
|
||||||
calibre_db.set_metadata_dirty(book.id)
|
calibre_db.set_metadata_dirty(book.id)
|
||||||
try:
|
try:
|
||||||
calibre_db.session.commit()
|
calibre_db.session.commit()
|
||||||
@@ -569,18 +435,166 @@ def table_xchange_author_title():
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def merge_metadata(to_save, meta):
|
def do_edit_book(book_id, upload_formats=None):
|
||||||
if to_save.get('author_name', "") == _('Unknown'):
|
modify_date = False
|
||||||
to_save['author_name'] = ''
|
edit_error = False
|
||||||
if to_save.get('book_title', "") == _('Unknown'):
|
|
||||||
to_save['book_title'] = ''
|
# create the function for sorting...
|
||||||
|
calibre_db.create_functions(config)
|
||||||
|
|
||||||
|
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
||||||
|
# Book not found
|
||||||
|
if not book:
|
||||||
|
flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
|
||||||
|
category="error")
|
||||||
|
return redirect(url_for("web.index"))
|
||||||
|
|
||||||
|
to_save = request.form.to_dict()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update folder of book on local disk
|
||||||
|
edited_books_id = None
|
||||||
|
title_author_error = None
|
||||||
|
# upload_mode = False
|
||||||
|
# handle book title change
|
||||||
|
if "title" in to_save:
|
||||||
|
title_change = handle_title_on_edit(book, to_save["title"])
|
||||||
|
# handle book author change
|
||||||
|
if not upload_formats:
|
||||||
|
input_authors, author_change = handle_author_on_edit(book, to_save["authors"])
|
||||||
|
if author_change or title_change:
|
||||||
|
edited_books_id = book.id
|
||||||
|
modify_date = True
|
||||||
|
title_author_error = helper.update_dir_structure(edited_books_id,
|
||||||
|
config.get_book_path(),
|
||||||
|
input_authors[0])
|
||||||
|
if title_author_error:
|
||||||
|
flash(title_author_error, category="error")
|
||||||
|
calibre_db.session.rollback()
|
||||||
|
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
||||||
|
|
||||||
|
# handle book ratings
|
||||||
|
modify_date |= edit_book_ratings(to_save, book)
|
||||||
|
else:
|
||||||
|
# handle upload other formats from local disk
|
||||||
|
to_save, edit_error = upload_book_formats(upload_formats, book, book_id, book.has_cover)
|
||||||
|
# handle upload covers from local disk
|
||||||
|
cover_upload_success = upload_cover(request, book)
|
||||||
|
if cover_upload_success or to_save.get("format_cover"):
|
||||||
|
book.has_cover = 1
|
||||||
|
modify_date = True
|
||||||
|
|
||||||
|
# upload new covers or new file formats to google drive
|
||||||
|
if config.config_use_google_drive:
|
||||||
|
gdriveutils.updateGdriveCalibreFromLocal()
|
||||||
|
|
||||||
|
if to_save.get("cover_url",):
|
||||||
|
if not current_user.role_upload():
|
||||||
|
edit_error = True
|
||||||
|
flash(_("User has no rights to upload cover"), category="error")
|
||||||
|
if to_save["cover_url"].endswith('/static/generic_cover.jpg'):
|
||||||
|
book.has_cover = 0
|
||||||
|
else:
|
||||||
|
result, error = helper.save_cover_from_url(to_save["cover_url"].strip(), book.path)
|
||||||
|
if result is True:
|
||||||
|
book.has_cover = 1
|
||||||
|
modify_date = True
|
||||||
|
helper.replace_cover_thumbnail_cache(book.id)
|
||||||
|
else:
|
||||||
|
edit_error = True
|
||||||
|
flash(error, category="error")
|
||||||
|
|
||||||
|
# Add default series_index to book
|
||||||
|
modify_date |= edit_book_series_index(to_save.get("series_index"), book)
|
||||||
|
# Handle book comments/description
|
||||||
|
modify_date |= edit_book_comments(Markup(to_save.get('comments')).unescape(), book)
|
||||||
|
# Handle identifiers
|
||||||
|
input_identifiers = identifier_list(to_save, book)
|
||||||
|
modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session)
|
||||||
|
if warning:
|
||||||
|
flash(_("Identifiers are not Case Sensitive, Overwriting Old Identifier"), category="warning")
|
||||||
|
modify_date |= modification
|
||||||
|
# Handle book tags
|
||||||
|
modify_date |= edit_book_tags(to_save.get('tags'), book)
|
||||||
|
# Handle book series
|
||||||
|
modify_date |= edit_book_series(to_save.get("series"), book)
|
||||||
|
# handle book publisher
|
||||||
|
modify_date |= edit_book_publisher(to_save.get('publisher'), book)
|
||||||
|
# handle book languages
|
||||||
|
try:
|
||||||
|
invalid = []
|
||||||
|
modify_date |= edit_book_languages(to_save.get('languages'), book, upload_mode=upload_formats,
|
||||||
|
invalid=invalid)
|
||||||
|
if invalid:
|
||||||
|
for lang in invalid:
|
||||||
|
flash(_("'%(langname)s' is not a valid language", langname=lang), category="warning")
|
||||||
|
except ValueError as e:
|
||||||
|
flash(str(e), category="error")
|
||||||
|
edit_error = True
|
||||||
|
# handle cc data
|
||||||
|
modify_date |= edit_all_cc_data(book_id, book, to_save)
|
||||||
|
|
||||||
|
if to_save.get("pubdate") is not None:
|
||||||
|
if to_save.get("pubdate"):
|
||||||
|
try:
|
||||||
|
book.pubdate = datetime.strptime(to_save["pubdate"], "%Y-%m-%d")
|
||||||
|
except ValueError as e:
|
||||||
|
book.pubdate = db.Books.DEFAULT_PUBDATE
|
||||||
|
flash(str(e), category="error")
|
||||||
|
edit_error = True
|
||||||
|
else:
|
||||||
|
book.pubdate = db.Books.DEFAULT_PUBDATE
|
||||||
|
|
||||||
|
if modify_date:
|
||||||
|
book.last_modified = datetime.now(timezone.utc)
|
||||||
|
kobo_sync_status.remove_synced_book(edited_books_id, all=True)
|
||||||
|
calibre_db.set_metadata_dirty(book.id)
|
||||||
|
|
||||||
|
calibre_db.session.merge(book)
|
||||||
|
calibre_db.session.commit()
|
||||||
|
if config.config_use_google_drive:
|
||||||
|
gdriveutils.updateGdriveCalibreFromLocal()
|
||||||
|
if edit_error is not True and title_author_error is not True and cover_upload_success is not False:
|
||||||
|
flash(_("Metadata successfully updated"), category="success")
|
||||||
|
|
||||||
|
if upload_formats:
|
||||||
|
resp = {"location": url_for('edit-book.show_edit_book', book_id=book_id)}
|
||||||
|
return Response(json.dumps(resp), mimetype='application/json')
|
||||||
|
|
||||||
|
if "detail_view" in to_save:
|
||||||
|
return redirect(url_for('web.show_book', book_id=book.id))
|
||||||
|
else:
|
||||||
|
return render_edit_book(book_id)
|
||||||
|
except ValueError as e:
|
||||||
|
log.error_or_exception("Error: {}".format(e))
|
||||||
|
calibre_db.session.rollback()
|
||||||
|
flash(str(e), category="error")
|
||||||
|
return redirect(url_for('web.show_book', book_id=book.id))
|
||||||
|
except (OperationalError, IntegrityError, StaleDataError, InterfaceError) as e:
|
||||||
|
log.error_or_exception("Database error: {}".format(e))
|
||||||
|
calibre_db.session.rollback()
|
||||||
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e), category="error")
|
||||||
|
return redirect(url_for('web.show_book', book_id=book.id))
|
||||||
|
except Exception as ex:
|
||||||
|
log.error_or_exception(ex)
|
||||||
|
calibre_db.session.rollback()
|
||||||
|
flash(_("Error editing book: {}".format(ex)), category="error")
|
||||||
|
return redirect(url_for('web.show_book', book_id=book.id))
|
||||||
|
|
||||||
|
|
||||||
|
def merge_metadata(book, meta, to_save):
|
||||||
|
if meta.cover:
|
||||||
|
to_save['cover_format'] = meta.cover
|
||||||
for s_field, m_field in [
|
for s_field, m_field in [
|
||||||
('tags', 'tags'), ('author_name', 'author'), ('series', 'series'),
|
('tags', 'tags'), ('authors', 'author'), ('series', 'series'),
|
||||||
('series_index', 'series_id'), ('languages', 'languages'),
|
('series_index', 'series_id'), ('languages', 'languages'),
|
||||||
('book_title', 'title')]:
|
('title', 'title'), ('comments', 'description')]:
|
||||||
to_save[s_field] = to_save[s_field] or getattr(meta, m_field, '')
|
try:
|
||||||
to_save["description"] = to_save["description"] or Markup(
|
val = None if len(getattr(book, s_field)) else getattr(meta, m_field, '')
|
||||||
getattr(meta, 'description', '')).unescape()
|
except TypeError:
|
||||||
|
val = None if len(str(getattr(book, s_field))) else getattr(meta, m_field, '')
|
||||||
|
if val:
|
||||||
|
to_save[s_field] = val
|
||||||
|
|
||||||
|
|
||||||
def identifier_list(to_save, book):
|
def identifier_list(to_save, book):
|
||||||
@@ -701,8 +715,8 @@ def create_book_on_upload(modify_date, meta):
|
|||||||
pubdate = datetime(101, 1, 1)
|
pubdate = datetime(101, 1, 1)
|
||||||
|
|
||||||
# Calibre adds books with utc as timezone
|
# Calibre adds books with utc as timezone
|
||||||
db_book = db.Books(title, "", sort_authors, datetime.utcnow(), pubdate,
|
db_book = db.Books(title, "", sort_authors, datetime.now(timezone.utc), pubdate,
|
||||||
'1', datetime.utcnow(), path, meta.cover, db_author, [], "")
|
'1', datetime.now(timezone.utc), path, meta.cover, db_author, [], "")
|
||||||
|
|
||||||
modify_date |= modify_database_object(input_authors, db_book.authors, db.Authors, calibre_db.session,
|
modify_date |= modify_database_object(input_authors, db_book.authors, db.Authors, calibre_db.session,
|
||||||
'author')
|
'author')
|
||||||
@@ -969,7 +983,7 @@ def render_edit_book(book_id):
|
|||||||
|
|
||||||
def edit_book_ratings(to_save, book):
|
def edit_book_ratings(to_save, book):
|
||||||
changed = False
|
changed = False
|
||||||
if to_save.get("rating", "").strip():
|
if strip_whitespaces(to_save.get("rating", "")):
|
||||||
old_rating = False
|
old_rating = False
|
||||||
if len(book.ratings) > 0:
|
if len(book.ratings) > 0:
|
||||||
old_rating = book.ratings[0].rating
|
old_rating = book.ratings[0].rating
|
||||||
@@ -992,84 +1006,93 @@ def edit_book_ratings(to_save, book):
|
|||||||
|
|
||||||
|
|
||||||
def edit_book_tags(tags, book):
|
def edit_book_tags(tags, book):
|
||||||
input_tags = tags.split(',')
|
if tags is not None:
|
||||||
input_tags = list(map(lambda it: it.strip(), input_tags))
|
input_tags = tags.split(',')
|
||||||
# Remove duplicates
|
input_tags = list(map(lambda it: strip_whitespaces(it), input_tags))
|
||||||
input_tags = helper.uniq(input_tags)
|
# Remove duplicates
|
||||||
return modify_database_object(input_tags, book.tags, db.Tags, calibre_db.session, 'tags')
|
input_tags = helper.uniq(input_tags)
|
||||||
|
return modify_database_object(input_tags, book.tags, db.Tags, calibre_db.session, 'tags')
|
||||||
|
return False
|
||||||
|
|
||||||
def edit_book_series(series, book):
|
def edit_book_series(series, book):
|
||||||
input_series = [series.strip()]
|
if series is not None:
|
||||||
input_series = [x for x in input_series if x != '']
|
input_series = [strip_whitespaces(series)]
|
||||||
return modify_database_object(input_series, book.series, db.Series, calibre_db.session, 'series')
|
input_series = [x for x in input_series if x != '']
|
||||||
|
return modify_database_object(input_series, book.series, db.Series, calibre_db.session, 'series')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def edit_book_series_index(series_index, book):
|
def edit_book_series_index(series_index, book):
|
||||||
# Add default series_index to book
|
if series_index:
|
||||||
modify_date = False
|
# Add default series_index to book
|
||||||
series_index = series_index or '1'
|
modify_date = False
|
||||||
if not series_index.replace('.', '', 1).isdigit():
|
series_index = series_index or '1'
|
||||||
flash(_("%(seriesindex)s is not a valid number, skipping", seriesindex=series_index), category="warning")
|
if not series_index.replace('.', '', 1).isdigit():
|
||||||
return False
|
flash(_("Seriesindex: %(seriesindex)s is not a valid number, skipping", seriesindex=series_index), category="warning")
|
||||||
if str(book.series_index) != series_index:
|
return False
|
||||||
book.series_index = series_index
|
if str(book.series_index) != series_index:
|
||||||
modify_date = True
|
book.series_index = series_index
|
||||||
return modify_date
|
modify_date = True
|
||||||
|
return modify_date
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Handle book comments/description
|
# Handle book comments/description
|
||||||
def edit_book_comments(comments, book):
|
def edit_book_comments(comments, book):
|
||||||
modify_date = False
|
if comments is not None:
|
||||||
if comments:
|
modify_date = False
|
||||||
comments = clean_string(comments, book.id)
|
|
||||||
if len(book.comments):
|
|
||||||
if book.comments[0].text != comments:
|
|
||||||
book.comments[0].text = comments
|
|
||||||
modify_date = True
|
|
||||||
else:
|
|
||||||
if comments:
|
if comments:
|
||||||
book.comments.append(db.Comments(comment=comments, book=book.id))
|
comments = clean_string(comments, book.id)
|
||||||
modify_date = True
|
if len(book.comments):
|
||||||
return modify_date
|
if book.comments[0].text != comments:
|
||||||
|
book.comments[0].text = comments
|
||||||
|
modify_date = True
|
||||||
|
else:
|
||||||
|
if comments:
|
||||||
|
book.comments.append(db.Comments(comment=comments, book=book.id))
|
||||||
|
modify_date = True
|
||||||
|
return modify_date
|
||||||
|
|
||||||
|
|
||||||
def edit_book_languages(languages, book, upload_mode=False, invalid=None):
|
def edit_book_languages(languages, book, upload_mode=False, invalid=None):
|
||||||
input_languages = languages.split(',')
|
if languages is not None:
|
||||||
unknown_languages = []
|
input_languages = languages.split(',')
|
||||||
if not upload_mode:
|
unknown_languages = []
|
||||||
input_l = isoLanguages.get_language_codes(get_locale(), input_languages, unknown_languages)
|
if not upload_mode:
|
||||||
else:
|
input_l = isoLanguages.get_language_code_from_name(get_locale(), input_languages, unknown_languages)
|
||||||
input_l = isoLanguages.get_valid_language_codes(get_locale(), input_languages, unknown_languages)
|
|
||||||
for lang in unknown_languages:
|
|
||||||
log.error("'%s' is not a valid language", lang)
|
|
||||||
if isinstance(invalid, list):
|
|
||||||
invalid.append(lang)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(_("'%(langname)s' is not a valid language", langname=lang))
|
input_l = isoLanguages.get_valid_language_codes_from_code(get_locale(), input_languages, unknown_languages)
|
||||||
# ToDo: Not working correct
|
for lang in unknown_languages:
|
||||||
if upload_mode and len(input_l) == 1:
|
log.error("'%s' is not a valid language", lang)
|
||||||
# If the language of the file is excluded from the users view, it's not imported, to allow the user to view
|
if isinstance(invalid, list):
|
||||||
# the book it's language is set to the filter language
|
invalid.append(lang)
|
||||||
if input_l[0] != current_user.filter_language() and current_user.filter_language() != "all":
|
else:
|
||||||
input_l[0] = calibre_db.session.query(db.Languages). \
|
raise ValueError(_("'%(langname)s' is not a valid language", langname=lang))
|
||||||
filter(db.Languages.lang_code == current_user.filter_language()).first().lang_code
|
# ToDo: Not working correct
|
||||||
# Remove duplicates
|
if upload_mode and len(input_l) == 1:
|
||||||
input_l = helper.uniq(input_l)
|
# If the language of the file is excluded from the users view, it's not imported, to allow the user to view
|
||||||
return modify_database_object(input_l, book.languages, db.Languages, calibre_db.session, 'languages')
|
# the book it's language is set to the filter language
|
||||||
|
if input_l[0] != current_user.filter_language() and current_user.filter_language() != "all":
|
||||||
|
input_l[0] = calibre_db.session.query(db.Languages). \
|
||||||
|
filter(db.Languages.lang_code == current_user.filter_language()).first().lang_code
|
||||||
|
# Remove duplicates from normalized langcodes
|
||||||
|
input_l = helper.uniq(input_l)
|
||||||
|
return modify_database_object(input_l, book.languages, db.Languages, calibre_db.session, 'languages')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def edit_book_publisher(publishers, book):
|
def edit_book_publisher(publishers, book):
|
||||||
changed = False
|
if publishers is not None:
|
||||||
if publishers:
|
changed = False
|
||||||
publisher = publishers.rstrip().strip()
|
if publishers:
|
||||||
if len(book.publishers) == 0 or (len(book.publishers) > 0 and publisher != book.publishers[0].name):
|
publisher = strip_whitespaces(publishers)
|
||||||
changed |= modify_database_object([publisher], book.publishers, db.Publishers, calibre_db.session,
|
if len(book.publishers) == 0 or (len(book.publishers) > 0 and publisher != book.publishers[0].name):
|
||||||
'publisher')
|
changed |= modify_database_object([publisher], book.publishers, db.Publishers, calibre_db.session,
|
||||||
elif len(book.publishers):
|
'publisher')
|
||||||
changed |= modify_database_object([], book.publishers, db.Publishers, calibre_db.session, 'publisher')
|
elif len(book.publishers):
|
||||||
return changed
|
changed |= modify_database_object([], book.publishers, db.Publishers, calibre_db.session, 'publisher')
|
||||||
|
return changed
|
||||||
|
return False
|
||||||
|
|
||||||
def edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string):
|
def edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string):
|
||||||
changed = False
|
changed = False
|
||||||
@@ -1109,7 +1132,7 @@ def edit_cc_data_string(book, c, to_save, cc_db_value, cc_string):
|
|||||||
changed = False
|
changed = False
|
||||||
if c.datatype == 'rating':
|
if c.datatype == 'rating':
|
||||||
to_save[cc_string] = str(int(float(to_save[cc_string]) * 2))
|
to_save[cc_string] = str(int(float(to_save[cc_string]) * 2))
|
||||||
if to_save[cc_string].strip() != cc_db_value:
|
if strip_whitespaces(to_save[cc_string]) != cc_db_value:
|
||||||
if cc_db_value is not None:
|
if cc_db_value is not None:
|
||||||
# remove old cc_val
|
# remove old cc_val
|
||||||
del_cc = getattr(book, cc_string)[0]
|
del_cc = getattr(book, cc_string)[0]
|
||||||
@@ -1119,15 +1142,15 @@ def edit_cc_data_string(book, c, to_save, cc_db_value, cc_string):
|
|||||||
changed = True
|
changed = True
|
||||||
cc_class = db.cc_classes[c.id]
|
cc_class = db.cc_classes[c.id]
|
||||||
new_cc = calibre_db.session.query(cc_class).filter(
|
new_cc = calibre_db.session.query(cc_class).filter(
|
||||||
cc_class.value == to_save[cc_string].strip()).first()
|
cc_class.value == strip_whitespaces(to_save[cc_string])).first()
|
||||||
# if no cc val is found add it
|
# if no cc val is found add it
|
||||||
if new_cc is None:
|
if new_cc is None:
|
||||||
new_cc = cc_class(value=to_save[cc_string].strip())
|
new_cc = cc_class(value=strip_whitespaces(to_save[cc_string]))
|
||||||
calibre_db.session.add(new_cc)
|
calibre_db.session.add(new_cc)
|
||||||
changed = True
|
changed = True
|
||||||
calibre_db.session.flush()
|
calibre_db.session.flush()
|
||||||
new_cc = calibre_db.session.query(cc_class).filter(
|
new_cc = calibre_db.session.query(cc_class).filter(
|
||||||
cc_class.value == to_save[cc_string].strip()).first()
|
cc_class.value == strip_whitespaces(to_save[cc_string])).first()
|
||||||
# add cc value to book
|
# add cc value to book
|
||||||
getattr(book, cc_string).append(new_cc)
|
getattr(book, cc_string).append(new_cc)
|
||||||
return changed, to_save
|
return changed, to_save
|
||||||
@@ -1150,61 +1173,66 @@ def edit_cc_data(book_id, book, to_save, cc):
|
|||||||
changed = False
|
changed = False
|
||||||
for c in cc:
|
for c in cc:
|
||||||
cc_string = "custom_column_" + str(c.id)
|
cc_string = "custom_column_" + str(c.id)
|
||||||
if not c.is_multiple:
|
if to_save.get(cc_string) is not None:
|
||||||
if len(getattr(book, cc_string)) > 0:
|
if not c.is_multiple:
|
||||||
cc_db_value = getattr(book, cc_string)[0].value
|
if len(getattr(book, cc_string)) > 0:
|
||||||
else:
|
cc_db_value = getattr(book, cc_string)[0].value
|
||||||
cc_db_value = None
|
|
||||||
if to_save[cc_string].strip():
|
|
||||||
if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]:
|
|
||||||
change, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string)
|
|
||||||
else:
|
else:
|
||||||
change, to_save = edit_cc_data_string(book, c, to_save, cc_db_value, cc_string)
|
cc_db_value = None
|
||||||
changed |= change
|
if strip_whitespaces(to_save[cc_string]):
|
||||||
|
if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]:
|
||||||
|
change, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string)
|
||||||
|
else:
|
||||||
|
change, to_save = edit_cc_data_string(book, c, to_save, cc_db_value, cc_string)
|
||||||
|
changed |= change
|
||||||
|
else:
|
||||||
|
if cc_db_value is not None:
|
||||||
|
# remove old cc_val
|
||||||
|
del_cc = getattr(book, cc_string)[0]
|
||||||
|
getattr(book, cc_string).remove(del_cc)
|
||||||
|
if not del_cc.books or len(del_cc.books) == 0:
|
||||||
|
calibre_db.session.delete(del_cc)
|
||||||
|
changed = True
|
||||||
else:
|
else:
|
||||||
if cc_db_value is not None:
|
input_tags = to_save[cc_string].split(',')
|
||||||
# remove old cc_val
|
input_tags = list(map(lambda it: strip_whitespaces(it), input_tags))
|
||||||
del_cc = getattr(book, cc_string)[0]
|
changed |= modify_database_object(input_tags,
|
||||||
getattr(book, cc_string).remove(del_cc)
|
getattr(book, cc_string),
|
||||||
if not del_cc.books or len(del_cc.books) == 0:
|
db.cc_classes[c.id],
|
||||||
calibre_db.session.delete(del_cc)
|
calibre_db.session,
|
||||||
changed = True
|
'custom')
|
||||||
else:
|
|
||||||
input_tags = to_save[cc_string].split(',')
|
|
||||||
input_tags = list(map(lambda it: it.strip(), input_tags))
|
|
||||||
changed |= modify_database_object(input_tags,
|
|
||||||
getattr(book, cc_string),
|
|
||||||
db.cc_classes[c.id],
|
|
||||||
calibre_db.session,
|
|
||||||
'custom')
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
# returns None if no file is uploaded
|
# returns False if an error occurs or no book is uploaded, in all other cases the ebook metadata to change is returned
|
||||||
# returns False if an error occurs, in all other cases the ebook metadata is returned
|
def upload_book_formats(requested_files, book, book_id, no_cover=True):
|
||||||
def upload_single_file(file_request, book, book_id):
|
|
||||||
# Check and handle Uploaded file
|
# Check and handle Uploaded file
|
||||||
requested_file = file_request.files.get('btn-upload-format', None)
|
to_save = dict()
|
||||||
|
error = False
|
||||||
allowed_extensions = config.config_upload_formats.split(',')
|
allowed_extensions = config.config_upload_formats.split(',')
|
||||||
if requested_file:
|
for requested_file in requested_files:
|
||||||
|
current_filename = requested_file.filename
|
||||||
if config.config_check_extensions and allowed_extensions != ['']:
|
if config.config_check_extensions and allowed_extensions != ['']:
|
||||||
if not validate_mime_type(requested_file, allowed_extensions):
|
if not validate_mime_type(requested_file, allowed_extensions):
|
||||||
flash(_("File type isn't allowed to be uploaded to this server"), category="error")
|
flash(_("File type isn't allowed to be uploaded to this server"), category="error")
|
||||||
return False
|
error = True
|
||||||
# check for empty request
|
continue
|
||||||
if requested_file.filename != '':
|
if current_filename != '':
|
||||||
if not current_user.role_upload():
|
if not current_user.role_upload():
|
||||||
flash(_("User has no rights to upload additional file formats"), category="error")
|
flash(_("User has no rights to upload additional file formats"), category="error")
|
||||||
return False
|
error = True
|
||||||
if '.' in requested_file.filename:
|
continue
|
||||||
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
|
if '.' in current_filename:
|
||||||
|
file_ext = current_filename.rsplit('.', 1)[-1].lower()
|
||||||
if file_ext not in allowed_extensions and '' not in allowed_extensions:
|
if file_ext not in allowed_extensions and '' not in allowed_extensions:
|
||||||
flash(_("File extension '%(ext)s' is not allowed to be uploaded to this server", ext=file_ext),
|
flash(_("File extension '%(ext)s' is not allowed to be uploaded to this server", ext=file_ext),
|
||||||
category="error")
|
category="error")
|
||||||
return False
|
error = True
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
flash(_('File to be uploaded must have an extension'), category="error")
|
flash(_('File to be uploaded must have an extension'), category="error")
|
||||||
return False
|
error = True
|
||||||
|
continue
|
||||||
|
|
||||||
file_name = book.path.rsplit('/', 1)[-1]
|
file_name = book.path.rsplit('/', 1)[-1]
|
||||||
filepath = os.path.normpath(os.path.join(config.get_book_path(), book.path))
|
filepath = os.path.normpath(os.path.join(config.get_book_path(), book.path))
|
||||||
@@ -1217,41 +1245,50 @@ def upload_single_file(file_request, book, book_id):
|
|||||||
except OSError:
|
except OSError:
|
||||||
flash(_("Failed to create path %(path)s (Permission denied).", path=filepath),
|
flash(_("Failed to create path %(path)s (Permission denied).", path=filepath),
|
||||||
category="error")
|
category="error")
|
||||||
return False
|
error = True
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
requested_file.save(saved_filename)
|
requested_file.save(saved_filename)
|
||||||
except OSError:
|
except OSError:
|
||||||
flash(_("Failed to store file %(file)s.", file=saved_filename), category="error")
|
flash(_("Failed to store file %(file)s.", file=saved_filename), category="error")
|
||||||
return False
|
error = True
|
||||||
|
continue
|
||||||
|
|
||||||
file_size = os.path.getsize(saved_filename)
|
file_size = os.path.getsize(saved_filename)
|
||||||
is_format = calibre_db.get_book_format(book_id, file_ext.upper())
|
|
||||||
|
|
||||||
# Format entry already exists, no need to update the database
|
# Format entry already exists, no need to update the database
|
||||||
if is_format:
|
if calibre_db.get_book_format(book_id, file_ext.upper()):
|
||||||
log.warning('Book format %s already existing', file_ext.upper())
|
log.warning('Book format %s already existing', file_ext.upper())
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
db_format = db.Data(book_id, file_ext.upper(), file_size, file_name)
|
db_format = db.Data(book_id, file_ext.upper(), file_size, file_name)
|
||||||
calibre_db.session.add(db_format)
|
calibre_db.session.add(db_format)
|
||||||
calibre_db.session.commit()
|
calibre_db.session.commit()
|
||||||
calibre_db.update_title_sort(config)
|
calibre_db.create_functions(config)
|
||||||
except (OperationalError, IntegrityError, StaleDataError) as e:
|
except (OperationalError, IntegrityError, StaleDataError) as e:
|
||||||
calibre_db.session.rollback()
|
calibre_db.session.rollback()
|
||||||
log.error_or_exception("Database error: {}".format(e))
|
log.error_or_exception("Database error: {}".format(e))
|
||||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e),
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e),
|
||||||
category="error")
|
category="error")
|
||||||
return False # return redirect(url_for('web.show_book', book_id=book.id))
|
error = True
|
||||||
|
continue
|
||||||
|
|
||||||
# Queue uploader info
|
# Queue uploader info
|
||||||
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))
|
||||||
upload_text = N_("File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=link)
|
upload_text = N_("File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=link)
|
||||||
WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(book.title)))
|
WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(book.title)))
|
||||||
|
meta = uploader.process(
|
||||||
return uploader.process(
|
saved_filename,
|
||||||
saved_filename, *os.path.splitext(requested_file.filename),
|
*os.path.splitext(current_filename),
|
||||||
rar_executable=config.config_rarfile_location)
|
rar_executable=config.config_rarfile_location,
|
||||||
return None
|
no_cover=no_cover)
|
||||||
|
merge_metadata(book, meta, to_save)
|
||||||
|
#if to_save.get('languages'):
|
||||||
|
# langs = []
|
||||||
|
# for lang_code in to_save['languages'].split(','):
|
||||||
|
# langs.append(isoLanguages.get_language_name(get_locale(), lang_code))
|
||||||
|
# to_save['languages'] = ",".join(langs)
|
||||||
|
return to_save, error
|
||||||
|
|
||||||
|
|
||||||
def upload_cover(cover_request, book):
|
def upload_cover(cover_request, book):
|
||||||
@@ -1274,7 +1311,7 @@ def upload_cover(cover_request, book):
|
|||||||
|
|
||||||
def handle_title_on_edit(book, book_title):
|
def handle_title_on_edit(book, book_title):
|
||||||
# handle book title
|
# handle book title
|
||||||
book_title = book_title.rstrip().strip()
|
book_title = strip_whitespaces(book_title)
|
||||||
if book.title != book_title:
|
if book.title != book_title:
|
||||||
if book_title == '':
|
if book_title == '':
|
||||||
book_title = _(u'Unknown')
|
book_title = _(u'Unknown')
|
||||||
@@ -1285,10 +1322,8 @@ def handle_title_on_edit(book, book_title):
|
|||||||
|
|
||||||
def handle_author_on_edit(book, author_name, update_stored=True):
|
def handle_author_on_edit(book, author_name, update_stored=True):
|
||||||
change = False
|
change = False
|
||||||
# handle author(s)
|
|
||||||
input_authors = prepare_authors(author_name, config.get_book_path(), config.config_use_google_drive)
|
input_authors = prepare_authors(author_name, config.get_book_path(), config.config_use_google_drive)
|
||||||
|
|
||||||
# change |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
|
|
||||||
# Search for each author if author is in database, if not, author name and sorted author name is generated new
|
# Search for each author if author is in database, if not, author name and sorted author name is generated new
|
||||||
# everything then is assembled for sorted author field in database
|
# everything then is assembled for sorted author field in database
|
||||||
sort_authors_list = list()
|
sort_authors_list = list()
|
||||||
@@ -1316,7 +1351,6 @@ def search_objects_remove(db_book_object, db_type, input_elements):
|
|||||||
if db_type == 'custom':
|
if db_type == 'custom':
|
||||||
type_elements = c_elements.value
|
type_elements = c_elements.value
|
||||||
else:
|
else:
|
||||||
# type_elements = c_elements.name
|
|
||||||
type_elements = c_elements
|
type_elements = c_elements
|
||||||
for inp_element in input_elements:
|
for inp_element in input_elements:
|
||||||
if type_elements == inp_element:
|
if type_elements == inp_element:
|
||||||
@@ -1368,11 +1402,9 @@ def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
|
|||||||
for add_element in add_elements:
|
for add_element in add_elements:
|
||||||
# check if an element with that name exists
|
# check if an element with that name exists
|
||||||
changed = True
|
changed = True
|
||||||
# db_session.query(db.Tags).filter((func.lower(db.Tags.name).ilike("GênOt"))).all()
|
db_element = db_session.query(db_object).filter((func.lower(db_filter).ilike(add_element))).all()
|
||||||
db_element = db_session.query(db_object).filter((func.lower(db_filter).ilike(add_element))).first()
|
|
||||||
# db_element = db_session.query(db_object).filter(func.lower(db_filter) == add_element.lower()).first()
|
|
||||||
# if no element is found add it
|
# if no element is found add it
|
||||||
if db_element is None:
|
if not db_element:
|
||||||
if db_type == 'author':
|
if db_type == 'author':
|
||||||
new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')))
|
new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')))
|
||||||
elif db_type == 'series':
|
elif db_type == 'series':
|
||||||
@@ -1386,12 +1418,11 @@ def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
|
|||||||
db_session.add(new_element)
|
db_session.add(new_element)
|
||||||
db_book_object.append(new_element)
|
db_book_object.append(new_element)
|
||||||
else:
|
else:
|
||||||
db_no_case = db_session.query(db_object).filter(db_filter == add_element).first()
|
if len(db_element) == 1:
|
||||||
if db_no_case:
|
db_element = create_objects_for_addition(db_element[0], add_element, db_type)
|
||||||
# check for new case of element
|
else:
|
||||||
db_element = create_objects_for_addition(db_element, add_element, db_type)
|
db_el = db_session.query(db_object).filter(db_filter == add_element).first()
|
||||||
#else:
|
db_element = db_element[0] if not db_el else db_el
|
||||||
# db_element = create_objects_for_addition(db_element, add_element, db_type)
|
|
||||||
# add element to book
|
# add element to book
|
||||||
db_book_object.append(db_element)
|
db_book_object.append(db_element)
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -28,16 +28,14 @@ log = logger.create()
|
|||||||
|
|
||||||
def do_calibre_export(book_id, book_format):
|
def do_calibre_export(book_id, book_format):
|
||||||
try:
|
try:
|
||||||
quotes = [3, 5, 7, 9]
|
quotes = [4, 6]
|
||||||
tmp_dir = get_temp_dir()
|
tmp_dir = get_temp_dir()
|
||||||
calibredb_binarypath = get_calibre_binarypath("calibredb")
|
calibredb_binarypath = get_calibre_binarypath("calibredb")
|
||||||
temp_file_name = str(uuid4())
|
temp_file_name = str(uuid4())
|
||||||
my_env = os.environ.copy()
|
my_env = os.environ.copy()
|
||||||
if config.config_calibre_split:
|
if config.config_calibre_split:
|
||||||
my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db")
|
my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db")
|
||||||
library_path = config.config_calibre_split_dir
|
library_path = config.get_book_path()
|
||||||
else:
|
|
||||||
library_path = config.config_calibre_dir
|
|
||||||
opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--with-library', library_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),
|
'--to-dir', tmp_dir, '--formats', book_format, "--template", "{}".format(temp_file_name),
|
||||||
str(book_id)]
|
str(book_id)]
|
||||||
|
|||||||
+7
-3
@@ -25,6 +25,7 @@ from . import config, logger
|
|||||||
from .helper import split_authors
|
from .helper import split_authors
|
||||||
from .epub_helper import get_content_opf, default_ns
|
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()
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ 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',
|
||||||
@@ -90,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].strip()
|
epub_metadata[s] = strip_whitespaces(tmp[0])
|
||||||
else:
|
else:
|
||||||
epub_metadata[s] = 'Unknown'
|
epub_metadata[s] = 'Unknown'
|
||||||
|
|
||||||
@@ -116,7 +117,10 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
|||||||
epub_metadata = parse_epub_series(ns, tree, epub_metadata)
|
epub_metadata = parse_epub_series(ns, tree, epub_metadata)
|
||||||
|
|
||||||
epub_zip = zipfile.ZipFile(tmp_file_path)
|
epub_zip = zipfile.ZipFile(tmp_file_path)
|
||||||
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path)
|
if not no_cover_processing:
|
||||||
|
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path)
|
||||||
|
else:
|
||||||
|
cover_file = None
|
||||||
|
|
||||||
identifiers = []
|
identifiers = []
|
||||||
for node in p.xpath('dc:identifier', namespaces=ns):
|
for node in p.xpath('dc:identifier', namespaces=ns):
|
||||||
|
|||||||
+12
-9
@@ -21,17 +21,18 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import copy
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
try:
|
|
||||||
import magic
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
from . import logger
|
from . import logger
|
||||||
|
|
||||||
log = logger.create()
|
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():
|
def get_temp_dir():
|
||||||
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
||||||
@@ -46,12 +47,15 @@ def del_temp_dir():
|
|||||||
|
|
||||||
|
|
||||||
def validate_mime_type(file_buffer, allowed_extensions):
|
def validate_mime_type(file_buffer, allowed_extensions):
|
||||||
|
if error:
|
||||||
|
log.error(error)
|
||||||
|
return False
|
||||||
mime = magic.Magic(mime=True)
|
mime = magic.Magic(mime=True)
|
||||||
allowed_mimetypes =list()
|
allowed_mimetypes = list()
|
||||||
for x in allowed_extensions:
|
for x in allowed_extensions:
|
||||||
try:
|
try:
|
||||||
allowed_mimetypes.append(mimetypes.types_map["." + x])
|
allowed_mimetypes.append(mimetypes.types_map["." + x])
|
||||||
except KeyError as e:
|
except KeyError:
|
||||||
log.error("Unkown mimetype for Extension: {}".format(x))
|
log.error("Unkown mimetype for Extension: {}".format(x))
|
||||||
tmp_mime_type = mime.from_buffer(file_buffer.read())
|
tmp_mime_type = mime.from_buffer(file_buffer.read())
|
||||||
file_buffer.seek(0)
|
file_buffer.seek(0)
|
||||||
@@ -66,6 +70,5 @@ def validate_mime_type(file_buffer, allowed_extensions):
|
|||||||
return True
|
return True
|
||||||
except:
|
except:
|
||||||
file_buffer.seek(0)
|
file_buffer.seek(0)
|
||||||
pass
|
log.error("Mimetype '{}' not found in allowed types".format(tmp_mime_type))
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|||||||
+4
-4
@@ -29,11 +29,11 @@ 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 .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:
|
||||||
@@ -102,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
|
||||||
|
|||||||
+23
-1
@@ -16,7 +16,7 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
|
||||||
@@ -27,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)
|
||||||
|
|
||||||
|
|||||||
+74
-97
@@ -25,7 +25,7 @@ import re
|
|||||||
import regex
|
import regex
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
import requests
|
import requests
|
||||||
import unidecode
|
import unidecode
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -34,7 +34,7 @@ from flask import send_from_directory, make_response, abort, url_for, Response
|
|||||||
from flask_babel import gettext as _
|
from flask_babel import 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
|
||||||
@@ -43,15 +43,16 @@ from markupsafe import escape
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import advocate
|
from . import cw_advocate
|
||||||
from advocate.exceptions import UnacceptableAddressException
|
from .cw_advocate.exceptions import UnacceptableAddressException
|
||||||
use_advocate = True
|
use_advocate = True
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
use_advocate = False
|
use_advocate = False
|
||||||
advocate = requests
|
advocate = requests
|
||||||
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
|
||||||
@@ -117,9 +118,11 @@ def convert_book_format(book_id, calibre_path, old_book_format, new_book_format,
|
|||||||
|
|
||||||
# Texts are not lazy translated as they are supposed to get send out as is
|
# 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):
|
||||||
WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None,
|
for email in ereader_mail.split(','):
|
||||||
config.get_mail_settings(), ereader_mail, N_("Test Email"),
|
email = strip_whitespaces(email)
|
||||||
_('This Email has been sent via Calibre-Web.')))
|
WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None,
|
||||||
|
config.get_mail_settings(), email, N_("Test Email"),
|
||||||
|
_('This Email has been sent via Calibre-Web.')))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@@ -225,9 +228,11 @@ def send_mail(book_id, book_format, convert, ereader_mail, calibrepath, user_id)
|
|||||||
converted_file_name = entry.name + '.' + book_format.lower()
|
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)
|
||||||
WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
|
for email in ereader_mail.split(','):
|
||||||
config.get_mail_settings(), ereader_mail,
|
email = strip_whitespaces(email)
|
||||||
email_text, _('This Email has been sent via Calibre-Web.'), book.id))
|
WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
|
||||||
|
config.get_mail_settings(), email,
|
||||||
|
email_text, _('This Email has been sent via Calibre-Web.'), book.id))
|
||||||
return
|
return
|
||||||
return _("The requested file could not be read. Maybe wrong permissions?")
|
return _("The requested file could not be read. Maybe wrong permissions?")
|
||||||
|
|
||||||
@@ -248,7 +253,7 @@ def get_valid_filename(value, replace_whitespace=True, chars=128):
|
|||||||
# pipe has to be replaced with comma
|
# 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")
|
||||||
@@ -263,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
|
||||||
|
|
||||||
|
|
||||||
@@ -323,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):
|
||||||
@@ -409,42 +414,10 @@ def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdriv
|
|||||||
file_format.name = all_new_name
|
file_format.name = all_new_name
|
||||||
|
|
||||||
|
|
||||||
'''def rename_all_authors(first_author, renamed_author, calibre_path="", localbook=None, gdrive=False):
|
|
||||||
# Create new_author_dir from parameter or from database
|
|
||||||
# Create new title_dir from database and add id
|
|
||||||
if first_author:
|
|
||||||
new_authordir = get_valid_filename(first_author, chars=96)
|
|
||||||
for r in renamed_author:
|
|
||||||
new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == r).first()
|
|
||||||
old_author_dir = get_valid_filename(r, chars=96)
|
|
||||||
new_author_rename_dir = get_valid_filename(new_author.name, chars=96)
|
|
||||||
if gdrive:
|
|
||||||
g_file = gd.getFileFromEbooksFolder(None, old_author_dir)
|
|
||||||
if g_file:
|
|
||||||
gd.moveGdriveFolderRemote(g_file, new_author_rename_dir)
|
|
||||||
gd.updateDatabaseOnEdit(g_file['id'], new_author_rename_dir)
|
|
||||||
else:
|
|
||||||
if os.path.isdir(os.path.join(calibre_path, old_author_dir)):
|
|
||||||
old_author_path = os.path.join(calibre_path, old_author_dir)
|
|
||||||
new_author_path = os.path.join(calibre_path, new_author_rename_dir)
|
|
||||||
try:
|
|
||||||
shutil.move(os.path.normcase(old_author_path), os.path.normcase(new_author_path))
|
|
||||||
except OSError as ex:
|
|
||||||
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
|
|
||||||
log.debug(ex, exc_info=True)
|
|
||||||
return _("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
|
|
||||||
src=old_author_path, dest=new_author_path, error=str(ex))
|
|
||||||
else:
|
|
||||||
new_authordir = get_valid_filename(localbook.authors[0].name, chars=96)
|
|
||||||
return new_authordir'''
|
|
||||||
|
|
||||||
|
|
||||||
def rename_author_path(first_author, old_author_dir, renamed_author, calibre_path="", gdrive=False):
|
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
|
||||||
new_authordir = get_valid_filename(first_author, chars=96)
|
new_authordir = get_valid_filename(first_author, chars=96)
|
||||||
# new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == renamed_author).first()
|
|
||||||
# old_author_dir = get_valid_filename(old_author_name, chars=96)
|
|
||||||
new_author_rename_dir = get_valid_filename(renamed_author, chars=96)
|
new_author_rename_dir = get_valid_filename(renamed_author, chars=96)
|
||||||
if gdrive:
|
if gdrive:
|
||||||
g_file = gd.getFileFromEbooksFolder(None, old_author_dir)
|
g_file = gd.getFileFromEbooksFolder(None, old_author_dir)
|
||||||
@@ -455,12 +428,15 @@ def rename_author_path(first_author, old_author_dir, renamed_author, calibre_pat
|
|||||||
old_author_path = os.path.join(calibre_path, old_author_dir)
|
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)
|
||||||
try:
|
try:
|
||||||
shutil.move(old_author_path, new_author_path)
|
os.rename(old_author_path, new_author_path)
|
||||||
except OSError as ex:
|
except OSError:
|
||||||
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
|
try:
|
||||||
log.debug(ex, exc_info=True)
|
shutil.move(old_author_path, new_author_path)
|
||||||
return _("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
|
except OSError as ex:
|
||||||
src=old_author_path, dest=new_author_path, error=str(ex))
|
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
|
||||||
|
log.error_or_exception(ex)
|
||||||
|
raise Exception(_("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
|
||||||
|
src=old_author_path, dest=new_author_path, error=str(ex)))
|
||||||
return new_authordir
|
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
|
||||||
@@ -490,7 +466,6 @@ def update_dir_structure_file(book_id, calibre_path, original_filepath, new_auth
|
|||||||
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('\\', '/')
|
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) + ' - ' \
|
all_new_name = get_valid_filename(local_book.title, chars=42) + ' - ' \
|
||||||
+ get_valid_filename(new_author, chars=42)
|
+ get_valid_filename(new_author, chars=42)
|
||||||
@@ -499,8 +474,6 @@ def update_dir_structure_file(book_id, calibre_path, original_filepath, new_auth
|
|||||||
|
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
# Rename all files from old names to new names
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -512,7 +485,7 @@ def upload_new_file_gdrive(book_id, first_author, title, title_dir, original_fil
|
|||||||
title_dir + " (" + str(book_id) + ")")
|
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 False # rename_files_on_change(first_author, renamed_author, local_book=book, gdrive=True)
|
return False
|
||||||
|
|
||||||
|
|
||||||
def update_dir_structure_gdrive(book_id, first_author):
|
def update_dir_structure_gdrive(book_id, first_author):
|
||||||
@@ -545,20 +518,22 @@ def update_dir_structure_gdrive(book_id, first_author):
|
|||||||
all_new_name = get_valid_filename(book.title, chars=42) + ' - ' \
|
all_new_name = get_valid_filename(book.title, chars=42) + ' - ' \
|
||||||
+ get_valid_filename(new_authordir, chars=42)
|
+ get_valid_filename(new_authordir, chars=42)
|
||||||
rename_all_files_on_change(book, book.path, book.path, all_new_name, gdrive=True) # todo: Move filenames on gdrive
|
rename_all_files_on_change(book, book.path, book.path, all_new_name, gdrive=True) # todo: Move filenames on gdrive
|
||||||
# change location in database to new author/title path
|
|
||||||
# book.path = os.path.join(authordir, new_titledir).replace('\\', '/')
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def move_files_on_change(calibre_path, new_author_dir, 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_author_dir, new_titledir)
|
new_path = os.path.join(calibre_path, new_author_dir, new_titledir)
|
||||||
# new_name = get_valid_filename(localbook.title, chars=96) + ' - ' + new_author_dir
|
|
||||||
try:
|
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(original_filepath, os.path.join(new_path, db_filename))
|
try:
|
||||||
log.debug("Moving title: %s to %s/%s", original_filepath, new_path)
|
shutil.move(original_filepath, os.path.join(new_path, db_filename))
|
||||||
|
except OSError:
|
||||||
|
log.error("Rename title from {} to {} failed with error, trying to "
|
||||||
|
"move without metadata".format(path, new_path))
|
||||||
|
shutil.move(original_filepath, os.path.join(new_path, db_filename), copy_function=shutil.copy)
|
||||||
|
log.debug("Moving title: %s to %s", original_filepath, new_path)
|
||||||
else:
|
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):
|
||||||
@@ -684,23 +659,26 @@ 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 = []
|
||||||
# if email is not deleted
|
for email in emails.split(','):
|
||||||
if email:
|
email = strip_whitespaces(email)
|
||||||
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
|
# if email is not deleted
|
||||||
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
|
if email:
|
||||||
email):
|
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
|
||||||
log.error("Invalid Email address format")
|
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
|
||||||
raise Exception(_("Invalid Email address format"))
|
email):
|
||||||
return email
|
log.error("Invalid Email address format for {}".format(email))
|
||||||
|
raise Exception(_("Invalid Email address format"))
|
||||||
|
valid_emails.append(email)
|
||||||
|
return ",".join(valid_emails)
|
||||||
|
|
||||||
|
|
||||||
def valid_password(check_password):
|
def valid_password(check_password):
|
||||||
@@ -810,24 +788,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -849,13 +826,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
|
||||||
@@ -864,7 +841,7 @@ def save_cover_from_url(url, book_path):
|
|||||||
if cli_param.allow_localhost:
|
if cli_param.allow_localhost:
|
||||||
img = requests.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
|
img = requests.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
|
||||||
elif use_advocate:
|
elif use_advocate:
|
||||||
img = advocate.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
|
img = cw_advocate.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
|
||||||
else:
|
else:
|
||||||
log.error("python module advocate is not installed but is needed")
|
log.error("python module advocate is not installed but is needed")
|
||||||
return False, _("Python module 'advocate' is not installed but is needed for cover uploads")
|
return False, _("Python module 'advocate' is not installed but is needed for cover uploads")
|
||||||
|
|||||||
+16
-18
@@ -15,24 +15,17 @@
|
|||||||
#
|
#
|
||||||
# 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__
|
|
||||||
get = languages.get
|
|
||||||
except ImportError:
|
|
||||||
from pycountry import languages as pyc_languages
|
from pycountry import languages as pyc_languages
|
||||||
try:
|
|
||||||
import pkg_resources
|
|
||||||
__version__ = pkg_resources.get_distribution('pycountry').version + ' (PyCountry)'
|
|
||||||
del pkg_resources
|
|
||||||
except (ImportError, Exception):
|
|
||||||
__version__ = "? (PyCountry)"
|
|
||||||
|
|
||||||
def _copy_fields(l):
|
def _copy_fields(l):
|
||||||
l.part1 = getattr(l, 'alpha_2', None)
|
l.part1 = getattr(l, 'alpha_2', None)
|
||||||
@@ -46,6 +39,11 @@ except ImportError:
|
|||||||
return _copy_fields(pyc_languages.get(alpha_2=part1))
|
return _copy_fields(pyc_languages.get(alpha_2=part1))
|
||||||
if name is not None:
|
if name is not None:
|
||||||
return _copy_fields(pyc_languages.get(name=name))
|
return _copy_fields(pyc_languages.get(name=name))
|
||||||
|
except ImportError as ex:
|
||||||
|
if sys.version_info >= (3, 12):
|
||||||
|
print("Python 3.12 isn't compatible with iso-639. Please install pycountry.")
|
||||||
|
from iso639 import languages
|
||||||
|
get = languages.get
|
||||||
|
|
||||||
|
|
||||||
def get_language_names(locale):
|
def get_language_names(locale):
|
||||||
@@ -69,20 +67,20 @@ def get_language_name(locale, lang_code):
|
|||||||
return name
|
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(locale, language_names, remainder=None):
|
def get_valid_language_codes_from_code(locale, language_names, remainder=None):
|
||||||
lang = list()
|
lang = list()
|
||||||
if "" in language_names:
|
if "" in language_names:
|
||||||
language_names.remove("")
|
language_names.remove("")
|
||||||
@@ -103,6 +101,6 @@ def get_lang3(lang):
|
|||||||
ret_value = lang
|
ret_value = lang
|
||||||
else:
|
else:
|
||||||
ret_value = ""
|
ret_value = ""
|
||||||
except KeyError:
|
except (KeyError, AttributeError):
|
||||||
ret_value = lang
|
ret_value = lang
|
||||||
return ret_value
|
return ret_value
|
||||||
|
|||||||
@@ -8138,6 +8138,384 @@ LANGUAGE_NAMES = {
|
|||||||
"zul": "Zulu",
|
"zul": "Zulu",
|
||||||
"zun": "Zuni"
|
"zun": "Zuni"
|
||||||
},
|
},
|
||||||
|
"sl": {
|
||||||
|
"abk": "abhazijski",
|
||||||
|
"ace": "achinese",
|
||||||
|
"ach": "Acoli",
|
||||||
|
"ada": "Adangme",
|
||||||
|
"ady": "Adyghe",
|
||||||
|
"aar": "afarski",
|
||||||
|
"afh": "Afrihili",
|
||||||
|
"afr": "afrikanski",
|
||||||
|
"ain": "Ainu (Japan)",
|
||||||
|
"aka": "Akan",
|
||||||
|
"akk": "akadski",
|
||||||
|
"sqi": "albanščina",
|
||||||
|
"ale": "aleutski",
|
||||||
|
"amh": "amharski",
|
||||||
|
"anp": "Angika",
|
||||||
|
"ara": "arabski",
|
||||||
|
"arg": "aragonski",
|
||||||
|
"arp": "Arapaho",
|
||||||
|
"arw": "araukanski",
|
||||||
|
"hye": "armenščina",
|
||||||
|
"asm": "asamski",
|
||||||
|
"ast": "Asturian",
|
||||||
|
"ava": "avarski",
|
||||||
|
"ave": "avestijski jeziki",
|
||||||
|
"awa": "Awadhi",
|
||||||
|
"aym": "Aymara",
|
||||||
|
"aze": "azerbajdžanski",
|
||||||
|
"ban": "balijščina",
|
||||||
|
"bal": "belučijski",
|
||||||
|
"bam": "bambarski",
|
||||||
|
"bas": "Basa (Cameroon)",
|
||||||
|
"bak": "baškirski",
|
||||||
|
"eus": "baskovščina",
|
||||||
|
"bej": "Beja",
|
||||||
|
"bel": "beloruščina",
|
||||||
|
"bem": "Bemba (Zambia)",
|
||||||
|
"ben": "bengalščina",
|
||||||
|
"bit": "Berinomo",
|
||||||
|
"bho": "Bhojpuri",
|
||||||
|
"bik": "bikolščina",
|
||||||
|
"byn": "Bilin",
|
||||||
|
"bin": "Bini",
|
||||||
|
"bis": "bislama",
|
||||||
|
"zbl": "Blissymbols",
|
||||||
|
"bos": "bošnjaščina",
|
||||||
|
"bra": "Braj",
|
||||||
|
"bre": "bretonščina",
|
||||||
|
"bug": "buginščina",
|
||||||
|
"bul": "bolgarščina",
|
||||||
|
"bua": "burjatščina",
|
||||||
|
"mya": "burmanščina",
|
||||||
|
"cad": "kadajščina?",
|
||||||
|
"cat": "katalonščina",
|
||||||
|
"ceb": "cebuanščina",
|
||||||
|
"chg": "Chagatai",
|
||||||
|
"cha": "čamorščina",
|
||||||
|
"che": "čečenščina",
|
||||||
|
"chr": "čerokeščina",
|
||||||
|
"chy": "čejenščina",
|
||||||
|
"chb": "čibčevščina",
|
||||||
|
"zho": "kitajščina",
|
||||||
|
"chn": "Chinook jargon",
|
||||||
|
"chp": "čipevščina",
|
||||||
|
"cho": "Choctaw",
|
||||||
|
"cht": "Cholón",
|
||||||
|
"chk": "Chuukese",
|
||||||
|
"chv": "čuvaščina",
|
||||||
|
"cop": "koptščina",
|
||||||
|
"cor": "kornijščina",
|
||||||
|
"cos": "korzijščina",
|
||||||
|
"cre": "krijščina",
|
||||||
|
"mus": "Creek",
|
||||||
|
"hrv": "hrvaščina",
|
||||||
|
"ces": "češčina",
|
||||||
|
"dak": "Dakota",
|
||||||
|
"dan": "danski",
|
||||||
|
"dar": "Dargwa",
|
||||||
|
"del": "Delaware",
|
||||||
|
"div": "Dhivehi",
|
||||||
|
"din": "Dinka",
|
||||||
|
"doi": "Dogri (macrolanguage)",
|
||||||
|
"dgr": "Dogrib",
|
||||||
|
"dua": "Duala",
|
||||||
|
"nld": "nizozemščina",
|
||||||
|
"dse": "Dutch Sign Language",
|
||||||
|
"dyu": "Dyula",
|
||||||
|
"dzo": "dzongkha",
|
||||||
|
"efi": "Efik",
|
||||||
|
"egy": "egipčanski",
|
||||||
|
"eka": "Ekajuk",
|
||||||
|
"elx": "elamščina",
|
||||||
|
"eng": "angleščina",
|
||||||
|
"enu": "Enu",
|
||||||
|
"myv": "Erzya",
|
||||||
|
"epo": "esperanto",
|
||||||
|
"est": "estonščina",
|
||||||
|
"ewe": "evenščina",
|
||||||
|
"ewo": "Ewondo",
|
||||||
|
"fan": "Fang (Equatorial Guinea)",
|
||||||
|
"fat": "Fanti",
|
||||||
|
"fao": "ferščina",
|
||||||
|
"fij": "fidžijščina",
|
||||||
|
"fil": "Filipino",
|
||||||
|
"fin": "finščina",
|
||||||
|
"fon": "Fon",
|
||||||
|
"fra": "francoščina",
|
||||||
|
"fur": "furlanščina",
|
||||||
|
"ful": "fulščina",
|
||||||
|
"gaa": "Ga",
|
||||||
|
"glg": "Galician",
|
||||||
|
"lug": "Ganda",
|
||||||
|
"gay": "gajščina?",
|
||||||
|
"gba": "Gbaya (Central African Republic)",
|
||||||
|
"hmj": "Ge",
|
||||||
|
"gez": "etiopščina?",
|
||||||
|
"kat": "gruzinščina",
|
||||||
|
"deu": "nemški",
|
||||||
|
"gil": "gilbertščina",
|
||||||
|
"gon": "Gondi",
|
||||||
|
"gor": "Gorontalo",
|
||||||
|
"got": "gotščina",
|
||||||
|
"grb": "Grebo",
|
||||||
|
"grn": "gvaranijščina",
|
||||||
|
"guj": "gudžaratščina",
|
||||||
|
"gwi": "Gwichʼin",
|
||||||
|
"hai": "haidščina",
|
||||||
|
"hau": "havščina",
|
||||||
|
"haw": "havajščina",
|
||||||
|
"heb": "hebrejščina",
|
||||||
|
"her": "Herero",
|
||||||
|
"hil": "hilingajnonščina",
|
||||||
|
"hin": "hindijščina",
|
||||||
|
"hmo": "hiri motu",
|
||||||
|
"hit": "hetitščina",
|
||||||
|
"hmn": "hmonščina; miaojščina",
|
||||||
|
"hun": "madžarščina",
|
||||||
|
"hup": "hupščina",
|
||||||
|
"iba": "ibanščina",
|
||||||
|
"isl": "islandščina",
|
||||||
|
"ido": "Ido",
|
||||||
|
"ibo": "Igbo",
|
||||||
|
"ilo": "Iloko",
|
||||||
|
"ind": "indonezijščina",
|
||||||
|
"inh": "inguščina",
|
||||||
|
"ina": "interlingva",
|
||||||
|
"ile": "Interlingue",
|
||||||
|
"iku": "inuktituščina",
|
||||||
|
"ipk": "Inupiaq",
|
||||||
|
"gle": "irščina",
|
||||||
|
"ita": "italijanščina",
|
||||||
|
"jpn": "japonščina",
|
||||||
|
"jav": "javanščina",
|
||||||
|
"jrb": "Judeo-Arabic",
|
||||||
|
"jpr": "Judeo-Persian",
|
||||||
|
"kbd": "kabardinščina",
|
||||||
|
"kab": "Kabyle",
|
||||||
|
"kac": "Kachin",
|
||||||
|
"kal": "Kalaallisut",
|
||||||
|
"xal": "Kalmyk",
|
||||||
|
"kam": "Kamba (Kenya)",
|
||||||
|
"kan": "kanareščina",
|
||||||
|
"kau": "Kanuri",
|
||||||
|
"kaa": "Kara-Kalpak",
|
||||||
|
"krc": "Karachay-Balkar",
|
||||||
|
"krl": "Karelian",
|
||||||
|
"kas": "kašmirščina",
|
||||||
|
"csb": "Kashubian",
|
||||||
|
"kaw": "kavi",
|
||||||
|
"kaz": "kazaščina",
|
||||||
|
"kha": "Khasi",
|
||||||
|
"kho": "Khotanese",
|
||||||
|
"kik": "kikujščina",
|
||||||
|
"kmb": "Kimbundu",
|
||||||
|
"kin": "Kinyarwanda",
|
||||||
|
"kir": "kirgiščina",
|
||||||
|
"tlh": "Klingon",
|
||||||
|
"kom": "komijščina",
|
||||||
|
"kon": "Kongo",
|
||||||
|
"kok": "Konkani (macrolanguage)",
|
||||||
|
"kor": "korejščina",
|
||||||
|
"kos": "Kosraean",
|
||||||
|
"kpe": "Kpelle",
|
||||||
|
"kua": "Kuanyama",
|
||||||
|
"kum": "kumiščina",
|
||||||
|
"kur": "kurdščina",
|
||||||
|
"kru": "Kurukh",
|
||||||
|
"kut": "kutenajščina",
|
||||||
|
"lad": "ladinščina",
|
||||||
|
"lah": "Lahnda",
|
||||||
|
"lam": "Lamba",
|
||||||
|
"lao": "laoščina",
|
||||||
|
"lat": "latinščina",
|
||||||
|
"lav": "latvijščina",
|
||||||
|
"lez": "lezginščina",
|
||||||
|
"lim": "Limburgan",
|
||||||
|
"lin": "lingala",
|
||||||
|
"lit": "litvanščina",
|
||||||
|
"jbo": "Lojban",
|
||||||
|
"loz": "Lozi",
|
||||||
|
"lub": "Luba-Katanga",
|
||||||
|
"lua": "lubalulujščina",
|
||||||
|
"lui": "Luiseno",
|
||||||
|
"smj": "Lule Sami",
|
||||||
|
"lun": "Lunda",
|
||||||
|
"luo": "Luo (Kenya and Tanzania)",
|
||||||
|
"lus": "Lushai",
|
||||||
|
"ltz": "Luxembourgish",
|
||||||
|
"mkd": "makedonščina",
|
||||||
|
"mad": "madurščina",
|
||||||
|
"mag": "Magahi",
|
||||||
|
"mai": "Maithili",
|
||||||
|
"mak": "makasarščina",
|
||||||
|
"mlg": "malgaščina",
|
||||||
|
"msa": "Malay (macrolanguage)",
|
||||||
|
"mal": "malajalščina",
|
||||||
|
"mlt": "malteščina",
|
||||||
|
"mnc": "Manchu",
|
||||||
|
"mdr": "Mandar",
|
||||||
|
"man": "Mandingo",
|
||||||
|
"mni": "manipurščina",
|
||||||
|
"glv": "manska gelščina",
|
||||||
|
"mri": "maorščina",
|
||||||
|
"arn": "Mapudungun",
|
||||||
|
"mar": "maratščina",
|
||||||
|
"chm": "Mari (Russia)",
|
||||||
|
"mah": "Marshallese",
|
||||||
|
"mwr": "Marwari",
|
||||||
|
"mas": "masajščina",
|
||||||
|
"men": "Mende (Sierra Leone)",
|
||||||
|
"mic": "Mi'kmaq",
|
||||||
|
"min": "Minangkabau",
|
||||||
|
"mwl": "Mirandese",
|
||||||
|
"moh": "mohoščina",
|
||||||
|
"mdf": "Moksha",
|
||||||
|
"lol": "Mongo",
|
||||||
|
"mon": "mongolščina",
|
||||||
|
"mos": "mosanščina",
|
||||||
|
"mul": "Več jezikov",
|
||||||
|
"nqo": "N'Ko",
|
||||||
|
"nau": "Nauru",
|
||||||
|
"nav": "navaščina",
|
||||||
|
"ndo": "Ndonga",
|
||||||
|
"nap": "napolitanščina",
|
||||||
|
"nia": "niaščina",
|
||||||
|
"niu": "niuejščina",
|
||||||
|
"zxx": "No linguistic content",
|
||||||
|
"nog": "Nogai",
|
||||||
|
"nor": "norveščina",
|
||||||
|
"nob": "Norwegian Bokmål",
|
||||||
|
"nno": "norveščina; nynorsk",
|
||||||
|
"nym": "Nyamwezi",
|
||||||
|
"nya": "Nyanja",
|
||||||
|
"nyn": "Nyankole",
|
||||||
|
"nyo": "Nyoro",
|
||||||
|
"nzi": "Nzima",
|
||||||
|
"oci": "Occitan (post 1500)",
|
||||||
|
"oji": "Ojibwa",
|
||||||
|
"orm": "Oromo",
|
||||||
|
"osa": "Osage",
|
||||||
|
"oss": "Ossetian",
|
||||||
|
"pal": "Pahlavi",
|
||||||
|
"pau": "palavanščina",
|
||||||
|
"pli": "Pali",
|
||||||
|
"pam": "Pampanga",
|
||||||
|
"pag": "pangasinanščina",
|
||||||
|
"pan": "Panjabi",
|
||||||
|
"pap": "papiamentu",
|
||||||
|
"fas": "perzijščina",
|
||||||
|
"phn": "feničanščina",
|
||||||
|
"pon": "Pohnpeian",
|
||||||
|
"pol": "poljščina",
|
||||||
|
"por": "portugalđščina",
|
||||||
|
"pus": "paštu",
|
||||||
|
"que": "Quechua",
|
||||||
|
"raj": "radžastanščina",
|
||||||
|
"rap": "rapanujščina",
|
||||||
|
"ron": "romunščina",
|
||||||
|
"roh": "Romansh",
|
||||||
|
"rom": "romščina",
|
||||||
|
"run": "rundščina",
|
||||||
|
"rus": "ruščina",
|
||||||
|
"smo": "samoanščina",
|
||||||
|
"sad": "Sandawe",
|
||||||
|
"sag": "Sango",
|
||||||
|
"san": "sanskrt",
|
||||||
|
"sat": "santalščina",
|
||||||
|
"srd": "sardinščina",
|
||||||
|
"sas": "Sasak",
|
||||||
|
"sco": "škotščina",
|
||||||
|
"sel": "selkupščina",
|
||||||
|
"srp": "srbščina",
|
||||||
|
"srr": "Serer",
|
||||||
|
"shn": "šanščina",
|
||||||
|
"sna": "šonščina",
|
||||||
|
"scn": "sicilijanščina",
|
||||||
|
"sid": "Sidamo",
|
||||||
|
"bla": "Siksika",
|
||||||
|
"snd": "sindščina",
|
||||||
|
"sin": "Sinhala",
|
||||||
|
"den": "Slave (Athapascan)",
|
||||||
|
"slk": "slovaščina",
|
||||||
|
"slv": "slovenščina",
|
||||||
|
"sog": "Sogdian",
|
||||||
|
"som": "Somali",
|
||||||
|
"snk": "Soninke",
|
||||||
|
"spa": "španščina",
|
||||||
|
"srn": "Sranan Tongo",
|
||||||
|
"suk": "Sukuma",
|
||||||
|
"sux": "sumerščina",
|
||||||
|
"sun": "sundščina",
|
||||||
|
"sus": "susuamijščina?",
|
||||||
|
"swa": "Swahili (macrolanguage)",
|
||||||
|
"ssw": "svazijščina?",
|
||||||
|
"swe": "švedščina",
|
||||||
|
"syr": "sirščina",
|
||||||
|
"tgl": "tagaloščina",
|
||||||
|
"tah": "tahitijščina",
|
||||||
|
"tgk": "tadžiščina",
|
||||||
|
"tmh": "Tamashek",
|
||||||
|
"tam": "tamilščina",
|
||||||
|
"tat": "tatarščina",
|
||||||
|
"tel": "Telugu",
|
||||||
|
"ter": "Tereno",
|
||||||
|
"tet": "Tetum",
|
||||||
|
"tha": "tajščina",
|
||||||
|
"bod": "tibetanščina",
|
||||||
|
"tig": "Tigre",
|
||||||
|
"tir": "Tigrinya",
|
||||||
|
"tem": "Timne",
|
||||||
|
"tiv": "Tiv",
|
||||||
|
"tli": "Tlingit",
|
||||||
|
"tpi": "tok pisin",
|
||||||
|
"tkl": "Tokelau",
|
||||||
|
"tog": "Tonga (Nyasa)",
|
||||||
|
"ton": "tonganščina",
|
||||||
|
"tsi": "tsimšijščina",
|
||||||
|
"tso": "Tsonga",
|
||||||
|
"tsn": "Tswana",
|
||||||
|
"tum": "Tumbuka",
|
||||||
|
"tur": "turščina",
|
||||||
|
"tuk": "turkmenščina",
|
||||||
|
"tvl": "tuvalujščina",
|
||||||
|
"tyv": "Tuvinian",
|
||||||
|
"twi": "Twi",
|
||||||
|
"udm": "Udmurt",
|
||||||
|
"uga": "ugaritščina",
|
||||||
|
"uig": "ujgurščina",
|
||||||
|
"ukr": "ukrajinščina",
|
||||||
|
"umb": "Umbundu",
|
||||||
|
"mis": "Uncoded languages",
|
||||||
|
"und": "nedoločen",
|
||||||
|
"urd": "urdujščina",
|
||||||
|
"uzb": "uzbeščina",
|
||||||
|
"vai": "vajščina",
|
||||||
|
"ven": "Venda",
|
||||||
|
"vie": "vietnamščina",
|
||||||
|
"vol": "Volapük",
|
||||||
|
"vot": "votjaščina",
|
||||||
|
"wln": "valonščina",
|
||||||
|
"war": "Waray (Philippines)",
|
||||||
|
"was": "Washo",
|
||||||
|
"cym": "valižanščina",
|
||||||
|
"wal": "Wolaytta",
|
||||||
|
"wol": "Wolof",
|
||||||
|
"xho": "koščina",
|
||||||
|
"sah": "jakutščina",
|
||||||
|
"yao": "jaojščina",
|
||||||
|
"yap": "Yapese",
|
||||||
|
"yid": "jidiš",
|
||||||
|
"yor": "jorubščina",
|
||||||
|
"zap": "Zapotec",
|
||||||
|
"zza": "Zaza",
|
||||||
|
"zen": "Zenaga",
|
||||||
|
"zha": "Zhuang",
|
||||||
|
"zul": "zulujščina",
|
||||||
|
"zun": "Zuni"
|
||||||
|
},
|
||||||
"sv": {
|
"sv": {
|
||||||
"aar": "Afar",
|
"aar": "Afar",
|
||||||
"abk": "Abchaziska",
|
"abk": "Abchaziska",
|
||||||
|
|||||||
+19
-6
@@ -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
|
||||||
|
|
||||||
@@ -112,11 +111,15 @@ 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
|
if not value:
|
||||||
return ('{0:.' + str(decimals) + 'f}').format(value).rstrip('0').rstrip('.')
|
return value
|
||||||
|
formated_value = ('{0:.' + str(decimals) + 'f}').format(value)
|
||||||
|
if formated_value.endswith('.' + "0" * decimals):
|
||||||
|
formated_value = formated_value.rstrip('0').rstrip('.')
|
||||||
|
return formated_value
|
||||||
|
|
||||||
|
|
||||||
@jinjia.app_template_filter('formatseriesindex')
|
'''@jinjia.app_template_filter('formatseriesindex')
|
||||||
def formatseriesindex_filter(series_index):
|
def formatseriesindex_filter(series_index):
|
||||||
if series_index:
|
if series_index:
|
||||||
try:
|
try:
|
||||||
@@ -127,6 +130,7 @@ def formatseriesindex_filter(series_index):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return series_index
|
return series_index
|
||||||
return 0
|
return 0
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
@jinjia.app_template_filter('escapedlink')
|
@jinjia.app_template_filter('escapedlink')
|
||||||
@@ -180,3 +184,12 @@ def get_cover_srcset(series):
|
|||||||
url = url_for('web.get_series_cover', series_id=series.id, resolution=shortname, c=cache_timestamp())
|
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
|
||||||
|
|||||||
+27
-21
@@ -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
|
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
|
||||||
@@ -132,7 +131,7 @@ 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")
|
||||||
@@ -151,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
|
||||||
@@ -376,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,
|
||||||
@@ -424,7 +423,7 @@ def get_series(book):
|
|||||||
|
|
||||||
|
|
||||||
def get_seriesindex(book):
|
def get_seriesindex(book):
|
||||||
return book.series_index or 1
|
return book.series_index if isinstance(book.series_index, float) else 1
|
||||||
|
|
||||||
|
|
||||||
def get_language(book):
|
def get_language(book):
|
||||||
@@ -487,14 +486,16 @@ def get_metadata(book):
|
|||||||
|
|
||||||
if get_series(book):
|
if get_series(book):
|
||||||
name = get_series(book)
|
name = get_series(book)
|
||||||
metadata["Series"] = {
|
try:
|
||||||
"Name": get_series(book),
|
metadata["Series"] = {
|
||||||
"Number": get_seriesindex(book), # ToDo Check int() ?
|
"Name": get_series(book),
|
||||||
"NumberFloat": float(get_seriesindex(book)),
|
"Number": get_seriesindex(book), # ToDo Check int() ?
|
||||||
# Get a deterministic id based on the series name.
|
"NumberFloat": float(get_seriesindex(book)),
|
||||||
"Id": str(uuid.uuid3(uuid.NAMESPACE_DNS, name)),
|
# Get a deterministic id based on the series name.
|
||||||
}
|
"Id": str(uuid.uuid3(uuid.NAMESPACE_DNS, name)),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
@@ -796,7 +797,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):
|
||||||
@@ -904,7 +905,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
|
||||||
|
|||||||
+5
-3
@@ -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)\
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
# 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,
|
||||||
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +41,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
|
||||||
|
|||||||
@@ -38,14 +38,16 @@ class Amazon(Metadata):
|
|||||||
__name__ = "Amazon"
|
__name__ = "Amazon"
|
||||||
__id__ = "amazon"
|
__id__ = "amazon"
|
||||||
headers = {'upgrade-insecure-requests': '1',
|
headers = {'upgrade-insecure-requests': '1',
|
||||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36',
|
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0',
|
||||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
|
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
|
||||||
'sec-gpc': '1',
|
'Sec-Fetch-Site': 'same-origin',
|
||||||
'sec-fetch-site': 'none',
|
'Sec-Fetch-Mode': 'navigate',
|
||||||
'sec-fetch-mode': 'navigate',
|
'Sec-Fetch-User': '?1',
|
||||||
'sec-fetch-user': '?1',
|
'Sec-Fetch-Dest': 'document',
|
||||||
'sec-fetch-dest': 'document',
|
'Upgrade-Insecure-Requests': '1',
|
||||||
'accept-encoding': 'gzip, deflate, br',
|
'Alt-Used' : 'www.amazon.com',
|
||||||
|
'Priority' : 'u=0, i',
|
||||||
|
'accept-encoding': 'gzip, deflate, br, zstd',
|
||||||
'accept-language': 'en-US,en;q=0.9'}
|
'accept-language': 'en-US,en;q=0.9'}
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
session.headers=headers
|
session.headers=headers
|
||||||
@@ -53,7 +55,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:
|
||||||
@@ -61,11 +62,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-ppd_csm_instrumentation_wrapper"})
|
||||||
if soup2 is None:
|
if soup2 is None:
|
||||||
return None
|
return []
|
||||||
try:
|
try:
|
||||||
match = MetaRecord(
|
match = MetaRecord(
|
||||||
title = "",
|
title = "",
|
||||||
@@ -88,7 +89,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):
|
||||||
@@ -107,13 +108,13 @@ class Amazon(Metadata):
|
|||||||
except (AttributeError, ValueError):
|
except (AttributeError, ValueError):
|
||||||
match.rating = 0
|
match.rating = 0
|
||||||
try:
|
try:
|
||||||
match.cover = soup2.find("img", attrs={"class": "a-dynamic-image frontImage"})["src"]
|
match.cover = soup2.find("img", attrs={"class": "a-dynamic-image"})["src"]
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
match.cover = ""
|
match.cover = ""
|
||||||
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:
|
||||||
@@ -133,7 +134,7 @@ class Amazon(Metadata):
|
|||||||
links_list = [next(filter(lambda i: "digital-text" in i["href"], x.findAll("a")))["href"] for x in
|
links_list = [next(filter(lambda i: "digital-text" in i["href"], x.findAll("a")))["href"] for x in
|
||||||
soup.findAll("div", attrs={"data-component-type": "s-search-result"})]
|
soup.findAll("div", attrs={"data-component-type": "s-search-result"})]
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:5])}
|
fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:3])}
|
||||||
val = list(map(lambda x : x.result() ,concurrent.futures.as_completed(fut)))
|
val = list(map(lambda x : x.result(), concurrent.futures.as_completed(fut)))
|
||||||
result = list(filter(lambda x: x, val))
|
result = list(filter(lambda x: x, val))
|
||||||
return [x[0] for x in sorted(result, key=itemgetter(1))] #sort by amazons listing order for best relevance
|
return [x[0] for x in sorted(result, key=itemgetter(1))] #sort by amazons listing order for best relevance
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -137,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("_", " ")
|
||||||
|
|||||||
+4
-3
@@ -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
|
||||||
|
|
||||||
@@ -340,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'])
|
||||||
|
|
||||||
@@ -364,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'])
|
||||||
|
|||||||
+29
-31
@@ -25,14 +25,15 @@ 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, g
|
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, constants
|
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,7 +95,7 @@ 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 current_user.check_visibility(constants.SIDEBAR_RECENT):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_RECENT):
|
||||||
abort(404)
|
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,
|
||||||
@@ -106,7 +107,7 @@ 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 current_user.check_visibility(constants.SIDEBAR_RANDOM):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_RANDOM):
|
||||||
abort(404)
|
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)
|
||||||
@@ -117,7 +118,7 @@ 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 current_user.check_visibility(constants.SIDEBAR_BEST_RATED):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_BEST_RATED):
|
||||||
abort(404)
|
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,
|
||||||
@@ -130,7 +131,7 @@ 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 current_user.check_visibility(constants.SIDEBAR_HOT):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_HOT):
|
||||||
abort(404)
|
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(
|
||||||
@@ -154,7 +155,7 @@ 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 current_user.check_visibility(constants.SIDEBAR_AUTHOR):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_AUTHOR):
|
||||||
abort(404)
|
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')
|
||||||
|
|
||||||
@@ -162,7 +163,7 @@ def feed_authorindex():
|
|||||||
@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 current_user.check_visibility(constants.SIDEBAR_AUTHOR):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_AUTHOR):
|
||||||
abort(404)
|
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)
|
||||||
@@ -185,7 +186,7 @@ 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 current_user.check_visibility(constants.SIDEBAR_PUBLISHER):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_PUBLISHER):
|
||||||
abort(404)
|
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)\
|
||||||
@@ -208,7 +209,7 @@ 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 current_user.check_visibility(constants.SIDEBAR_CATEGORY):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY):
|
||||||
abort(404)
|
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')
|
||||||
|
|
||||||
@@ -216,7 +217,7 @@ def feed_categoryindex():
|
|||||||
@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 current_user.check_visibility(constants.SIDEBAR_CATEGORY):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY):
|
||||||
abort(404)
|
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)
|
||||||
@@ -241,7 +242,7 @@ 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 current_user.check_visibility(constants.SIDEBAR_SERIES):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_SERIES):
|
||||||
abort(404)
|
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')
|
||||||
|
|
||||||
@@ -249,7 +250,7 @@ def feed_seriesindex():
|
|||||||
@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 current_user.check_visibility(constants.SIDEBAR_SERIES):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_SERIES):
|
||||||
abort(404)
|
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)
|
||||||
@@ -280,7 +281,7 @@ 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 current_user.check_visibility(constants.SIDEBAR_RATING):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_RATING):
|
||||||
abort(404)
|
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'),
|
||||||
@@ -308,7 +309,7 @@ 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 current_user.check_visibility(constants.SIDEBAR_FORMAT):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_FORMAT):
|
||||||
abort(404)
|
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)\
|
||||||
@@ -339,14 +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 current_user.check_visibility(constants.SIDEBAR_LANGUAGE):
|
if not auth.current_user().check_visibility(constants.SIDEBAR_LANGUAGE):
|
||||||
abort(404)
|
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))
|
||||||
@@ -368,11 +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 (current_user.is_authenticated or g.allow_anonymous):
|
if not (auth.current_user().is_authenticated or g.allow_anonymous):
|
||||||
abort(404)
|
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)
|
||||||
@@ -382,14 +383,14 @@ 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 (current_user.is_authenticated or g.allow_anonymous):
|
if not (auth.current_user().is_authenticated or g.allow_anonymous):
|
||||||
abort(404)
|
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()
|
||||||
@@ -422,12 +423,9 @@ 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(401)
|
||||||
if "Kobo" in request.headers.get('User-Agent'):
|
client = "kobo" if "Kobo" in request.headers.get('User-Agent') else ""
|
||||||
client = "kobo"
|
|
||||||
else:
|
|
||||||
client = ""
|
|
||||||
return get_download_link(book_id, book_format.lower(), client)
|
return get_download_link(book_id, book_format.lower(), client)
|
||||||
|
|
||||||
|
|
||||||
@@ -468,7 +466,7 @@ 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 (current_user.check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not current_user.is_anonymous):
|
if not (auth.current_user().check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not auth.current_user().is_anonymous):
|
||||||
return abort(403)
|
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)
|
||||||
@@ -478,7 +476,7 @@ 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 (current_user.check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not current_user.is_anonymous):
|
if not (auth.current_user().check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not auth.current_user().is_anonymous):
|
||||||
return abort(403)
|
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)
|
||||||
|
|||||||
Executable → Regular
+3
-2
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
+3
-3
@@ -21,7 +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.tempFolder import TaskDeleteTempFolder
|
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
|
||||||
@@ -33,7 +33,7 @@ def get_scheduled_tasks(reconnect=True):
|
|||||||
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
|
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
|
||||||
|
|
||||||
# Delete temp folder
|
# Delete temp folder
|
||||||
tasks.append([lambda: TaskDeleteTempFolder(), 'delete temp', True])
|
tasks.append([lambda: TaskClean(), 'delete temp', True])
|
||||||
|
|
||||||
# Generate metadata.opf file for each changed book
|
# Generate metadata.opf file for each changed book
|
||||||
if config.schedule_metadata_backup:
|
if config.schedule_metadata_backup:
|
||||||
@@ -94,7 +94,7 @@ def register_startup_tasks():
|
|||||||
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration):
|
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:
|
else:
|
||||||
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskDeleteTempFolder(), 'delete temp', True]])
|
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskClean(), 'delete temp', True]])
|
||||||
|
|
||||||
|
|
||||||
def should_task_be_running(start, duration):
|
def should_task_be_running(start, duration):
|
||||||
|
|||||||
+47
-20
@@ -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":
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
if custom_query == "":
|
||||||
db.cc_classes[c.id].value == (custom_query == "True")))
|
q = q.filter(~getattr(db.Books, 'custom_column_' + str(c.id)).
|
||||||
elif c.datatype == 'int' or c.datatype == 'float':
|
any(db.cc_classes[c.id].value >= 0))
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
else:
|
||||||
db.cc_classes[c.id].value == custom_query))
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
elif c.datatype == 'rating':
|
db.cc_classes[c.id].value == bool(custom_query == "True")))
|
||||||
|
elif custom_query != '' and custom_query is not None:
|
||||||
|
if c.datatype == 'rating':
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
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",
|
||||||
@@ -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,21 +258,21 @@ 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")
|
||||||
rating_low = term.get("ratinghigh")
|
rating_low = term.get("ratinghigh")
|
||||||
rating_high = term.get("ratinglow")
|
rating_high = term.get("ratinglow")
|
||||||
description = term.get("comment")
|
description = term.get("comments")
|
||||||
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
|
||||||
@@ -274,10 +288,23 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
|||||||
cc_present = True
|
cc_present = True
|
||||||
if column_end:
|
if column_end:
|
||||||
search_term.extend(["{} <= {}".format(c.name,
|
search_term.extend(["{} <= {}".format(c.name,
|
||||||
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
|
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
|
||||||
format='medium')
|
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
|
||||||
|
|||||||
@@ -24,16 +24,15 @@ 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))
|
|
||||||
|
|
||||||
meta = Blueprint("metadata", __name__)
|
meta = Blueprint("metadata", __name__)
|
||||||
|
|
||||||
@@ -81,7 +80,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 +94,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 +121,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()
|
||||||
@@ -130,7 +129,7 @@ def metadata_search():
|
|||||||
locale = get_locale()
|
locale = get_locale()
|
||||||
if query:
|
if query:
|
||||||
static_cover = url_for("static", filename="generic_cover.jpg")
|
static_cover = url_for("static", filename="generic_cover.jpg")
|
||||||
# start = current_milli_time()
|
# ret = cl[0].search(query, static_cover, locale)
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
meta = {
|
meta = {
|
||||||
executor.submit(c.search, query, static_cover, locale): c
|
executor.submit(c.search, query, static_cover, locale): c
|
||||||
@@ -139,5 +138,4 @@ def metadata_search():
|
|||||||
}
|
}
|
||||||
for future in concurrent.futures.as_completed(meta):
|
for future in concurrent.futures.as_completed(meta):
|
||||||
data.extend([asdict(x) for x in future.result() if x])
|
data.extend([asdict(x) for x in future.result() if x])
|
||||||
# log.info({'Time elapsed {}'.format(current_milli_time()-start)})
|
|
||||||
return Response(json.dumps(data), mimetype="application/json")
|
return Response(json.dumps(data), mimetype="application/json")
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import os
|
|||||||
import errno
|
import errno
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import asyncio
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class BackgroundScheduler:
|
|||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = super(BackgroundScheduler, cls).__new__(cls)
|
cls._instance = super(BackgroundScheduler, cls).__new__(cls)
|
||||||
cls.log = logger.create()
|
cls.log = logger.create()
|
||||||
|
logger.logging.getLogger('tzlocal').setLevel(logger.logging.WARNING)
|
||||||
cls.scheduler = BScheduler()
|
cls.scheduler = BScheduler()
|
||||||
cls.scheduler.start()
|
cls.scheduler.start()
|
||||||
|
|
||||||
|
|||||||
+47
-37
@@ -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()
|
||||||
@@ -80,7 +80,7 @@ def add_to_shelf(shelf_id, book_id):
|
|||||||
return "%s is a invalid Book Id. Could not be added to Shelf" % book_id, 400
|
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()
|
||||||
@@ -103,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:
|
||||||
@@ -139,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()
|
||||||
@@ -155,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()
|
||||||
@@ -185,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()
|
||||||
@@ -212,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):
|
||||||
@@ -229,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:
|
||||||
@@ -250,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
|
||||||
@@ -259,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):
|
||||||
@@ -271,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:
|
||||||
@@ -418,29 +418,37 @@ 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 == 'pubnew':
|
if sort_param == 'stored':
|
||||||
change_shelf_order(shelf_id, [db.Books.pubdate.desc()])
|
sort_param = current_user.get_view_property("shelf", 'stored')
|
||||||
if sort_param == 'pubold':
|
else:
|
||||||
change_shelf_order(shelf_id, [db.Books.pubdate])
|
current_user.set_view_property("shelf", 'stored', sort_param)
|
||||||
if sort_param == 'abc':
|
if sort_param == 'pubnew':
|
||||||
change_shelf_order(shelf_id, [db.Books.sort])
|
change_shelf_order(shelf_id, [db.Books.pubdate.desc()])
|
||||||
if sort_param == 'zyx':
|
if sort_param == 'pubold':
|
||||||
change_shelf_order(shelf_id, [db.Books.sort.desc()])
|
change_shelf_order(shelf_id, [db.Books.pubdate])
|
||||||
if sort_param == 'new':
|
if sort_param == 'shelfnew':
|
||||||
change_shelf_order(shelf_id, [db.Books.timestamp.desc()])
|
change_shelf_order(shelf_id, [ub.BookShelf.date_added.desc()])
|
||||||
if sort_param == 'old':
|
if sort_param == 'shelfold':
|
||||||
change_shelf_order(shelf_id, [db.Books.timestamp])
|
change_shelf_order(shelf_id, [ub.BookShelf.date_added])
|
||||||
if sort_param == 'authaz':
|
if sort_param == 'abc':
|
||||||
change_shelf_order(shelf_id, [db.Books.author_sort.asc(), db.Series.name, db.Books.series_index])
|
change_shelf_order(shelf_id, [db.Books.sort])
|
||||||
if sort_param == 'authza':
|
if sort_param == 'zyx':
|
||||||
change_shelf_order(shelf_id, [db.Books.author_sort.desc(),
|
change_shelf_order(shelf_id, [db.Books.sort.desc()])
|
||||||
db.Series.name.desc(),
|
if sort_param == 'new':
|
||||||
db.Books.series_index.desc()])
|
change_shelf_order(shelf_id, [db.Books.timestamp.desc()])
|
||||||
|
if sort_param == 'old':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.timestamp])
|
||||||
|
if sort_param == 'authaz':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.author_sort.asc(), db.Series.name, db.Books.series_index])
|
||||||
|
if sort_param == 'authza':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.author_sort.desc(),
|
||||||
|
db.Series.name.desc(),
|
||||||
|
db.Books.series_index.desc()])
|
||||||
page = "shelf.html"
|
page = "shelf.html"
|
||||||
pagesize = 0
|
pagesize = 0
|
||||||
else:
|
else:
|
||||||
@@ -453,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()
|
||||||
@@ -472,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"))
|
||||||
|
|||||||
@@ -3268,6 +3268,10 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] > div.
|
|||||||
left: auto !important
|
left: auto !important
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ul.dropdown-menu.offscreen {
|
||||||
|
margin: 2px -160px 0
|
||||||
|
}
|
||||||
|
|
||||||
div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] .dropdown-menu.offscreen {
|
div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] .dropdown-menu.offscreen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 120px;
|
top: 120px;
|
||||||
@@ -4333,6 +4337,7 @@ body.advanced_search > div.container-fluid > div.row-fluid > div.col-sm-10 > div
|
|||||||
|
|
||||||
.navbar-right > li > ul.dropdown-menu.offscreen {
|
.navbar-right > li > ul.dropdown-menu.offscreen {
|
||||||
right: -10px
|
right: -10px
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login .plexBack, body.login > div.container-fluid > div.row-fluid > div.col-sm-2, body.login > div.navbar.navbar-default.navbar-static-top > div > form {
|
.login .plexBack, body.login > div.container-fluid > div.row-fluid > div.col-sm-2, body.login > div.navbar.navbar-default.navbar-static-top > div > form {
|
||||||
@@ -7951,3 +7956,5 @@ div.comments[data-readmore] {
|
|||||||
transition: height 300ms;
|
transition: height 300ms;
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu > .offscreen
|
||||||
|
|||||||
@@ -15,5 +15,5 @@
|
|||||||
|
|
||||||
.blackTheme {
|
.blackTheme {
|
||||||
background: #000;
|
background: #000;
|
||||||
color: #fff
|
color: #fff;
|
||||||
}
|
}
|
||||||
Vendored
+15
@@ -1894,6 +1894,8 @@
|
|||||||
width:100%;
|
width:100%;
|
||||||
height:100%;
|
height:100%;
|
||||||
margin:0;
|
margin:0;
|
||||||
|
top:0;
|
||||||
|
left:0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor) > .resizers{
|
.annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor) > .resizers{
|
||||||
@@ -2646,6 +2648,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pdfViewer.copyAll{
|
||||||
|
cursor:wait;
|
||||||
|
}
|
||||||
|
|
||||||
.pdfViewer .canvasWrapper{
|
.pdfViewer .canvasWrapper{
|
||||||
overflow:hidden;
|
overflow:hidden;
|
||||||
width:100%;
|
width:100%;
|
||||||
@@ -3010,6 +3016,15 @@ body{
|
|||||||
scrollbar-color:var(--scrollbar-color) var(--scrollbar-bg-color);
|
scrollbar-color:var(--scrollbar-color) var(--scrollbar-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.wait::before{
|
||||||
|
content:"";
|
||||||
|
position:fixed;
|
||||||
|
width:100%;
|
||||||
|
height:100%;
|
||||||
|
z-index:100000;
|
||||||
|
cursor:wait;
|
||||||
|
}
|
||||||
|
|
||||||
.hidden,
|
.hidden,
|
||||||
[hidden]{
|
[hidden]{
|
||||||
display:none !important;
|
display:none !important;
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#panels a {
|
#panels a {
|
||||||
visibility: hidden;
|
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -551,7 +550,7 @@ input:-moz-placeholder { color: #454545; }
|
|||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
height: 320px;
|
/* height: 320px; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content > div {
|
.md-content > div {
|
||||||
@@ -826,3 +825,10 @@ input:-moz-placeholder { color: #454545; }
|
|||||||
.icon-columns::before { content: '\e810'; } /* '' */
|
.icon-columns::before { content: '\e810'; } /* '' */
|
||||||
.icon-list::before { content: '\e800'; } /* '' */
|
.icon-list::before { content: '\e800'; } /* '' */
|
||||||
.icon-resize-small::before { content: '\e808'; } /* '' */
|
.icon-resize-small::before { content: '\e808'; } /* '' */
|
||||||
|
|
||||||
|
#progress{
|
||||||
|
right: 4rem;
|
||||||
|
bottom: 4rem;
|
||||||
|
width: fit-content;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,3 +27,21 @@
|
|||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-family: Open Sans;
|
||||||
|
padding-right: 10px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item~button {
|
||||||
|
display: inline-block;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 5%;
|
||||||
|
margin-right: 1%;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
Executable → Regular
@@ -3,9 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
/* global Bloodhound, language, Modernizr, tinymce, getPath */
|
/* global Bloodhound, language, Modernizr, tinymce, getPath */
|
||||||
|
|
||||||
if ($("#description").length) {
|
if ($("#comments").length && typeof tinymce !== "undefined") {
|
||||||
tinymce.init({
|
tinymce.init({
|
||||||
selector: "#description",
|
selector: "#comments",
|
||||||
plugins: 'code',
|
plugins: 'code',
|
||||||
branding: false,
|
branding: false,
|
||||||
menubar: "edit view format",
|
menubar: "edit view format",
|
||||||
@@ -93,7 +93,7 @@ var authors = new Bloodhound({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".form-group #bookAuthor").typeahead(
|
$(".form-group #authors").typeahead(
|
||||||
{
|
{
|
||||||
highlight: true,
|
highlight: true,
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
@@ -243,13 +243,13 @@ $("#search").on("change input.typeahead:selected", function(event) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#btn-upload-format").on("change", function () {
|
/*$("#btn-upload-format").on("change", function () {
|
||||||
var filename = $(this).val();
|
var filename = $(this).val();
|
||||||
if (filename.substring(3, 11) === "fakepath") {
|
if (filename.substring(3, 11) === "fakepath") {
|
||||||
filename = filename.substring(12);
|
filename = filename.substring(12);
|
||||||
} // Remove c:\fake at beginning from localhost chrome
|
} // Remove c:\fake at beginning from localhost chrome
|
||||||
$("#upload-format").text(filename);
|
$("#upload-format").text(filename);
|
||||||
});
|
});*/
|
||||||
|
|
||||||
$("#btn-upload-cover").on("change", function () {
|
$("#btn-upload-cover").on("change", function () {
|
||||||
var filename = $(this).val();
|
var filename = $(this).val();
|
||||||
@@ -261,8 +261,8 @@ $("#btn-upload-cover").on("change", function () {
|
|||||||
|
|
||||||
$("#xchange").click(function () {
|
$("#xchange").click(function () {
|
||||||
this.blur();
|
this.blur();
|
||||||
var title = $("#book_title").val();
|
var title = $("#title").val();
|
||||||
$("#book_title").val($("#bookAuthor").val());
|
$("#title").val($("#authors").val());
|
||||||
$("#bookAuthor").val(title);
|
$("#authors").val(title);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ $(function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function populateForm (book) {
|
function populateForm (book) {
|
||||||
tinymce.get("description").setContent(book.description);
|
tinymce.get("comments").setContent(book.description);
|
||||||
var uniqueTags = getUniqueValues('tags', book)
|
var uniqueTags = getUniqueValues('tags', book)
|
||||||
var uniqueLanguages = getUniqueValues('languages', book)
|
var uniqueLanguages = getUniqueValues('languages', book)
|
||||||
var ampSeparatedAuthors = (book.authors || []).join(" & ");
|
var ampSeparatedAuthors = (book.authors || []).join(" & ");
|
||||||
$("#bookAuthor").val(ampSeparatedAuthors);
|
$("#authors").val(ampSeparatedAuthors);
|
||||||
$("#book_title").val(book.title);
|
$("#title").val(book.title);
|
||||||
$("#tags").val(uniqueTags.join(", "));
|
$("#tags").val(uniqueTags.join(", "));
|
||||||
$("#languages").val(uniqueLanguages.join(", "));
|
$("#languages").val(uniqueLanguages.join(", "));
|
||||||
$("#rating").data("rating").setValue(Math.round(book.rating));
|
$("#rating").data("rating").setValue(Math.round(book.rating));
|
||||||
@@ -172,7 +172,7 @@ $(function () {
|
|||||||
|
|
||||||
$("#get_meta").click(function () {
|
$("#get_meta").click(function () {
|
||||||
populate_provider();
|
populate_provider();
|
||||||
var bookTitle = $("#book_title").val();
|
var bookTitle = $("#title").val();
|
||||||
$("#keyword").val(bookTitle);
|
$("#keyword").val(bookTitle);
|
||||||
keyword = bookTitle;
|
keyword = bookTitle;
|
||||||
doSearch(bookTitle);
|
doSearch(bookTitle);
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
!function(a){a.fn.datepicker.dates.sl={days:["Nedelja","Ponedeljek","Torek","Sreda","Četrtek","Petek","Sobota"],daysShort:["Ned","Pon","Tor","Sre","Čet","Pet","Sob"],daysMin:["Ne","Po","To","Sr","Če","Pe","So"],months:["Januar","Februar","Marec","April","Maj","Junij","Julij","Avgust","September","Oktober","November","December"],monthsShort:["Jan","Feb","Mar","Apr","Maj","Jun","Jul","Avg","Sep","Okt","Nov","Dec"],today:"Danes",weekStart:1}}(jQuery);
|
||||||
Vendored
+5653
-326
File diff suppressed because it is too large
Load Diff
Vendored
+5641
-253
File diff suppressed because one or more lines are too long
Vendored
+446
-290
File diff suppressed because it is too large
Load Diff
+44
-8
@@ -130,8 +130,13 @@ $(".container-fluid").bind('drop', function (e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (dt.files.length) {
|
if (dt.files.length) {
|
||||||
$("#btn-upload")[0].files = dt.files;
|
if($("#btn-upload-format").length) {
|
||||||
$("#form-upload").submit();
|
$("#btn-upload-format")[0].files = dt.files;
|
||||||
|
$("#form-upload-format").submit();
|
||||||
|
} else {
|
||||||
|
$("#btn-upload")[0].files = dt.files;
|
||||||
|
$("#form-upload").submit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -140,12 +145,25 @@ $("#btn-upload").change(function() {
|
|||||||
$("#form-upload").submit();
|
$("#form-upload").submit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#btn-upload-format").change(function() {
|
||||||
|
$("#form-upload-format").submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
$("#form-upload").uploadprogress({
|
$("#form-upload").uploadprogress({
|
||||||
redirect_url: getPath() + "/", //"{{ url_for('web.index')}}",
|
redirect_url: getPath() + "/",
|
||||||
uploadedMsg: $("#form-upload").data("message"), //"{{_('Upload done, processing, please wait...')}}",
|
uploadedMsg: $("#form-upload").data("message"),
|
||||||
modalTitle: $("#form-upload").data("title"), //"{{_('Uploading...')}}",
|
modalTitle: $("#form-upload").data("title"),
|
||||||
modalFooter: $("#form-upload").data("footer"), //"{{_('Close')}}",
|
modalFooter: $("#form-upload").data("footer"),
|
||||||
modalTitleFailed: $("#form-upload").data("failed") //"{{_('Error')}}"
|
modalTitleFailed: $("#form-upload").data("failed")
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#form-upload-format").uploadprogress({
|
||||||
|
redirect_url: getPath() + "/",
|
||||||
|
uploadedMsg: $("#form-upload-format").data("message"),
|
||||||
|
modalTitle: $("#form-upload-format").data("title"),
|
||||||
|
modalFooter: $("#form-upload-format").data("footer"),
|
||||||
|
modalTitleFailed: $("#form-upload-format").data("failed")
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
@@ -160,15 +178,21 @@ $(document).ready(function() {
|
|||||||
|
|
||||||
$(".session").click(function() {
|
$(".session").click(function() {
|
||||||
window.sessionStorage.setItem("back", window.location.pathname);
|
window.sessionStorage.setItem("back", window.location.pathname);
|
||||||
|
window.sessionStorage.setItem("search", window.location.search);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#back").click(function() {
|
$("#back").click(function() {
|
||||||
var loc = sessionStorage.getItem("back");
|
var loc = sessionStorage.getItem("back");
|
||||||
|
var param = sessionStorage.getItem("search");
|
||||||
if (!loc) {
|
if (!loc) {
|
||||||
loc = $(this).data("back");
|
loc = $(this).data("back");
|
||||||
}
|
}
|
||||||
sessionStorage.removeItem("back");
|
sessionStorage.removeItem("back");
|
||||||
window.location.href = loc;
|
sessionStorage.removeItem("search");
|
||||||
|
if (param === null) {
|
||||||
|
param = "";
|
||||||
|
}
|
||||||
|
window.location.href = loc + param;
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -598,6 +622,7 @@ $(function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("#toggle_order_shelf").click(function() {
|
$("#toggle_order_shelf").click(function() {
|
||||||
|
$("#toggle_order_shelf").toggleClass("dummy");
|
||||||
$("#new").toggleClass("disabled");
|
$("#new").toggleClass("disabled");
|
||||||
$("#old").toggleClass("disabled");
|
$("#old").toggleClass("disabled");
|
||||||
$("#asc").toggleClass("disabled");
|
$("#asc").toggleClass("disabled");
|
||||||
@@ -606,9 +631,20 @@ $(function() {
|
|||||||
$("#auth_za").toggleClass("disabled");
|
$("#auth_za").toggleClass("disabled");
|
||||||
$("#pub_new").toggleClass("disabled");
|
$("#pub_new").toggleClass("disabled");
|
||||||
$("#pub_old").toggleClass("disabled");
|
$("#pub_old").toggleClass("disabled");
|
||||||
|
$("#shelf_new").toggleClass("disabled");
|
||||||
|
$("#shelf_old").toggleClass("disabled");
|
||||||
var alternative_text = $("#toggle_order_shelf").data('alt-text');
|
var alternative_text = $("#toggle_order_shelf").data('alt-text');
|
||||||
|
var status = $("#toggle_order_shelf").hasClass("dummy") ? "on" : "off";
|
||||||
$("#toggle_order_shelf").data('alt-text', $("#toggle_order_shelf").html());
|
$("#toggle_order_shelf").data('alt-text', $("#toggle_order_shelf").html());
|
||||||
$("#toggle_order_shelf").html(alternative_text);
|
$("#toggle_order_shelf").html(alternative_text);
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
method:"post",
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
|
dataType: "json",
|
||||||
|
url: getPath() + "/ajax/view",
|
||||||
|
data: "{\"shelf\": {\"man\": \"" + status + "\"}}",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#btndeluser").click(function() {
|
$("#btndeluser").click(function() {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* waits until queue is finished, meaning the book is done loading
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
function qFinished(callback){
|
||||||
|
let timeout=setInterval(()=>{
|
||||||
|
if(reader.rendition.q.running===undefined)
|
||||||
|
clearInterval(timeout);
|
||||||
|
callback();
|
||||||
|
},300
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateProgress(){
|
||||||
|
let data=reader.rendition.location.end;
|
||||||
|
return Math.round(epub.locations.percentageFromCfi(data.cfi)*100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// register new event emitter locationchange that fires on urlchange
|
||||||
|
// source: https://stackoverflow.com/a/52809105/21941129
|
||||||
|
(() => {
|
||||||
|
let oldPushState = history.pushState;
|
||||||
|
history.pushState = function pushState() {
|
||||||
|
let ret = oldPushState.apply(this, arguments);
|
||||||
|
window.dispatchEvent(new Event('locationchange'));
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
|
||||||
|
let oldReplaceState = history.replaceState;
|
||||||
|
history.replaceState = function replaceState() {
|
||||||
|
let ret = oldReplaceState.apply(this, arguments);
|
||||||
|
window.dispatchEvent(new Event('locationchange'));
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
window.dispatchEvent(new Event('locationchange'));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.addEventListener('locationchange',()=>{
|
||||||
|
let newPos=calculateProgress();
|
||||||
|
progressDiv.textContent=newPos+"%";
|
||||||
|
});
|
||||||
|
|
||||||
|
var epub=ePub(calibre.bookUrl)
|
||||||
|
|
||||||
|
let progressDiv=document.getElementById("progress");
|
||||||
|
|
||||||
|
qFinished(()=>{
|
||||||
|
epub.locations.generate().then(()=> {
|
||||||
|
window.dispatchEvent(new Event('locationchange'))
|
||||||
|
});
|
||||||
|
})
|
||||||
@@ -13,10 +13,9 @@ var reader;
|
|||||||
bookmarks: calibre.bookmark ? [calibre.bookmark] : []
|
bookmarks: calibre.bookmark ? [calibre.bookmark] : []
|
||||||
});
|
});
|
||||||
|
|
||||||
reader.rendition.themes.register("lightTheme", "/static/css/epub_themes.css");
|
Object.keys(themes).forEach(function (theme) {
|
||||||
reader.rendition.themes.register("darkTheme", "/static/css/epub_themes.css");
|
reader.rendition.themes.register(theme, themes[theme].css_path);
|
||||||
reader.rendition.themes.register("sepiaTheme", "/static/css/epub_themes.css");
|
});
|
||||||
reader.rendition.themes.register("blackTheme", "/static/css/epub_themes.css");
|
|
||||||
|
|
||||||
if (calibre.useBookmarks) {
|
if (calibre.useBookmarks) {
|
||||||
reader.on("reader:bookmarked", updateBookmark.bind(reader, "add"));
|
reader.on("reader:bookmarked", updateBookmark.bind(reader, "add"));
|
||||||
@@ -78,6 +77,8 @@ var reader;
|
|||||||
alert(error);
|
alert(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default settings load
|
||||||
|
const theme = localStorage.getItem("calibre.reader.theme") ?? "lightTheme";
|
||||||
|
selectTheme(theme);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -685,6 +685,17 @@ function ratingFormatter(value, row) {
|
|||||||
return (value/2);
|
return (value/2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seriesIndexFormatter(value, row) {
|
||||||
|
if (!value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
formated_value = Number(value).toFixed(2);
|
||||||
|
if (formated_value.endsWith(".00")) {
|
||||||
|
formated_value = parseInt(formated_value).toString();
|
||||||
|
}
|
||||||
|
return formated_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Do some hiding disabling after user list is loaded */
|
/* Do some hiding disabling after user list is loaded */
|
||||||
function loadSuccess() {
|
function loadSuccess() {
|
||||||
@@ -849,6 +860,7 @@ function BookCheckboxChange(checkbox, userId, field) {
|
|||||||
},
|
},
|
||||||
success: handleListServerResponse
|
success: handleListServerResponse
|
||||||
});
|
});
|
||||||
|
console.log("test");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,12 +49,6 @@ pdfjs-download-button =
|
|||||||
# Length of the translation matters since we are in a mobile context, with limited screen estate.
|
# Length of the translation matters since we are in a mobile context, with limited screen estate.
|
||||||
pdfjs-download-button-label = Pellgargañ
|
pdfjs-download-button-label = Pellgargañ
|
||||||
pdfjs-bookmark-button-label = Pajenn a-vremañ
|
pdfjs-bookmark-button-label = Pajenn a-vremañ
|
||||||
# Used in Firefox for Android.
|
|
||||||
pdfjs-open-in-app-button =
|
|
||||||
.title = Digeriñ en arload
|
|
||||||
# Used in Firefox for Android.
|
|
||||||
# Length of the translation matters since we are in a mobile context, with limited screen estate.
|
|
||||||
pdfjs-open-in-app-button-label = Digeriñ en arload
|
|
||||||
|
|
||||||
## Secondary toolbar and context menu
|
## Secondary toolbar and context menu
|
||||||
|
|
||||||
@@ -214,6 +208,7 @@ pdfjs-find-next-button =
|
|||||||
pdfjs-find-next-button-label = War-lerc'h
|
pdfjs-find-next-button-label = War-lerc'h
|
||||||
pdfjs-find-highlight-checkbox = Usskediñ pep tra
|
pdfjs-find-highlight-checkbox = Usskediñ pep tra
|
||||||
pdfjs-find-match-case-checkbox-label = Teurel evezh ouzh ar pennlizherennoù
|
pdfjs-find-match-case-checkbox-label = Teurel evezh ouzh ar pennlizherennoù
|
||||||
|
pdfjs-find-match-diacritics-checkbox-label = Doujañ d’an tiredoù
|
||||||
pdfjs-find-entire-word-checkbox-label = Gerioù a-bezh
|
pdfjs-find-entire-word-checkbox-label = Gerioù a-bezh
|
||||||
pdfjs-find-reached-top = Tizhet eo bet derou ar bajenn, kenderc'hel diouzh an diaz
|
pdfjs-find-reached-top = Tizhet eo bet derou ar bajenn, kenderc'hel diouzh an diaz
|
||||||
pdfjs-find-reached-bottom = Tizhet eo bet dibenn ar bajenn, kenderc'hel diouzh ar c'hrec'h
|
pdfjs-find-reached-bottom = Tizhet eo bet dibenn ar bajenn, kenderc'hel diouzh ar c'hrec'h
|
||||||
@@ -311,3 +306,7 @@ pdfjs-editor-alt-text-save-button = Enrollañ
|
|||||||
|
|
||||||
## Color picker
|
## Color picker
|
||||||
|
|
||||||
|
|
||||||
|
## Show all highlights
|
||||||
|
## This is a toggle button to show/hide all the highlights.
|
||||||
|
|
||||||
|
|||||||
@@ -51,12 +51,6 @@ pdfjs-download-button-label = Sækja
|
|||||||
pdfjs-bookmark-button =
|
pdfjs-bookmark-button =
|
||||||
.title = Núverandi síða (Skoða vefslóð frá núverandi síðu)
|
.title = Núverandi síða (Skoða vefslóð frá núverandi síðu)
|
||||||
pdfjs-bookmark-button-label = Núverandi síða
|
pdfjs-bookmark-button-label = Núverandi síða
|
||||||
# Used in Firefox for Android.
|
|
||||||
pdfjs-open-in-app-button =
|
|
||||||
.title = Opna í smáforriti
|
|
||||||
# Used in Firefox for Android.
|
|
||||||
# Length of the translation matters since we are in a mobile context, with limited screen estate.
|
|
||||||
pdfjs-open-in-app-button-label = Opna í smáforriti
|
|
||||||
|
|
||||||
## Secondary toolbar and context menu
|
## Secondary toolbar and context menu
|
||||||
|
|
||||||
@@ -284,7 +278,7 @@ pdfjs-text-annotation-type =
|
|||||||
|
|
||||||
## Password
|
## Password
|
||||||
|
|
||||||
pdfjs-password-label = Sláðu inn lykilorð til að opna þessa PDF skrá.
|
pdfjs-password-label = Settu inn lykilorð til að opna þessa PDF-skrá.
|
||||||
pdfjs-password-invalid = Ógilt lykilorð. Reyndu aftur.
|
pdfjs-password-invalid = Ógilt lykilorð. Reyndu aftur.
|
||||||
pdfjs-password-ok-button = Í lagi
|
pdfjs-password-ok-button = Í lagi
|
||||||
pdfjs-password-cancel-button = Hætta við
|
pdfjs-password-cancel-button = Hætta við
|
||||||
@@ -304,8 +298,6 @@ pdfjs-editor-stamp-button-label = Bæta við eða breyta myndum
|
|||||||
pdfjs-editor-highlight-button =
|
pdfjs-editor-highlight-button =
|
||||||
.title = Áherslulita
|
.title = Áherslulita
|
||||||
pdfjs-editor-highlight-button-label = Áherslulita
|
pdfjs-editor-highlight-button-label = Áherslulita
|
||||||
pdfjs-highlight-floating-button =
|
|
||||||
.title = Áherslulita
|
|
||||||
pdfjs-highlight-floating-button1 =
|
pdfjs-highlight-floating-button1 =
|
||||||
.title = Áherslulita
|
.title = Áherslulita
|
||||||
.aria-label = Áherslulita
|
.aria-label = Áherslulita
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ pdfjs-text-annotation-type =
|
|||||||
## Password
|
## Password
|
||||||
|
|
||||||
pdfjs-password-label = この PDF ファイルを開くためのパスワードを入力してください。
|
pdfjs-password-label = この PDF ファイルを開くためのパスワードを入力してください。
|
||||||
pdfjs-password-invalid = 無効なパスワードです。もう一度やり直してください。
|
pdfjs-password-invalid = パスワードが正しくありません。もう一度試してください。
|
||||||
pdfjs-password-ok-button = OK
|
pdfjs-password-ok-button = OK
|
||||||
pdfjs-password-cancel-button = キャンセル
|
pdfjs-password-cancel-button = キャンセル
|
||||||
pdfjs-web-fonts-disabled = ウェブフォントが無効になっています: 埋め込まれた PDF のフォントを使用できません。
|
pdfjs-web-fonts-disabled = ウェブフォントが無効になっています: 埋め込まれた PDF のフォントを使用できません。
|
||||||
@@ -298,8 +298,6 @@ pdfjs-editor-stamp-button-label = 画像を追加または編集
|
|||||||
pdfjs-editor-highlight-button =
|
pdfjs-editor-highlight-button =
|
||||||
.title = 強調します
|
.title = 強調します
|
||||||
pdfjs-editor-highlight-button-label = 強調
|
pdfjs-editor-highlight-button-label = 強調
|
||||||
pdfjs-highlight-floating-button =
|
|
||||||
.title = 強調
|
|
||||||
pdfjs-highlight-floating-button1 =
|
pdfjs-highlight-floating-button1 =
|
||||||
.title = 強調
|
.title = 強調
|
||||||
.aria-label = 強調します
|
.aria-label = 強調します
|
||||||
|
|||||||
@@ -51,12 +51,6 @@ pdfjs-download-button-label = Sader
|
|||||||
pdfjs-bookmark-button =
|
pdfjs-bookmark-button =
|
||||||
.title = Asebter amiran (Sken-d tansa URL seg usebter amiran)
|
.title = Asebter amiran (Sken-d tansa URL seg usebter amiran)
|
||||||
pdfjs-bookmark-button-label = Asebter amiran
|
pdfjs-bookmark-button-label = Asebter amiran
|
||||||
# Used in Firefox for Android.
|
|
||||||
pdfjs-open-in-app-button =
|
|
||||||
.title = Ldi deg usnas
|
|
||||||
# Used in Firefox for Android.
|
|
||||||
# Length of the translation matters since we are in a mobile context, with limited screen estate.
|
|
||||||
pdfjs-open-in-app-button-label = Ldi deg usnas
|
|
||||||
|
|
||||||
## Secondary toolbar and context menu
|
## Secondary toolbar and context menu
|
||||||
|
|
||||||
@@ -301,8 +295,27 @@ pdfjs-editor-ink-button-label = Suneɣ
|
|||||||
pdfjs-editor-stamp-button =
|
pdfjs-editor-stamp-button =
|
||||||
.title = Rnu neɣ ẓreg tugniwin
|
.title = Rnu neɣ ẓreg tugniwin
|
||||||
pdfjs-editor-stamp-button-label = Rnu neɣ ẓreg tugniwin
|
pdfjs-editor-stamp-button-label = Rnu neɣ ẓreg tugniwin
|
||||||
pdfjs-editor-remove-button =
|
pdfjs-editor-highlight-button =
|
||||||
.title = Kkes
|
.title = Derrer
|
||||||
|
pdfjs-editor-highlight-button-label = Derrer
|
||||||
|
pdfjs-highlight-floating-button1 =
|
||||||
|
.title = Derrer
|
||||||
|
.aria-label = Derrer
|
||||||
|
pdfjs-highlight-floating-button-label = Derrer
|
||||||
|
|
||||||
|
## Remove button for the various kind of editor.
|
||||||
|
|
||||||
|
pdfjs-editor-remove-ink-button =
|
||||||
|
.title = Kkes asuneɣ
|
||||||
|
pdfjs-editor-remove-freetext-button =
|
||||||
|
.title = Kkes aḍris
|
||||||
|
pdfjs-editor-remove-stamp-button =
|
||||||
|
.title = Kkes tugna
|
||||||
|
pdfjs-editor-remove-highlight-button =
|
||||||
|
.title = Kkes aderrer
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
# Editor Parameters
|
# Editor Parameters
|
||||||
pdfjs-editor-free-text-color-input = Initen
|
pdfjs-editor-free-text-color-input = Initen
|
||||||
pdfjs-editor-free-text-size-input = Teɣzi
|
pdfjs-editor-free-text-size-input = Teɣzi
|
||||||
@@ -312,6 +325,8 @@ pdfjs-editor-ink-opacity-input = Tebrek
|
|||||||
pdfjs-editor-stamp-add-image-button =
|
pdfjs-editor-stamp-add-image-button =
|
||||||
.title = Rnu tawlaft
|
.title = Rnu tawlaft
|
||||||
pdfjs-editor-stamp-add-image-button-label = Rnu tawlaft
|
pdfjs-editor-stamp-add-image-button-label = Rnu tawlaft
|
||||||
|
# This refers to the thickness of the line used for free highlighting (not bound to text)
|
||||||
|
pdfjs-editor-free-highlight-thickness-input = Tuzert
|
||||||
pdfjs-free-text =
|
pdfjs-free-text =
|
||||||
.aria-label = Amaẓrag n uḍris
|
.aria-label = Amaẓrag n uḍris
|
||||||
pdfjs-free-text-default-content = Bdu tira...
|
pdfjs-free-text-default-content = Bdu tira...
|
||||||
@@ -335,3 +350,37 @@ pdfjs-editor-alt-text-decorative-tooltip = Yettwacreḍ d adlag
|
|||||||
## Editor resizers
|
## Editor resizers
|
||||||
## This is used in an aria label to help to understand the role of the resizer.
|
## This is used in an aria label to help to understand the role of the resizer.
|
||||||
|
|
||||||
|
pdfjs-editor-resizer-label-top-left = Tiɣmert n ufella n zelmeḍ — semsawi teɣzi
|
||||||
|
pdfjs-editor-resizer-label-top-middle = Talemmat n ufella — semsawi teɣzi
|
||||||
|
pdfjs-editor-resizer-label-top-right = Tiɣmert n ufella n yeffus — semsawi teɣzi
|
||||||
|
pdfjs-editor-resizer-label-middle-right = Talemmast tayeffust — semsawi teɣzi
|
||||||
|
pdfjs-editor-resizer-label-bottom-right = Tiɣmert n wadda n yeffus — semsawi teɣzi
|
||||||
|
pdfjs-editor-resizer-label-bottom-middle = Talemmat n wadda — semsawi teɣzi
|
||||||
|
pdfjs-editor-resizer-label-bottom-left = Tiɣmert n wadda n zelmeḍ — semsawi teɣzi
|
||||||
|
pdfjs-editor-resizer-label-middle-left = Talemmast tazelmdaḍt — semsawi teɣzi
|
||||||
|
|
||||||
|
## Color picker
|
||||||
|
|
||||||
|
# This means "Color used to highlight text"
|
||||||
|
pdfjs-editor-highlight-colorpicker-label = Ini n uderrer
|
||||||
|
pdfjs-editor-colorpicker-button =
|
||||||
|
.title = Senfel ini
|
||||||
|
pdfjs-editor-colorpicker-dropdown =
|
||||||
|
.aria-label = Afran n yiniten
|
||||||
|
pdfjs-editor-colorpicker-yellow =
|
||||||
|
.title = Awraɣ
|
||||||
|
pdfjs-editor-colorpicker-green =
|
||||||
|
.title = Azegzaw
|
||||||
|
pdfjs-editor-colorpicker-blue =
|
||||||
|
.title = Amidadi
|
||||||
|
pdfjs-editor-colorpicker-pink =
|
||||||
|
.title = Axuxi
|
||||||
|
pdfjs-editor-colorpicker-red =
|
||||||
|
.title = Azggaɣ
|
||||||
|
|
||||||
|
## Show all highlights
|
||||||
|
## This is a toggle button to show/hide all the highlights.
|
||||||
|
|
||||||
|
pdfjs-editor-highlight-show-all-button-label = Sken akk
|
||||||
|
pdfjs-editor-highlight-show-all-button =
|
||||||
|
.title = Sken akk
|
||||||
|
|||||||
@@ -51,12 +51,6 @@ pdfjs-download-button-label = Last ned
|
|||||||
pdfjs-bookmark-button =
|
pdfjs-bookmark-button =
|
||||||
.title = Gjeldande side (sjå URL frå gjeldande side)
|
.title = Gjeldande side (sjå URL frå gjeldande side)
|
||||||
pdfjs-bookmark-button-label = Gjeldande side
|
pdfjs-bookmark-button-label = Gjeldande side
|
||||||
# Used in Firefox for Android.
|
|
||||||
pdfjs-open-in-app-button =
|
|
||||||
.title = Opne i app
|
|
||||||
# Used in Firefox for Android.
|
|
||||||
# Length of the translation matters since we are in a mobile context, with limited screen estate.
|
|
||||||
pdfjs-open-in-app-button-label = Opne i app
|
|
||||||
|
|
||||||
## Secondary toolbar and context menu
|
## Secondary toolbar and context menu
|
||||||
|
|
||||||
@@ -301,9 +295,24 @@ pdfjs-editor-ink-button-label = Teikne
|
|||||||
pdfjs-editor-stamp-button =
|
pdfjs-editor-stamp-button =
|
||||||
.title = Legg til eller rediger bilde
|
.title = Legg til eller rediger bilde
|
||||||
pdfjs-editor-stamp-button-label = Legg til eller rediger bilde
|
pdfjs-editor-stamp-button-label = Legg til eller rediger bilde
|
||||||
|
pdfjs-editor-highlight-button =
|
||||||
|
.title = Markere
|
||||||
|
pdfjs-editor-highlight-button-label = Markere
|
||||||
|
pdfjs-highlight-floating-button1 =
|
||||||
|
.title = Markere
|
||||||
|
.aria-label = Markere
|
||||||
|
pdfjs-highlight-floating-button-label = Markere
|
||||||
|
|
||||||
## Remove button for the various kind of editor.
|
## Remove button for the various kind of editor.
|
||||||
|
|
||||||
|
pdfjs-editor-remove-ink-button =
|
||||||
|
.title = Fjern teikninga
|
||||||
|
pdfjs-editor-remove-freetext-button =
|
||||||
|
.title = Fjern tekst
|
||||||
|
pdfjs-editor-remove-stamp-button =
|
||||||
|
.title = Fjern bildet
|
||||||
|
pdfjs-editor-remove-highlight-button =
|
||||||
|
.title = Fjern utheving
|
||||||
|
|
||||||
##
|
##
|
||||||
|
|
||||||
@@ -316,6 +325,10 @@ pdfjs-editor-ink-opacity-input = Ugjennomskinleg
|
|||||||
pdfjs-editor-stamp-add-image-button =
|
pdfjs-editor-stamp-add-image-button =
|
||||||
.title = Legg til bilde
|
.title = Legg til bilde
|
||||||
pdfjs-editor-stamp-add-image-button-label = Legg til bilde
|
pdfjs-editor-stamp-add-image-button-label = Legg til bilde
|
||||||
|
# This refers to the thickness of the line used for free highlighting (not bound to text)
|
||||||
|
pdfjs-editor-free-highlight-thickness-input = Tjukkleik
|
||||||
|
pdfjs-editor-free-highlight-thickness-title =
|
||||||
|
.title = Endre tjukn når du markerer andre element enn tekst
|
||||||
pdfjs-free-text =
|
pdfjs-free-text =
|
||||||
.aria-label = Tekstredigering
|
.aria-label = Tekstredigering
|
||||||
pdfjs-free-text-default-content = Byrje å skrive…
|
pdfjs-free-text-default-content = Byrje å skrive…
|
||||||
@@ -345,9 +358,23 @@ pdfjs-editor-alt-text-textarea =
|
|||||||
## Editor resizers
|
## Editor resizers
|
||||||
## This is used in an aria label to help to understand the role of the resizer.
|
## This is used in an aria label to help to understand the role of the resizer.
|
||||||
|
|
||||||
|
pdfjs-editor-resizer-label-top-left = Øvste venstre hjørne – endre størrelse
|
||||||
|
pdfjs-editor-resizer-label-top-middle = Øvst i midten — endre størrelse
|
||||||
|
pdfjs-editor-resizer-label-top-right = Øvste høgre hjørne – endre størrelse
|
||||||
|
pdfjs-editor-resizer-label-middle-right = Midt til høgre – endre størrelse
|
||||||
|
pdfjs-editor-resizer-label-bottom-right = Nedste høgre hjørne – endre størrelse
|
||||||
|
pdfjs-editor-resizer-label-bottom-middle = Nedst i midten — endre størrelse
|
||||||
|
pdfjs-editor-resizer-label-bottom-left = Nedste venstre hjørne – endre størrelse
|
||||||
|
pdfjs-editor-resizer-label-middle-left = Midt til venstre — endre størrelse
|
||||||
|
|
||||||
## Color picker
|
## Color picker
|
||||||
|
|
||||||
|
# This means "Color used to highlight text"
|
||||||
|
pdfjs-editor-highlight-colorpicker-label = Uthevingsfarge
|
||||||
|
pdfjs-editor-colorpicker-button =
|
||||||
|
.title = Endre farge
|
||||||
|
pdfjs-editor-colorpicker-dropdown =
|
||||||
|
.aria-label = Fargeval
|
||||||
pdfjs-editor-colorpicker-yellow =
|
pdfjs-editor-colorpicker-yellow =
|
||||||
.title = Gul
|
.title = Gul
|
||||||
pdfjs-editor-colorpicker-green =
|
pdfjs-editor-colorpicker-green =
|
||||||
@@ -358,3 +385,10 @@ pdfjs-editor-colorpicker-pink =
|
|||||||
.title = Rosa
|
.title = Rosa
|
||||||
pdfjs-editor-colorpicker-red =
|
pdfjs-editor-colorpicker-red =
|
||||||
.title = Raud
|
.title = Raud
|
||||||
|
|
||||||
|
## Show all highlights
|
||||||
|
## This is a toggle button to show/hide all the highlights.
|
||||||
|
|
||||||
|
pdfjs-editor-highlight-show-all-button-label = Vis alle
|
||||||
|
pdfjs-editor-highlight-show-all-button =
|
||||||
|
.title = Vis alle
|
||||||
|
|||||||
@@ -302,6 +302,10 @@ pdfjs-editor-stamp-button-label = Dodajanje ali urejanje slik
|
|||||||
pdfjs-editor-highlight-button =
|
pdfjs-editor-highlight-button =
|
||||||
.title = Označevalnik
|
.title = Označevalnik
|
||||||
pdfjs-editor-highlight-button-label = Označevalnik
|
pdfjs-editor-highlight-button-label = Označevalnik
|
||||||
|
pdfjs-highlight-floating-button1 =
|
||||||
|
.title = Označi
|
||||||
|
.aria-label = Označi
|
||||||
|
pdfjs-highlight-floating-button-label = Označi
|
||||||
|
|
||||||
## Remove button for the various kind of editor.
|
## Remove button for the various kind of editor.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
# Copyright (C) 2019 decentral1se
|
# Copyright (C) 2024 OzzieIsaacs
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
@@ -16,28 +15,9 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License
|
# 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/>.
|
||||||
#
|
|
||||||
# """Calibre-web distribution package setuptools installer."""
|
|
||||||
|
|
||||||
from setuptools import setup
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import codecs
|
|
||||||
|
|
||||||
here = os.path.abspath(os.path.dirname(__file__))
|
|
||||||
|
|
||||||
def read(*parts):
|
def strip_whitespaces(text):
|
||||||
with codecs.open(os.path.join(here, *parts), 'r') as fp:
|
return re.sub(r"(^[\s\u200B-\u200D\ufeff]+)|([\s\u200B-\u200D\ufeff]+$)","", text)
|
||||||
return fp.read()
|
|
||||||
|
|
||||||
def find_version(*file_paths):
|
|
||||||
version_file = read(*file_paths)
|
|
||||||
version_match = re.search(r"^STABLE_VERSION\s+=\s+{['\"]version['\"]:\s*['\"](.*)['\"]}",
|
|
||||||
version_file, re.M)
|
|
||||||
if version_match:
|
|
||||||
return version_match.group(1)
|
|
||||||
raise RuntimeError("Unable to find version string.")
|
|
||||||
|
|
||||||
setup(
|
|
||||||
version=find_version("src", "calibreweb", "cps", "constants.py")
|
|
||||||
)
|
|
||||||
@@ -16,31 +16,48 @@
|
|||||||
# 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 urllib.request import urlopen
|
import datetime
|
||||||
|
|
||||||
from flask_babel import lazy_gettext as N_
|
from flask_babel import lazy_gettext as N_
|
||||||
|
from sqlalchemy.sql.expression import or_
|
||||||
|
|
||||||
from cps import logger, file_helper
|
from cps import logger, file_helper, ub
|
||||||
from cps.services.worker import CalibreTask
|
from cps.services.worker import CalibreTask
|
||||||
|
|
||||||
|
|
||||||
class TaskDeleteTempFolder(CalibreTask):
|
class TaskClean(CalibreTask):
|
||||||
def __init__(self, task_message=N_('Delete temp folder contents')):
|
def __init__(self, task_message=N_('Delete temp folder contents')):
|
||||||
super(TaskDeleteTempFolder, self).__init__(task_message)
|
super(TaskClean, self).__init__(task_message)
|
||||||
self.log = logger.create()
|
self.log = logger.create()
|
||||||
|
self.app_db_session = ub.get_new_session_instance()
|
||||||
|
|
||||||
def run(self, worker_thread):
|
def run(self, worker_thread):
|
||||||
|
# delete temp folder
|
||||||
try:
|
try:
|
||||||
file_helper.del_temp_dir()
|
file_helper.del_temp_dir()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
except (PermissionError, OSError) as e:
|
except (PermissionError, OSError) as e:
|
||||||
self.log.error("Error deleting temp folder: {}".format(e))
|
self.log.error("Error deleting temp folder: {}".format(e))
|
||||||
|
# delete expired session keys
|
||||||
|
self.log.debug("Deleted expired session_keys" )
|
||||||
|
expiry = int(datetime.datetime.now().timestamp())
|
||||||
|
try:
|
||||||
|
self.app_db_session.query(ub.User_Sessions).filter(or_(ub.User_Sessions.expiry < expiry,
|
||||||
|
ub.User_Sessions.expiry == None)).delete()
|
||||||
|
self.app_db_session.commit()
|
||||||
|
except Exception as ex:
|
||||||
|
self.log.debug('Error deleting expired session keys: ' + str(ex))
|
||||||
|
self._handleError('Error deleting expired session keys: ' + str(ex))
|
||||||
|
self.app_db_session.rollback()
|
||||||
|
return
|
||||||
|
|
||||||
self._handleSuccess()
|
self._handleSuccess()
|
||||||
|
self.app_db_session.remove()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return "Delete Temp Folder"
|
return "Clean up"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_cancellable(self):
|
def is_cancellable(self):
|
||||||
+351
-329
@@ -1,329 +1,351 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
# Copyright (C) 2020 pwr
|
# Copyright (C) 2020 pwr
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# 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 os
|
import os
|
||||||
import re
|
import re
|
||||||
from glob import glob
|
import glob
|
||||||
from shutil import copyfile, copyfileobj
|
from shutil import copyfile, copyfileobj
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
from time import time
|
from time import time
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from flask_babel import lazy_gettext as N_
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
from cps.services.worker import CalibreTask
|
from cps.services.worker import CalibreTask
|
||||||
from cps import db
|
from cps import db
|
||||||
from cps import logger, config
|
from cps import logger, config
|
||||||
from cps.subproc_wrapper import process_open
|
from cps.subproc_wrapper import process_open
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from cps.kobo_sync_status import remove_synced_book
|
from cps.kobo_sync_status import remove_synced_book
|
||||||
from cps.ub import init_db_thread
|
from cps.ub import init_db_thread
|
||||||
from cps.file_helper import get_temp_dir
|
from cps.file_helper import get_temp_dir
|
||||||
|
|
||||||
from cps.tasks.mail import TaskEmail
|
from cps.tasks.mail import TaskEmail
|
||||||
from cps import gdriveutils, helper
|
from cps import gdriveutils, helper
|
||||||
from cps.constants import SUPPORTED_CALIBRE_BINARIES
|
from cps.constants import SUPPORTED_CALIBRE_BINARIES
|
||||||
|
from cps.string_helper import strip_whitespaces
|
||||||
log = logger.create()
|
|
||||||
|
log = logger.create()
|
||||||
current_milli_time = lambda: int(round(time() * 1000))
|
|
||||||
|
current_milli_time = lambda: int(round(time() * 1000))
|
||||||
|
|
||||||
class TaskConvert(CalibreTask):
|
|
||||||
def __init__(self, file_path, book_id, task_message, settings, ereader_mail, user=None):
|
class TaskConvert(CalibreTask):
|
||||||
super(TaskConvert, self).__init__(task_message)
|
def __init__(self, file_path, book_id, task_message, settings, ereader_mail, user=None):
|
||||||
self.worker_thread = None
|
super(TaskConvert, self).__init__(task_message)
|
||||||
self.file_path = file_path
|
self.worker_thread = None
|
||||||
self.book_id = book_id
|
self.file_path = file_path
|
||||||
self.title = ""
|
self.book_id = book_id
|
||||||
self.settings = settings
|
self.title = ""
|
||||||
self.ereader_mail = ereader_mail
|
self.settings = settings
|
||||||
self.user = user
|
self.ereader_mail = ereader_mail
|
||||||
|
self.user = user
|
||||||
self.results = dict()
|
|
||||||
|
self.results = dict()
|
||||||
def run(self, worker_thread):
|
|
||||||
self.worker_thread = worker_thread
|
def run(self, worker_thread):
|
||||||
if config.config_use_google_drive:
|
self.worker_thread = worker_thread
|
||||||
worker_db = db.CalibreDB(expire_on_commit=False, init=True)
|
if config.config_use_google_drive:
|
||||||
cur_book = worker_db.get_book(self.book_id)
|
worker_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
self.title = cur_book.title
|
cur_book = worker_db.get_book(self.book_id)
|
||||||
data = worker_db.get_book_format(self.book_id, self.settings['old_book_format'])
|
self.title = cur_book.title
|
||||||
df = gdriveutils.getFileFromEbooksFolder(cur_book.path,
|
data = worker_db.get_book_format(self.book_id, self.settings['old_book_format'])
|
||||||
data.name + "." + self.settings['old_book_format'].lower())
|
df = gdriveutils.getFileFromEbooksFolder(cur_book.path,
|
||||||
df_cover = gdriveutils.getFileFromEbooksFolder(cur_book.path, "cover.jpg")
|
data.name + "." + self.settings['old_book_format'].lower())
|
||||||
if df:
|
df_cover = gdriveutils.getFileFromEbooksFolder(cur_book.path, "cover.jpg")
|
||||||
datafile_cover = None
|
if df:
|
||||||
datafile = os.path.join(config.get_book_path(),
|
datafile_cover = None
|
||||||
cur_book.path,
|
datafile = os.path.join(config.get_book_path(),
|
||||||
data.name + "." + self.settings['old_book_format'].lower())
|
cur_book.path,
|
||||||
if df_cover:
|
data.name + "." + self.settings['old_book_format'].lower())
|
||||||
datafile_cover = os.path.join(config.get_book_path(),
|
if df_cover:
|
||||||
cur_book.path, "cover.jpg")
|
datafile_cover = os.path.join(config.get_book_path(),
|
||||||
if not os.path.exists(os.path.join(config.get_book_path(), cur_book.path)):
|
cur_book.path, "cover.jpg")
|
||||||
os.makedirs(os.path.join(config.get_book_path(), cur_book.path))
|
if not os.path.exists(os.path.join(config.get_book_path(), cur_book.path)):
|
||||||
df.GetContentFile(datafile)
|
os.makedirs(os.path.join(config.get_book_path(), cur_book.path))
|
||||||
if df_cover:
|
df.GetContentFile(datafile)
|
||||||
df_cover.GetContentFile(datafile_cover)
|
if df_cover:
|
||||||
worker_db.session.close()
|
df_cover.GetContentFile(datafile_cover)
|
||||||
else:
|
worker_db.session.close()
|
||||||
# ToDo Include cover in error handling
|
else:
|
||||||
error_message = _("%(format)s not found on Google Drive: %(fn)s",
|
# ToDo Include cover in error handling
|
||||||
format=self.settings['old_book_format'],
|
error_message = _("%(format)s not found on Google Drive: %(fn)s",
|
||||||
fn=data.name + "." + self.settings['old_book_format'].lower())
|
format=self.settings['old_book_format'],
|
||||||
worker_db.session.close()
|
fn=data.name + "." + self.settings['old_book_format'].lower())
|
||||||
return self._handleError(error_message)
|
worker_db.session.close()
|
||||||
|
return self._handleError(error_message)
|
||||||
filename = self._convert_ebook_format()
|
|
||||||
if config.config_use_google_drive:
|
filename = self._convert_ebook_format()
|
||||||
os.remove(self.file_path + '.' + self.settings['old_book_format'].lower())
|
if config.config_use_google_drive:
|
||||||
if df_cover:
|
os.remove(self.file_path + '.' + self.settings['old_book_format'].lower())
|
||||||
os.remove(os.path.join(config.config_calibre_dir, cur_book.path, "cover.jpg"))
|
if df_cover:
|
||||||
|
os.remove(os.path.join(config.config_calibre_dir, cur_book.path, "cover.jpg"))
|
||||||
if filename:
|
|
||||||
if config.config_use_google_drive:
|
if filename:
|
||||||
# Upload files to gdrive
|
if config.config_use_google_drive:
|
||||||
gdriveutils.updateGdriveCalibreFromLocal()
|
# Upload files to gdrive
|
||||||
self._handleSuccess()
|
gdriveutils.updateGdriveCalibreFromLocal()
|
||||||
if self.ereader_mail:
|
self._handleSuccess()
|
||||||
# if we're sending to E-Reader after converting, create a one-off task and run it immediately
|
if self.ereader_mail:
|
||||||
# todo: figure out how to incorporate this into the progress
|
# if we're sending to E-Reader after converting, create a one-off task and run it immediately
|
||||||
try:
|
# todo: figure out how to incorporate this into the progress
|
||||||
EmailText = N_("%(book)s send to E-Reader", book=escape(self.title))
|
try:
|
||||||
worker_thread.add(self.user, TaskEmail(self.settings['subject'],
|
EmailText = N_(u"%(book)s send to E-Reader", book=escape(self.title))
|
||||||
self.results["path"],
|
for email in self.ereader_mail.split(','):
|
||||||
filename,
|
email = strip_whitespaces(email)
|
||||||
self.settings,
|
worker_thread.add(self.user, TaskEmail(self.settings['subject'],
|
||||||
self.ereader_mail,
|
self.results["path"],
|
||||||
EmailText,
|
filename,
|
||||||
self.settings['body'],
|
self.settings,
|
||||||
id=self.book_id,
|
email,
|
||||||
internal=True)
|
EmailText,
|
||||||
)
|
self.settings['body'],
|
||||||
except Exception as ex:
|
id=self.book_id,
|
||||||
return self._handleError(str(ex))
|
internal=True)
|
||||||
|
)
|
||||||
def _convert_ebook_format(self):
|
except Exception as ex:
|
||||||
error_message = None
|
return self._handleError(str(ex))
|
||||||
local_db = db.CalibreDB(expire_on_commit=False, init=True)
|
|
||||||
file_path = self.file_path
|
def _convert_ebook_format(self):
|
||||||
book_id = self.book_id
|
error_message = None
|
||||||
format_old_ext = '.' + self.settings['old_book_format'].lower()
|
local_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
format_new_ext = '.' + self.settings['new_book_format'].lower()
|
file_path = self.file_path
|
||||||
|
book_id = self.book_id
|
||||||
# check to see if destination format already exists - or if book is in database
|
format_old_ext = '.' + self.settings['old_book_format'].lower()
|
||||||
# if it does - mark the conversion task as complete and return a success
|
format_new_ext = '.' + self.settings['new_book_format'].lower()
|
||||||
# this will allow to send to E-Reader workflow to continue to work
|
|
||||||
if os.path.isfile(file_path + format_new_ext) or\
|
# check to see if destination format already exists - or if book is in database
|
||||||
local_db.get_book_format(self.book_id, self.settings['new_book_format']):
|
# if it does - mark the conversion task as complete and return a success
|
||||||
log.info("Book id %d already converted to %s", book_id, format_new_ext)
|
# this will allow to send to E-Reader workflow to continue to work
|
||||||
cur_book = local_db.get_book(book_id)
|
if os.path.isfile(file_path + format_new_ext) or\
|
||||||
self.title = cur_book.title
|
local_db.get_book_format(self.book_id, self.settings['new_book_format']):
|
||||||
self.results['path'] = cur_book.path
|
log.info("Book id %d already converted to %s", book_id, format_new_ext)
|
||||||
self.results['title'] = self.title
|
cur_book = local_db.get_book(book_id)
|
||||||
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id)\
|
self.title = cur_book.title
|
||||||
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
|
self.results['path'] = cur_book.path
|
||||||
if not new_format:
|
self.results['title'] = self.title
|
||||||
new_format = db.Data(name=os.path.basename(file_path),
|
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id)\
|
||||||
book_format=self.settings['new_book_format'].upper(),
|
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
|
||||||
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
|
if not new_format:
|
||||||
try:
|
new_format = db.Data(name=os.path.basename(file_path),
|
||||||
local_db.session.merge(new_format)
|
book_format=self.settings['new_book_format'].upper(),
|
||||||
local_db.session.commit()
|
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
|
||||||
except SQLAlchemyError as e:
|
try:
|
||||||
local_db.session.rollback()
|
local_db.session.merge(new_format)
|
||||||
log.error("Database error: %s", e)
|
local_db.session.commit()
|
||||||
local_db.session.close()
|
except SQLAlchemyError as e:
|
||||||
self._handleError(N_("Oops! Database Error: %(error)s.", error=e))
|
local_db.session.rollback()
|
||||||
return
|
log.error("Database error: %s", e)
|
||||||
self._handleSuccess()
|
local_db.session.close()
|
||||||
local_db.session.close()
|
self._handleError(N_("Oops! Database Error: %(error)s.", error=e))
|
||||||
return os.path.basename(file_path + format_new_ext)
|
return
|
||||||
else:
|
self._handleSuccess()
|
||||||
log.info("Book id %d - target format of %s does not exist. Moving forward with convert.",
|
local_db.session.close()
|
||||||
book_id,
|
return os.path.basename(file_path + format_new_ext)
|
||||||
format_new_ext)
|
else:
|
||||||
|
log.info("Book id %d - target format of %s does not exist. Moving forward with convert.",
|
||||||
if config.config_kepubifypath and format_old_ext == '.epub' and format_new_ext == '.kepub':
|
book_id,
|
||||||
check, error_message = self._convert_kepubify(file_path,
|
format_new_ext)
|
||||||
format_old_ext,
|
|
||||||
format_new_ext)
|
if config.config_kepubifypath and format_old_ext == '.epub' and format_new_ext == '.kepub':
|
||||||
else:
|
check, error_message = self._convert_kepubify(file_path,
|
||||||
# check if calibre converter-executable is existing
|
format_old_ext,
|
||||||
if not os.path.exists(config.config_converterpath):
|
format_new_ext)
|
||||||
self._handleError(N_("Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath))
|
else:
|
||||||
return
|
# check if calibre converter-executable is existing
|
||||||
has_cover = local_db.get_book(book_id).has_cover
|
if not os.path.exists(config.config_converterpath):
|
||||||
check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext, has_cover)
|
self._handleError(N_("Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath))
|
||||||
|
return
|
||||||
if check == 0:
|
has_cover = local_db.get_book(book_id).has_cover
|
||||||
cur_book = local_db.get_book(book_id)
|
check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext, has_cover)
|
||||||
if os.path.isfile(file_path + format_new_ext):
|
|
||||||
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id) \
|
if check == 0:
|
||||||
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
|
cur_book = local_db.get_book(book_id)
|
||||||
if not new_format:
|
if os.path.isfile(file_path + format_new_ext):
|
||||||
new_format = db.Data(name=cur_book.data[0].name,
|
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id) \
|
||||||
book_format=self.settings['new_book_format'].upper(),
|
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
|
||||||
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
|
if not new_format:
|
||||||
try:
|
new_format = db.Data(name=cur_book.data[0].name,
|
||||||
local_db.session.merge(new_format)
|
book_format=self.settings['new_book_format'].upper(),
|
||||||
local_db.session.commit()
|
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
|
||||||
if self.settings['new_book_format'].upper() in ['KEPUB', 'EPUB', 'EPUB3']:
|
try:
|
||||||
ub_session = init_db_thread()
|
local_db.session.merge(new_format)
|
||||||
remove_synced_book(book_id, True, ub_session)
|
local_db.session.commit()
|
||||||
ub_session.close()
|
if self.settings['new_book_format'].upper() in ['KEPUB', 'EPUB', 'EPUB3']:
|
||||||
except SQLAlchemyError as e:
|
ub_session = init_db_thread()
|
||||||
local_db.session.rollback()
|
remove_synced_book(book_id, True, ub_session)
|
||||||
log.error("Database error: %s", e)
|
ub_session.close()
|
||||||
local_db.session.close()
|
except SQLAlchemyError as e:
|
||||||
self._handleError(error_message)
|
local_db.session.rollback()
|
||||||
return
|
log.error("Database error: %s", e)
|
||||||
self.results['path'] = cur_book.path
|
local_db.session.close()
|
||||||
self.title = cur_book.title
|
self._handleError(error_message)
|
||||||
self.results['title'] = self.title
|
return
|
||||||
if not config.config_use_google_drive:
|
self.results['path'] = cur_book.path
|
||||||
self._handleSuccess()
|
self.title = cur_book.title
|
||||||
return os.path.basename(file_path + format_new_ext)
|
self.results['title'] = self.title
|
||||||
else:
|
if not config.config_use_google_drive:
|
||||||
error_message = N_('%(format)s format not found on disk', format=format_new_ext.upper())
|
self._handleSuccess()
|
||||||
local_db.session.close()
|
return os.path.basename(file_path + format_new_ext)
|
||||||
log.info("ebook converter failed with error while converting book")
|
else:
|
||||||
if not error_message:
|
error_message = N_('%(format)s format not found on disk', format=format_new_ext.upper())
|
||||||
error_message = N_('Ebook converter failed with unknown error')
|
local_db.session.close()
|
||||||
else:
|
log.info("ebook converter failed with error while converting book")
|
||||||
log.error(error_message)
|
if not error_message:
|
||||||
self._handleError(error_message)
|
error_message = N_('Ebook converter failed with unknown error')
|
||||||
return
|
else:
|
||||||
|
log.error(error_message)
|
||||||
def _convert_kepubify(self, file_path, format_old_ext, format_new_ext):
|
self._handleError(error_message)
|
||||||
if config.config_embed_metadata and config.config_binariesdir:
|
return
|
||||||
tmp_dir, temp_file_name = helper.do_calibre_export(self.book_id, format_old_ext[1:])
|
|
||||||
filename = os.path.join(tmp_dir, temp_file_name + format_old_ext)
|
def _convert_kepubify(self, file_path, format_old_ext, format_new_ext):
|
||||||
temp_file_path = tmp_dir
|
if config.config_embed_metadata and config.config_binariesdir:
|
||||||
else:
|
tmp_dir, temp_file_name = helper.do_calibre_export(self.book_id, format_old_ext[1:])
|
||||||
filename = file_path + format_old_ext
|
filename = os.path.join(tmp_dir, temp_file_name + format_old_ext)
|
||||||
temp_file_path = os.path.dirname(file_path)
|
temp_file_path = tmp_dir
|
||||||
quotes = [1, 3]
|
else:
|
||||||
command = [config.config_kepubifypath, filename, '-o', temp_file_path, '-i']
|
filename = file_path + format_old_ext
|
||||||
try:
|
temp_file_path = os.path.dirname(file_path)
|
||||||
p = process_open(command, quotes)
|
quotes = [1, 3]
|
||||||
except OSError as e:
|
command = [config.config_kepubifypath, filename, '-o', temp_file_path, '-i']
|
||||||
return 1, N_("Kepubify-converter failed: %(error)s", error=e)
|
try:
|
||||||
self.progress = 0.01
|
p = process_open(command, quotes)
|
||||||
while True:
|
except OSError as e:
|
||||||
nextline = p.stdout.readlines()
|
return 1, N_("Kepubify-converter failed: %(error)s", error=e)
|
||||||
nextline = [x.strip('\n') for x in nextline if x != '\n']
|
self.progress = 0.01
|
||||||
for line in nextline:
|
while True:
|
||||||
log.debug(line)
|
nextline = p.stdout.readlines()
|
||||||
if p.poll() is not None:
|
nextline = [x.strip('\n') for x in nextline if x != '\n']
|
||||||
break
|
for line in nextline:
|
||||||
|
log.debug(line)
|
||||||
# process returncode
|
if p.poll() is not None:
|
||||||
check = p.returncode
|
break
|
||||||
|
|
||||||
# move file
|
# process returncode
|
||||||
if check == 0:
|
check = p.returncode
|
||||||
converted_file = glob(os.path.splitext(filename)[0] + "*.kepub.epub")
|
|
||||||
if len(converted_file) == 1:
|
# move file
|
||||||
copyfile(converted_file[0], (file_path + format_new_ext))
|
if check == 0:
|
||||||
os.unlink(converted_file[0])
|
converted_file = glob.glob(glob.escape(os.path.splitext(filename)[0]) + "*.kepub.epub")
|
||||||
else:
|
if len(converted_file) == 1:
|
||||||
return 1, N_("Converted file not found or more than one file in folder %(folder)s",
|
copyfile(converted_file[0], (file_path + format_new_ext))
|
||||||
folder=os.path.dirname(file_path))
|
os.unlink(converted_file[0])
|
||||||
return check, None
|
else:
|
||||||
|
return 1, N_("Converted file not found or more than one file in folder %(folder)s",
|
||||||
def _convert_calibre(self, file_path, format_old_ext, format_new_ext, has_cover):
|
folder=os.path.dirname(file_path))
|
||||||
path_tmp_opf = None
|
return check, None
|
||||||
try:
|
|
||||||
# path_tmp_opf = self._embed_metadata()
|
def _convert_calibre(self, file_path, format_old_ext, format_new_ext, has_cover):
|
||||||
if config.config_embed_metadata:
|
path_tmp_opf = None
|
||||||
quotes = [3, 5]
|
try:
|
||||||
tmp_dir = get_temp_dir()
|
# path_tmp_opf = self._embed_metadata()
|
||||||
calibredb_binarypath = os.path.join(config.config_binariesdir, SUPPORTED_CALIBRE_BINARIES["calibredb"])
|
if config.config_embed_metadata:
|
||||||
my_env = os.environ.copy()
|
quotes = [5]
|
||||||
if config.config_calibre_split:
|
tmp_dir = get_temp_dir()
|
||||||
my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db")
|
calibredb_binarypath = os.path.join(config.config_binariesdir, SUPPORTED_CALIBRE_BINARIES["calibredb"])
|
||||||
library_path = config.config_calibre_split_dir
|
my_env = os.environ.copy()
|
||||||
else:
|
if config.config_calibre_split:
|
||||||
library_path = config.config_calibre_dir
|
my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db")
|
||||||
|
library_path = config.config_calibre_split_dir
|
||||||
opf_command = [calibredb_binarypath, 'show_metadata', '--as-opf', str(self.book_id),
|
else:
|
||||||
'--with-library', library_path]
|
library_path = config.config_calibre_dir
|
||||||
p = process_open(opf_command, quotes, my_env)
|
|
||||||
p.wait()
|
opf_command = [calibredb_binarypath, 'show_metadata', '--as-opf', str(self.book_id),
|
||||||
path_tmp_opf = os.path.join(tmp_dir, "metadata_" + str(uuid4()) + ".opf")
|
'--with-library', library_path]
|
||||||
with open(path_tmp_opf, 'w') as fd:
|
p = process_open(opf_command, quotes, my_env, newlines=False)
|
||||||
copyfileobj(p.stdout, fd)
|
lines = list()
|
||||||
|
while p.poll() is None:
|
||||||
quotes = [1, 2, 4, 6]
|
lines.append(p.stdout.readline())
|
||||||
command = [config.config_converterpath, (file_path + format_old_ext),
|
check = p.returncode
|
||||||
(file_path + format_new_ext)]
|
calibre_traceback = p.stderr.readlines()
|
||||||
if config.config_embed_metadata:
|
if check == 0:
|
||||||
command.extend(['--from-opf', path_tmp_opf])
|
path_tmp_opf = os.path.join(tmp_dir, "metadata_" + str(uuid4()) + ".opf")
|
||||||
if has_cover:
|
with open(path_tmp_opf, 'wb') as fd:
|
||||||
command.extend(['--cover', os.path.join(os.path.dirname(file_path), 'cover.jpg')])
|
fd.write(b''.join(lines))
|
||||||
quotes_index = 3
|
else:
|
||||||
if config.config_calibre:
|
error_message = ""
|
||||||
parameters = config.config_calibre.split(" ")
|
for ele in calibre_traceback:
|
||||||
for param in parameters:
|
if not ele.startswith('Traceback') and not ele.startswith(' File'):
|
||||||
command.append(param)
|
error_message = N_("Calibre failed with error: %(error)s", error=ele)
|
||||||
quotes.append(quotes_index)
|
return check, error_message
|
||||||
quotes_index += 1
|
quotes = [1, 2]
|
||||||
|
quotes_index = 3
|
||||||
p = process_open(command, quotes, newlines=False)
|
command = [config.config_converterpath, (file_path + format_old_ext),
|
||||||
except OSError as e:
|
(file_path + format_new_ext)]
|
||||||
return 1, N_("Ebook-converter failed: %(error)s", error=e)
|
if config.config_embed_metadata:
|
||||||
|
quotes.append(4)
|
||||||
while p.poll() is None:
|
quotes_index = 5
|
||||||
nextline = p.stdout.readline()
|
command.extend(['--from-opf', path_tmp_opf])
|
||||||
if isinstance(nextline, bytes):
|
if has_cover:
|
||||||
nextline = nextline.decode('utf-8', errors="ignore").strip('\r\n')
|
quotes.append(6)
|
||||||
if nextline:
|
command.extend(['--cover', os.path.join(os.path.dirname(file_path), 'cover.jpg')])
|
||||||
log.debug(nextline)
|
quotes_index = 7
|
||||||
# parse progress string from calibre-converter
|
if config.config_calibre:
|
||||||
progress = re.search(r"(\d+)%\s.*", nextline)
|
parameters = re.findall(r"(--[\w-]+)(?:(\s(?:(\".+\")|(?:.+?)))(?:\s|$))?",
|
||||||
if progress:
|
config.config_calibre, re.IGNORECASE | re.UNICODE)
|
||||||
self.progress = int(progress.group(1)) / 100
|
if parameters:
|
||||||
if config.config_use_google_drive:
|
for param in parameters:
|
||||||
self.progress *= 0.9
|
command.append(strip_whitespaces(param[0]))
|
||||||
|
quotes_index += 1
|
||||||
# process returncode
|
if param[1] != "":
|
||||||
check = p.returncode
|
parsed = strip_whitespaces(param[1]).strip("\"")
|
||||||
calibre_traceback = p.stderr.readlines()
|
command.append(parsed)
|
||||||
error_message = ""
|
quotes.append(quotes_index)
|
||||||
for ele in calibre_traceback:
|
quotes_index += 1
|
||||||
ele = ele.decode('utf-8', errors="ignore").strip('\n')
|
p = process_open(command, quotes, newlines=False)
|
||||||
log.debug(ele)
|
except OSError as e:
|
||||||
if not ele.startswith('Traceback') and not ele.startswith(' File'):
|
return 1, N_("Ebook-converter failed: %(error)s", error=e)
|
||||||
error_message = N_("Calibre failed with error: %(error)s", error=ele)
|
|
||||||
return check, error_message
|
while p.poll() is None:
|
||||||
|
nextline = p.stdout.readline()
|
||||||
@property
|
if isinstance(nextline, bytes):
|
||||||
def name(self):
|
nextline = nextline.decode('utf-8', errors="ignore").strip('\r\n')
|
||||||
return N_("Convert")
|
if nextline:
|
||||||
|
log.debug(nextline)
|
||||||
def __str__(self):
|
# parse progress string from calibre-converter
|
||||||
if self.ereader_mail:
|
progress = re.search(r"(\d+)%\s.*", nextline)
|
||||||
return "Convert Book {} and mail it to {}".format(self.book_id, self.ereader_mail)
|
if progress:
|
||||||
else:
|
self.progress = int(progress.group(1)) / 100
|
||||||
return "Convert Book {}".format(self.book_id)
|
if config.config_use_google_drive:
|
||||||
|
self.progress *= 0.9
|
||||||
@property
|
|
||||||
def is_cancellable(self):
|
# process returncode
|
||||||
return False
|
check = p.returncode
|
||||||
|
calibre_traceback = p.stderr.readlines()
|
||||||
|
error_message = ""
|
||||||
|
for ele in calibre_traceback:
|
||||||
|
ele = ele.decode('utf-8', errors="ignore").strip('\n')
|
||||||
|
log.debug(ele)
|
||||||
|
if not ele.startswith('Traceback') and not ele.startswith(' File'):
|
||||||
|
error_message = N_("Calibre failed with error: %(error)s", error=ele)
|
||||||
|
return check, error_message
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return N_("Convert")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.ereader_mail:
|
||||||
|
return "Convert Book {} and mail it to {}".format(self.book_id, self.ereader_mail)
|
||||||
|
else:
|
||||||
|
return "Convert Book {}".format(self.book_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cancellable(self):
|
||||||
|
return False
|
||||||
|
|||||||
+5
-13
@@ -16,11 +16,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 urllib.request import urlopen
|
|
||||||
|
|
||||||
from flask_babel import lazy_gettext as N_
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
from cps import config, logger
|
from cps import config, logger, db, ub
|
||||||
from cps.services.worker import CalibreTask
|
from cps.services.worker import CalibreTask
|
||||||
|
|
||||||
|
|
||||||
@@ -28,18 +26,12 @@ class TaskReconnectDatabase(CalibreTask):
|
|||||||
def __init__(self, task_message=N_('Reconnecting Calibre database')):
|
def __init__(self, task_message=N_('Reconnecting Calibre database')):
|
||||||
super(TaskReconnectDatabase, self).__init__(task_message)
|
super(TaskReconnectDatabase, self).__init__(task_message)
|
||||||
self.log = logger.create()
|
self.log = logger.create()
|
||||||
self.listen_address = config.get_config_ipaddress()
|
self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
self.listen_port = config.config_port
|
|
||||||
|
|
||||||
def run(self, worker_thread):
|
def run(self, worker_thread):
|
||||||
address = self.listen_address if self.listen_address else 'localhost'
|
self.calibre_db.reconnect_db(config, ub.app_DB_path)
|
||||||
port = self.listen_port if self.listen_port else 8083
|
self.calibre_db.session.close()
|
||||||
|
self._handleSuccess()
|
||||||
try:
|
|
||||||
urlopen('http://' + address + ':' + str(port) + '/reconnect')
|
|
||||||
self._handleSuccess()
|
|
||||||
except Exception as ex:
|
|
||||||
self._handleError('Unable to reconnect Calibre database: ' + str(ex))
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
|
|||||||
+3
-2
@@ -34,6 +34,7 @@ from cps.services import gmail
|
|||||||
from cps.embed_helper import do_calibre_export
|
from cps.embed_helper import do_calibre_export
|
||||||
from cps import logger, config
|
from cps import logger, config
|
||||||
from cps import gdriveutils
|
from cps import gdriveutils
|
||||||
|
from cps.string_helper import strip_whitespaces
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -127,9 +128,9 @@ class TaskEmail(CalibreTask):
|
|||||||
try:
|
try:
|
||||||
# Parse out the address from the From line, and then the domain from that
|
# Parse out the address from the From line, and then the domain from that
|
||||||
from_email = parseaddr(self.settings["mail_from"])[1]
|
from_email = parseaddr(self.settings["mail_from"])[1]
|
||||||
msgid_domain = from_email.partition('@')[2].strip()
|
msgid_domain = strip_whitespaces(from_email.partition('@')[2])
|
||||||
# This can sometimes sneak through parseaddr if the input is malformed
|
# This can sometimes sneak through parseaddr if the input is malformed
|
||||||
msgid_domain = msgid_domain.rstrip('>').strip()
|
msgid_domain = strip_whitespaces(msgid_domain.rstrip('>'))
|
||||||
except Exception:
|
except Exception:
|
||||||
msgid_domain = ''
|
msgid_domain = ''
|
||||||
return msgid_domain or 'calibre-web.com'
|
return msgid_domain or 'calibre-web.com'
|
||||||
|
|||||||
+16
-15
@@ -20,14 +20,13 @@ import os
|
|||||||
from shutil import copyfile, copyfileobj
|
from shutil import copyfile, copyfileobj
|
||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from .. import constants
|
from .. import constants
|
||||||
from cps import config, db, fs, gdriveutils, logger, ub
|
from cps import config, db, fs, gdriveutils, logger, ub
|
||||||
from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED
|
from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED
|
||||||
from datetime import datetime
|
|
||||||
from sqlalchemy import func, text, or_
|
from sqlalchemy import func, text, or_
|
||||||
from flask_babel import lazy_gettext as N_
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from wand.image import Image
|
from wand.image import Image
|
||||||
use_IM = True
|
use_IM = True
|
||||||
@@ -36,7 +35,7 @@ except (ImportError, RuntimeError) as e:
|
|||||||
|
|
||||||
|
|
||||||
def get_resize_height(resolution):
|
def get_resize_height(resolution):
|
||||||
return int(225 * resolution)
|
return int(255 * resolution)
|
||||||
|
|
||||||
|
|
||||||
def get_resize_width(resolution, original_width, original_height):
|
def get_resize_width(resolution, original_width, original_height):
|
||||||
@@ -70,11 +69,11 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
|||||||
self.log = logger.create()
|
self.log = logger.create()
|
||||||
self.book_id = book_id
|
self.book_id = book_id
|
||||||
self.app_db_session = ub.get_new_session_instance()
|
self.app_db_session = ub.get_new_session_instance()
|
||||||
# self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
|
||||||
self.cache = fs.FileSystem()
|
self.cache = fs.FileSystem()
|
||||||
self.resolutions = [
|
self.resolutions = [
|
||||||
constants.COVER_THUMBNAIL_SMALL,
|
constants.COVER_THUMBNAIL_SMALL,
|
||||||
constants.COVER_THUMBNAIL_MEDIUM
|
constants.COVER_THUMBNAIL_MEDIUM,
|
||||||
|
constants.COVER_THUMBNAIL_LARGE
|
||||||
]
|
]
|
||||||
|
|
||||||
def run(self, worker_thread):
|
def run(self, worker_thread):
|
||||||
@@ -124,7 +123,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
|||||||
.query(ub.Thumbnail) \
|
.query(ub.Thumbnail) \
|
||||||
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
|
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
|
||||||
.filter(ub.Thumbnail.entity_id == book_id) \
|
.filter(ub.Thumbnail.entity_id == book_id) \
|
||||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc))) \
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
def create_book_cover_thumbnails(self, book):
|
def create_book_cover_thumbnails(self, book):
|
||||||
@@ -166,7 +165,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
|||||||
self.app_db_session.rollback()
|
self.app_db_session.rollback()
|
||||||
|
|
||||||
def update_book_cover_thumbnail(self, book, thumbnail):
|
def update_book_cover_thumbnail(self, book, thumbnail):
|
||||||
thumbnail.generated_at = datetime.utcnow()
|
thumbnail.generated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.app_db_session.commit()
|
self.app_db_session.commit()
|
||||||
@@ -198,9 +197,11 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
|||||||
img.format = thumbnail.format
|
img.format = thumbnail.format
|
||||||
img.save(filename=filename)
|
img.save(filename=filename)
|
||||||
else:
|
else:
|
||||||
with open(filename, 'rb') as fd:
|
stream.seek(0)
|
||||||
|
with open(filename, 'wb') as fd:
|
||||||
copyfileobj(stream, fd)
|
copyfileobj(stream, fd)
|
||||||
|
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
# Bubble exception to calling function
|
# Bubble exception to calling function
|
||||||
self.log.debug('Error generating thumbnail file: ' + str(ex))
|
self.log.debug('Error generating thumbnail file: ' + str(ex))
|
||||||
@@ -323,12 +324,12 @@ class TaskGenerateSeriesThumbnails(CalibreTask):
|
|||||||
.all()
|
.all()
|
||||||
|
|
||||||
def get_series_thumbnails(self, series_id):
|
def get_series_thumbnails(self, series_id):
|
||||||
return self.app_db_session \
|
return (self.app_db_session
|
||||||
.query(ub.Thumbnail) \
|
.query(ub.Thumbnail)
|
||||||
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES) \
|
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES)
|
||||||
.filter(ub.Thumbnail.entity_id == series_id) \
|
.filter(ub.Thumbnail.entity_id == series_id)
|
||||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc)))
|
||||||
.all()
|
.all())
|
||||||
|
|
||||||
def create_series_thumbnail(self, series, series_books, resolution):
|
def create_series_thumbnail(self, series, series_books, resolution):
|
||||||
thumbnail = ub.Thumbnail()
|
thumbnail = ub.Thumbnail()
|
||||||
@@ -347,7 +348,7 @@ class TaskGenerateSeriesThumbnails(CalibreTask):
|
|||||||
self.app_db_session.rollback()
|
self.app_db_session.rollback()
|
||||||
|
|
||||||
def update_series_thumbnail(self, series_books, thumbnail):
|
def update_series_thumbnail(self, series_books, thumbnail):
|
||||||
thumbnail.generated_at = datetime.utcnow()
|
thumbnail.generated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.app_db_session.commit()
|
self.app_db_session.commit()
|
||||||
|
|||||||
+5
-3
@@ -17,7 +17,7 @@
|
|||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
|
|
||||||
from flask import Blueprint, jsonify
|
from flask import Blueprint, jsonify
|
||||||
from flask_login import login_required, current_user
|
from .cw_login import current_user
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_babel import format_datetime
|
from flask_babel import format_datetime
|
||||||
from babel.units import format_unit
|
from babel.units import format_unit
|
||||||
@@ -26,6 +26,7 @@ from . import logger
|
|||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS, STAT_ENDED, \
|
from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS, STAT_ENDED, \
|
||||||
STAT_CANCELLED
|
STAT_CANCELLED
|
||||||
|
from .usermanagement import user_login_required
|
||||||
|
|
||||||
tasks = Blueprint('tasks', __name__)
|
tasks = Blueprint('tasks', __name__)
|
||||||
|
|
||||||
@@ -33,14 +34,14 @@ log = logger.create()
|
|||||||
|
|
||||||
|
|
||||||
@tasks.route("/ajax/emailstat")
|
@tasks.route("/ajax/emailstat")
|
||||||
@login_required
|
@user_login_required
|
||||||
def get_email_status_json():
|
def get_email_status_json():
|
||||||
tasks = WorkerThread.get_instance().tasks
|
tasks = WorkerThread.get_instance().tasks
|
||||||
return jsonify(render_task_status(tasks))
|
return jsonify(render_task_status(tasks))
|
||||||
|
|
||||||
|
|
||||||
@tasks.route("/tasks")
|
@tasks.route("/tasks")
|
||||||
@login_required
|
@user_login_required
|
||||||
def get_tasks_status():
|
def get_tasks_status():
|
||||||
# if current user admin, show all email, otherwise only own emails
|
# if current user admin, show all email, otherwise only own emails
|
||||||
return render_title_template('tasks.html', title=_("Tasks"), page="tasks")
|
return render_title_template('tasks.html', title=_("Tasks"), page="tasks")
|
||||||
@@ -81,6 +82,7 @@ def render_task_status(tasklist):
|
|||||||
ret['task_id'] = task.id
|
ret['task_id'] = task.id
|
||||||
ret['stat'] = task.stat
|
ret['stat'] = task.stat
|
||||||
ret['is_cancellable'] = task.is_cancellable
|
ret['is_cancellable'] = task.is_cancellable
|
||||||
|
ret['error'] = task.error
|
||||||
|
|
||||||
rendered_tasklist.append(ret)
|
rendered_tasklist.append(ret)
|
||||||
|
|
||||||
|
|||||||
@@ -62,18 +62,16 @@
|
|||||||
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for format in entry.Books.data %}
|
{% if entry.Books.data|music %}
|
||||||
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
|
|
||||||
<span class="glyphicon glyphicon-music"></span>
|
<span class="glyphicon glyphicon-music"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
|
||||||
</p>
|
</p>
|
||||||
{% if entry.Books.series.__len__() > 0 %}
|
{% if entry.Books.series.__len__() > 0 %}
|
||||||
<p class="series">
|
<p class="series">
|
||||||
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.Books.series[0].id )}}">
|
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.Books.series[0].id )}}">
|
||||||
{{entry.Books.series[0].name}}
|
{{entry.Books.series[0].name}}
|
||||||
</a>
|
</a>
|
||||||
({{entry.Books.series_index|formatseriesindex}})
|
({{entry.Books.series_index|formatfloat(2)}})
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.Books.ratings.__len__() > 0 %}
|
{% if entry.Books.ratings.__len__() > 0 %}
|
||||||
@@ -124,7 +122,7 @@
|
|||||||
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id )}}">
|
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id )}}">
|
||||||
{{entry.series[0].name}}
|
{{entry.series[0].name}}
|
||||||
</a>
|
</a>
|
||||||
({{entry.series_index|formatseriesindex}})
|
({{entry.series_index|formatfloat(2)}})
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="rating">
|
<div class="rating">
|
||||||
|
|||||||
@@ -47,26 +47,41 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if current_user.role_upload() and g.allow_upload %}
|
||||||
|
|
||||||
|
<div class="text-center more-stuff"><!--h4 aria-label="Upload new book format"></h4-->
|
||||||
|
<form id="form-upload-format" action="{{ url_for('edit-book.upload') }}" data-title="{{_('Uploading...')}}" data-footer="{{_('Close')}}" data-failed="{{_('Error')}}" data-message="{{_('Upload done, processing, please wait...')}}" method="post" enctype="multipart/form-data">
|
||||||
|
<div class="text-center">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="book_id" value="{{ book.id }}">
|
||||||
|
<div role="group" aria-label="Upload new book format">
|
||||||
|
<label class="btn btn-primary btn-file" for="btn-upload-format">{{ _('Upload Format') }}</label>
|
||||||
|
<div class="upload-format-input-text" id="upload-format"></div>
|
||||||
|
<input id="btn-upload-format" name="btn-upload-format" type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}" multiple>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<form role="form" action="{{ url_for('edit-book.edit_book', book_id=book.id) }}" method="post" enctype="multipart/form-data" id="book_edit_frm">
|
<form role="form" action="{{ url_for('edit-book.edit_book', book_id=book.id) }}" method="post" enctype="multipart/form-data" id="book_edit_frm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="col-sm-9 col-xs-12">
|
<div class="col-sm-9 col-xs-12">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="book_title">{{_('Book Title')}}</label>
|
<label for="title">{{_('Book Title')}}</label>
|
||||||
<input type="text" class="form-control" name="book_title" id="book_title" value="{{book.title}}">
|
<input type="text" class="form-control" name="title" id="title" value="{{book.title}}">
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<button type="button" class="btn btn-default" id="xchange" ><span class="glyphicon glyphicon-arrow-up"></span><span class="glyphicon glyphicon-arrow-down"></span></button>
|
<button type="button" class="btn btn-default" id="xchange" ><span class="glyphicon glyphicon-arrow-up"></span><span class="glyphicon glyphicon-arrow-down"></span></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="author_div" class="form-group">
|
<div id="author_div" class="form-group">
|
||||||
<label for="bookAuthor">{{_('Author')}}</label>
|
<label for="bookAuthor">{{_('Author')}}</label>
|
||||||
<input type="text" class="form-control typeahead" autocomplete="off" name="author_name" id="bookAuthor" value="{{' & '.join(authors)}}">
|
<input type="text" class="form-control typeahead" autocomplete="off" name="authors" id="authors" value="{{' & '.join(authors)}}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="description">{{_('Description')}}</label>
|
<label for="comments">{{_('Description')}}</label>
|
||||||
<textarea class="form-control" name="description" id="description" rows="7">{% if book.comments %}{{book.comments[0].text}}{%endif%}</textarea>
|
<textarea class="form-control" name="comments" id="comments" rows="7">{% if book.comments %}{{book.comments[0].text}}{%endif%}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -93,7 +108,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="series_index">{{_('Series ID')}}</label>
|
<label for="series_index">{{_('Series ID')}}</label>
|
||||||
<input type="number" step="0.01" min="0" placeholder="1" class="form-control" name="series_index" id="series_index" value="{{book.series_index}}">
|
<input type="number" step="0.01" min="0" placeholder="1" class="form-control" name="series_index" id="series_index" value="{{book.series_index|formatfloat(2)}}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="rating">{{_('Rating')}}</label>
|
<label for="rating">{{_('Rating')}}</label>
|
||||||
@@ -196,13 +211,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.role_upload() and g.allow_upload %}
|
|
||||||
<div role="group" aria-label="Upload new book format">
|
|
||||||
<label class="btn btn-primary btn-file" for="btn-upload-format">{{ _('Upload Format') }}</label>
|
|
||||||
<div class="upload-format-input-text" id="upload-format"></div>
|
|
||||||
<input id="btn-upload-format" name="btn-upload-format" type="file">
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="checkbox">
|
<div class="checkbox">
|
||||||
<label>
|
<label>
|
||||||
@@ -288,7 +296,7 @@
|
|||||||
'no_result': {{_('No Result(s) found! Please try another keyword.')|safe|tojson}},
|
'no_result': {{_('No Result(s) found! Please try another keyword.')|safe|tojson}},
|
||||||
'author': {{_('Author')|safe|tojson}},
|
'author': {{_('Author')|safe|tojson}},
|
||||||
'publisher': {{_('Publisher')|safe|tojson}},
|
'publisher': {{_('Publisher')|safe|tojson}},
|
||||||
'description': {{_('Description')|safe|tojson}},
|
'comments': {{_('Description')|safe|tojson}},
|
||||||
'source': {{_('Source')|safe|tojson}},
|
'source': {{_('Source')|safe|tojson}},
|
||||||
};
|
};
|
||||||
var language = '{{ current_user.locale }}';
|
var language = '{{ current_user.locale }}';
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
{{ text_table_row('authors', _('Enter Authors'),_('Authors'), true, true) }}
|
{{ text_table_row('authors', _('Enter Authors'),_('Authors'), true, true) }}
|
||||||
{{ text_table_row('tags', _('Enter Categories'),_('Categories'), false, true) }}
|
{{ text_table_row('tags', _('Enter Categories'),_('Categories'), false, true) }}
|
||||||
{{ text_table_row('series', _('Enter Series'),_('Series'), false, true) }}
|
{{ text_table_row('series', _('Enter Series'),_('Series'), false, true) }}
|
||||||
<th data-field="series_index" id="series_index" data-visible="{{visiblility.get('series_index')}}" data-edit-validate="{{ _('This Field is Required') }}" data-sortable="true" {% if current_user.role_edit() %} data-editable-type="number" data-editable-placeholder="1" data-editable-step="0.01" data-editable-min="0" data-editable-url="{{ url_for('edit-book.edit_list_book', param='series_index')}}" data-edit="true" data-editable-title="{{_('Enter Title')}}"{% endif %}>{{_('Series Index')}}</th>
|
<th data-field="series_index" id="series_index" data-visible="{{visiblility.get('series_index')}}" data-formatter="seriesIndexFormatter" data-edit-validate="{{ _('This Field is Required') }}" data-sortable="true" {% if current_user.role_edit() %} data-editable-type="number" data-editable-placeholder="1" data-editable-step="0.01" data-editable-min="0" data-editable-url="{{ url_for('edit-book.edit_list_book', param='series_index')}}" data-edit="true" data-editable-title="{{_('Enter Title')}}"{% endif %}>{{_('Series Index')}}</th>
|
||||||
{{ text_table_row('languages', _('Enter Languages'),_('Languages'), false, true) }}
|
{{ text_table_row('languages', _('Enter Languages'),_('Languages'), false, true) }}
|
||||||
<!--th data-field="pubdate" data-type="date" data-visible="{{visiblility.get('pubdate')}}" data-viewformat="dd.mm.yyyy" id="pubdate" data-sortable="true">{{_('Publishing Date')}}</th-->
|
<!--th data-field="pubdate" data-type="date" data-visible="{{visiblility.get('pubdate')}}" data-viewformat="dd.mm.yyyy" id="pubdate" data-sortable="true">{{_('Publishing Date')}}</th-->
|
||||||
{{ text_table_row('publishers', _('Enter Publishers'),_('Publishers'), false, true) }}
|
{{ text_table_row('publishers', _('Enter Publishers'),_('Publishers'), false, true) }}
|
||||||
|
|||||||
Executable → Regular
@@ -20,7 +20,7 @@
|
|||||||
{{ _('Download') }} :
|
{{ _('Download') }} :
|
||||||
</button>
|
</button>
|
||||||
{% for format in entry.data %}
|
{% for format in entry.data %}
|
||||||
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower) }}"
|
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}"
|
||||||
id="btnGroupDrop1{{ format.format|lower }}" class="btn btn-primary"
|
id="btnGroupDrop1{{ format.format|lower }}" class="btn btn-primary"
|
||||||
role="button">
|
role="button">
|
||||||
<span class="glyphicon glyphicon-download"></span>{{ format.format }}
|
<span class="glyphicon glyphicon-download"></span>{{ format.format }}
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<ul class="dropdown-menu" aria-labelledby="btnGroupDrop1">
|
<ul class="dropdown-menu" aria-labelledby="btnGroupDrop1">
|
||||||
{% for format in entry.data %}
|
{% for format in entry.data %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower) }}">{{ format.format }}
|
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}">{{ format.format }}
|
||||||
({{ format.uncompressed_size|filesizeformat }})</a></li>
|
({{ format.uncompressed_size|filesizeformat }})</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.series|length > 0 %}
|
{% if entry.series|length > 0 %}
|
||||||
<p>{{ _("Book %(index)s of %(range)s", index=entry.series_index | formatfloat(2), range=(url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id)|escapedlink(entry.series[0].name))|safe) }}</p>
|
<p>{{ _("Book %(index)s of %(range)s", index=entry.series_index|formatfloat(2), range=(url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id)|escapedlink(entry.series[0].name))|safe) }}</p>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,6 @@
|
|||||||
<img
|
<img
|
||||||
srcset="{{ srcset }}"
|
srcset="{{ srcset }}"
|
||||||
src="{{ url_for('web.get_series_cover', series_id=series.id, resolution='og', c='day'|cache_timestamp) }}"
|
src="{{ url_for('web.get_series_cover', series_id=series.id, resolution='og', c='day'|cache_timestamp) }}"
|
||||||
alt="{{ book_title }}"
|
alt="{{ title }}"
|
||||||
/>
|
/>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.Books.series[0].id )}}">
|
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.Books.series[0].id )}}">
|
||||||
{{entry.Books.series[0].name}}
|
{{entry.Books.series[0].name}}
|
||||||
</a>
|
</a>
|
||||||
({{entry.Books.series_index|formatseriesindex}})
|
({{entry.Books.series_index|formatfloat(2)}})
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.Books.ratings.__len__() > 0 %}
|
{% if entry.Books.ratings.__len__() > 0 %}
|
||||||
@@ -119,11 +119,9 @@
|
|||||||
<a class="author-name" href="{{url_for('web.books_list', data='author', book_id=author.id, sort_param='stored') }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
<a class="author-name" href="{{url_for('web.books_list', data='author', book_id=author.id, sort_param='stored') }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for format in entry.Books.data %}
|
{% if entry.Books.data|music %}
|
||||||
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
|
|
||||||
<span class="glyphicon glyphicon-music"></span>
|
<span class="glyphicon glyphicon-music"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%endfor%}
|
|
||||||
</p>
|
</p>
|
||||||
{% if entry.Books.series.__len__() > 0 %}
|
{% if entry.Books.series.__len__() > 0 %}
|
||||||
<p class="series">
|
<p class="series">
|
||||||
@@ -134,7 +132,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span>{{entry.Books.series[0].name}}</span>
|
<span>{{entry.Books.series[0].name}}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
({{entry.Books.series_index|formatseriesindex}})
|
({{entry.Books.series_index|formatfloat(2)}})
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.Books.ratings.__len__() > 0 %}
|
{% if entry.Books.ratings.__len__() > 0 %}
|
||||||
|
|||||||
@@ -62,13 +62,13 @@
|
|||||||
{% if g.current_theme == 1 %}
|
{% if g.current_theme == 1 %}
|
||||||
<li class="dropdown"><a href="#" class="dropdown-toggle profileDrop" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><span class="glyphicon glyphicon-user"></span></a>
|
<li class="dropdown"><a href="#" class="dropdown-toggle profileDrop" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><span class="glyphicon glyphicon-user"></span></a>
|
||||||
<ul class="dropdown-menu profileDropli">
|
<ul class="dropdown-menu profileDropli">
|
||||||
<li><a id="top_user" data-text="{{_('Account')}}" href="{{url_for('web.profile')}}"><span class="glyphicon glyphicon-user"></span> <span class="hidden-sm">{{current_user.name}}</span></a></li>
|
<li><a id="top_user" data-text="{{_('Account')}}" href="{{url_for('web.profile')}}" ><span class="glyphicon glyphicon-user"></span> <span class="hidden-sm">{{current_user.name}}</span></a></li>
|
||||||
{% if g.allow_registration and not current_user.is_authenticated %}
|
{% if g.allow_registration and not current_user.is_authenticated %}
|
||||||
<li><a id="login" href="{{url_for('web.login')}}"><span class="glyphicon glyphicon-log-in"></span> {{_('Login')}}</a></li>
|
<li><a id="login" href="{{url_for('web.login')}}"><span class="glyphicon glyphicon-log-in"></span> {{_('Login')}}</a></li>
|
||||||
<li><a id="register" href="{{url_for('web.register')}}"><span class="glyphicon glyphicon-user"></span> {{_('Register')}}</a></li>
|
<li><a id="register" href="{{url_for('web.register')}}"><span class="glyphicon glyphicon-user"></span> {{_('Register')}}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not current_user.is_anonymous %}
|
{% if not current_user.is_anonymous %}
|
||||||
<li><a id="logout" href="{{url_for('web.logout')}}"><span class="glyphicon glyphicon-log-out"></span> <span class="hidden-sm">{{_('Logout')}}</span></a></li>
|
<li><a id="logout" href="{{url_for('web.logout', next=(request.path + "?" + request.query_string.decode("utf-8")).rstrip("?"))}}"><span class="glyphicon glyphicon-log-out"></span> <span class="hidden-sm">{{_('Logout')}}</span></a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<span class="btn btn-default btn-file">{{_('Upload')}}<input id="btn-upload" name="btn-upload"
|
<span class="btn btn-default btn-file">{{_('Upload')}}<input id="btn-upload" name="btn-upload"
|
||||||
type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}" multiple></span>
|
type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}" multiple></span>
|
||||||
|
<input class="hide" id="btn-upload2" name="btn-upload2" type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
@@ -91,9 +92,9 @@
|
|||||||
<li><a id="top_admin" data-text="{{_('Settings')}}" href="{{url_for('admin.admin')}}"><span class="glyphicon glyphicon-dashboard"></span> <span class="hidden-sm">{{_('Admin')}}</span></a></li>
|
<li><a id="top_admin" data-text="{{_('Settings')}}" href="{{url_for('admin.admin')}}"><span class="glyphicon glyphicon-dashboard"></span> <span class="hidden-sm">{{_('Admin')}}</span></a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if g.current_theme == 0 %}
|
{% if g.current_theme == 0 %}
|
||||||
<li><a id="top_user" data-text="{{_('Account')}}" href="{{url_for('web.profile')}}"><span class="glyphicon glyphicon-user"></span> <span class="hidden-sm">{{current_user.name}}</span></a></li>
|
<li><a id="top_user" data-text="{{_('Account')}}" href="{% if not current_user.is_anonymous %}{{url_for('web.profile')}}{% else %}{{url_for('web.login', next=(request.path + "?" + request.query_string.decode("utf-8")).rstrip("?"))}}{% endif %}"><span class="glyphicon glyphicon-user"></span> <span class="hidden-sm">{{current_user.name}}</span></a></li>
|
||||||
{% if not current_user.is_anonymous %}
|
{% if not current_user.is_anonymous %}
|
||||||
<li><a id="logout" href="{{url_for('web.logout')}}"><span class="glyphicon glyphicon-log-out"></span> <span class="hidden-sm">{{_('Logout')}}</span></a></li>
|
<li><a id="logout" href="{{url_for('web.logout', next=(request.path + "?" + request.query_string.decode("utf-8")).rstrip("?"))}}"><span class="glyphicon glyphicon-log-out"></span> <span class="hidden-sm">{{_('Logout')}}</span></a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.series|length > 0 %}
|
{% if entry.series|length > 0 %}
|
||||||
<p>{{_("Book %(index)s of %(range)s", index=entry.series_index | formatfloat(2), range=(url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id)|escapedlink(entry.series[0].name))|safe)}}</p>
|
<p>{{_("Book %(index)s of %(range)s", index=entry.series_index|formatfloat(2), range=(url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id)|escapedlink(entry.series[0].name))|safe)}}</p>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"input": {
|
|
||||||
"placeholder": "a placeholder"
|
|
||||||
},
|
|
||||||
"nav": {
|
|
||||||
"home": "Home",
|
|
||||||
"page1": "Page One",
|
|
||||||
"page2": "Page Two"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+171
-105
@@ -1,33 +1,35 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html class="no-js">
|
<html class="no-js">
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
|
||||||
<title>{{_('epub Reader')}} | {{title}}</title>
|
|
||||||
<meta name="description" content="">
|
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
{% if g.google_site_verification|length > 0 %}
|
|
||||||
<meta name="google-site-verification" content="{{g.google_site_verification}}">
|
|
||||||
{% endif %}
|
|
||||||
<link rel="apple-touch-icon" sizes="140x140" href="{{ url_for('static', filename='favicon.ico') }}">
|
|
||||||
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/libs/normalize.css') }}">
|
<head>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
<meta charset="utf-8">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/popup.css') }}">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/reader.css') }}">
|
<title>{{_('epub Reader')}} | {{title}}</title>
|
||||||
</head>
|
<meta name="description" content="">
|
||||||
<body>
|
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||||
<div id="sidebar">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
{% if g.google_site_verification|length > 0 %}
|
||||||
|
<meta name="google-site-verification" content="{{g.google_site_verification}}">
|
||||||
|
{% endif %}
|
||||||
|
<link rel="apple-touch-icon" sizes="140x140" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||||
|
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/libs/normalize.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/popup.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/reader.css') }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="sidebar">
|
||||||
<div id="panels">
|
<div id="panels">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<!--input id="searchBox" placeholder="search" type="search"-->
|
<!--input id="searchBox" placeholder="search" type="search"-->
|
||||||
|
|
||||||
<!--a id="show-Search" class="show_view icon-search" data-view="Search">Search</a-->
|
<!--a id="show-Search" class="show_view icon-search" data-view="Search">Search</a-->
|
||||||
<a id="show-Toc" class="show_view icon-list-1 active" data-view="Toc">TOC</a>
|
<a id="show-Toc" class="show_view icon-list-1 active" data-view="Toc">TOC</a>
|
||||||
<a id="show-Bookmarks" class="show_view icon-bookmark" data-view="Bookmarks">Bookmarks</a>
|
<a id="show-Bookmarks" class="show_view icon-bookmark" data-view="Bookmarks">Bookmarks</a>
|
||||||
<!--a id="show-Notes" class="show_view icon-edit" data-view="Notes">Notes</a-->
|
<!--a id="show-Notes" class="show_view icon-edit" data-view="Notes">Notes</a-->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div id="tocView" class="view">
|
<div id="tocView" class="view">
|
||||||
@@ -36,7 +38,7 @@
|
|||||||
<ul id="searchResults"></ul>
|
<ul id="searchResults"></ul>
|
||||||
</div-->
|
</div-->
|
||||||
<div id="bookmarksView" class="view">
|
<div id="bookmarksView" class="view">
|
||||||
<ul id="bookmarks"></ul>
|
<ul id="bookmarks"></ul>
|
||||||
</div>
|
</div>
|
||||||
<!--div id="notesView" class="view">
|
<!--div id="notesView" class="view">
|
||||||
<div id="new-note">
|
<div id="new-note">
|
||||||
@@ -45,110 +47,174 @@
|
|||||||
</div>
|
</div>
|
||||||
<ol id="notes"></ol>
|
<ol id="notes"></ol>
|
||||||
</div-->
|
</div-->
|
||||||
</div>
|
</div>
|
||||||
<div id="main">
|
<div id="main">
|
||||||
|
|
||||||
<div id="titlebar">
|
<div id="titlebar">
|
||||||
<div id="opener">
|
<div id="opener">
|
||||||
<a id="slider" class="icon-menu">Menu</a>
|
<a id="slider" class="icon-menu">Menu</a>
|
||||||
</div>
|
</div>
|
||||||
<div id="metainfo">
|
<div id="metainfo">
|
||||||
<span id="book-title"></span>
|
<span id="book-title"></span>
|
||||||
<span id="title-seperator"> – </span>
|
<span id="title-seperator"> – </span>
|
||||||
<span id="chapter-title"></span>
|
<span id="chapter-title"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="title-controls">
|
<div id="title-controls">
|
||||||
<a id="bookmark" class="icon-bookmark-empty">Bookmark</a>
|
<a id="bookmark" class="icon-bookmark-empty">Bookmark</a>
|
||||||
<a id="setting" class="icon-cog">Settings</a>
|
<a id="setting" class="icon-cog">Settings</a>
|
||||||
<a id="fullscreen" class="icon-resize-full">Fullscreen</a>
|
<a id="fullscreen" class="icon-resize-full">Fullscreen</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="divider"></div>
|
<div id="divider"></div>
|
||||||
<div id="prev" class="arrow">‹</div>
|
<div id="prev" class="arrow">‹</div>
|
||||||
<div id="viewer"></div>
|
<div id="viewer"></div>
|
||||||
<div id="next" class="arrow">›</div>
|
<div id="next" class="arrow">›</div>
|
||||||
|
<div id="progress">0%</div>
|
||||||
|
<div id="loader"><img src="{{ url_for('static', filename='img/loader.gif') }}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal md-effect-1" id="settings-modal">
|
||||||
|
<div class="md-content">
|
||||||
|
<h3>{{_('Settings')}}</h3>
|
||||||
|
<div class="form-group themes" id="themes">
|
||||||
|
Choose a theme below: <br />
|
||||||
|
|
||||||
<div id="loader"><img src="{{ url_for('static', filename='img/loader.gif') }}"></div>
|
<!-- Hardcoded a tick in the light theme button because it is the "default" theme. Need to find a way to do this dynamically on startup-->
|
||||||
</div>
|
<button type="button" id="lightTheme" class="lightTheme" onclick="selectTheme(this.id)"><span
|
||||||
<div class="modal md-effect-1" id="settings-modal">
|
id="lightSelected">✓</span>{{_('Light')}}</button>
|
||||||
<div class="md-content">
|
<button type="button" id="darkTheme" class="darkTheme" onclick="selectTheme(this.id)"><span
|
||||||
<h3>{{_('Settings')}}</h3>
|
id="darkSelected"> </span>{{_('Dark')}}</button>
|
||||||
<div class="themes" id="themes">
|
<button type="button" id="sepiaTheme" class="sepiaTheme" onclick="selectTheme(this.id)"><span
|
||||||
Choose a theme below: <br />
|
id="sepiaSelected"> </span>{{_('Sepia')}}</button>
|
||||||
|
<button type="button" id="blackTheme" class="blackTheme" onclick="selectTheme(this.id)"><span
|
||||||
<!-- Hardcoded a tick in the light theme button because it is the "default" theme. Need to find a way to do this dynamically on startup-->
|
id="blackSelected"> </span>{{_('Black')}}</button>
|
||||||
<button type="button" id="lightTheme" class="lightTheme" onclick="selectTheme(this.id)"><span id="lightSelected">✓</span>{{_('Light')}}</button>
|
</div>
|
||||||
<button type="button" id="darkTheme" class="darkTheme" onclick="selectTheme(this.id)"><span id="darkSelected"> </span>{{_('Dark')}}</button>
|
<div class="form-group">
|
||||||
<button type="button" id="sepiaTheme" class="sepiaTheme" onclick="selectTheme(this.id)"><span id="sepiaSelected"> </span>{{_('Sepia')}}</button>
|
<p>
|
||||||
<button type="button" id="blackTheme" class="blackTheme" onclick="selectTheme(this.id)"><span id="blackSelected"> </span>{{_('Black')}}</button>
|
<input type="checkbox" id="sidebarReflow"
|
||||||
</div>
|
name="sidebarReflow">{{_('Reflow text when sidebars are open.')}}
|
||||||
<div>
|
</p>
|
||||||
<p>
|
</div>
|
||||||
<input type="checkbox" id="sidebarReflow" name="sidebarReflow">{{_('Reflow text when sidebars are open.')}}
|
<div class="form-group fontSizeWrapper">
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="fontSizeWrapper">
|
|
||||||
<div class="slider">
|
<div class="slider">
|
||||||
<label for="fader">{{ _('Font Sizes') }}</label>
|
<label for="fader">{{ _('Font Sizes') }}</label>
|
||||||
<input type="range" min="75" max="200" value="100" id="fontSizeFader" step="25">
|
<input type="range" min="75" max="200" value="100" id="fontSizeFader" step="25">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="font" id="font">
|
||||||
|
<label class="item">{{_('Font')}}:</label>
|
||||||
|
<button type="button" id="default" onclick="selectFont(this.id)"><span>✓</span>{{_('Default')}}</button>
|
||||||
|
<button type="button" id="Yahei" onclick="selectFont(this.id)"><span></span>{{_('Yahei')}}</button>
|
||||||
|
<button type="button" id="SimSun" onclick="selectFont(this.id)"><span></span>{{_('SimSun')}}</button>
|
||||||
|
<button type="button" id="KaiTi" onclick="selectFont(this.id)"><span></span>{{_('KaiTi')}}</button>
|
||||||
|
<button type="button" id="Arial" onclick="selectFont(this.id)"><span></span>{{_('Arial')}}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="closer icon-cancel-circled"></div>
|
<div class="layou" id="layout">
|
||||||
</div>
|
<label class="item">{{ _('Spread') }}:</label>
|
||||||
</div>
|
<button type="button" id="spread" onclick="spread(this.id)"><span>✓</span>{{_('Two columns')}}</button>
|
||||||
<div class="overlay"></div>
|
<button type="button" id="nonespread" onclick="spread(this.id)"><span></span>{{_('One column')}}</button>
|
||||||
<script src="{{ url_for('static', filename='js/libs/jquery.min.js') }}"></script>
|
</div>
|
||||||
<script src="{{ url_for('static', filename='js/compress/jszip_epub.min.js') }}">
|
<div class="closer icon-cancel-circled"></div>
|
||||||
</script> <script src="{{ url_for('static', filename='js/libs/epub.min.js') }}"></script>
|
</div>
|
||||||
<script type="text/javascript">
|
</div>
|
||||||
window.calibre = {
|
<div class="overlay"></div>
|
||||||
filePath: "{{ url_for('static', filename='js/libs/') }}",
|
<script src="{{ url_for('static', filename='js/libs/jquery.min.js') }}"></script>
|
||||||
cssPath: "{{ url_for('static', filename='css/') }}",
|
<script src="{{ url_for('static', filename='js/compress/jszip_epub.min.js') }}">
|
||||||
bookmarkUrl: "{{ url_for('web.set_bookmark', book_id=bookid, book_format='EPUB') }}",
|
</script>
|
||||||
bookUrl: "{{ url_for('web.serve_book', book_id=bookid, book_format='epub', anyname='file.epub') }}",
|
<script src="{{ url_for('static', filename='js/libs/epub.min.js') }}"></script>
|
||||||
bookmark: "{{ bookmark.bookmark_key if bookmark != None }}",
|
<script type="text/javascript">
|
||||||
useBookmarks: "{{ current_user.is_authenticated | tojson }}"
|
window.calibre = {
|
||||||
};
|
filePath: "{{ url_for('static', filename='js/libs/') }}",
|
||||||
|
cssPath: "{{ url_for('static', filename='css/') }}",
|
||||||
|
bookmarkUrl: "{{ url_for('web.set_bookmark', book_id=bookid, book_format='EPUB') }}",
|
||||||
|
bookUrl: "{{ url_for('web.serve_book', book_id=bookid, book_format='epub', anyname='file.epub') }}",
|
||||||
|
bookmark: "{{ bookmark.bookmark_key if bookmark != None }}",
|
||||||
|
useBookmarks: "{{ current_user.is_authenticated | tojson }}"
|
||||||
|
};
|
||||||
|
|
||||||
|
window.themes = {
|
||||||
|
"darkTheme": {
|
||||||
|
"bgColor": "#202124",
|
||||||
|
"css_path": "{{ url_for('static', filename='css/epub_themes.css') }}",
|
||||||
|
"title-color": "#fff"
|
||||||
|
},
|
||||||
|
"lightTheme": {
|
||||||
|
"bgColor": "white",
|
||||||
|
"css_path": "{{ url_for('static', filename='css/epub_themes.css') }}",
|
||||||
|
"title-color": "#4f4f4f"
|
||||||
|
},
|
||||||
|
"sepiaTheme": {
|
||||||
|
"bgColor": "#ece1ca",
|
||||||
|
"css_path": "{{ url_for('static', filename='css/epub_themes.css') }}",
|
||||||
|
"title-color": "#4f4f4f"
|
||||||
|
},
|
||||||
|
"blackTheme": {
|
||||||
|
"bgColor": "black",
|
||||||
|
"css_path": "{{ url_for('static', filename='css/epub_themes.css') }}",
|
||||||
|
"title-color": "#fff"
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function selectTheme (id) {
|
||||||
|
let tickSpans = document.getElementById("themes").querySelectorAll("span");
|
||||||
|
|
||||||
function selectTheme(id) {
|
|
||||||
tickSpans = document.getElementById("themes").querySelectorAll("span");
|
|
||||||
|
|
||||||
// Remove tick mark from all theme buttons
|
// Remove tick mark from all theme buttons
|
||||||
for(var i = 0; i < tickSpans.length; i++) {
|
tickSpans.forEach(function (tickSpan) {
|
||||||
document.getElementById(tickSpans[i].id).textContent = "";
|
document.getElementById(tickSpan.id).textContent = "";
|
||||||
}
|
});
|
||||||
|
|
||||||
// Add tick mark to the button corresponding to the currently selected theme
|
// Add tick mark to the button corresponding to the currently selected theme
|
||||||
document.getElementById(id).querySelector("span").textContent = "✓";
|
document.getElementById(id).querySelector("span").textContent = "✓";
|
||||||
|
|
||||||
|
// Saving theme to local storage
|
||||||
|
localStorage.setItem("calibre.reader.theme", id);
|
||||||
|
|
||||||
// Apply theme to epubjs iframe
|
// Apply theme to epubjs iframe
|
||||||
reader.rendition.themes.select(id);
|
reader.rendition.themes.select(id);
|
||||||
|
|
||||||
// Apply theme to rest of the page. TODO - Do this smarter
|
// Apply theme to rest of the page.
|
||||||
if (id == "darkTheme") {
|
document.getElementById("main").style.backgroundColor = themes[id]["bgColor"];
|
||||||
document.getElementById("main").style.backgroundColor = "#202124";
|
document.getElementById("titlebar").style.color = themes[id]["title-color"] || "#fff";
|
||||||
}
|
|
||||||
else if (id == "lightTheme") {
|
|
||||||
document.getElementById("main").style.backgroundColor = "white";
|
|
||||||
}
|
|
||||||
else if (id == "sepiaTheme") {
|
|
||||||
document.getElementById("main").style.backgroundColor = "#ece1ca";
|
|
||||||
}
|
|
||||||
else if (id == "blackTheme") {
|
|
||||||
document.getElementById("main").style.backgroundColor = "black";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// font size settings logic
|
// font size settings logic
|
||||||
let fontSizeFader = document.getElementById('fontSizeFader');
|
let fontSizeFader = document.getElementById('fontSizeFader');
|
||||||
fontSizeFader.addEventListener ("change", function () {
|
fontSizeFader.addEventListener("change", function () {
|
||||||
reader.rendition.themes.fontSize(`${this.value}%`)
|
reader.rendition.themes.fontSize(`${this.value}%`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let defaultFont;
|
||||||
|
|
||||||
|
function selectFont(id) {
|
||||||
|
if (!defaultFont) {
|
||||||
|
defaultFont = reader.rendition.getContents()[0]?.css('font-family');
|
||||||
|
}
|
||||||
|
|
||||||
|
spans = document.getElementById("font").querySelectorAll("span");
|
||||||
|
for(var i = 0; i < spans.length; i++) {
|
||||||
|
spans[i].textContent = "";
|
||||||
|
}
|
||||||
|
document.getElementById(id).querySelector("span").textContent = "✓";
|
||||||
|
|
||||||
|
if (id == 'default') {
|
||||||
|
reader.rendition.themes.font(defaultFont);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reader.rendition.themes.font(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spread(id) {
|
||||||
|
spans = document.getElementById("layout").querySelectorAll("span");
|
||||||
|
for(var i = 0; i < spans.length; i++) {
|
||||||
|
spans[i].textContent = "";
|
||||||
|
}
|
||||||
|
document.getElementById(id).querySelector("span").textContent = "✓";
|
||||||
|
reader.rendition.spread(id==='spread'?true:'none');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='js/libs/screenfull.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/libs/screenfull.min.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/libs/reader.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/libs/reader.min.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/reading/epub.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/reading/epub.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/reading/epub-progress.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user