Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70298c69b6 | ||
|
|
f0ca56522d | ||
|
|
ee124ba50c | ||
|
|
29b05b48a4 | ||
|
|
98967c926b | ||
|
|
7d7e762469 | ||
|
|
a48a047287 | ||
|
|
ec8afde6b2 | ||
|
|
458576a141 | ||
|
|
ae5ccf6e91 | ||
|
|
51eb6bddf6 | ||
|
|
57759e378c | ||
|
|
aecc68d11e |
@@ -11,7 +11,7 @@ assignees: ''
|
|||||||
|
|
||||||
After 6 years of more or less intensive programming on Calibre-Web, I need a break.
|
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.
|
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 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.
|
I will look into the issues and maybe also the PRs from time to time, but don't expect a quick response from me.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ assignees: ''
|
|||||||
|
|
||||||
After 6 years of more or less intensive programming on Calibre-Web, I need a break.
|
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.
|
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 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.
|
I will look into the issues and maybe also the PRs from time to time, but don't expect a quick response from me.
|
||||||
|
|
||||||
Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
|
Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
|
||||||
|
|||||||
+4
-4
@@ -20,7 +20,7 @@ Some of the user languages in Calibre-Web having missing translations. We are ha
|
|||||||
|
|
||||||
### **Documentation**
|
### **Documentation**
|
||||||
|
|
||||||
The Calibre-Web documentation is hosted in the GitHub [Wiki](https://github.com/janeczku/calibre-web/wiki). The Wiki is open to everybody, if you find a problem, feel free to correct it. If information is missing, you are welcome to add it. The content will be reviewed time by time. Please try to be consistent with the form with the other Wiki pages (e.g. the project name is Calibre-Web with 2 capital letters and a dash in between).
|
The Calibre-Web documentation is hosted in the Github [Wiki](https://github.com/janeczku/calibre-web/wiki). The Wiki is open to everybody, if you find a problem, feel free to correct it. If information is missing, you are welcome to add it. The content will be reviewed time by time. Please try to be consistent with the form with the other Wiki pages (e.g. the project name is Calibre-Web with 2 capital letters and a dash in between).
|
||||||
|
|
||||||
### **Reporting a bug**
|
### **Reporting a bug**
|
||||||
|
|
||||||
@@ -28,12 +28,12 @@ Do not open up a GitHub issue if the bug is a **security vulnerability** in Cali
|
|||||||
|
|
||||||
Ensure the **bug was not already reported** by searching on GitHub under [Issues](https://github.com/janeczku/calibre-web/issues). Please also check if a solution for your problem can be found in the [wiki](https://github.com/janeczku/calibre-web/wiki).
|
Ensure the **bug was not already reported** by searching on GitHub under [Issues](https://github.com/janeczku/calibre-web/issues). Please also check if a solution for your problem can be found in the [wiki](https://github.com/janeczku/calibre-web/wiki).
|
||||||
|
|
||||||
If you're unable to find an **open issue** addressing the problem, open a [new one](https://github.com/janeczku/calibre-web/issues/new/choose). Be sure to include a **title** and **clear description**, as much relevant information as possible, the **issue form** helps you provide the right information. Deleting the form and just pasting the stack trace doesn't speed up fixing the problem. If your issue could be resolved, consider closing the issue.
|
If you're unable to find an **open issue** addressing the problem, open a [new one](https://github.com/janeczku/calibre-web/issues/new/choose). Be sure to include a **title** and **clear description**, as much relevant information as possible, the **issue form** helps you providing the right information. Deleting the form and just pasting the stack trace doesn't speed up fixing the problem. If your issue could be resolved, consider closing the issue.
|
||||||
|
|
||||||
### **Feature Request**
|
### **Feature Request**
|
||||||
|
|
||||||
If there is a feature missing in Calibre-Web and you can't find a feature request in the [Issues](https://github.com/janeczku/calibre-web/issues) section, you could create a [feature request](https://github.com/janeczku/calibre-web/issues/new?assignees=&labels=&template=feature_request.md&title=).
|
If there is a feature missing in Calibre-Web and you can't find a feature request in the [Issues](https://github.com/janeczku/calibre-web/issues) section, you could create a [feature request](https://github.com/janeczku/calibre-web/issues/new?assignees=&labels=&template=feature_request.md&title=).
|
||||||
We will not extend Calibre-Web with any more login abilities or add further files storages, or file syncing ability. Calibre-Web is made for home usage for company in-house usage, so requests regarding any sorts of social interaction capability, payment routines, search engine or website analytics integration will not be implemented.
|
We will not extend Calibre-Web with any more login abilities or add further files storages, or file syncing ability. Furthermore Calibre-Web is made for home usage for company in-house usage, so requests regarding any sorts of social interaction capability, payment routines, search engine or web site analytics integration will not be implemented.
|
||||||
|
|
||||||
### **Contributing code to Calibre-Web**
|
### **Contributing code to Calibre-Web**
|
||||||
|
|
||||||
@@ -42,5 +42,5 @@ Open a new GitHub pull request with the patch. Ensure the PR description clearly
|
|||||||
In case your code enhances features of Calibre-Web: Create your pull request for the development branch if your enhancement consists of more than some lines of code in a local section of Calibre-Webs code. This makes it easier to test it and check all implication before it's made public.
|
In case your code enhances features of Calibre-Web: Create your pull request for the development branch if your enhancement consists of more than some lines of code in a local section of Calibre-Webs code. This makes it easier to test it and check all implication before it's made public.
|
||||||
|
|
||||||
Please check if your code runs with python 3, python 2 is no longer supported. If possible and the feature is related to operating system functions, try to check it on Windows and Linux.
|
Please check if your code runs with python 3, python 2 is no longer supported. If possible and the feature is related to operating system functions, try to check it on Windows and Linux.
|
||||||
Calibre-Web is automatically tested on Linux in combination with python 3.8. The code for testing is in a [separate repo](https://github.com/OzzieIsaacs/calibre-web-test) on GitHub. It uses unit tests and performs real system tests with selenium; it would be great if you could consider also writing some tests.
|
Calibre-Web is automatically tested on Linux in combination with python 3.8. The code for testing is in a [separate repo](https://github.com/OzzieIsaacs/calibre-web-test) on Github. It uses unit tests and performs real system tests with selenium; it would be great if you could consider also writing some tests.
|
||||||
A static code analysis is done by Codacy, but it's partly broken and doesn't run automatically. You could check your code with ESLint before contributing, a configuration file can be found in the projects root folder.
|
A static code analysis is done by Codacy, but it's partly broken and doesn't run automatically. You could check your code with ESLint before contributing, a configuration file can be found in the projects root folder.
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
graft src/calibreweb
|
graft src/calibreweb
|
||||||
global-exclude __pycache__
|
|
||||||
global-exclude *.pyc
|
|
||||||
|
|||||||
@@ -19,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. [Troubleshooting](#troubleshooting)
|
5. [Contributor Recognition](#contributor-recognition)
|
||||||
6. [Contributor Recognition](#contributor-recognition)
|
6. [Contact](#contact)
|
||||||
7. [Contact](#contact)
|
7. [Contributing to Calibre-Web](#contributing-to-calibre-web)
|
||||||
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.*
|
||||||
|
|
||||||

|

|
||||||
@@ -57,103 +57,52 @@ 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`
|
||||||
|
|
||||||
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:
|
*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.*
|
||||||
```
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
*Note: Users of Raspberry Pi OS may encounter installation issues. If you do, try upgrading pip and/or installing cargo as follows:*
|
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).
|
||||||
```
|
|
||||||
./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. **Access Calibre-Web**: Open your browser and navigate to:
|
1. Open your browser and navigate to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
|
||||||
```
|
2. Log in with the default admin credentials
|
||||||
http://localhost:8083
|
3. If you don't have a Calibre database, you can use [this database](https://github.com/janeczku/calibre-web/raw/master/library/metadata.db) (move it out of the Calibre-Web folder to prevent overwriting during updates)
|
||||||
```
|
4. Set `Location of Calibre database` to the path of the folder containing your Calibre library (metadata.db) and click "Save"
|
||||||
or for the OPDS catalog:
|
5. Optionally, use Google Drive to host your Calibre library by following the [Google Drive integration guide](https://github.com/janeczku/calibre-web/wiki/G-Drive-Setup#using-google-drive-integration)
|
||||||
```
|
6. Configure your Calibre-Web instance via the admin page, referring to the [Basic Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#basic-configuration) and [UI Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#ui-configuration) guides
|
||||||
http://localhost:8083/opds
|
|
||||||
```
|
#### Default Admin Login:
|
||||||
2. **Log in**: Use the default admin credentials:
|
- **Username:** admin
|
||||||
- **Username:** admin
|
- **Password:** admin123
|
||||||
- **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 Version**: Ensure you have Python 3.7 or newer.
|
- Python 3.7+
|
||||||
- **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.
|
- [Imagemagick](https://imagemagick.org/script/download.php) for cover extraction from EPUBs (Windows users may need to install [Ghostscript](https://ghostscript.com/releases/gsdnld.html) for PDF cover extraction)
|
||||||
- **Optional Tools**:
|
- Windows users need to install [libmagic for 32bit python](https://gnuwin32.sourceforge.net/downlinks/file.php) or [libmagic for 64bit python](https://github.com/nscaife/file-windows/releases/tag/20170108), depending on the python version; The files need to be installed in path (e.g. script folder of your Calibre-Web venv, or in the root folder of Calibre-Web
|
||||||
- **Calibre desktop program**: Recommended for on-the-fly conversion and metadata editing. Set the path to Calibre’s converter tool on the setup page.
|
- Optional: [Calibre desktop program](https://calibre-ebook.com/download) for on-the-fly conversion and metadata editing (set "calibre's converter tool" path on the setup page)
|
||||||
- **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.
|
- 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)
|
||||||
|
|
||||||
## Docker Images
|
## Docker Images
|
||||||
|
|
||||||
Pre-built Docker images are available:
|
Pre-built Docker images are available in the following Docker Hub repositories (maintained by the LinuxServer team):
|
||||||
|
|
||||||
### **LinuxServer - x64, aarch64**
|
#### **LinuxServer - x64, aarch64**
|
||||||
- **Docker Hub**: [linuxserver/calibre-web](https://hub.docker.com/r/linuxserver/calibre-web)
|
- [Docker Hub](https://hub.docker.com/r/linuxserver/calibre-web)
|
||||||
- **GitHub**: [linuxserver/docker-calibre-web](https://github.com/linuxserver/docker-calibre-web)
|
- [GitHub](https://github.com/linuxserver/docker-calibre-web)
|
||||||
- **Optional Calibre layer**: [linuxserver/docker-mods](https://github.com/linuxserver/docker-mods/tree/universal-calibre)
|
- [GitHub - Optional Calibre layer](https://github.com/linuxserver/docker-mods/tree/universal-calibre)
|
||||||
|
|
||||||
To include the Calibre `ebook-convert` binary (x64 only), add the environment variable:
|
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.
|
||||||
```
|
|
||||||
DOCKER_MODS=linuxserver/mods:universal-calibre
|
|
||||||
```
|
|
||||||
in your Docker run/compose file. Omit this variable for a lightweight image.
|
|
||||||
|
|
||||||
- **Paths Configuration**:
|
Both the Calibre-Web and Calibre-Mod images are automatically rebuilt on new releases and updates.
|
||||||
- Set **Path to Calibre Binaries** to `/usr/bin`.
|
|
||||||
- Set **Path to Unrar** to `/usr/bin/unrar`.
|
|
||||||
|
|
||||||
## Troubleshooting
|
- Set "Path to Calibre Binaries" to `/usr/bin`
|
||||||
|
- 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.
|
|
||||||
- You need to enable uploads under `Basic settings` for this option to appear
|
|
||||||
|
|
||||||
- **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
|
||||||
|
|
||||||
@@ -167,21 +116,4 @@ For more information, How To's, and FAQs, please visit the [Wiki](https://github
|
|||||||
|
|
||||||
## Contributing to Calibre-Web
|
## Contributing to Calibre-Web
|
||||||
|
|
||||||
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.
|
Check out our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|||||||
+38
-38
@@ -10,44 +10,44 @@ 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 | 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.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 | |
|
| V 0.6.23 | Cookies are no longer stored for opds basic authentication and proxy authentication ||
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,26 +42,9 @@ def hide_console_windows():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# Only hide the console window on Windows when there is no interactive
|
if os.name == "nt":
|
||||||
# console attached. This preserves the ability to use Ctrl+C from a
|
|
||||||
# terminal (stdin is a tty) to stop the server.
|
|
||||||
if os.name == "nt" and not sys.stdin.isatty():
|
|
||||||
hide_console_windows()
|
hide_console_windows()
|
||||||
|
main()
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
# Allow stopping the server with Ctrl+C from an interactive console, after reboot of calibre-web
|
|
||||||
# (initally the SIGINT signal CTRL+C is catched in server.py via the signal callback).
|
|
||||||
try:
|
|
||||||
# Try to perform a graceful stop if web_server is available.
|
|
||||||
# Import here to avoid import-time side-effects when not needed.
|
|
||||||
from cps import web_server
|
|
||||||
web_server.stop()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
print('\nCalibre-Web: received interrupt, shutting down')
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+17
-23
@@ -26,16 +26,17 @@ import os
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask.sessions import SecureCookieSessionInterface
|
|
||||||
from .MyLoginManager import MyLoginManager
|
from .MyLoginManager import MyLoginManager
|
||||||
from flask_principal import Principal
|
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
|
||||||
@@ -56,17 +57,17 @@ mimetypes.init()
|
|||||||
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
||||||
mimetypes.add_type('application/epub+zip', '.epub')
|
mimetypes.add_type('application/epub+zip', '.epub')
|
||||||
mimetypes.add_type('application/epub+zip', '.kepub')
|
mimetypes.add_type('application/epub+zip', '.kepub')
|
||||||
mimetypes.add_type('application/fb2+zip', '.fb2')
|
mimetypes.add_type('text/xml', '.fb2')
|
||||||
mimetypes.add_type('application/x-mobipocket-ebook', '.mobi')
|
mimetypes.add_type('application/octet-stream', '.mobi')
|
||||||
mimetypes.add_type('application/octet-stream', '.prc')
|
mimetypes.add_type('application/octet-stream', '.prc')
|
||||||
mimetypes.add_type('application/x-mobipocket-ebook', '.azw')
|
mimetypes.add_type('application/vnd.amazon.ebook', '.azw')
|
||||||
mimetypes.add_type('application/x-mobipocket-ebook', '.azw3')
|
mimetypes.add_type('application/x-mobi8-ebook', '.azw3')
|
||||||
mimetypes.add_type('application/x-cbr', '.cbr')
|
mimetypes.add_type('application/x-rar', '.cbr')
|
||||||
mimetypes.add_type('application/x-cbz', '.cbz')
|
mimetypes.add_type('application/zip', '.cbz')
|
||||||
mimetypes.add_type('application/x-tar', '.cbt')
|
mimetypes.add_type('application/x-tar', '.cbt')
|
||||||
mimetypes.add_type('application/x-7z-compressed', '.cb7')
|
mimetypes.add_type('application/x-7z-compressed', '.cb7')
|
||||||
mimetypes.add_type('image/vnd.djvu', '.djv')
|
mimetypes.add_type('image/vnd.djv', '.djv')
|
||||||
mimetypes.add_type('image/vnd.djvu', '.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('audio/x-m4a', '.m4a')
|
mimetypes.add_type('audio/x-m4a', '.m4a')
|
||||||
@@ -78,15 +79,14 @@ mimetypes.add_type('audio/ogg', '.ogg')
|
|||||||
mimetypes.add_type('application/ogg', '.oga')
|
mimetypes.add_type('application/ogg', '.oga')
|
||||||
mimetypes.add_type('text/css', '.css')
|
mimetypes.add_type('text/css', '.css')
|
||||||
mimetypes.add_type('application/x-ms-reader', '.lit')
|
mimetypes.add_type('application/x-ms-reader', '.lit')
|
||||||
mimetypes.add_type('text/javascript', '.js')
|
mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
|
||||||
mimetypes.add_type('text/rtf', '.rtf')
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.update(
|
app.config.update(
|
||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SAMESITE='Lax',
|
SESSION_COOKIE_SAMESITE='Strict',
|
||||||
REMEMBER_COOKIE_SAMESITE='Strict',
|
REMEMBER_COOKIE_SAMESITE='Strict',
|
||||||
WTF_CSRF_SSL_STRICT=False,
|
WTF_CSRF_SSL_STRICT=False,
|
||||||
SESSION_COOKIE_NAME=os.environ.get('COOKIE_PREFIX', "") + "session",
|
SESSION_COOKIE_NAME=os.environ.get('COOKIE_PREFIX', "") + "session",
|
||||||
@@ -104,26 +104,19 @@ if wtf_present:
|
|||||||
else:
|
else:
|
||||||
csrf = None
|
csrf = None
|
||||||
|
|
||||||
calibre_db = db.CalibreDB(app)
|
calibre_db = db.CalibreDB()
|
||||||
|
|
||||||
web_server = WebServer()
|
web_server = WebServer()
|
||||||
|
|
||||||
updater_thread = Updater()
|
updater_thread = Updater()
|
||||||
|
|
||||||
if limiter_present:
|
if limiter_present:
|
||||||
limiter = Limiter(key_func=True, headers_enabled=True, in_memory_fallback_enabled=True, default_limits=[],
|
limiter = Limiter(key_func=True, headers_enabled=True, auto_check=False, swallow_errors=False)
|
||||||
swallow_errors=True)
|
|
||||||
else:
|
else:
|
||||||
limiter = None
|
limiter = None
|
||||||
|
|
||||||
class ScriptNameSessionInterface(SecureCookieSessionInterface):
|
|
||||||
def get_cookie_path(self, app):
|
|
||||||
# Called once per response, after request context exists
|
|
||||||
return app.wsgi_app.script_name.rstrip("/") or "/"
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app.session_interface = ScriptNameSessionInterface()
|
|
||||||
if csrf:
|
if csrf:
|
||||||
csrf.init_app(app)
|
csrf.init_app(app)
|
||||||
|
|
||||||
@@ -155,7 +148,9 @@ def create_app():
|
|||||||
lm.anonymous_user = ub.Anonymous
|
lm.anonymous_user = ub.Anonymous
|
||||||
lm.session_protection = 'strong' if config.config_session == 1 else "basic"
|
lm.session_protection = 'strong' if config.config_session == 1 else "basic"
|
||||||
|
|
||||||
db.CalibreDB.update_config(config, config.config_calibre_dir, cli_param.settings_path)
|
db.CalibreDB.update_config(config)
|
||||||
|
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path)
|
||||||
|
calibre_db.init_db()
|
||||||
|
|
||||||
updater_thread.init_updater(config, web_server)
|
updater_thread.init_updater(config, web_server)
|
||||||
# Perform dry run of updater and exit afterward
|
# Perform dry run of updater and exit afterward
|
||||||
@@ -188,7 +183,6 @@ 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)
|
||||||
|
|||||||
+7
-5
@@ -23,7 +23,7 @@
|
|||||||
import sys
|
import sys
|
||||||
import platform
|
import platform
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from importlib.metadata import metadata
|
import importlib
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
@@ -41,18 +41,20 @@ req = dep_check.load_dependencies(False)
|
|||||||
opt = dep_check.load_dependencies(True)
|
opt = dep_check.load_dependencies(True)
|
||||||
for i in (req + opt):
|
for i in (req + opt):
|
||||||
modules[i[1]] = i[0]
|
modules[i[1]] = i[0]
|
||||||
modules['Jinja2'] = metadata("jinja2")["Version"]
|
modules['Jinja2'] = importlib.metadata.version("jinja2")
|
||||||
if sys.version_info < (3, 12):
|
try:
|
||||||
modules['pySqlite'] = sqlite3.version
|
modules['pySqlite'] = sqlite3.version
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
modules['SQLite'] = sqlite3.sqlite_version
|
modules['SQLite'] = sqlite3.sqlite_version
|
||||||
sorted_modules = OrderedDict((sorted(modules.items(), key=lambda x: x[0].casefold())))
|
sorted_modules = OrderedDict((sorted(modules.items(), key=lambda x: x[0].casefold())))
|
||||||
|
|
||||||
|
|
||||||
def collect_stats():
|
def collect_stats():
|
||||||
if constants.NIGHTLY_VERSION[0] == "$Format:%H$":
|
if constants.NIGHTLY_VERSION[0] == "$Format:%H$":
|
||||||
calibre_web_version = constants.STABLE_VERSION.replace("b", " Beta")
|
calibre_web_version = constants.STABLE_VERSION['version'].replace("b", " Beta")
|
||||||
else:
|
else:
|
||||||
calibre_web_version = (constants.STABLE_VERSION.replace("b", " Beta") + ' - '
|
calibre_web_version = (constants.STABLE_VERSION['version'].replace("b", " Beta") + ' - '
|
||||||
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
|
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
|
||||||
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'))
|
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'))
|
||||||
|
|
||||||
|
|||||||
+41
-68
@@ -32,15 +32,14 @@ from datetime import time as datetime_time
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, \
|
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
|
||||||
send_from_directory, g, jsonify
|
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from .cw_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, format_time, format_datetime, format_timedelta
|
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
|
||||||
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, ArgumentError
|
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
|
||||||
from sqlalchemy.sql.expression import func, or_, text
|
from sqlalchemy.sql.expression import func, or_, text
|
||||||
|
|
||||||
from . import constants, logger, helper, services, cli_param
|
from . import constants, logger, helper, services, cli_param
|
||||||
@@ -53,7 +52,7 @@ from .gdriveutils import is_gdrive_ready, gdrive_support
|
|||||||
from .render_template import render_title_template, get_sidebar_config
|
from .render_template import render_title_template, get_sidebar_config
|
||||||
from .services.worker import WorkerThread
|
from .services.worker import WorkerThread
|
||||||
from .usermanagement import user_login_required
|
from .usermanagement import user_login_required
|
||||||
from .cw_babel import get_available_translations, get_available_locale, get_user_locale_language
|
from .babel import get_available_translations, get_available_locale, get_user_locale_language
|
||||||
from . import debug_info
|
from . import debug_info
|
||||||
from .string_helper import strip_whitespaces
|
from .string_helper import strip_whitespaces
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ def before_request():
|
|||||||
g.allow_upload = config.config_uploading
|
g.allow_upload = config.config_uploading
|
||||||
g.current_theme = config.config_theme
|
g.current_theme = config.config_theme
|
||||||
g.config_authors_max = config.config_authors_max
|
g.config_authors_max = config.config_authors_max
|
||||||
if ('/static/' not in request.path and not config.db_configured and
|
if '/static/' not in request.path and not config.db_configured and \
|
||||||
request.endpoint not in ('admin.ajax_db_config',
|
request.endpoint not in ('admin.ajax_db_config',
|
||||||
'admin.simulatedbchange',
|
'admin.simulatedbchange',
|
||||||
'admin.db_configuration',
|
'admin.db_configuration',
|
||||||
@@ -127,7 +126,7 @@ def before_request():
|
|||||||
'web.login_post',
|
'web.login_post',
|
||||||
'web.logout',
|
'web.logout',
|
||||||
'admin.load_dialogtexts',
|
'admin.load_dialogtexts',
|
||||||
'admin.ajax_pathchooser')):
|
'admin.ajax_pathchooser'):
|
||||||
return redirect(url_for('admin.db_configuration'))
|
return redirect(url_for('admin.db_configuration'))
|
||||||
|
|
||||||
|
|
||||||
@@ -145,6 +144,7 @@ def shutdown():
|
|||||||
show_text = {}
|
show_text = {}
|
||||||
if task in (0, 1): # valid commandos received
|
if task in (0, 1): # valid commandos received
|
||||||
# close all database connections
|
# close all database connections
|
||||||
|
calibre_db.dispose()
|
||||||
ub.dispose()
|
ub.dispose()
|
||||||
|
|
||||||
if task == 0:
|
if task == 0:
|
||||||
@@ -189,8 +189,8 @@ def reconnect():
|
|||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/updateThumbnails", methods=['POST'])
|
@admi.route("/ajax/updateThumbnails", methods=['POST'])
|
||||||
@user_login_required
|
|
||||||
@admin_required
|
@admin_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']:
|
||||||
@@ -306,15 +306,7 @@ 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:
|
||||||
try:
|
custom_values = calibre_db.session.query(db.cc_classes[config.config_restricted_column]).all()
|
||||||
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:
|
||||||
@@ -349,9 +341,8 @@ def list_users():
|
|||||||
if sort not in ub.User.__table__.columns.keys():
|
if sort not in ub.User.__table__.columns.keys():
|
||||||
sort = "id"
|
sort = "id"
|
||||||
order = request.args.get("order", "").lower()
|
order = request.args.get("order", "").lower()
|
||||||
|
|
||||||
if sort != "state" and order:
|
if sort != "state" and order:
|
||||||
if not order in ["asc", "desc"]:
|
|
||||||
order = "asc"
|
|
||||||
order = text(sort + " " + order)
|
order = text(sort + " " + order)
|
||||||
elif not state:
|
elif not state:
|
||||||
order = ub.User.id.asc()
|
order = ub.User.id.asc()
|
||||||
@@ -380,25 +371,29 @@ def list_users():
|
|||||||
user.default = get_user_locale_language(user.default_language)
|
user.default = get_user_locale_language(user.default_language)
|
||||||
|
|
||||||
table_entries = {'totalNotFiltered': total_count, 'total': filtered_count, "rows": users}
|
table_entries = {'totalNotFiltered': total_count, 'total': filtered_count, "rows": users}
|
||||||
return make_response(json.dumps(table_entries, cls=db.AlchemyEncoder))
|
js_list = json.dumps(table_entries, cls=db.AlchemyEncoder)
|
||||||
|
response = make_response(js_list)
|
||||||
|
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/deleteuser", methods=['POST'])
|
@admi.route("/ajax/deleteuser", methods=['POST'])
|
||||||
@user_login_required
|
@user_login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete_user():
|
def delete_user():
|
||||||
user_ids = request.get_json().get("userid")
|
user_ids = request.form.to_dict(flat=False)
|
||||||
|
users = None
|
||||||
message = ""
|
message = ""
|
||||||
try:
|
if "userid[]" in user_ids:
|
||||||
users = ub.session.query(ub.User).filter(ub.User.id.in_(user_ids)).all()
|
users = ub.session.query(ub.User).filter(ub.User.id.in_(user_ids['userid[]'])).all()
|
||||||
except (ArgumentError):
|
elif "userid" in user_ids:
|
||||||
users = None
|
users = ub.session.query(ub.User).filter(ub.User.id == user_ids['userid'][0]).all()
|
||||||
count = 0
|
count = 0
|
||||||
errors = list()
|
errors = list()
|
||||||
success = list()
|
success = list()
|
||||||
if not users:
|
if not users:
|
||||||
log.error("User not found")
|
log.error("User not found")
|
||||||
return make_response(jsonify(type="danger", message=_("User not found")))
|
return Response(json.dumps({'type': "danger", 'message': _("User not found")}), mimetype='application/json')
|
||||||
for user in users:
|
for user in users:
|
||||||
try:
|
try:
|
||||||
message = _delete_user(user)
|
message = _delete_user(user)
|
||||||
@@ -408,13 +403,13 @@ def delete_user():
|
|||||||
errors.append({'type': "danger", 'message': str(ex)})
|
errors.append({'type': "danger", 'message': str(ex)})
|
||||||
|
|
||||||
if count == 1:
|
if count == 1:
|
||||||
log.info("User {} deleted".format(user_ids[0]))
|
log.info("User {} deleted".format(user_ids))
|
||||||
success = [{'type': "success", 'message': message}]
|
success = [{'type': "success", 'message': message}]
|
||||||
elif count > 1:
|
elif count > 1:
|
||||||
log.info("Users {} deleted".format(", ".join([str(user_id) for user_id in user_ids])))
|
log.info("Users {} deleted".format(user_ids))
|
||||||
success = [{'type': "success", 'message': _("{} users deleted successfully").format(count)}]
|
success = [{'type': "success", 'message': _("{} users deleted successfully").format(count)}]
|
||||||
success.extend(errors)
|
success.extend(errors)
|
||||||
return make_response(jsonify(success))
|
return Response(json.dumps(success), mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/getlocale")
|
@admi.route("/ajax/getlocale")
|
||||||
@@ -426,7 +421,7 @@ def table_get_locale():
|
|||||||
current_locale = get_locale()
|
current_locale = get_locale()
|
||||||
for loc in locale:
|
for loc in locale:
|
||||||
ret.append({'value': str(loc), 'text': loc.get_language_name(current_locale)})
|
ret.append({'value': str(loc), 'text': loc.get_language_name(current_locale)})
|
||||||
return json.dumps(sorted(ret, key=lambda x: x['text']))
|
return json.dumps(ret)
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/getdefaultlanguage")
|
@admi.route("/ajax/getdefaultlanguage")
|
||||||
@@ -438,7 +433,7 @@ def table_get_default_lang():
|
|||||||
ret.append({'value': 'all', 'text': _('Show All')})
|
ret.append({'value': 'all', 'text': _('Show All')})
|
||||||
for lang in languages:
|
for lang in languages:
|
||||||
ret.append({'value': lang.lang_code, 'text': lang.name})
|
ret.append({'value': lang.lang_code, 'text': lang.name})
|
||||||
return json.dumps(sorted(ret, key=lambda x: x['text']))
|
return json.dumps(ret)
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/editlistusers/<param>", methods=['POST'])
|
@admi.route("/ajax/editlistusers/<param>", methods=['POST'])
|
||||||
@@ -496,10 +491,10 @@ def edit_list_user(param):
|
|||||||
if not ub.session.query(ub.User). \
|
if not ub.session.query(ub.User). \
|
||||||
filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
|
filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
|
||||||
ub.User.id != user.id).count():
|
ub.User.id != user.id).count():
|
||||||
return make_response(
|
return Response(
|
||||||
jsonify([{'type': "danger",
|
json.dumps([{'type': "danger",
|
||||||
'message': _("No admin user remaining, can't remove admin role",
|
'message': _("No admin user remaining, can't remove admin role",
|
||||||
nick=user.name)}]))
|
nick=user.name)}]), mimetype='application/json')
|
||||||
user.role &= ~value
|
user.role &= ~value
|
||||||
else:
|
else:
|
||||||
raise Exception(_("Value has to be true or false"))
|
raise Exception(_("Value has to be true or false"))
|
||||||
@@ -618,8 +613,6 @@ def load_dialogtexts(element_id):
|
|||||||
texts["main"] = _('Do you really want to delete this domain?')
|
texts["main"] = _('Do you really want to delete this domain?')
|
||||||
elif element_id == "btndeluser":
|
elif element_id == "btndeluser":
|
||||||
texts["main"] = _('Do you really want to delete this user?')
|
texts["main"] = _('Do you really want to delete this user?')
|
||||||
elif element_id == "btndelbook":
|
|
||||||
texts["main"] = _('Do you really want to delete this book?')
|
|
||||||
elif element_id == "delete_shelf":
|
elif element_id == "delete_shelf":
|
||||||
texts["main"] = _('Are you sure you want to delete this shelf?')
|
texts["main"] = _('Are you sure you want to delete this shelf?')
|
||||||
elif element_id == "select_locale":
|
elif element_id == "select_locale":
|
||||||
@@ -628,10 +621,6 @@ def load_dialogtexts(element_id):
|
|||||||
texts["main"] = _('Are you sure you want to change visible book languages for selected user(s)?')
|
texts["main"] = _('Are you sure you want to change visible book languages for selected user(s)?')
|
||||||
elif element_id == "role":
|
elif element_id == "role":
|
||||||
texts["main"] = _('Are you sure you want to change the selected role for the selected user(s)?')
|
texts["main"] = _('Are you sure you want to change the selected role for the selected user(s)?')
|
||||||
elif element_id == "archive_books":
|
|
||||||
texts["main"] = _('Are you sure you want to change the archive status for the selected book(s)?')
|
|
||||||
elif element_id == "read_books":
|
|
||||||
texts["main"] = _('Are you sure you want to change the read status for the selected book(s)?')
|
|
||||||
elif element_id == "restrictions":
|
elif element_id == "restrictions":
|
||||||
texts["main"] = _('Are you sure you want to change the selected restrictions for the selected user(s)?')
|
texts["main"] = _('Are you sure you want to change the selected restrictions for the selected user(s)?')
|
||||||
elif element_id == "sidebar_view":
|
elif element_id == "sidebar_view":
|
||||||
@@ -951,7 +940,7 @@ def do_full_kobo_sync(userid):
|
|||||||
count = ub.session.query(ub.KoboSyncedBooks).filter(userid == ub.KoboSyncedBooks.user_id).delete()
|
count = ub.session.query(ub.KoboSyncedBooks).filter(userid == ub.KoboSyncedBooks.user_id).delete()
|
||||||
message = _("{} sync entries deleted").format(count)
|
message = _("{} sync entries deleted").format(count)
|
||||||
ub.session_commit(message)
|
ub.session_commit(message)
|
||||||
return make_response(jsonify(type="success", message=message))
|
return Response(json.dumps([{"type": "success", "message": message}]), mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
def check_valid_read_column(column):
|
def check_valid_read_column(column):
|
||||||
@@ -993,14 +982,8 @@ 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:
|
||||||
try:
|
tags = calibre_db.session.query(db.cc_classes[config.config_restricted_column]) \
|
||||||
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()
|
||||||
.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":
|
||||||
@@ -1268,7 +1251,7 @@ def _configuration_ldap_helper(to_save):
|
|||||||
@admin_required
|
@admin_required
|
||||||
def simulatedbchange():
|
def simulatedbchange():
|
||||||
db_change, db_valid = _db_simulate_change()
|
db_change, db_valid = _db_simulate_change()
|
||||||
return make_response(jsonify(change=db_change, valid=db_valid))
|
return Response(json.dumps({"change": db_change, "valid": db_valid}), mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/user/new", methods=["GET", "POST"])
|
@admi.route("/admin/user/new", methods=["GET", "POST"])
|
||||||
@@ -1740,12 +1723,14 @@ def _db_configuration_update_helper():
|
|||||||
return _db_configuration_result(_("Books path not valid"), gdrive_error)
|
return _db_configuration_result(_("Books path not valid"), gdrive_error)
|
||||||
else:
|
else:
|
||||||
_config_string(to_save, "config_calibre_split_dir")
|
_config_string(to_save, "config_calibre_split_dir")
|
||||||
if (db_change or not db_valid or not config.db_configured
|
|
||||||
or config.config_calibre_dir != to_save["config_calibre_dir"]):
|
if db_change or not db_valid or not config.db_configured \
|
||||||
|
or config.config_calibre_dir != to_save["config_calibre_dir"]:
|
||||||
if not os.path.exists(metadata_db) or not to_save['config_calibre_dir']:
|
if not os.path.exists(metadata_db) or not to_save['config_calibre_dir']:
|
||||||
return _db_configuration_result(_('DB Location is not Valid, Please Enter Correct Path'), gdrive_error)
|
return _db_configuration_result(_('DB Location is not Valid, Please Enter Correct Path'), gdrive_error)
|
||||||
else:
|
else:
|
||||||
calibre_db.setup_db(to_save['config_calibre_dir'], ub.app_DB_path)
|
calibre_db.setup_db(to_save['config_calibre_dir'], ub.app_DB_path)
|
||||||
|
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
||||||
# if db changed -> delete shelfs, delete download books, delete read books, kobo sync...
|
# if db changed -> delete shelfs, delete download books, delete read books, kobo sync...
|
||||||
if db_change:
|
if db_change:
|
||||||
log.info("Calibre Database changed, all Calibre-Web info related to old Database gets deleted")
|
log.info("Calibre Database changed, all Calibre-Web info related to old Database gets deleted")
|
||||||
@@ -1759,20 +1744,11 @@ 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, config.config_calibre_dir, ub.app_DB_path)
|
calibre_db.update_config(config)
|
||||||
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
|
||||||
if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK):
|
if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK):
|
||||||
flash(_("DB is not Writeable"), category="warning")
|
flash(_("DB is not Writeable"), category="warning")
|
||||||
calibre_db.update_config(config, config.config_calibre_dir, ub.app_DB_path)
|
calibre_db.update_config(config)
|
||||||
config.save()
|
config.save()
|
||||||
return _db_configuration_result(None, gdrive_error)
|
return _db_configuration_result(None, gdrive_error)
|
||||||
|
|
||||||
@@ -1839,9 +1815,6 @@ def _configuration_update_helper():
|
|||||||
services.goodreads_support.connect(config.config_goodreads_api_key,
|
services.goodreads_support.connect(config.config_goodreads_api_key,
|
||||||
config.config_use_goodreads)
|
config.config_use_goodreads)
|
||||||
|
|
||||||
# Google Books API configuration
|
|
||||||
reboot_required |=_config_string(to_save, "config_googlebooks_api_key")
|
|
||||||
|
|
||||||
_config_int(to_save, "config_updatechannel")
|
_config_int(to_save, "config_updatechannel")
|
||||||
|
|
||||||
# Reverse proxy login configuration
|
# Reverse proxy login configuration
|
||||||
@@ -1903,7 +1876,7 @@ def _configuration_result(error_flash=None, reboot=False):
|
|||||||
resp['result'] = [{'type': "success", 'message': _("Calibre-Web configuration updated")}]
|
resp['result'] = [{'type': "success", 'message': _("Calibre-Web configuration updated")}]
|
||||||
resp['reboot'] = reboot
|
resp['reboot'] = reboot
|
||||||
resp['config_upload'] = config.config_upload_formats
|
resp['config_upload'] = config.config_upload_formats
|
||||||
return make_response(jsonify(resp))
|
return Response(json.dumps(resp), mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
def _db_configuration_result(error_flash=None, gdrive_error=None):
|
def _db_configuration_result(error_flash=None, gdrive_error=None):
|
||||||
@@ -2108,7 +2081,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"=(.*?)($|(?<!\\),)", user, re.IGNORECASE | re.UNICODE)
|
match = re.search(field + r"=([@\.\d\s\w-]+)", user, re.IGNORECASE | re.UNICODE)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
else:
|
else:
|
||||||
@@ -2116,7 +2089,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(r"([a-zA-Z0-9-]+)=%s", filtr, re.IGNORECASE | re.UNICODE)
|
match = re.search("([a-zA-Z0-9-]+)=%s", filtr, re.IGNORECASE | re.UNICODE)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
else:
|
else:
|
||||||
|
|||||||
+12
-6
@@ -16,6 +16,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/>.
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
import mutagen
|
import mutagen
|
||||||
import base64
|
import base64
|
||||||
from . import cover, logger
|
from . import cover, logger
|
||||||
@@ -49,12 +51,13 @@ def get_audio_file_info(tmp_file_path, original_file_extension, original_file_na
|
|||||||
if not pubdate:
|
if not pubdate:
|
||||||
pubdate = str(audio_file.tags.get('TDOR').text[0]) if "TDOR" in audio_file.tags else None
|
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:
|
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]
|
cover_info = cover_data[0]
|
||||||
for dat in cover_data:
|
for dat in cover_data:
|
||||||
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
|
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
|
||||||
cover_info = dat
|
cover_info = dat
|
||||||
break
|
break
|
||||||
tmp_cover_name = cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
||||||
elif original_file_extension in [".ogg", ".flac", ".opus", ".ogv"]:
|
elif original_file_extension in [".ogg", ".flac", ".opus", ".ogv"]:
|
||||||
title = audio_file.tags.get('TITLE')[0] if "TITLE" in audio_file else None
|
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
|
author = audio_file.tags.get('ARTIST')[0] if "ARTIST" in audio_file else None
|
||||||
@@ -67,15 +70,17 @@ def get_audio_file_info(tmp_file_path, original_file_extension, original_file_na
|
|||||||
cover_data = audio_file.tags.get('METADATA_BLOCK_PICTURE')
|
cover_data = audio_file.tags.get('METADATA_BLOCK_PICTURE')
|
||||||
if not no_cover_processing:
|
if not no_cover_processing:
|
||||||
if cover_data:
|
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_info = mutagen.flac.Picture(base64.b64decode(cover_data[0]))
|
||||||
tmp_cover_name = cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
||||||
if hasattr(audio_file, "pictures"):
|
if hasattr(audio_file, "pictures"):
|
||||||
cover_info = audio_file.pictures[0]
|
cover_info = audio_file.pictures[0]
|
||||||
for dat in audio_file.pictures:
|
for dat in audio_file.pictures:
|
||||||
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
|
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
|
||||||
cover_info = dat
|
cover_info = dat
|
||||||
break
|
break
|
||||||
tmp_cover_name = cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
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"]:
|
elif original_file_extension in [".aac"]:
|
||||||
title = audio_file.tags.get('Title').value if "Title" in audio_file else None
|
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
|
author = audio_file.tags.get('Artist').value if "Artist" in audio_file else None
|
||||||
@@ -87,7 +92,7 @@ def get_audio_file_info(tmp_file_path, original_file_extension, original_file_na
|
|||||||
pubdate = audio_file.tags.get('Year').value if "Year" 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)']
|
cover_data = audio_file.tags['Cover Art (Front)']
|
||||||
if cover_data and not no_cover_processing:
|
if cover_data and not no_cover_processing:
|
||||||
tmp_cover_name = tmp_file_path + '.jpg'
|
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||||
with open(tmp_cover_name, "wb") as cover_file:
|
with open(tmp_cover_name, "wb") as cover_file:
|
||||||
cover_file.write(cover_data.value.split(b"\x00",1)[1])
|
cover_file.write(cover_data.value.split(b"\x00",1)[1])
|
||||||
elif original_file_extension in [".asf"]:
|
elif original_file_extension in [".asf"]:
|
||||||
@@ -101,7 +106,7 @@ def get_audio_file_info(tmp_file_path, original_file_extension, original_file_na
|
|||||||
pubdate = audio_file.tags.get('Year')[0].value if "Year" 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)
|
cover_data = audio_file.tags.get('WM/Picture', None)
|
||||||
if cover_data and not no_cover_processing:
|
if cover_data and not no_cover_processing:
|
||||||
tmp_cover_name = tmp_file_path + '.jpg'
|
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||||
with open(tmp_cover_name, "wb") as cover_file:
|
with open(tmp_cover_name, "wb") as cover_file:
|
||||||
cover_file.write(cover_data[0].value)
|
cover_file.write(cover_data[0].value)
|
||||||
elif original_file_extension in [".mp4", ".m4a", ".m4b"]:
|
elif original_file_extension in [".mp4", ".m4a", ".m4b"]:
|
||||||
@@ -115,6 +120,7 @@ def get_audio_file_info(tmp_file_path, original_file_extension, original_file_na
|
|||||||
pubdate = audio_file.tags.get('©day')[0] if "©day" in audio_file.tags else None
|
pubdate = audio_file.tags.get('©day')[0] if "©day" in audio_file.tags else None
|
||||||
cover_data = audio_file.tags.get('covr', None)
|
cover_data = audio_file.tags.get('covr', None)
|
||||||
if cover_data and not no_cover_processing:
|
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
|
cover_type = None
|
||||||
for c in cover_data:
|
for c in cover_data:
|
||||||
if c.imageformat == mutagen.mp4.AtomDataType.JPEG:
|
if c.imageformat == mutagen.mp4.AtomDataType.JPEG:
|
||||||
@@ -126,7 +132,7 @@ def get_audio_file_info(tmp_file_path, original_file_extension, original_file_na
|
|||||||
cover_bin = c
|
cover_bin = c
|
||||||
break
|
break
|
||||||
if cover_type:
|
if cover_type:
|
||||||
tmp_cover_name = cover.cover_processing(tmp_file_path, cover_bin, cover_type)
|
cover.cover_processing(tmp_file_path, cover_bin, cover_type)
|
||||||
else:
|
else:
|
||||||
logger.error("Unknown covertype in file {} ".format(original_file_name))
|
logger.error("Unknown covertype in file {} ".format(original_file_name))
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def get_user_locale_language(user_language):
|
|||||||
|
|
||||||
|
|
||||||
def get_available_locale():
|
def get_available_locale():
|
||||||
return sorted(babel.list_translations(), key=lambda x: x.display_name.lower())
|
return [Locale('en')] + babel.list_translations()
|
||||||
|
|
||||||
|
|
||||||
def get_available_translations():
|
def get_available_translations():
|
||||||
@@ -19,6 +19,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/>.
|
||||||
|
|
||||||
|
|
||||||
|
from cps.pagination import Pagination
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_babel import get_locale
|
from flask_babel import get_locale
|
||||||
|
|||||||
+2
-7
@@ -35,13 +35,8 @@ 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", "br", "h1", "h2", "h3", "h4", "h5", "h6", "img"])
|
allowed_tags.extend(["p", "span", "div", "pre", "br", "h1", "h2", "h3", "h4", "h5", "h6"])
|
||||||
allowed_attributes = {
|
safe_text = clean_html(unsafe_text, tags=set(allowed_tags))
|
||||||
"*": ["class", "style"],
|
|
||||||
"a": ["href", "title", "rel"],
|
|
||||||
"img": ["src", "alt", "title", "width", "height"],
|
|
||||||
}
|
|
||||||
safe_text = clean_html(unsafe_text, tags=set(allowed_tags), attributes=allowed_attributes)
|
|
||||||
else:
|
else:
|
||||||
safe_text = clean_html(unsafe_text)
|
safe_text = clean_html(unsafe_text)
|
||||||
except ParserError as e:
|
except ParserError as e:
|
||||||
|
|||||||
+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.replace("b", " Beta")
|
return "Calibre-Web version: %s - unknown git-clone" % _STABLE_VERSION['version'].replace("b", " Beta")
|
||||||
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION.replace("b", " Beta"), _NIGHTLY_VERSION[1])
|
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'].replace("b", " Beta"), _NIGHTLY_VERSION[1])
|
||||||
|
|
||||||
|
|
||||||
class CliParameter(object):
|
class CliParameter(object):
|
||||||
|
|||||||
+10
-10
@@ -65,7 +65,7 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
|
|||||||
cover_data = extension = None
|
cover_data = extension = None
|
||||||
if original_file_extension.upper() == '.CBZ':
|
if original_file_extension.upper() == '.CBZ':
|
||||||
cf = zipfile.ZipFile(tmp_file_name)
|
cf = zipfile.ZipFile(tmp_file_name)
|
||||||
for name in sorted(cf.namelist()):
|
for name in cf.namelist():
|
||||||
ext = os.path.splitext(name)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
@@ -74,7 +74,7 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
|
|||||||
break
|
break
|
||||||
elif original_file_extension.upper() == '.CBT':
|
elif original_file_extension.upper() == '.CBT':
|
||||||
cf = tarfile.TarFile(tmp_file_name)
|
cf = tarfile.TarFile(tmp_file_name)
|
||||||
for name in sorted(cf.getnames()):
|
for name in cf.getnames():
|
||||||
ext = os.path.splitext(name)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
@@ -85,18 +85,18 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
|
|||||||
try:
|
try:
|
||||||
rarfile.UNRAR_TOOL = rar_executable
|
rarfile.UNRAR_TOOL = rar_executable
|
||||||
cf = rarfile.RarFile(tmp_file_name)
|
cf = rarfile.RarFile(tmp_file_name)
|
||||||
for name in sorted(cf.namelist()):
|
for name in cf.namelist():
|
||||||
ext = os.path.splitext(name)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
if extension in cover.COVER_EXTENSIONS:
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
cover_data = cf.read(name)
|
cover_data = cf.read([name])
|
||||||
break
|
break
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.error('Rarfile failed with error: {}'.format(ex))
|
log.error('Rarfile failed with error: {}'.format(ex))
|
||||||
elif original_file_extension.upper() == '.CB7' and use_7zip:
|
elif original_file_extension.upper() == '.CB7' and use_7zip:
|
||||||
cf = py7zr.SevenZipFile(tmp_file_name)
|
cf = py7zr.SevenZipFile(tmp_file_name)
|
||||||
for name in sorted(cf.getnames()):
|
for name in cf.getnames():
|
||||||
ext = os.path.splitext(name)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
@@ -109,13 +109,13 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
|
|||||||
return cover_data, extension
|
return cover_data, extension
|
||||||
|
|
||||||
|
|
||||||
def _extract_cover(tmp_file_path, original_file_extension, rar_executable):
|
def _extract_cover(tmp_file_name, original_file_extension, rar_executable):
|
||||||
cover_data = extension = None
|
cover_data = extension = None
|
||||||
if use_comic_meta:
|
if use_comic_meta:
|
||||||
try:
|
try:
|
||||||
archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable)
|
archive = ComicArchive(tmp_file_name, rar_exe_path=rar_executable)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
archive = ComicArchive(tmp_file_path)
|
archive = ComicArchive(tmp_file_name)
|
||||||
name_list = archive.getPageNameList if hasattr(archive, "getPageNameList") else archive.get_page_name_list
|
name_list = archive.getPageNameList if hasattr(archive, "getPageNameList") else archive.get_page_name_list
|
||||||
for index, name in enumerate(name_list()):
|
for index, name in enumerate(name_list()):
|
||||||
ext = os.path.splitext(name)
|
ext = os.path.splitext(name)
|
||||||
@@ -126,8 +126,8 @@ def _extract_cover(tmp_file_path, original_file_extension, rar_executable):
|
|||||||
cover_data = get_page(index)
|
cover_data = get_page(index)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
cover_data, extension = _extract_cover_from_archive(original_file_extension, tmp_file_path, rar_executable)
|
cover_data, extension = _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_executable)
|
||||||
return cover.cover_processing(tmp_file_path, 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, no_cover_processing):
|
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rar_executable, no_cover_processing):
|
||||||
|
|||||||
+8
-20
@@ -84,8 +84,8 @@ class _Settings(_Base):
|
|||||||
config_authors_max = Column(Integer, default=0)
|
config_authors_max = Column(Integer, default=0)
|
||||||
config_read_column = Column(Integer, default=0)
|
config_read_column = Column(Integer, default=0)
|
||||||
config_title_regex = Column(String,
|
config_title_regex = Column(String,
|
||||||
default=r"^(A|The|An|Der|Die|Das|Den|Ein|Eine"
|
default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine'
|
||||||
r"|Einen|Dem|Des|Einem|Eines|Le|La|Les|L'|Un|Une)(\s+|(?<='))")
|
r'|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+')
|
||||||
config_theme = Column(Integer, default=0)
|
config_theme = Column(Integer, default=0)
|
||||||
|
|
||||||
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
|
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
|
||||||
@@ -117,7 +117,6 @@ class _Settings(_Base):
|
|||||||
|
|
||||||
config_use_goodreads = Column(Boolean, default=False)
|
config_use_goodreads = Column(Boolean, default=False)
|
||||||
config_goodreads_api_key = Column(String)
|
config_goodreads_api_key = Column(String)
|
||||||
config_googlebooks_api_key = Column(String, default='')
|
|
||||||
config_register_email = Column(Boolean, default=False)
|
config_register_email = Column(Boolean, default=False)
|
||||||
config_login_type = Column(Integer, default=0)
|
config_login_type = Column(Integer, default=0)
|
||||||
|
|
||||||
@@ -326,9 +325,7 @@ class ConfigSQL(object):
|
|||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
storage = {}
|
storage = {}
|
||||||
for k, v in self.__dict__.items():
|
for k, v in self.__dict__.items():
|
||||||
if k[0] != '_' and not k.endswith("_e") and not k == "cli" \
|
if k[0] != '_' and not k.endswith("_e") and not k == "cli":
|
||||||
and 'api' not in k.lower() and 'token' not in k.lower() \
|
|
||||||
and 'secret' not in k.lower():
|
|
||||||
storage[k] = v
|
storage[k] = v
|
||||||
return storage
|
return storage
|
||||||
|
|
||||||
@@ -405,16 +402,14 @@ class ConfigSQL(object):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def get_book_path(self):
|
def get_book_path(self):
|
||||||
return self.config_calibre_split_dir if self.config_calibre_split else self.config_calibre_dir
|
return self.config_calibre_split_dir if self.config_calibre_split_dir else self.config_calibre_dir
|
||||||
|
|
||||||
def store_calibre_uuid(self, calibre_db, Library_table):
|
def store_calibre_uuid(self, calibre_db, Library_table):
|
||||||
from . import app
|
|
||||||
try:
|
try:
|
||||||
with app.app_context():
|
calibre_uuid = calibre_db.session.query(Library_table).one_or_none()
|
||||||
calibre_uuid = calibre_db.session.query(Library_table).one_or_none()
|
if self.config_calibre_uuid != calibre_uuid.uuid:
|
||||||
if self.config_calibre_uuid != calibre_uuid.uuid:
|
self.config_calibre_uuid = calibre_uuid.uuid
|
||||||
self.config_calibre_uuid = calibre_uuid.uuid
|
self.save()
|
||||||
self.save()
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -488,8 +483,6 @@ def autodetect_calibre_binaries():
|
|||||||
"C:\\program files(x86)\\calibre\\",
|
"C:\\program files(x86)\\calibre\\",
|
||||||
"C:\\program files(x86)\\calibre2\\",
|
"C:\\program files(x86)\\calibre2\\",
|
||||||
"C:\\program files\\calibre2\\"]
|
"C:\\program files\\calibre2\\"]
|
||||||
elif sys.platform.startswith("freebsd"):
|
|
||||||
calibre_path = ["/usr/local/bin/"]
|
|
||||||
else:
|
else:
|
||||||
calibre_path = ["/opt/calibre/"]
|
calibre_path = ["/opt/calibre/"]
|
||||||
for element in calibre_path:
|
for element in calibre_path:
|
||||||
@@ -520,8 +513,6 @@ def autodetect_unrar_binary():
|
|||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
|
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
|
||||||
"C:\\program files(x86)\\WinRar\\unRAR.exe"]
|
"C:\\program files(x86)\\WinRar\\unRAR.exe"]
|
||||||
elif sys.platform.startswith("freebsd"):
|
|
||||||
calibre_path = ["/usr/local/bin/unrar"]
|
|
||||||
else:
|
else:
|
||||||
calibre_path = ["/usr/bin/unrar"]
|
calibre_path = ["/usr/bin/unrar"]
|
||||||
for element in calibre_path:
|
for element in calibre_path:
|
||||||
@@ -534,8 +525,6 @@ def autodetect_kepubify_binary():
|
|||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe",
|
calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe",
|
||||||
"C:\\program files(x86)\\kepubify\\kepubify-windows-64Bit.exe"]
|
"C:\\program files(x86)\\kepubify\\kepubify-windows-64Bit.exe"]
|
||||||
elif sys.platform.startswith("freebsd"):
|
|
||||||
calibre_path = ["/usr/local/bin/kepubify"]
|
|
||||||
else:
|
else:
|
||||||
calibre_path = ["/opt/kepubify/kepubify-linux-64bit", "/opt/kepubify/kepubify-linux-32bit"]
|
calibre_path = ["/opt/kepubify/kepubify-linux-64bit", "/opt/kepubify/kepubify-linux-32bit"]
|
||||||
for element in calibre_path:
|
for element in calibre_path:
|
||||||
@@ -585,7 +574,6 @@ def get_encryption_key(key_path):
|
|||||||
try:
|
try:
|
||||||
with open(key_file, "wb") as f:
|
with open(key_file, "wb") as f:
|
||||||
f.write(key)
|
f.write(key)
|
||||||
os.chmod(key_file, 0o600)
|
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
error = e
|
error = e
|
||||||
return key, error
|
return key, error
|
||||||
|
|||||||
+6
-6
@@ -19,6 +19,9 @@
|
|||||||
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')
|
||||||
@@ -37,12 +40,9 @@ STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static')
|
|||||||
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
|
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
|
||||||
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
|
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
|
||||||
|
|
||||||
# Cache dir - use CACHE_DIRECTORY environment variable, otherwise CACHE_DIR (for backwards compatibility),
|
# Cache dir - use CACHE_DIR environment variable, otherwise use the default directory: cps/cache
|
||||||
# otherwise use the default directory: cps/cache
|
|
||||||
DEFAULT_CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache')
|
DEFAULT_CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache')
|
||||||
CACHE_DIRECTORY = os.environ.get(
|
CACHE_DIR = os.environ.get('CACHE_DIR', DEFAULT_CACHE_DIR)
|
||||||
'CACHE_DIRECTORY', os.environ.get('CACHE_DIR', DEFAULT_CACHE_DIR)
|
|
||||||
)
|
|
||||||
|
|
||||||
if HOME_CONFIG:
|
if HOME_CONFIG:
|
||||||
home_dir = os.path.join(os.path.expanduser("~"), ".calibre-web")
|
home_dir = os.path.join(os.path.expanduser("~"), ".calibre-web")
|
||||||
@@ -175,7 +175,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 = '0.6.27b'
|
STABLE_VERSION = {'version': '0.6.24b'}
|
||||||
|
|
||||||
NIGHTLY_VERSION = dict()
|
NIGHTLY_VERSION = dict()
|
||||||
NIGHTLY_VERSION[0] = '$Format:%H$'
|
NIGHTLY_VERSION[0] = '$Format:%H$'
|
||||||
|
|||||||
+3
-4
@@ -29,14 +29,13 @@ NO_JPEG_EXTENSIONS = ['.png', '.webp', '.bmp']
|
|||||||
COVER_EXTENSIONS = ['.png', '.webp', '.bmp', '.jpg', '.jpeg']
|
COVER_EXTENSIONS = ['.png', '.webp', '.bmp', '.jpg', '.jpeg']
|
||||||
|
|
||||||
|
|
||||||
def cover_processing(tmp_file_path, img, extension):
|
def cover_processing(tmp_file_name, img, extension):
|
||||||
# tmp_cover_name = os.path.join(os.path.dirname(tmp_file_name), 'cover.jpg')
|
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_name), 'cover.jpg')
|
||||||
tmp_cover_name = tmp_file_path + '.jpg'
|
|
||||||
if extension in NO_JPEG_EXTENSIONS:
|
if extension in NO_JPEG_EXTENSIONS:
|
||||||
if use_IM:
|
if use_IM:
|
||||||
with Image(blob=img) as imgc:
|
with Image(blob=img) as imgc:
|
||||||
imgc.format = 'jpeg'
|
imgc.format = 'jpeg'
|
||||||
imgc.transform_colorspace('srgb')
|
imgc.transform_colorspace('rgb')
|
||||||
imgc.save(filename=tmp_cover_name)
|
imgc.save(filename=tmp_cover_name)
|
||||||
return tmp_cover_name
|
return tmp_cover_name
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
#
|
|
||||||
# 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
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#
|
|
||||||
# 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")
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
#
|
|
||||||
# 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)
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
#
|
|
||||||
# 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",
|
|
||||||
)
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
#
|
|
||||||
# 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)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
#
|
|
||||||
# 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
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
#
|
|
||||||
# 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
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
#
|
|
||||||
# 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()
|
|
||||||
@@ -7,6 +7,7 @@ from flask import abort
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask import flash
|
from flask import flash
|
||||||
from flask import g
|
from flask import g
|
||||||
|
from flask import has_app_context
|
||||||
from flask import redirect
|
from flask import redirect
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask import session
|
from flask import session
|
||||||
@@ -468,7 +469,7 @@ class LoginManager:
|
|||||||
config = current_app.config
|
config = current_app.config
|
||||||
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||||
domain = config.get("REMEMBER_COOKIE_DOMAIN")
|
domain = config.get("REMEMBER_COOKIE_DOMAIN")
|
||||||
path = config.get("REMEMBER_COOKIE_PATH", current_app.wsgi_app.script_name)
|
path = config.get("REMEMBER_COOKIE_PATH", "/")
|
||||||
|
|
||||||
secure = config.get("REMEMBER_COOKIE_SECURE", COOKIE_SECURE)
|
secure = config.get("REMEMBER_COOKIE_SECURE", COOKIE_SECURE)
|
||||||
httponly = config.get("REMEMBER_COOKIE_HTTPONLY", COOKIE_HTTPONLY)
|
httponly = config.get("REMEMBER_COOKIE_HTTPONLY", COOKIE_HTTPONLY)
|
||||||
@@ -519,5 +520,36 @@ class LoginManager:
|
|||||||
config = current_app.config
|
config = current_app.config
|
||||||
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||||
domain = config.get("REMEMBER_COOKIE_DOMAIN")
|
domain = config.get("REMEMBER_COOKIE_DOMAIN")
|
||||||
path = config.get("REMEMBER_COOKIE_PATH", current_app.wsgi_app.script_name)
|
path = config.get("REMEMBER_COOKIE_PATH", "/")
|
||||||
response.delete_cookie(cookie_name, domain=domain, path=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
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ import json
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
import unidecode
|
import unidecode
|
||||||
# from weakref import WeakSet
|
from weakref import WeakSet
|
||||||
from uuid import uuid4
|
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
|
||||||
from sqlalchemy import Table, Column, ForeignKey, CheckConstraint
|
from sqlalchemy import Table, Column, ForeignKey, CheckConstraint
|
||||||
from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
|
from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
|
||||||
from sqlalchemy.orm import relationship, sessionmaker, scoped_session, selectinload
|
from sqlalchemy.orm import relationship, sessionmaker, scoped_session
|
||||||
from sqlalchemy.orm.collections import InstrumentedList
|
from sqlalchemy.orm.collections import InstrumentedList
|
||||||
from sqlalchemy.ext.declarative import DeclarativeMeta
|
from sqlalchemy.ext.declarative import DeclarativeMeta
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import OperationalError
|
||||||
@@ -45,7 +45,7 @@ from sqlalchemy.ext.associationproxy import association_proxy
|
|||||||
from .cw_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, g, Flask
|
from flask import flash
|
||||||
|
|
||||||
from . import logger, ub, isoLanguages
|
from . import logger, ub, isoLanguages
|
||||||
from .pagination import Pagination
|
from .pagination import Pagination
|
||||||
@@ -102,16 +102,6 @@ class Identifiers(Base):
|
|||||||
type = Column(String(collation='NOCASE'), nullable=False, default="isbn")
|
type = Column(String(collation='NOCASE'), nullable=False, default="isbn")
|
||||||
val = Column(String(collation='NOCASE'), nullable=False)
|
val = Column(String(collation='NOCASE'), nullable=False)
|
||||||
book = Column(Integer, ForeignKey('books.id'), nullable=False)
|
book = Column(Integer, ForeignKey('books.id'), nullable=False)
|
||||||
amazon = {
|
|
||||||
"jp": "co.jp",
|
|
||||||
"uk": "co.uk",
|
|
||||||
"us": "com",
|
|
||||||
"au": "com.au",
|
|
||||||
"be": "com.be",
|
|
||||||
"br": "com.br",
|
|
||||||
"tr": "com.tr",
|
|
||||||
"mx": "com.mx",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, val, id_type, book):
|
def __init__(self, val, id_type, book):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -124,11 +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_"):
|
||||||
label_amazon = "Amazon.{0}"
|
return "Amazon.{0}".format(format_type[7:])
|
||||||
country_code = format_type[7:].lower()
|
|
||||||
if country_code not in self.amazon:
|
|
||||||
return label_amazon.format(country_code)
|
|
||||||
return label_amazon.format(self.amazon[country_code])
|
|
||||||
elif format_type == "isbn":
|
elif format_type == "isbn":
|
||||||
return "ISBN"
|
return "ISBN"
|
||||||
elif format_type == "doi":
|
elif format_type == "doi":
|
||||||
@@ -151,12 +137,6 @@ class Identifiers(Base):
|
|||||||
return "ISSN"
|
return "ISSN"
|
||||||
elif format_type == "isfdb":
|
elif format_type == "isfdb":
|
||||||
return "ISFDB"
|
return "ISFDB"
|
||||||
elif format_type == "storygraph":
|
|
||||||
return "StoryGraph"
|
|
||||||
elif format_type == "ebooks":
|
|
||||||
return "eBooks.com"
|
|
||||||
elif format_type == "smashwords":
|
|
||||||
return "Smashwords"
|
|
||||||
if format_type == "lubimyczytac":
|
if format_type == "lubimyczytac":
|
||||||
return "Lubimyczytac"
|
return "Lubimyczytac"
|
||||||
if format_type == "databazeknih":
|
if format_type == "databazeknih":
|
||||||
@@ -169,11 +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_'):
|
||||||
link_amazon = "https://amazon.{0}/dp/{1}"
|
return "https://amazon.{0}/dp/{1}".format(format_type[7:], self.val)
|
||||||
country_code = format_type[7:].lower()
|
|
||||||
if country_code not in self.amazon:
|
|
||||||
return link_amazon.format(country_code, self.val)
|
|
||||||
return link_amazon.format(self.amazon[country_code], 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":
|
||||||
@@ -197,15 +173,9 @@ class Identifiers(Base):
|
|||||||
elif format_type == "issn":
|
elif format_type == "issn":
|
||||||
return "https://portal.issn.org/resource/ISSN/{0}".format(self.val)
|
return "https://portal.issn.org/resource/ISSN/{0}".format(self.val)
|
||||||
elif format_type == "isfdb":
|
elif format_type == "isfdb":
|
||||||
return "https://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val)
|
return "http://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val)
|
||||||
elif format_type == "databazeknih":
|
elif format_type == "databazeknih":
|
||||||
return "https://www.databazeknih.cz/knihy/{0}".format(self.val)
|
return "https://www.databazeknih.cz/knihy/{0}".format(self.val)
|
||||||
elif format_type == "storygraph":
|
|
||||||
return "https://app.thestorygraph.com/books/{0}".format(self.val)
|
|
||||||
elif format_type == "ebooks":
|
|
||||||
return "https://www.ebooks.com/en-us/book/{0}".format(self.val)
|
|
||||||
elif format_type == "smashwords":
|
|
||||||
return "https://www.smashwords.com/books/view/{0}".format(self.val)
|
|
||||||
elif self.val.lower().startswith("javascript:"):
|
elif self.val.lower().startswith("javascript:"):
|
||||||
return quote(self.val)
|
return quote(self.val)
|
||||||
elif self.val.lower().startswith("data:"):
|
elif self.val.lower().startswith("data:"):
|
||||||
@@ -416,8 +386,8 @@ class Books(Base):
|
|||||||
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)
|
||||||
# isbn = Column(String(collation='NOCASE'), default="")
|
isbn = Column(String(collation='NOCASE'), default="")
|
||||||
# flags = Column(Integer, nullable=False, default=1)
|
flags = Column(Integer, nullable=False, default=1)
|
||||||
|
|
||||||
authors = relationship(Authors, secondary=books_authors_link, backref='books')
|
authors = relationship(Authors, secondary=books_authors_link, backref='books')
|
||||||
tags = relationship(Tags, secondary=books_tags_link, backref='books', order_by="Tags.name")
|
tags = relationship(Tags, secondary=books_tags_link, backref='books', order_by="Tags.name")
|
||||||
@@ -449,14 +419,7 @@ class Books(Base):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def atom_timestamp(self):
|
def atom_timestamp(self):
|
||||||
# OPDS atom:updated is defined as "the most recent instant in time
|
return self.timestamp.strftime('%Y-%m-%dT%H:%M:%S+00:00') or ''
|
||||||
# when the entry was modified". Books.timestamp is the date added and
|
|
||||||
# never changes after import, so metadata and cover edits were
|
|
||||||
# invisible to OPDS sync clients. Use last_modified, which Calibre
|
|
||||||
# updates on every metadata or cover change; fall back to timestamp
|
|
||||||
# only if last_modified happens to be missing.
|
|
||||||
t = self.last_modified or self.timestamp
|
|
||||||
return t.strftime('%Y-%m-%dT%H:%M:%S+00:00') if t else ''
|
|
||||||
|
|
||||||
|
|
||||||
class CustomColumns(Base):
|
class CustomColumns(Base):
|
||||||
@@ -547,25 +510,34 @@ class AlchemyEncoder(json.JSONEncoder):
|
|||||||
|
|
||||||
|
|
||||||
class CalibreDB:
|
class CalibreDB:
|
||||||
|
_init = False
|
||||||
|
engine = None
|
||||||
config = None
|
config = None
|
||||||
config_calibre_dir = None
|
session_factory = None
|
||||||
app_db_path = None
|
# This is a WeakSet so that references here don't keep other CalibreDB
|
||||||
|
# instances alive once they reach the end of their respective scopes
|
||||||
|
instances = WeakSet()
|
||||||
|
|
||||||
def __init__(self, _app: Flask=None): # , expire_on_commit=True, init=False):
|
def __init__(self, expire_on_commit=True, init=False):
|
||||||
""" Initialize a new CalibreDB session
|
""" Initialize a new CalibreDB session
|
||||||
"""
|
"""
|
||||||
self.Session = None
|
self.session = None
|
||||||
#if init:
|
if init:
|
||||||
# self.init_db(expire_on_commit)
|
self.init_db(expire_on_commit)
|
||||||
if _app is not None and not _app._got_first_request:
|
|
||||||
self.init_app(_app)
|
|
||||||
|
|
||||||
def init_app(self, _app):
|
def init_db(self, expire_on_commit=True):
|
||||||
_app.teardown_appcontext(self.teardown)
|
if self._init:
|
||||||
|
self.init_session(expire_on_commit)
|
||||||
|
|
||||||
|
self.instances.add(self)
|
||||||
|
|
||||||
|
def init_session(self, expire_on_commit=True):
|
||||||
|
self.session = self.session_factory()
|
||||||
|
self.session.expire_on_commit = expire_on_commit
|
||||||
|
self.create_functions(self.config)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setup_db_cc_classes(cls, cc):
|
def setup_db_cc_classes(cls, cc):
|
||||||
global cc_classes
|
|
||||||
cc_ids = []
|
cc_ids = []
|
||||||
books_custom_column_links = {}
|
books_custom_column_links = {}
|
||||||
for row in cc:
|
for row in cc:
|
||||||
@@ -633,6 +605,8 @@ class CalibreDB:
|
|||||||
secondary=books_custom_column_links[cc_id[0]],
|
secondary=books_custom_column_links[cc_id[0]],
|
||||||
backref='books'))
|
backref='books'))
|
||||||
|
|
||||||
|
return cc_classes
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def check_valid_db(cls, config_calibre_dir, app_db_path, config_calibre_uuid):
|
def check_valid_db(cls, config_calibre_dir, app_db_path, config_calibre_uuid):
|
||||||
if not config_calibre_dir:
|
if not config_calibre_dir:
|
||||||
@@ -647,11 +621,12 @@ class CalibreDB:
|
|||||||
connect_args={'check_same_thread': False},
|
connect_args={'check_same_thread': False},
|
||||||
poolclass=StaticPool)
|
poolclass=StaticPool)
|
||||||
with check_engine.begin() as connection:
|
with check_engine.begin() as connection:
|
||||||
connection.execute(text("attach database '{}' as calibre;".format(dbpath.replace("'", "''"))))
|
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
|
||||||
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path.replace("'", "''"))))
|
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
|
||||||
local_session = scoped_session(sessionmaker())
|
local_session = scoped_session(sessionmaker())
|
||||||
local_session.configure(bind=connection)
|
local_session.configure(bind=connection)
|
||||||
database_uuid = local_session().query(Library_Id).one_or_none()
|
database_uuid = local_session().query(Library_Id).one_or_none()
|
||||||
|
# local_session.dispose()
|
||||||
|
|
||||||
check_engine.connect()
|
check_engine.connect()
|
||||||
db_change = config_calibre_uuid != database_uuid.uuid
|
db_change = config_calibre_uuid != database_uuid.uuid
|
||||||
@@ -659,30 +634,13 @@ class CalibreDB:
|
|||||||
return False, False
|
return False, False
|
||||||
return True, db_change
|
return True, db_change
|
||||||
|
|
||||||
def teardown(self, exception):
|
|
||||||
ctx = g.get("lib_sql")
|
|
||||||
if ctx:
|
|
||||||
ctx.close()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def session(self):
|
|
||||||
# connect or get active connection
|
|
||||||
if not g.get("lib_sql"):
|
|
||||||
g.lib_sql = self.connect()
|
|
||||||
return g.lib_sql
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def update_config(cls, config, config_calibre_dir, app_db_path):
|
def update_config(cls, config):
|
||||||
cls.config = config
|
cls.config = config
|
||||||
cls.config_calibre_dir = config_calibre_dir
|
|
||||||
cls.app_db_path = app_db_path
|
|
||||||
|
|
||||||
|
|
||||||
def connect(self):
|
|
||||||
return self.setup_db(self.config_calibre_dir, self.app_db_path)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setup_db(cls, config_calibre_dir, app_db_path):
|
def setup_db(cls, config_calibre_dir, app_db_path):
|
||||||
|
cls.dispose()
|
||||||
|
|
||||||
if not config_calibre_dir:
|
if not config_calibre_dir:
|
||||||
cls.config.invalidate()
|
cls.config.invalidate()
|
||||||
@@ -694,17 +652,16 @@ class CalibreDB:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
engine = create_engine('sqlite://',
|
cls.engine = create_engine('sqlite://',
|
||||||
echo=False,
|
echo=False,
|
||||||
isolation_level="SERIALIZABLE",
|
isolation_level="SERIALIZABLE",
|
||||||
connect_args={'check_same_thread': False},
|
connect_args={'check_same_thread': False},
|
||||||
poolclass=StaticPool)
|
poolclass=StaticPool)
|
||||||
with engine.begin() as connection:
|
with cls.engine.begin() as connection:
|
||||||
connection.execute(text('PRAGMA cache_size = 10000;'))
|
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
|
||||||
connection.execute(text("attach database '{}' as calibre;".format(dbpath.replace("'", "''"))))
|
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
|
||||||
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path.replace("'", "''"))))
|
|
||||||
|
|
||||||
conn = engine.connect()
|
conn = cls.engine.connect()
|
||||||
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
|
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
cls.config.invalidate(ex)
|
cls.config.invalidate(ex)
|
||||||
@@ -720,10 +677,13 @@ class CalibreDB:
|
|||||||
log.error_or_exception(e)
|
log.error_or_exception(e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return scoped_session(sessionmaker(autocommit=False,
|
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
|
||||||
autoflush=False,
|
autoflush=True,
|
||||||
bind=engine, future=True))
|
bind=cls.engine, future=True))
|
||||||
|
for inst in cls.instances:
|
||||||
|
inst.init_session()
|
||||||
|
|
||||||
|
cls._init = True
|
||||||
|
|
||||||
def get_book(self, book_id):
|
def get_book(self, book_id):
|
||||||
return self.session.query(Books).filter(Books.id == book_id).first()
|
return self.session.query(Books).filter(Books.id == book_id).first()
|
||||||
@@ -753,8 +713,7 @@ class CalibreDB:
|
|||||||
.filter(self.common_filters(allow_show_archived)).first())
|
.filter(self.common_filters(allow_show_archived)).first())
|
||||||
|
|
||||||
def get_book_by_uuid(self, book_uuid):
|
def get_book_by_uuid(self, book_uuid):
|
||||||
return self.session.query(Books).filter(Books.uuid == book_uuid). \
|
return self.session.query(Books).filter(Books.uuid == book_uuid).first()
|
||||||
filter(self.common_filters()).first()
|
|
||||||
|
|
||||||
def get_book_format(self, book_id, file_format):
|
def get_book_format(self, book_id, file_format):
|
||||||
return self.session.query(Data).filter(Data.book == book_id).filter(Data.format == file_format).first()
|
return self.session.query(Data).filter(Data.book == book_id).filter(Data.format == file_format).first()
|
||||||
@@ -909,36 +868,28 @@ class CalibreDB:
|
|||||||
for entry in entries:
|
for entry in entries:
|
||||||
if combined:
|
if combined:
|
||||||
sort_authors = entry.Books.author_sort.split('&')
|
sort_authors = entry.Books.author_sort.split('&')
|
||||||
authors_list = entry.Books.authors
|
ids = [a.id for a in entry.Books.authors]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
sort_authors = entry.author_sort.split('&')
|
sort_authors = entry.author_sort.split('&')
|
||||||
authors_list = entry.authors
|
ids = [a.id for a in entry.authors]
|
||||||
|
authors_ordered = list()
|
||||||
# Create dictionary for O(1) lookup instead of nested loops
|
# error = False
|
||||||
authors_by_sort = {}
|
|
||||||
authors_by_id = {}
|
|
||||||
for author in authors_list:
|
|
||||||
authors_by_sort[author.sort] = author
|
|
||||||
authors_by_id[author.id] = author
|
|
||||||
|
|
||||||
authors_ordered = []
|
|
||||||
ids_remaining = set(authors_by_id.keys())
|
|
||||||
|
|
||||||
# Order authors based on sort field using dictionary lookup
|
|
||||||
for auth in sort_authors:
|
for auth in sort_authors:
|
||||||
auth = strip_whitespaces(auth)
|
auth = strip_whitespaces(auth)
|
||||||
if auth in authors_by_sort:
|
results = self.session.query(Authors).filter(Authors.sort == auth).all()
|
||||||
author = authors_by_sort[auth]
|
# ToDo: How to handle not found author name
|
||||||
authors_ordered.append(author)
|
if not len(results):
|
||||||
ids_remaining.discard(author.id)
|
log.error("Author {} not found to display name in right order".format(auth))
|
||||||
else:
|
# error = True
|
||||||
# This can happen if author_sort has stale data or formatting issues
|
break
|
||||||
book_id = entry.id if isinstance(entry, Books) else (entry.Books.id if combined else entry.id)
|
for r in results:
|
||||||
log.warning("Author '{}' of book {} not found in author list, skipping in sort order".format(auth, book_id))
|
if r.id in ids:
|
||||||
|
authors_ordered.append(r)
|
||||||
# Add any remaining authors not in sort order
|
ids.remove(r.id)
|
||||||
for author_id in ids_remaining:
|
for author_id in ids:
|
||||||
authors_ordered.append(authors_by_id[author_id])
|
result = self.session.query(Authors).filter(Authors.id == author_id).first()
|
||||||
|
authors_ordered.append(result)
|
||||||
|
|
||||||
if list_return:
|
if list_return:
|
||||||
if combined:
|
if combined:
|
||||||
@@ -952,13 +903,16 @@ 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.create_functions()
|
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=r.name.replace(*replace)) for r in entries])
|
json_dumps = json.dumps([dict(name=r.name.replace(*replace)) for r in entries])
|
||||||
return json_dumps
|
return json_dumps
|
||||||
|
|
||||||
def check_exists_book(self, authr, title):
|
def check_exists_book(self, authr, title):
|
||||||
self.create_functions()
|
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:
|
||||||
@@ -968,93 +922,36 @@ 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_whitespaces(term).lower()
|
strip_whitespaces(term).lower()
|
||||||
self.create_functions()
|
self.create_functions()
|
||||||
|
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||||
# Try FTS5 search first for better performance
|
q = list()
|
||||||
fts_ids = None
|
|
||||||
# Check if FTS5 table exists before attempting search
|
|
||||||
if not hasattr(self, '_fts_available'):
|
|
||||||
try:
|
|
||||||
result = self.session.execute(
|
|
||||||
text("SELECT name FROM sqlite_master WHERE type='table' AND name='books_fts'")
|
|
||||||
).fetchone()
|
|
||||||
self._fts_available = result is not None
|
|
||||||
except Exception:
|
|
||||||
self._fts_available = False
|
|
||||||
|
|
||||||
if self._fts_available:
|
|
||||||
try:
|
|
||||||
# Escape FTS5 special characters to prevent query errors
|
|
||||||
term_fts = term.replace('"', '""')
|
|
||||||
# Wrap in quotes for phrase matching and better accuracy
|
|
||||||
fts_results = self.session.execute(
|
|
||||||
text("SELECT DISTINCT rowid FROM books_fts WHERE books_fts MATCH :term"),
|
|
||||||
{"term": f'"{term_fts}"'}
|
|
||||||
).fetchall()
|
|
||||||
if fts_results:
|
|
||||||
fts_ids = [r[0] for r in fts_results]
|
|
||||||
except Exception as ex:
|
|
||||||
# FTS5 query failed, fall back to traditional search
|
|
||||||
log.debug("FTS5 search failed for term '{}', using fallback: {}".format(term, ex))
|
|
||||||
|
|
||||||
# Build base query with optimized joins
|
|
||||||
base_query = self.generate_linked_query(config.config_read_column, Books)
|
|
||||||
base_query = base_query.filter(self.common_filters(True))
|
|
||||||
|
|
||||||
# Apply eager loading for authors to avoid N+1 queries
|
|
||||||
base_query = base_query.options(selectinload(Books.authors))
|
|
||||||
|
|
||||||
if len(join) == 6:
|
|
||||||
base_query = base_query.outerjoin(join[0], join[1]).outerjoin(join[2]).outerjoin(join[3], join[4]).outerjoin(join[5])
|
|
||||||
if len(join) == 3:
|
|
||||||
base_query = base_query.outerjoin(join[0], join[1]).outerjoin(join[2])
|
|
||||||
elif len(join) == 2:
|
|
||||||
base_query = base_query.outerjoin(join[0], join[1])
|
|
||||||
elif len(join) == 1:
|
|
||||||
base_query = base_query.outerjoin(join[0])
|
|
||||||
|
|
||||||
# If FTS5 found results, use those IDs
|
|
||||||
if fts_ids:
|
|
||||||
return base_query.filter(Books.id.in_(fts_ids))
|
|
||||||
|
|
||||||
# Fallback to traditional search with optimized subqueries
|
|
||||||
author_terms = re.split("[, ]+", term)
|
author_terms = re.split("[, ]+", term)
|
||||||
|
|
||||||
# Use subquery for authors to avoid expensive .any() with OR
|
|
||||||
author_subquery = self.session.query(books_authors_link.c.book).join(
|
|
||||||
Authors, books_authors_link.c.author == Authors.id
|
|
||||||
)
|
|
||||||
author_filters = []
|
|
||||||
for author_term in author_terms:
|
for author_term in author_terms:
|
||||||
author_filters.append(func.lower(Authors.name).ilike("%" + author_term + "%"))
|
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + author_term + "%")))
|
||||||
if author_filters:
|
query = self.generate_linked_query(config.config_read_column, Books)
|
||||||
author_subquery = author_subquery.filter(and_(*author_filters))
|
if len(join) == 6:
|
||||||
|
query = query.outerjoin(join[0], join[1]).outerjoin(join[2]).outerjoin(join[3], join[4]).outerjoin(join[5])
|
||||||
|
if len(join) == 3:
|
||||||
|
query = query.outerjoin(join[0], join[1]).outerjoin(join[2])
|
||||||
|
elif len(join) == 2:
|
||||||
|
query = query.outerjoin(join[0], join[1])
|
||||||
|
elif len(join) == 1:
|
||||||
|
query = query.outerjoin(join[0])
|
||||||
|
|
||||||
# Build optimized filter expressions
|
|
||||||
cc = self.get_cc_columns(config, filter_config_custom_read=True)
|
cc = self.get_cc_columns(config, filter_config_custom_read=True)
|
||||||
filter_expression = [
|
filter_expression = [Books.tags.any(func.lower(Tags.name).ilike("%" + term + "%")),
|
||||||
Books.id.in_(self.session.query(books_tags_link.c.book).join(
|
Books.series.any(func.lower(Series.name).ilike("%" + term + "%")),
|
||||||
Tags, books_tags_link.c.tag == Tags.id
|
Books.authors.any(and_(*q)),
|
||||||
).filter(func.lower(Tags.name).ilike("%" + term + "%"))),
|
Books.publishers.any(func.lower(Publishers.name).ilike("%" + term + "%")),
|
||||||
Books.id.in_(self.session.query(books_series_link.c.book).join(
|
func.lower(Books.title).ilike("%" + term + "%")]
|
||||||
Series, books_series_link.c.series == Series.id
|
|
||||||
).filter(func.lower(Series.name).ilike("%" + term + "%"))),
|
|
||||||
Books.id.in_(author_subquery),
|
|
||||||
Books.id.in_(self.session.query(books_publishers_link.c.book).join(
|
|
||||||
Publishers, books_publishers_link.c.publisher == Publishers.id
|
|
||||||
).filter(func.lower(Publishers.name).ilike("%" + term + "%"))),
|
|
||||||
func.lower(Books.title).ilike("%" + term + "%")
|
|
||||||
]
|
|
||||||
|
|
||||||
for c in cc:
|
for c in cc:
|
||||||
if c.datatype not in ["datetime", "rating", "bool", "int", "float"]:
|
if c.datatype not in ["datetime", "rating", "bool", "int", "float"]:
|
||||||
filter_expression.append(
|
filter_expression.append(
|
||||||
getattr(Books,
|
getattr(Books,
|
||||||
'custom_column_' + str(c.id)).any(
|
'custom_column_' + str(c.id)).any(
|
||||||
func.lower(cc_classes[c.id].value).ilike("%" + term + "%")))
|
func.lower(cc_classes[c.id].value).ilike("%" + term + "%")))
|
||||||
|
return query.filter(self.common_filters(True)).filter(or_(*filter_expression))
|
||||||
return base_query.filter(or_(*filter_expression))
|
|
||||||
|
|
||||||
def get_cc_columns(self, config, filter_config_custom_read=False):
|
def get_cc_columns(self, config, filter_config_custom_read=False):
|
||||||
tmp_cc = self.session.query(CustomColumns).filter(CustomColumns.datatype.notin_(cc_exceptions)).all()
|
tmp_cc = self.session.query(CustomColumns).filter(CustomColumns.datatype.notin_(cc_exceptions)).all()
|
||||||
@@ -1076,32 +973,18 @@ class CalibreDB:
|
|||||||
def get_search_results(self, term, config, offset=None, order=None, limit=None, *join):
|
def get_search_results(self, term, config, offset=None, order=None, limit=None, *join):
|
||||||
order = order[0] if order else [Books.sort]
|
order = order[0] if order else [Books.sort]
|
||||||
pagination = None
|
pagination = None
|
||||||
|
result = self.search_query(term, config, *join).order_by(*order).all()
|
||||||
|
result_count = len(result)
|
||||||
if offset is not None and limit is not None:
|
if offset is not None and limit is not None:
|
||||||
offset = int(offset)
|
offset = int(offset)
|
||||||
limit_int = int(limit)
|
limit_all = offset + int(limit)
|
||||||
|
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
|
||||||
# Use LIMIT+1 pattern to estimate total count without expensive count()
|
|
||||||
query = self.search_query(term, config, *join).order_by(*order)
|
|
||||||
result = query.limit(offset + limit_int + 1).all()
|
|
||||||
|
|
||||||
# Check if there are more results
|
|
||||||
has_more = len(result) > (offset + limit_int)
|
|
||||||
if has_more:
|
|
||||||
result_count = offset + limit_int + 1 # Estimate: at least this many
|
|
||||||
else:
|
|
||||||
result_count = len(result)
|
|
||||||
|
|
||||||
# Extract the page of results
|
|
||||||
result = result[offset:offset + limit_int]
|
|
||||||
pagination = Pagination((offset / limit_int + 1), limit_int, result_count)
|
|
||||||
else:
|
else:
|
||||||
# No pagination, fetch all results
|
offset = 0
|
||||||
result = self.search_query(term, config, *join).order_by(*order).all()
|
limit_all = result_count
|
||||||
result_count = len(result)
|
|
||||||
|
|
||||||
ub.store_combo_ids(result)
|
ub.store_combo_ids(result)
|
||||||
entries = self.order_authors(result, list_return=True, combined=True)
|
entries = self.order_authors(result[offset:limit_all], list_return=True, combined=True)
|
||||||
|
|
||||||
return entries, result_count, pagination
|
return entries, result_count, pagination
|
||||||
|
|
||||||
@@ -1126,7 +1009,7 @@ class CalibreDB:
|
|||||||
.filter(self.common_filters())
|
.filter(self.common_filters())
|
||||||
.count())
|
.count())
|
||||||
if no_lang_count:
|
if no_lang_count:
|
||||||
tags.append([Category(_("None"), "None", "none"), no_lang_count])
|
tags.append([Category(_("None"), "none"), no_lang_count])
|
||||||
return sorted(tags, key=lambda x: x[0].name.lower(), reverse=reverse_order)
|
return sorted(tags, key=lambda x: x[0].name.lower(), reverse=reverse_order)
|
||||||
else:
|
else:
|
||||||
if not languages:
|
if not languages:
|
||||||
@@ -1164,11 +1047,43 @@ class CalibreDB:
|
|||||||
except sqliteOperationalError:
|
except sqliteOperationalError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def dispose(cls):
|
||||||
|
# global session
|
||||||
|
|
||||||
|
for inst in cls.instances:
|
||||||
|
old_session = inst.session
|
||||||
|
inst.session = None
|
||||||
|
if old_session:
|
||||||
|
try:
|
||||||
|
old_session.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if old_session.bind:
|
||||||
|
try:
|
||||||
|
old_session.bind.dispose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for attr in list(Books.__dict__.keys()):
|
||||||
|
if attr.startswith("custom_column_"):
|
||||||
|
setattr(Books, attr, None)
|
||||||
|
|
||||||
|
for db_class in cc_classes.values():
|
||||||
|
Base.metadata.remove(db_class.__table__)
|
||||||
|
cc_classes.clear()
|
||||||
|
|
||||||
|
for table in reversed(Base.metadata.sorted_tables):
|
||||||
|
name = table.key
|
||||||
|
if name.startswith("custom_column_") or name.startswith("books_custom_column_"):
|
||||||
|
if table is not None:
|
||||||
|
Base.metadata.remove(table)
|
||||||
|
|
||||||
def reconnect_db(self, config, app_db_path):
|
def reconnect_db(self, config, app_db_path):
|
||||||
# self.dispose()
|
self.dispose()
|
||||||
# self.engine.dispose()
|
self.engine.dispose()
|
||||||
self.setup_db(config.config_calibre_dir, app_db_path)
|
self.setup_db(config.config_calibre_dir, app_db_path)
|
||||||
self.update_config(config, config.config_calibre_dir, app_db_path)
|
self.update_config(config)
|
||||||
|
|
||||||
|
|
||||||
def lcase(s):
|
def lcase(s):
|
||||||
@@ -1180,25 +1095,14 @@ def lcase(s):
|
|||||||
return s.lower()
|
return s.lower()
|
||||||
|
|
||||||
|
|
||||||
def title_sort(title, config):
|
|
||||||
# calibre sort stuff
|
|
||||||
title_pat = re.compile(config.config_title_regex, re.IGNORECASE)
|
|
||||||
match = title_pat.search(title)
|
|
||||||
if match:
|
|
||||||
prep = match.group(1)
|
|
||||||
title = title[len(prep):] + ', ' + prep
|
|
||||||
return strip_whitespaces(title)
|
|
||||||
|
|
||||||
class Category:
|
class Category:
|
||||||
name = None
|
name = None
|
||||||
sort = None
|
|
||||||
id = None
|
id = None
|
||||||
count = None
|
count = None
|
||||||
rating = None
|
rating = None
|
||||||
|
|
||||||
def __init__(self, name, cat_id, rating=None):
|
def __init__(self, name, cat_id, rating=None):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.sort = name
|
|
||||||
self.id = cat_id
|
self.id = cat_id
|
||||||
self.rating = rating
|
self.rating = rating
|
||||||
self.count = 1
|
self.count = 1
|
||||||
|
|||||||
+4
-3
@@ -23,10 +23,11 @@ import zipfile
|
|||||||
import json
|
import json
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from flask_babel.speaklater import LazyString
|
from flask_babel.speaklater import LazyString
|
||||||
from importlib.metadata import metadata
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import send_file
|
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,7 @@ 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)
|
||||||
version = metadata("flask")["Version"]
|
version = importlib.metadata.version("flask")
|
||||||
if int(version.split('.')[0]) < 2:
|
if int(version.split('.')[0]) < 2:
|
||||||
return send_file(wfd,
|
return send_file(wfd,
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
@@ -73,7 +74,7 @@ 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)
|
||||||
version = metadata("flask")["Version"]
|
version = importlib.metadata.version("flask")
|
||||||
if int(version.split('.')[0]) < 2:
|
if int(version.split('.')[0]) < 2:
|
||||||
return send_file(memory_zip,
|
return send_file(memory_zip,
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
|
|||||||
+2
-18
@@ -39,24 +39,8 @@ 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\.]+)?(?:\s?;\s?'
|
res = re.match(r'(.*?)([<=>\s]+)([\d\.]+),?\s?([<=>\s]+)?([\d\.]+)?', line.strip())
|
||||||
r'(?:(python_version)\s?([<=>]+)\s?\'([\d\.]+)\'|'
|
|
||||||
r'(sys_platform)\s?([\!=]+)\s?\'([\w]+)\'))?', line.strip())
|
|
||||||
try:
|
try:
|
||||||
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 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:
|
||||||
@@ -79,7 +63,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('.')[:3]]
|
dep_version_int = [int(x) if x.isnumeric() else 0 for x in dep[0].split('.')]
|
||||||
low_check = [int(x) for x in dep[3].split('.')]
|
low_check = [int(x) for x in dep[3].split('.')]
|
||||||
high_check = [int(x) for x in dep[5].split('.')]
|
high_check = [int(x) for x in dep[5].split('.')]
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
|
|||||||
+198
-402
@@ -28,11 +28,11 @@ from shutil import copyfile
|
|||||||
from markupsafe import escape, Markup # dependency of flask
|
from markupsafe import escape, Markup # dependency of flask
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, request, flash, redirect, url_for, abort, jsonify, make_response, Response
|
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 .cw_login import current_user
|
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
|
||||||
@@ -73,18 +73,17 @@ def edit_required(f):
|
|||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/deletebook", methods=["POST"])
|
@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"])
|
||||||
@user_login_required
|
@user_login_required
|
||||||
def delete_books_ajax():
|
def delete_book_from_details(book_id):
|
||||||
book_ids = request.get_json().get("bookid")
|
return Response(delete_book_from_table(book_id, "", True), mimetype='application/json')
|
||||||
return check_delete_book(book_ids, "", True)
|
|
||||||
|
|
||||||
|
|
||||||
@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"])
|
||||||
@user_login_required
|
@user_login_required
|
||||||
def delete_book(book_id, book_format):
|
def delete_book_ajax(book_id, book_format):
|
||||||
return check_delete_book(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', ""))
|
||||||
|
|
||||||
|
|
||||||
@editbook.route("/admin/book/<int:book_id>", methods=['GET'])
|
@editbook.route("/admin/book/<int:book_id>", methods=['GET'])
|
||||||
@@ -159,7 +158,7 @@ def upload():
|
|||||||
if len(request.files.getlist("btn-upload")) < 2:
|
if len(request.files.getlist("btn-upload")) < 2:
|
||||||
if current_user.role_edit() or current_user.role_admin():
|
if current_user.role_edit() or current_user.role_admin():
|
||||||
resp = {"location": url_for('edit-book.show_edit_book', book_id=book_id)}
|
resp = {"location": url_for('edit-book.show_edit_book', book_id=book_id)}
|
||||||
return make_response(jsonify(resp))
|
return Response(json.dumps(resp), mimetype='application/json')
|
||||||
else:
|
else:
|
||||||
resp = {"location": url_for('web.show_book', book_id=book_id)}
|
resp = {"location": url_for('web.show_book', book_id=book_id)}
|
||||||
return Response(json.dumps(resp), mimetype='application/json')
|
return Response(json.dumps(resp), mimetype='application/json')
|
||||||
@@ -168,7 +167,7 @@ def upload():
|
|||||||
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 make_response(jsonify(location=url_for("web.index")))
|
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
@@ -207,247 +206,118 @@ def table_get_custom_enum(c_id):
|
|||||||
ret.append({'value': "", 'text': ""})
|
ret.append({'value': "", 'text': ""})
|
||||||
for idx, en in enumerate(cc.get_display_dict()['enum_values']):
|
for idx, en in enumerate(cc.get_display_dict()['enum_values']):
|
||||||
ret.append({'value': en, 'text': en})
|
ret.append({'value': en, 'text': en})
|
||||||
return make_response(jsonify(ret))
|
return json.dumps(ret)
|
||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/editbooks/<param>", methods=['POST'])
|
@editbook.route("/ajax/editbooks/<param>", methods=['POST'])
|
||||||
@login_required_if_no_ano
|
@login_required_if_no_ano
|
||||||
@edit_required
|
@edit_required
|
||||||
def edit_list_book(param):
|
def edit_list_book(param):
|
||||||
vals = request.get_json()
|
vals = request.form.to_dict()
|
||||||
multi = vals.get('multi', False) == "True"
|
book = calibre_db.get_book(vals['pk'])
|
||||||
ret_value = edit_book_param(param, vals, multi)
|
sort_param = ""
|
||||||
if isinstance(ret_value, dict):
|
ret = ""
|
||||||
return jsonify(ret_value)
|
try:
|
||||||
else:
|
if param == 'series_index':
|
||||||
return ret_value
|
edit_book_series_index(vals['value'], book)
|
||||||
|
ret = Response(json.dumps({'success': True, 'newValue': book.series_index}), mimetype='application/json')
|
||||||
@editbook.route("/ajax/editselectedbooks", methods=['POST'])
|
elif param == 'tags':
|
||||||
@login_required_if_no_ano
|
edit_book_tags(vals['value'], book)
|
||||||
@edit_required
|
ret = Response(json.dumps({'success': True, 'newValue': ', '.join([tag.name for tag in book.tags])}),
|
||||||
def edit_selected_books():
|
mimetype='application/json')
|
||||||
d = request.get_json()
|
elif param == 'series':
|
||||||
selections = d.get('selections')
|
edit_book_series(vals['value'], book)
|
||||||
title = d.get('title')
|
ret = Response(json.dumps({'success': True, 'newValue': ', '.join([serie.name for serie in book.series])}),
|
||||||
title_sort = d.get('title_sort')
|
mimetype='application/json')
|
||||||
author_sort = d.get('author_sort')
|
elif param == 'publishers':
|
||||||
authors = d.get('authors')
|
edit_book_publisher(vals['value'], book)
|
||||||
categories = d.get('categories')
|
ret = Response(json.dumps({'success': True,
|
||||||
series = d.get('series')
|
'newValue': ', '.join([publisher.name for publisher in book.publishers])}),
|
||||||
languages = d.get('languages')
|
mimetype='application/json')
|
||||||
publishers = d.get('publishers')
|
elif param == 'languages':
|
||||||
comments = d.get('comments')
|
invalid = list()
|
||||||
|
edit_book_languages(vals['value'], book, invalid=invalid)
|
||||||
if not (
|
if invalid:
|
||||||
title or title_sort or authors or categories or series or languages or publishers or comments) or not selections:
|
ret = Response(json.dumps({'success': False,
|
||||||
return _("Parameter not found"), 400
|
'msg': 'Invalid languages in request: {}'.format(','.join(invalid))}),
|
||||||
vals = {
|
mimetype='application/json')
|
||||||
"pk": selections,
|
|
||||||
"value": None,
|
|
||||||
"checkA": d.get('checkA'),
|
|
||||||
"checkT": d.get('checkT'),
|
|
||||||
}
|
|
||||||
res = list()
|
|
||||||
if title:
|
|
||||||
vals['value'] = title
|
|
||||||
out = edit_book_param('title', vals, True)
|
|
||||||
if out[0].get('success') != True:
|
|
||||||
res.extend(out)
|
|
||||||
if title_sort:
|
|
||||||
vals['value'] = title_sort
|
|
||||||
out = edit_book_param('sort', vals, True)
|
|
||||||
if out[0].get('success') != True:
|
|
||||||
res.extend(out)
|
|
||||||
if author_sort:
|
|
||||||
vals['value'] = author_sort
|
|
||||||
out = edit_book_param('author_sort', vals, True)
|
|
||||||
if out[0].get('success') != True:
|
|
||||||
res.extend(out)
|
|
||||||
if authors:
|
|
||||||
vals['value'] = authors
|
|
||||||
out = edit_book_param('authors', vals, True)
|
|
||||||
if out[0].get('success') != True:
|
|
||||||
res.extend(out)
|
|
||||||
if categories:
|
|
||||||
vals['value'] = categories
|
|
||||||
out = edit_book_param('tags', vals, True)
|
|
||||||
if out[0].get('success') != True:
|
|
||||||
res.extend(out)
|
|
||||||
if series:
|
|
||||||
vals['value'] = series
|
|
||||||
out = edit_book_param('series', vals, True)
|
|
||||||
if out[0].get('success') != True:
|
|
||||||
res.extend(out)
|
|
||||||
if languages:
|
|
||||||
vals['value'] = languages
|
|
||||||
out = edit_book_param('languages', vals, True)
|
|
||||||
if out[0].get('success') != True:
|
|
||||||
res.extend(out)
|
|
||||||
if publishers:
|
|
||||||
vals['value'] = publishers
|
|
||||||
out = edit_book_param('publishers', vals, True)
|
|
||||||
if out[0].get('success') != True:
|
|
||||||
res.extend(out)
|
|
||||||
if comments:
|
|
||||||
vals['value'] = comments
|
|
||||||
out = edit_book_param('comments', vals, True)
|
|
||||||
if out[0].get('success') != True:
|
|
||||||
res.extend(out)
|
|
||||||
if len(res) == 0:
|
|
||||||
return jsonify([{'success': True, "msg": _("Changes successfully applied")}])
|
|
||||||
else:
|
|
||||||
return jsonify(res)
|
|
||||||
|
|
||||||
# Separated from /editbooks so that /editselectedbooks can also use this
|
|
||||||
#
|
|
||||||
# param: the property of the book to be changed
|
|
||||||
# vals - JSON Object:
|
|
||||||
# {
|
|
||||||
# 'pk': "the book id",
|
|
||||||
# 'value': "changes value of param to what's passed here"
|
|
||||||
# 'checkA': "Optional. Used to check if autosort author is enabled. Assumed as true if not passed"
|
|
||||||
# 'checkT': "Optional. Used to check if autotitle author is enabled. Assumed as true if not passed"
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
@login_required_if_no_ano
|
|
||||||
@edit_required
|
|
||||||
def edit_book_param(param, vals, multi=False):
|
|
||||||
elements = vals.get('pk',[])
|
|
||||||
if vals.get('value', None) is None:
|
|
||||||
return {'success':False, 'msg':_("Value is missing on request")}
|
|
||||||
if not elements or len(elements) > 1 and multi == False:
|
|
||||||
return {"success":False, "msg":_("Oops! Selected book is unavailable. File does not exist or is not accessible")}
|
|
||||||
ret = {}
|
|
||||||
out = list()
|
|
||||||
for elem in elements:
|
|
||||||
book = calibre_db.get_book(elem)
|
|
||||||
if not book:
|
|
||||||
ret = {"success": False,
|
|
||||||
"msg": _("Oops! Selected book is unavailable. File does not exist or is not accessible")}
|
|
||||||
if multi:
|
|
||||||
out.append(ret)
|
|
||||||
continue
|
|
||||||
else:
|
else:
|
||||||
return ret
|
lang_names = list()
|
||||||
calibre_db.create_functions(config)
|
for lang in book.languages:
|
||||||
sort_param = ""
|
lang_names.append(isoLanguages.get_language_name(get_locale(), lang.lang_code))
|
||||||
try:
|
ret = Response(json.dumps({'success': True, 'newValue': ', '.join(lang_names)}),
|
||||||
if param == 'series_index':
|
mimetype='application/json')
|
||||||
edit_book_series_index(vals['value'], book)
|
elif param == 'author_sort':
|
||||||
ret = {"success":True,
|
book.author_sort = vals['value']
|
||||||
"newValue":book.series_index}
|
ret = Response(json.dumps({'success': True, 'newValue': book.author_sort}),
|
||||||
elif param == 'tags':
|
mimetype='application/json')
|
||||||
edit_book_tags(vals['value'], book)
|
elif param == 'title':
|
||||||
ret = {"success":True,
|
sort_param = book.sort
|
||||||
"newValue":', '.join([tag.name for tag in book.tags])}
|
if handle_title_on_edit(book, vals.get('value', "")):
|
||||||
elif param == 'series':
|
rename_error = helper.update_dir_structure(book.id, config.get_book_path())
|
||||||
edit_book_series(vals['value'], book)
|
|
||||||
ret = {"success":True,
|
|
||||||
"newValue":', '.join([serie.name for serie in book.series])}
|
|
||||||
elif param == 'publishers':
|
|
||||||
edit_book_publisher(vals['value'], book)
|
|
||||||
ret = {"success":True,
|
|
||||||
"newValue":', '.join([publisher.name for publisher in book.publishers])}
|
|
||||||
elif param == 'languages':
|
|
||||||
invalid = list()
|
|
||||||
edit_book_languages(vals['value'], book, invalid=invalid)
|
|
||||||
if invalid:
|
|
||||||
ret = {"success": False, "msg": 'Invalid languages in request: {}'.format(','.join(invalid))}
|
|
||||||
if multi:
|
|
||||||
out.append(ret)
|
|
||||||
else:
|
|
||||||
lang_names = list()
|
|
||||||
for lang in book.languages:
|
|
||||||
lang_names.append(isoLanguages.get_language_name(get_locale(), lang.lang_code))
|
|
||||||
ret = {"success":True,
|
|
||||||
"newValue":', '.join(lang_names)}
|
|
||||||
elif param == 'author_sort':
|
|
||||||
book.author_sort = vals['value']
|
|
||||||
ret = {"success":True,
|
|
||||||
"newValue":book.author_sort}
|
|
||||||
elif param == 'title':
|
|
||||||
sort_param = book.sort
|
|
||||||
if handle_title_on_edit(book, vals.get('value', "")):
|
|
||||||
rename_error = helper.update_dir_structure(book.id, config.get_book_path())
|
|
||||||
if not rename_error:
|
|
||||||
calibre_db.session.commit()
|
|
||||||
ret = {"success":True,
|
|
||||||
"newValue":book.title}
|
|
||||||
else:
|
|
||||||
calibre_db.session.rollback()
|
|
||||||
ret = {"success":False, "msg":rename_error}
|
|
||||||
if multi:
|
|
||||||
out.append(ret)
|
|
||||||
elif param == 'sort':
|
|
||||||
book.sort = vals['value']
|
|
||||||
ret = {"success":True,
|
|
||||||
"newValue":book.sort}
|
|
||||||
elif param == 'comments':
|
|
||||||
edit_book_comments(vals['value'], book)
|
|
||||||
ret = {"success":True,
|
|
||||||
"newValue":book.comments[0].text}
|
|
||||||
elif param == 'authors':
|
|
||||||
input_authors, __ = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == True)
|
|
||||||
rename_error = helper.update_dir_structure(book.id, config.get_book_path(), input_authors[0])
|
|
||||||
if not rename_error:
|
if not rename_error:
|
||||||
calibre_db.session.commit()
|
ret = Response(json.dumps({'success': True, 'newValue': book.title}),
|
||||||
ret = {"success":True,
|
mimetype='application/json')
|
||||||
"newValue":' & '.join([author.replace('|', ',') for author in input_authors])}
|
|
||||||
else:
|
else:
|
||||||
calibre_db.session.rollback()
|
ret = Response(json.dumps({'success': False,
|
||||||
ret = {"success":False, "msg":rename_error}
|
'msg': rename_error}),
|
||||||
if multi:
|
mimetype='application/json')
|
||||||
out.append(ret)
|
elif param == 'sort':
|
||||||
elif param == 'is_archived':
|
book.sort = vals['value']
|
||||||
is_archived = change_archived_books(book.id, vals['value'] == "True",
|
ret = Response(json.dumps({'success': True, 'newValue': book.sort}),
|
||||||
message="Book {} archive bit set to: {}".format(book.id,
|
mimetype='application/json')
|
||||||
vals['value']))
|
elif param == 'comments':
|
||||||
if is_archived:
|
edit_book_comments(vals['value'], book)
|
||||||
kobo_sync_status.remove_synced_book(book.id)
|
ret = Response(json.dumps({'success': True, 'newValue': book.comments[0].text}),
|
||||||
continue
|
mimetype='application/json')
|
||||||
elif param == 'read_status':
|
elif param == 'authors':
|
||||||
error = helper.edit_book_read_status(book.id, vals['value'] == "True")
|
input_authors, __ = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true")
|
||||||
if error:
|
rename_error = helper.update_dir_structure(book.id, config.get_book_path(), input_authors[0])
|
||||||
if multi:
|
if not rename_error:
|
||||||
out.append({"success":False, "msg":error})
|
ret = Response(json.dumps({
|
||||||
continue
|
'success': True,
|
||||||
else:
|
'newValue': ' & '.join([author.replace('|', ',') for author in input_authors])}),
|
||||||
return error, 400
|
mimetype='application/json')
|
||||||
continue
|
|
||||||
elif param.startswith("custom_column_"):
|
|
||||||
new_val = dict()
|
|
||||||
new_val[param] = vals['value']
|
|
||||||
edit_single_cc_data(book.id, book, param[14:], new_val)
|
|
||||||
# ToDo: Very hacky find better solution
|
|
||||||
if vals['value'] in ["True", "False"]:
|
|
||||||
ret = {}
|
|
||||||
else:
|
|
||||||
ret = {"success":True, "newValue":vals['value']}
|
|
||||||
else:
|
else:
|
||||||
if multi:
|
ret = Response(json.dumps({'success': False,
|
||||||
out.append({"success":False, "msg":_("Parameter not found")})
|
'msg': rename_error}),
|
||||||
continue
|
mimetype='application/json')
|
||||||
return _("Parameter not found"), 400
|
elif param == 'is_archived':
|
||||||
book.last_modified = datetime.now(timezone.utc)
|
is_archived = change_archived_books(book.id, vals['value'] == "True",
|
||||||
|
message="Book {} archive bit set to: {}".format(book.id, vals['value']))
|
||||||
calibre_db.session.commit()
|
if is_archived:
|
||||||
# revert change for sort if automatic fields link is deactivated
|
kobo_sync_status.remove_synced_book(book.id)
|
||||||
if param == 'title' and vals.get('checkT') == False:
|
return ""
|
||||||
book.sort = sort_param
|
elif param == 'read_status':
|
||||||
calibre_db.session.commit()
|
ret = helper.edit_book_read_status(book.id, vals['value'] == "True")
|
||||||
except (OperationalError, IntegrityError, StaleDataError, AttributeError) as e:
|
if ret:
|
||||||
calibre_db.session.rollback()
|
return ret, 400
|
||||||
log.error_or_exception("Database error: {}".format(e))
|
elif param.startswith("custom_column_"):
|
||||||
ret = {"success":False, "msg":'Database error: {}'.format(e.orig if hasattr(e, "orig") else e)}
|
new_val = dict()
|
||||||
if multi:
|
new_val[param] = vals['value']
|
||||||
out.append(ret)
|
edit_single_cc_data(book.id, book, param[14:], new_val)
|
||||||
if multi:
|
# ToDo: Very hacky find better solution
|
||||||
if len(out) > 0:
|
if vals['value'] in ["True", "False"]:
|
||||||
return out
|
ret = ""
|
||||||
|
else:
|
||||||
|
ret = Response(json.dumps({'success': True, 'newValue': vals['value']}),
|
||||||
|
mimetype='application/json')
|
||||||
else:
|
else:
|
||||||
return [ret]
|
return _("Parameter not found"), 400
|
||||||
else:
|
book.last_modified = datetime.now(timezone.utc)
|
||||||
return ret
|
|
||||||
|
calibre_db.session.commit()
|
||||||
|
# revert change for sort if automatic fields link is deactivated
|
||||||
|
if param == 'title' and vals.get('checkT') == "false":
|
||||||
|
book.sort = sort_param
|
||||||
|
calibre_db.session.commit()
|
||||||
|
except (OperationalError, IntegrityError, StaleDataError) as e:
|
||||||
|
calibre_db.session.rollback()
|
||||||
|
log.error_or_exception("Database error: {}".format(e))
|
||||||
|
ret = Response(json.dumps({'success': False,
|
||||||
|
'msg': 'Database error: {}'.format(e.orig if hasattr(e, "orig") else e)}),
|
||||||
|
mimetype='application/json')
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/sort_value/<field>/<int:bookid>")
|
@editbook.route("/ajax/sort_value/<field>/<int:bookid>")
|
||||||
@@ -457,13 +327,13 @@ def get_sorted_entry(field, bookid):
|
|||||||
book = calibre_db.get_filtered_book(bookid)
|
book = calibre_db.get_filtered_book(bookid)
|
||||||
if book:
|
if book:
|
||||||
if field == 'title':
|
if field == 'title':
|
||||||
return make_response(jsonify(sort=book.sort))
|
return json.dumps({'sort': book.sort})
|
||||||
elif field == 'authors':
|
elif field == 'authors':
|
||||||
return make_response(jsonify(author_sort=book.author_sort))
|
return json.dumps({'author_sort': book.author_sort})
|
||||||
if field == 'sort':
|
if field == 'sort':
|
||||||
return make_response(jsonify(sort=book.title))
|
return json.dumps({'sort': book.title})
|
||||||
if field == 'author_sort':
|
if field == 'author_sort':
|
||||||
return make_response(jsonify(authors=" & ".join([a.name for a in calibre_db.order_authors([book])])))
|
return json.dumps({'authors': " & ".join([a.name for a in calibre_db.order_authors([book])])})
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -479,56 +349,7 @@ def simulate_merge_list_book():
|
|||||||
from_book = []
|
from_book = []
|
||||||
for book_id in vals:
|
for book_id in vals:
|
||||||
from_book.append(calibre_db.get_book(book_id).title)
|
from_book.append(calibre_db.get_book(book_id).title)
|
||||||
return make_response(jsonify({'to': to_book, 'from': from_book}))
|
return json.dumps({'to': to_book, 'from': from_book})
|
||||||
return ""
|
|
||||||
|
|
||||||
@editbook.route("/ajax/displayselectedbooks", methods=['POST'])
|
|
||||||
@user_login_required
|
|
||||||
@edit_required
|
|
||||||
def display_selected_books():
|
|
||||||
vals = request.get_json().get('selections')
|
|
||||||
books = []
|
|
||||||
if vals:
|
|
||||||
for book_id in vals:
|
|
||||||
books.append(calibre_db.get_book(book_id).title)
|
|
||||||
return json.dumps({'books': books})
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@editbook.route("/ajax/archiveselectedbooks", methods=['POST'])
|
|
||||||
@login_required_if_no_ano
|
|
||||||
@edit_required
|
|
||||||
def archive_selected_books():
|
|
||||||
vals = request.get_json().get('selections')
|
|
||||||
state = request.get_json().get('archive')
|
|
||||||
if vals:
|
|
||||||
for book_id in vals:
|
|
||||||
is_archived = change_archived_books(book_id, state,
|
|
||||||
message="Book {} archive bit set to: {}".format(book_id, state))
|
|
||||||
if is_archived:
|
|
||||||
kobo_sync_status.remove_synced_book(book_id)
|
|
||||||
return json.dumps({'success': True})
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
@editbook.route("/ajax/readselectedbooks", methods=['POST'])
|
|
||||||
@user_login_required
|
|
||||||
@edit_required
|
|
||||||
def read_selected_books():
|
|
||||||
vals = request.get_json().get('selections')
|
|
||||||
markAsRead = request.get_json().get('markAsRead')
|
|
||||||
if vals:
|
|
||||||
try:
|
|
||||||
for book_id in vals:
|
|
||||||
ret = helper.edit_book_read_status(book_id, markAsRead)
|
|
||||||
|
|
||||||
except (OperationalError, IntegrityError, StaleDataError) as e:
|
|
||||||
calibre_db.session.rollback()
|
|
||||||
log.error_or_exception("Database error: {}".format(e))
|
|
||||||
ret = Response(json.dumps({'success': False,
|
|
||||||
'msg': 'Database error: {}'.format(e.orig if hasattr(e, "orig") else e)}),
|
|
||||||
mimetype='application/json')
|
|
||||||
|
|
||||||
return json.dumps({'success': True})
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -565,8 +386,8 @@ def merge_list_book():
|
|||||||
element.format,
|
element.format,
|
||||||
element.uncompressed_size,
|
element.uncompressed_size,
|
||||||
to_name))
|
to_name))
|
||||||
check_delete_book([from_book.id], "", True)
|
delete_book_from_table(from_book.id, "", True)
|
||||||
return make_response(jsonify(success=True))
|
return json.dumps({'success': True})
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -606,11 +427,11 @@ def table_xchange_author_title():
|
|||||||
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))
|
||||||
return make_response(jsonify(success=False))
|
return json.dumps({'success': False})
|
||||||
|
|
||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
gdriveutils.updateGdriveCalibreFromLocal()
|
gdriveutils.updateGdriveCalibreFromLocal()
|
||||||
return make_response(jsonify(success=True))
|
return json.dumps({'success': True})
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -680,7 +501,6 @@ def do_edit_book(book_id, upload_formats=None):
|
|||||||
modify_date = True
|
modify_date = True
|
||||||
helper.replace_cover_thumbnail_cache(book.id)
|
helper.replace_cover_thumbnail_cache(book.id)
|
||||||
else:
|
else:
|
||||||
edit_error = True
|
|
||||||
flash(error, category="error")
|
flash(error, category="error")
|
||||||
|
|
||||||
# Add default series_index to book
|
# Add default series_index to book
|
||||||
@@ -738,7 +558,7 @@ def do_edit_book(book_id, upload_formats=None):
|
|||||||
|
|
||||||
if upload_formats:
|
if upload_formats:
|
||||||
resp = {"location": url_for('edit-book.show_edit_book', book_id=book_id)}
|
resp = {"location": url_for('edit-book.show_edit_book', book_id=book_id)}
|
||||||
return make_response(jsonify(resp))
|
return Response(json.dumps(resp), mimetype='application/json')
|
||||||
|
|
||||||
if "detail_view" in to_save:
|
if "detail_view" in to_save:
|
||||||
return redirect(url_for('web.show_book', book_id=book.id))
|
return redirect(url_for('web.show_book', book_id=book.id))
|
||||||
@@ -842,9 +662,8 @@ def prepare_authors(authr, calibre_path, gdrive=False):
|
|||||||
all_new_name = helper.get_valid_filename(one_book.title, chars=42) + ' - ' \
|
all_new_name = helper.get_valid_filename(one_book.title, chars=42) + ' - ' \
|
||||||
+ helper.get_valid_filename(renamed_author.name, chars=42)
|
+ helper.get_valid_filename(renamed_author.name, chars=42)
|
||||||
# change location in database to new author/title path
|
# change location in database to new author/title path
|
||||||
error = helper.rename_all_files_on_change(one_book, new_path, new_path, all_new_name, gdrive)
|
helper.rename_all_files_on_change(one_book, new_path, new_path, all_new_name, gdrive)
|
||||||
if error:
|
|
||||||
flash(error)
|
|
||||||
return input_authors
|
return input_authors
|
||||||
|
|
||||||
|
|
||||||
@@ -948,17 +767,17 @@ def file_handling_on_upload(requested_file):
|
|||||||
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 None, make_response(jsonify(location=url_for("web.index")))
|
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||||
if '.' in requested_file.filename:
|
if '.' in requested_file.filename:
|
||||||
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
|
file_ext = requested_file.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(
|
flash(
|
||||||
_("File extension '%(ext)s' is not allowed to be uploaded to this server",
|
_("File extension '%(ext)s' is not allowed to be uploaded to this server",
|
||||||
ext=file_ext), category="error")
|
ext=file_ext), category="error")
|
||||||
return None, make_response(jsonify(location=url_for("web.index")))
|
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||||
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 None, make_response(jsonify(location=url_for("web.index")))
|
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||||
|
|
||||||
# extract metadata from file
|
# extract metadata from file
|
||||||
try:
|
try:
|
||||||
@@ -967,7 +786,7 @@ def file_handling_on_upload(requested_file):
|
|||||||
log.error("File %s could not saved to temp dir", requested_file.filename)
|
log.error("File %s could not saved to temp dir", requested_file.filename)
|
||||||
flash(_("File %(filename)s could not saved to temp dir",
|
flash(_("File %(filename)s could not saved to temp dir",
|
||||||
filename=requested_file.filename), category="error")
|
filename=requested_file.filename), category="error")
|
||||||
return None, make_response(jsonify(location=url_for("web.index")))
|
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||||
return meta, None
|
return meta, None
|
||||||
|
|
||||||
|
|
||||||
@@ -1036,109 +855,86 @@ def delete_whole_book(book_id, book):
|
|||||||
calibre_db.session.query(db.Books).filter(db.Books.id == book_id).delete()
|
calibre_db.session.query(db.Books).filter(db.Books.id == book_id).delete()
|
||||||
|
|
||||||
|
|
||||||
def render_delete_book_result(book_format, book_id, location=""):
|
def render_delete_book_result(book_format, json_response, warning, book_id, location=""):
|
||||||
if book_format:
|
if book_format:
|
||||||
flash(_('Book Format Successfully Deleted'), category="success")
|
|
||||||
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
|
||||||
else:
|
|
||||||
flash(_('Book Successfully Deleted'), category="success")
|
|
||||||
return redirect(get_redirect_location(location, "web.index"))
|
|
||||||
|
|
||||||
|
|
||||||
def check_delete_book(book_id, book_format, json_response, location=""):
|
|
||||||
if current_user.role_delete_books():
|
|
||||||
if json_response:
|
if json_response:
|
||||||
# if json response is set, it's possible to delete more than one book, but never a format is deleted
|
return json.dumps([warning, {"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
||||||
res = list()
|
"type": "success",
|
||||||
for b in book_id:
|
"format": book_format,
|
||||||
ret = delete_book_from_table(b)
|
"message": _('Book Format Successfully Deleted')}])
|
||||||
if ret:
|
|
||||||
res.extend([ret])
|
|
||||||
if len(res) == 0:
|
|
||||||
return [{"location": get_redirect_location(location, "web.index"),
|
|
||||||
"type": "success",
|
|
||||||
"format": "",
|
|
||||||
"message": _('Book Successfully Deleted')}]
|
|
||||||
return jsonify(res)
|
|
||||||
else:
|
else:
|
||||||
return delete_book_from_UI(book_id, book_format, location)
|
flash(_('Book Format Successfully Deleted'), category="success")
|
||||||
|
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
||||||
|
else:
|
||||||
|
if json_response:
|
||||||
|
return json.dumps([warning, {"location": get_redirect_location(location, "web.index"),
|
||||||
|
"type": "success",
|
||||||
|
"format": book_format,
|
||||||
|
"message": _('Book Successfully Deleted')}])
|
||||||
|
else:
|
||||||
|
flash(_('Book Successfully Deleted'), category="success")
|
||||||
|
return redirect(get_redirect_location(location, "web.index"))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_book_from_table(book_id, book_format, json_response, location=""):
|
||||||
|
warning = {}
|
||||||
|
if current_user.role_delete_books():
|
||||||
|
book = calibre_db.get_book(book_id)
|
||||||
|
if book:
|
||||||
|
try:
|
||||||
|
result, error = helper.delete_book(book, config.get_book_path(), book_format=book_format.upper())
|
||||||
|
if not result:
|
||||||
|
if json_response:
|
||||||
|
return json.dumps([{"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
||||||
|
"type": "danger",
|
||||||
|
"format": "",
|
||||||
|
"message": error}])
|
||||||
|
else:
|
||||||
|
flash(error, category="error")
|
||||||
|
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
||||||
|
if error:
|
||||||
|
if json_response:
|
||||||
|
warning = {"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
||||||
|
"type": "warning",
|
||||||
|
"format": "",
|
||||||
|
"message": error}
|
||||||
|
else:
|
||||||
|
flash(error, category="warning")
|
||||||
|
if not book_format:
|
||||||
|
delete_whole_book(book_id, book)
|
||||||
|
else:
|
||||||
|
calibre_db.session.query(db.Data).filter(db.Data.book == book.id).\
|
||||||
|
filter(db.Data.format == book_format).delete()
|
||||||
|
if book_format.upper() in ['KEPUB', 'EPUB', 'EPUB3']:
|
||||||
|
kobo_sync_status.remove_synced_book(book.id, True)
|
||||||
|
calibre_db.session.commit()
|
||||||
|
except Exception as ex:
|
||||||
|
log.error_or_exception(ex)
|
||||||
|
calibre_db.session.rollback()
|
||||||
|
if json_response:
|
||||||
|
return json.dumps([{"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
||||||
|
"type": "danger",
|
||||||
|
"format": "",
|
||||||
|
"message": ex}])
|
||||||
|
else:
|
||||||
|
flash(str(ex), category="error")
|
||||||
|
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
||||||
|
|
||||||
|
else:
|
||||||
|
# book not found
|
||||||
|
log.error('Book with id "%s" could not be deleted: not found', book_id)
|
||||||
|
return render_delete_book_result(book_format, json_response, warning, book_id, location)
|
||||||
message = _("You are missing permissions to delete books")
|
message = _("You are missing permissions to delete books")
|
||||||
if json_response:
|
if json_response:
|
||||||
try:
|
return json.dumps({"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
||||||
return jsonify({"location": url_for("edit-book.show_edit_book", book_id=int(book_id)),
|
"type": "danger",
|
||||||
"type": "danger",
|
"format": "",
|
||||||
"format": "",
|
"message": message})
|
||||||
"message": message})
|
|
||||||
except TypeError as e:
|
|
||||||
return jsonify({"location": url_for("web.index"), "type": "danger", "format": "",
|
|
||||||
"message": str(e)})
|
|
||||||
else:
|
else:
|
||||||
flash(message, category="error")
|
flash(message, category="error")
|
||||||
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
||||||
|
|
||||||
|
|
||||||
def delete_book_from_UI(book_id, book_format, location=""):
|
|
||||||
book = calibre_db.get_book(book_id)
|
|
||||||
if book:
|
|
||||||
try:
|
|
||||||
result, error = helper.delete_book(book, config.get_book_path(), book_format=book_format.upper())
|
|
||||||
if not result:
|
|
||||||
flash(error, category="error")
|
|
||||||
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
|
||||||
if error:
|
|
||||||
flash(error, category="warning")
|
|
||||||
if not book_format:
|
|
||||||
delete_whole_book(book_id, book)
|
|
||||||
else:
|
|
||||||
calibre_db.session.query(db.Data).filter(db.Data.book == book.id). \
|
|
||||||
filter(db.Data.format == book_format).delete()
|
|
||||||
if book_format.upper() in ['KEPUB', 'EPUB', 'EPUB3']:
|
|
||||||
kobo_sync_status.remove_synced_book(book.id, True)
|
|
||||||
calibre_db.session.commit()
|
|
||||||
except Exception as ex:
|
|
||||||
log.error_or_exception(ex)
|
|
||||||
calibre_db.session.rollback()
|
|
||||||
flash(str(ex), category="error")
|
|
||||||
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
|
||||||
else:
|
|
||||||
# book not found
|
|
||||||
log.error('Book with id "%s" could not be deleted: not found', book_id)
|
|
||||||
return render_delete_book_result(book_format, book_id, location)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_book_from_table(book_id):
|
|
||||||
book = calibre_db.get_book(book_id)
|
|
||||||
if book:
|
|
||||||
try:
|
|
||||||
result, error = helper.delete_book(book, config.get_book_path(), book_format="")
|
|
||||||
if not result:
|
|
||||||
return {"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
|
||||||
"type": "danger",
|
|
||||||
"format": "",
|
|
||||||
"message": error}
|
|
||||||
delete_whole_book(book_id, book)
|
|
||||||
calibre_db.session.commit()
|
|
||||||
if error:
|
|
||||||
return {"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
|
||||||
"type": "warning",
|
|
||||||
"format": "",
|
|
||||||
"message": error}
|
|
||||||
except Exception as ex:
|
|
||||||
log.error_or_exception(ex)
|
|
||||||
calibre_db.session.rollback()
|
|
||||||
return {"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
|
||||||
"type": "danger",
|
|
||||||
"format": "",
|
|
||||||
"message": ex}
|
|
||||||
else:
|
|
||||||
# book not found
|
|
||||||
log.error('Book with id "%s" could not be deleted: not found', book_id)
|
|
||||||
return {"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
|
||||||
"type": "danger",
|
|
||||||
"format": "",
|
|
||||||
"message": _('Book with id "{}" could not be deleted: not found'.format(book_id))}
|
|
||||||
|
|
||||||
|
|
||||||
def render_edit_book(book_id):
|
def render_edit_book(book_id):
|
||||||
cc = calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.datatype.notin_(db.cc_exceptions)).all()
|
cc = calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.datatype.notin_(db.cc_exceptions)).all()
|
||||||
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
||||||
@@ -1611,7 +1407,7 @@ def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
|
|||||||
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':
|
||||||
new_element = db_object(add_element, db.title_sort(add_element, config))
|
new_element = db_object(add_element, add_element)
|
||||||
elif db_type == 'custom':
|
elif db_type == 'custom':
|
||||||
new_element = db_object(value=add_element)
|
new_element = db_object(value=add_element)
|
||||||
elif db_type == 'publisher':
|
elif db_type == 'publisher':
|
||||||
@@ -1642,7 +1438,7 @@ def create_objects_for_addition(db_element, add_element, db_type):
|
|||||||
elif db_type == 'series':
|
elif db_type == 'series':
|
||||||
if db_element.name != add_element:
|
if db_element.name != add_element:
|
||||||
db_element.name = add_element
|
db_element.name = add_element
|
||||||
db_element.sort = db.title_sort(add_element, config)
|
db_element.sort = add_element
|
||||||
elif db_type == 'author':
|
elif db_type == 'author':
|
||||||
if db_element.name != add_element:
|
if db_element.name != add_element:
|
||||||
db_element.name = add_element
|
db_element.name = add_element
|
||||||
|
|||||||
+1
-2
@@ -36,8 +36,7 @@ def do_calibre_export(book_id, book_format):
|
|||||||
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.get_book_path()
|
library_path = config.get_book_path()
|
||||||
opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--dont-save-cover',
|
opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--with-library', library_path,
|
||||||
'--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)]
|
||||||
p = process_open(opf_command, quotes, my_env)
|
p = process_open(opf_command, quotes, my_env)
|
||||||
|
|||||||
+2
-2
@@ -56,7 +56,7 @@ def get_epub_layout(book, book_data):
|
|||||||
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
|
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
|
||||||
|
|
||||||
layout = p.xpath('pkg:meta[@property="rendition:layout"]/text()', namespaces=default_ns)
|
layout = p.xpath('pkg:meta[@property="rendition:layout"]/text()', namespaces=default_ns)
|
||||||
except (etree.XMLSyntaxError, KeyError, IndexError, OSError, UnicodeDecodeError) as e:
|
except (etree.XMLSyntaxError, KeyError, IndexError, OSError) as e:
|
||||||
log.error("Could not parse epub metadata of book {} during kobo sync: {}".format(book.id, e))
|
log.error("Could not parse epub metadata of book {} during kobo sync: {}".format(book.id, e))
|
||||||
layout = []
|
layout = []
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ def parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path):
|
|||||||
for cs in cover_section:
|
for cs in cover_section:
|
||||||
if cs.endswith('.xhtml') or cs.endswith('.html'):
|
if cs.endswith('.xhtml') or cs.endswith('.html'):
|
||||||
markup = epub_zip.read(os.path.join(cover_path, cs))
|
markup = epub_zip.read(os.path.join(cover_path, cs))
|
||||||
markup_tree = etree.fromstring(markup, parser=etree.XMLParser(resolve_entities=False, no_network=True))
|
markup_tree = etree.fromstring(markup)
|
||||||
# no matter xhtml or html with no namespace
|
# no matter xhtml or html with no namespace
|
||||||
img_src = markup_tree.xpath("//*[local-name() = 'img']/@src")
|
img_src = markup_tree.xpath("//*[local-name() = 'img']/@src")
|
||||||
# Alternative image source
|
# Alternative image source
|
||||||
|
|||||||
+2
-6
@@ -53,20 +53,16 @@ def updateEpub(src, dest, filename, data, ):
|
|||||||
zf.writestr(filename, data)
|
zf.writestr(filename, data)
|
||||||
|
|
||||||
|
|
||||||
# Safe parser: disable entity resolution and network access to prevent XXE attacks
|
|
||||||
_safe_parser = etree.XMLParser(resolve_entities=False, no_network=True)
|
|
||||||
|
|
||||||
|
|
||||||
def get_content_opf(file_path, ns=None):
|
def get_content_opf(file_path, ns=None):
|
||||||
if ns is None:
|
if ns is None:
|
||||||
ns = default_ns
|
ns = default_ns
|
||||||
epubZip = zipfile.ZipFile(file_path)
|
epubZip = zipfile.ZipFile(file_path)
|
||||||
txt = epubZip.read('META-INF/container.xml')
|
txt = epubZip.read('META-INF/container.xml')
|
||||||
tree = etree.fromstring(txt, parser=_safe_parser)
|
tree = etree.fromstring(txt)
|
||||||
cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
||||||
cf = epubZip.read(cf_name)
|
cf = epubZip.read(cf_name)
|
||||||
|
|
||||||
return etree.fromstring(cf, parser=_safe_parser), cf_name
|
return etree.fromstring(cf), cf_name
|
||||||
|
|
||||||
|
|
||||||
def create_new_metadata_backup(book, custom_columns, export_language, translated_cover_name, lang_type=3):
|
def create_new_metadata_backup(book, custom_columns, export_language, translated_cover_name, lang_type=3):
|
||||||
|
|||||||
+4
-51
@@ -18,67 +18,38 @@
|
|||||||
|
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from flask import render_template, request, flash, make_response
|
from flask import render_template
|
||||||
from flask_limiter import RateLimitExceeded
|
|
||||||
from flask_babel import gettext as _
|
|
||||||
from werkzeug.exceptions import default_exceptions
|
from werkzeug.exceptions import default_exceptions
|
||||||
|
|
||||||
from .cw_login import current_user
|
|
||||||
try:
|
try:
|
||||||
from werkzeug.exceptions import FailedDependency
|
from werkzeug.exceptions import FailedDependency
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from werkzeug.exceptions import UnprocessableEntity as FailedDependency
|
from werkzeug.exceptions import UnprocessableEntity as FailedDependency
|
||||||
|
|
||||||
from . import config, app, logger, services
|
from . import config, app, logger, services
|
||||||
from .render_template import render_title_template
|
|
||||||
from .web import render_login
|
|
||||||
from .usermanagement import auth
|
|
||||||
from cps.string_helper import strip_whitespaces
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
# custom error page
|
# custom error page
|
||||||
|
|
||||||
def error_http(error):
|
def error_http(error):
|
||||||
headers = {'WWW-Authenticate': 'Basic realm="calibre-web"'} if error.code == 401 else {}
|
|
||||||
return render_template('http_error.html',
|
return render_template('http_error.html',
|
||||||
error_code="Error {0}".format(error.code),
|
error_code="Error {0}".format(error.code),
|
||||||
error_name=error.name,
|
error_name=error.name,
|
||||||
issue=False,
|
issue=False,
|
||||||
goto_admin=False,
|
|
||||||
unconfigured=not config.db_configured,
|
unconfigured=not config.db_configured,
|
||||||
instance=config.config_calibre_web_title
|
instance=config.config_calibre_web_title
|
||||||
), error.code, headers
|
), error.code
|
||||||
|
|
||||||
|
|
||||||
def internal_error(error):
|
def internal_error(error):
|
||||||
if (isinstance(error.original_exception, AttributeError) and
|
|
||||||
error.original_exception.args[0] == "'NoneType' object has no attribute 'query'"
|
|
||||||
and error.original_exception.name == "query"):
|
|
||||||
return render_template('http_error.html',
|
|
||||||
error_code="Database Error",
|
|
||||||
error_name='The library used is invalid or has permission errors',
|
|
||||||
issue=False,
|
|
||||||
goto_admin=True,
|
|
||||||
unconfigured=False,
|
|
||||||
error_stack="",
|
|
||||||
instance=config.config_calibre_web_title
|
|
||||||
), 500
|
|
||||||
log.error("500 Internal Server Error: %s", traceback.format_exc())
|
|
||||||
error_stack = ""
|
|
||||||
try:
|
|
||||||
if current_user.is_authenticated and current_user.role_admin():
|
|
||||||
error_stack = traceback.format_exc().split("\n")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return render_template('http_error.html',
|
return render_template('http_error.html',
|
||||||
error_code="500 Internal Server Error",
|
error_code="500 Internal Server Error",
|
||||||
error_name='The server encountered an internal error and was unable to complete your '
|
error_name='The server encountered an internal error and was unable to complete your '
|
||||||
'request. There is an error in the application.',
|
'request. There is an error in the application.',
|
||||||
issue=True,
|
issue=True,
|
||||||
goto_admin=False,
|
|
||||||
unconfigured=False,
|
unconfigured=False,
|
||||||
error_stack=error_stack,
|
error_stack=traceback.format_exc().split("\n"),
|
||||||
instance=config.config_calibre_web_title
|
instance=config.config_calibre_web_title
|
||||||
), 500
|
), 500
|
||||||
|
|
||||||
@@ -99,21 +70,3 @@ def init_errorhandler():
|
|||||||
log.debug('LDAP server not accessible while trying to login to opds feed')
|
log.debug('LDAP server not accessible while trying to login to opds feed')
|
||||||
return error_http(FailedDependency())
|
return error_http(FailedDependency())
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(RateLimitExceeded)
|
|
||||||
def handle_rate_limit(__):
|
|
||||||
log.error("Rate limit exceeded {}".format(request.endpoint))
|
|
||||||
if "register" in request.endpoint:
|
|
||||||
flash(_(u"Please wait one minute to register next user"), category="error")
|
|
||||||
return render_title_template('register.html', config=config, title=_("Register"), page="register")
|
|
||||||
elif "login" in request.endpoint:
|
|
||||||
form = request.form.to_dict()
|
|
||||||
username = strip_whitespaces(form.get('username', "")).lower().replace("\n", "").replace("\r", "")
|
|
||||||
flash(_("Please wait one minute before next login"), category="error")
|
|
||||||
return render_login(username, form.get("password", ""))
|
|
||||||
elif "opds" in request.endpoint:
|
|
||||||
return auth.auth_error_callback(429)
|
|
||||||
else:
|
|
||||||
return make_response('', 429)
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -20,9 +20,6 @@ from lxml import etree
|
|||||||
|
|
||||||
from .constants import BookMeta
|
from .constants import BookMeta
|
||||||
|
|
||||||
# Safe parser: disable entity resolution and network access to prevent XXE attacks
|
|
||||||
_safe_parser = etree.XMLParser(resolve_entities=False, no_network=True)
|
|
||||||
|
|
||||||
|
|
||||||
def get_fb2_info(tmp_file_path, original_file_extension):
|
def get_fb2_info(tmp_file_path, original_file_extension):
|
||||||
|
|
||||||
@@ -32,7 +29,7 @@ def get_fb2_info(tmp_file_path, original_file_extension):
|
|||||||
}
|
}
|
||||||
|
|
||||||
fb2_file = open(tmp_file_path, encoding="utf-8")
|
fb2_file = open(tmp_file_path, encoding="utf-8")
|
||||||
tree = etree.fromstring(fb2_file.read().encode(), parser=_safe_parser)
|
tree = etree.fromstring(fb2_file.read().encode())
|
||||||
|
|
||||||
authors = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:author', namespaces=ns)
|
authors = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:author', namespaces=ns)
|
||||||
|
|
||||||
|
|||||||
+1
-10
@@ -34,15 +34,6 @@ except ImportError as e:
|
|||||||
error = "Cannot import python-magic, checking uploaded file metadata will not work: {}".format(e)
|
error = "Cannot import python-magic, checking uploaded file metadata will not work: {}".format(e)
|
||||||
|
|
||||||
|
|
||||||
def get_mimetype(ext):
|
|
||||||
# overwrite some mimetypes for proper file detection
|
|
||||||
mimes = {".fb2": "text/xml",
|
|
||||||
".cbz": "application/zip",
|
|
||||||
".cbr": "application/x-rar"
|
|
||||||
}
|
|
||||||
return mimes.get(ext, mimetypes.types_map[ext])
|
|
||||||
|
|
||||||
|
|
||||||
def get_temp_dir():
|
def get_temp_dir():
|
||||||
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
||||||
if not os.path.isdir(tmp_dir):
|
if not os.path.isdir(tmp_dir):
|
||||||
@@ -63,7 +54,7 @@ def validate_mime_type(file_buffer, allowed_extensions):
|
|||||||
allowed_mimetypes = list()
|
allowed_mimetypes = list()
|
||||||
for x in allowed_extensions:
|
for x in allowed_extensions:
|
||||||
try:
|
try:
|
||||||
allowed_mimetypes.append(get_mimetype("." + x))
|
allowed_mimetypes.append(mimetypes.types_map["." + x])
|
||||||
except KeyError:
|
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())
|
||||||
|
|||||||
@@ -17,7 +17,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/>.
|
||||||
|
|
||||||
from . import logger
|
from . import logger
|
||||||
from .constants import CACHE_DIRECTORY
|
from .constants import CACHE_DIR
|
||||||
from os import makedirs, remove
|
from os import makedirs, remove
|
||||||
from os.path import isdir, isfile, join
|
from os.path import isdir, isfile, join
|
||||||
from shutil import rmtree
|
from shutil import rmtree
|
||||||
@@ -25,7 +25,7 @@ from shutil import rmtree
|
|||||||
|
|
||||||
class FileSystem:
|
class FileSystem:
|
||||||
_instance = None
|
_instance = None
|
||||||
_cache_dir = CACHE_DIRECTORY
|
_cache_dir = CACHE_DIR
|
||||||
|
|
||||||
def __new__(cls):
|
def __new__(cls):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
|
|||||||
+1
-1
@@ -140,7 +140,7 @@ try:
|
|||||||
if response:
|
if response:
|
||||||
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
|
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
|
||||||
if not response['deleted'] and response['file']['title'] == 'metadata.db' \
|
if not response['deleted'] and response['file']['title'] == 'metadata.db' \
|
||||||
and response['file']['md5Checksum'] != hashlib.md5(dbpath).hexdigest(): # nosec
|
and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec
|
||||||
tmp_dir = get_temp_dir()
|
tmp_dir = get_temp_dir()
|
||||||
|
|
||||||
log.info('Database file updated')
|
log.info('Database file updated')
|
||||||
|
|||||||
+68
-69
@@ -30,7 +30,7 @@ import requests
|
|||||||
import unidecode
|
import unidecode
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from flask import send_from_directory, make_response, abort, url_for, Response, request, after_this_request
|
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
|
||||||
@@ -43,10 +43,10 @@ from markupsafe import escape
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from . import cw_advocate
|
import advocate
|
||||||
from .cw_advocate.exceptions import UnacceptableAddressException
|
from advocate.exceptions import UnacceptableAddressException
|
||||||
use_advocate = True
|
use_advocate = True
|
||||||
except ImportError as e:
|
except ImportError:
|
||||||
use_advocate = False
|
use_advocate = False
|
||||||
advocate = requests
|
advocate = requests
|
||||||
UnacceptableAddressException = MissingSchema = BaseException
|
UnacceptableAddressException = MissingSchema = BaseException
|
||||||
@@ -199,7 +199,7 @@ def check_send_to_ereader(entry):
|
|||||||
# Check if a reader is existing for any of the book formats, if not, return empty list, otherwise return
|
# Check if a reader is existing for any of the book formats, if not, return empty list, otherwise return
|
||||||
# list with supported formats
|
# list with supported formats
|
||||||
def check_read_formats(entry):
|
def check_read_formats(entry):
|
||||||
extensions_reader = {'TXT', 'PDF', 'EPUB', 'KEPUB', 'CBZ', 'CBT', 'CBR', 'DJVU', 'DJV'}
|
extensions_reader = {'TXT', 'PDF', 'EPUB', 'CBZ', 'CBT', 'CBR', 'DJVU', 'DJV'}
|
||||||
book_formats = list()
|
book_formats = list()
|
||||||
if len(entry.data):
|
if len(entry.data):
|
||||||
for ele in iter(entry.data):
|
for ele in iter(entry.data):
|
||||||
@@ -237,7 +237,7 @@ def send_mail(book_id, book_format, convert, ereader_mail, calibrepath, user_id)
|
|||||||
return _("The requested file could not be read. Maybe wrong permissions?")
|
return _("The requested file could not be read. Maybe wrong permissions?")
|
||||||
|
|
||||||
|
|
||||||
def get_valid_filename(value, replace_whitespace=True, chars=128, force_unidecode=False):
|
def get_valid_filename(value, replace_whitespace=True, chars=128):
|
||||||
"""
|
"""
|
||||||
Returns the given string converted to a string that can be used for a clean
|
Returns the given string converted to a string that can be used for a clean
|
||||||
filename. Limits num characters to 128 max.
|
filename. Limits num characters to 128 max.
|
||||||
@@ -245,7 +245,7 @@ def get_valid_filename(value, replace_whitespace=True, chars=128, force_unidecod
|
|||||||
if value[-1:] == '.':
|
if value[-1:] == '.':
|
||||||
value = value[:-1]+'_'
|
value = value[:-1]+'_'
|
||||||
value = value.replace("/", "_").replace(":", "_").strip('\0')
|
value = value.replace("/", "_").replace(":", "_").strip('\0')
|
||||||
if config.config_unicode_filename or force_unidecode:
|
if config.config_unicode_filename:
|
||||||
value = (unidecode.unidecode(value))
|
value = (unidecode.unidecode(value))
|
||||||
if replace_whitespace:
|
if replace_whitespace:
|
||||||
# *+:\"/<>? are replaced by _
|
# *+:\"/<>? are replaced by _
|
||||||
@@ -307,16 +307,18 @@ def edit_book_read_status(book_id, read_status=None):
|
|||||||
if not config.config_read_column:
|
if not config.config_read_column:
|
||||||
book = ub.session.query(ub.ReadBook).filter(and_(ub.ReadBook.user_id == int(current_user.id),
|
book = ub.session.query(ub.ReadBook).filter(and_(ub.ReadBook.user_id == int(current_user.id),
|
||||||
ub.ReadBook.book_id == book_id)).first()
|
ub.ReadBook.book_id == book_id)).first()
|
||||||
if not book:
|
if book:
|
||||||
read_book = ub.ReadBook(user_id=current_user.id, book_id=book_id)
|
if read_status is None:
|
||||||
book = read_book
|
if book.read_status == ub.ReadBook.STATUS_FINISHED:
|
||||||
if read_status is None:
|
book.read_status = ub.ReadBook.STATUS_UNREAD
|
||||||
if book.read_status == ub.ReadBook.STATUS_FINISHED:
|
else:
|
||||||
book.read_status = ub.ReadBook.STATUS_UNREAD
|
book.read_status = ub.ReadBook.STATUS_FINISHED
|
||||||
else:
|
else:
|
||||||
book.read_status = ub.ReadBook.STATUS_FINISHED
|
book.read_status = ub.ReadBook.STATUS_FINISHED if read_status else ub.ReadBook.STATUS_UNREAD
|
||||||
else:
|
else:
|
||||||
book.read_status = ub.ReadBook.STATUS_FINISHED if read_status == True else ub.ReadBook.STATUS_UNREAD
|
read_book = ub.ReadBook(user_id=current_user.id, book_id=book_id)
|
||||||
|
read_book.read_status = ub.ReadBook.STATUS_FINISHED
|
||||||
|
book = read_book
|
||||||
if not book.kobo_reading_state:
|
if not book.kobo_reading_state:
|
||||||
kobo_reading_state = ub.KoboReadingState(user_id=current_user.id, book_id=book_id)
|
kobo_reading_state = ub.KoboReadingState(user_id=current_user.id, book_id=book_id)
|
||||||
kobo_reading_state.current_bookmark = ub.KoboBookmark()
|
kobo_reading_state.current_bookmark = ub.KoboBookmark()
|
||||||
@@ -327,7 +329,7 @@ def edit_book_read_status(book_id, read_status=None):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
calibre_db.create_functions(config)
|
calibre_db.create_functions(config)
|
||||||
book = calibre_db.get_filtered_book(book_id, True)
|
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):
|
||||||
if read_status is None:
|
if read_status is None:
|
||||||
@@ -394,16 +396,10 @@ def delete_book_file(book, calibrepath, book_format=None):
|
|||||||
def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdrive=False):
|
def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdrive=False):
|
||||||
for file_format in one_book.data:
|
for file_format in one_book.data:
|
||||||
if not gdrive:
|
if not gdrive:
|
||||||
try:
|
if not os.path.exists(new_path):
|
||||||
if not os.path.exists(new_path):
|
os.makedirs(new_path)
|
||||||
os.makedirs(new_path)
|
shutil.move(os.path.join(old_path, file_format.name + '.' + file_format.format.lower()),
|
||||||
shutil.move(os.path.join(old_path, file_format.name + '.' + file_format.format.lower()),
|
os.path.join(new_path, all_new_name + '.' + file_format.format.lower()))
|
||||||
os.path.join(new_path, all_new_name + '.' + file_format.format.lower()))
|
|
||||||
except (PermissionError, FileNotFoundError) as ex:
|
|
||||||
log.error("Moving book-id %s folder %s failed: %s", one_book.id, new_path, ex)
|
|
||||||
return _("Moving book path of Book %(book_id)s to: '%(src)s' failed with error: %(error)s",
|
|
||||||
book_id=one_book.id, src=new_path, error=str(ex))
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
g_file = gd.getFileFromEbooksFolder(old_path,
|
g_file = gd.getFileFromEbooksFolder(old_path,
|
||||||
file_format.name + '.' + file_format.format.lower())
|
file_format.name + '.' + file_format.format.lower())
|
||||||
@@ -416,13 +412,14 @@ def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdriv
|
|||||||
|
|
||||||
# change name in Database
|
# change name in Database
|
||||||
file_format.name = all_new_name
|
file_format.name = all_new_name
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -471,15 +468,17 @@ def update_dir_structure_file(book_id, calibre_path, original_filepath, new_auth
|
|||||||
db_filename,
|
db_filename,
|
||||||
original_filepath,
|
original_filepath,
|
||||||
path)
|
path)
|
||||||
if not error:
|
# 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)
|
||||||
# Book folder already moved, only files need to be renamed
|
# Book folder already moved, only files need to be renamed
|
||||||
renameerror = rename_all_files_on_change(local_book, new_path, new_path, all_new_name)
|
rename_all_files_on_change(local_book, new_path, new_path, all_new_name)
|
||||||
|
|
||||||
if error or renameerror:
|
if error:
|
||||||
return error or renameerror
|
return error
|
||||||
|
|
||||||
|
# Rename all files from old names to new names
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -491,7 +490,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
|
return False # rename_files_on_change(first_author, renamed_author, local_book=book, gdrive=True)
|
||||||
|
|
||||||
|
|
||||||
def update_dir_structure_gdrive(book_id, first_author):
|
def update_dir_structure_gdrive(book_id, first_author):
|
||||||
@@ -519,26 +518,24 @@ def update_dir_structure_gdrive(book_id, first_author):
|
|||||||
book.path = new_authordir + '/' + book.path.split('/')[1]
|
book.path = new_authordir + '/' + book.path.split('/')[1]
|
||||||
gd.updateDatabaseOnEdit(g_file['id'], book.path)
|
gd.updateDatabaseOnEdit(g_file['id'], book.path)
|
||||||
else:
|
else:
|
||||||
return _('File %(file)s not found on Google Drive', file=authordir) # file not found
|
return _('File %(file)s not found on Google Drive', file=authordir) # file not found'''
|
||||||
if titledir != new_titledir or authordir != new_authordir :
|
if titledir != new_titledir or authordir != new_authordir :
|
||||||
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)
|
||||||
return 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)
|
||||||
try:
|
shutil.move(original_filepath, os.path.join(new_path, db_filename))
|
||||||
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)
|
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
|
||||||
@@ -559,13 +556,33 @@ def move_files_on_change(calibre_path, new_author_dir, new_titledir, localbook,
|
|||||||
log.error("Deleting authorpath for book %s failed: %s", localbook.id, ex)
|
log.error("Deleting authorpath for book %s failed: %s", localbook.id, ex)
|
||||||
# change location in database to new author/title path
|
# change location in database to new author/title path
|
||||||
localbook.path = os.path.join(new_author_dir, new_titledir).replace('\\', '/')
|
localbook.path = os.path.join(new_author_dir, new_titledir).replace('\\', '/')
|
||||||
except (OSError, FileNotFoundError) as ex:
|
except OSError as ex:
|
||||||
log.error_or_exception("Rename title from {} to {} failed with error: {}".format(path, new_path, ex))
|
log.error_or_exception("Rename title from {} to {} failed with error: {}".format(path, new_path, ex))
|
||||||
return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
|
return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
|
||||||
src=path, dest=new_path, error=str(ex))
|
src=path, dest=new_path, error=str(ex))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def rename_files_on_change(first_author,
|
||||||
|
renamed_author,
|
||||||
|
local_book,
|
||||||
|
original_filepath="",
|
||||||
|
path="",
|
||||||
|
calibre_path="",
|
||||||
|
gdrive=False):
|
||||||
|
# Rename all files from old names to new names
|
||||||
|
#try:
|
||||||
|
#clean_author_database(renamed_author, calibre_path, gdrive=gdrive)
|
||||||
|
#if first_author and first_author not in renamed_author:
|
||||||
|
# clean_author_database([first_author], calibre_path, local_book, gdrive)
|
||||||
|
#if not gdrive and not renamed_author and not original_filepath and len(os.listdir(os.path.dirname(path))) == 0:
|
||||||
|
# shutil.rmtree(os.path.dirname(path))
|
||||||
|
#except (OSError, FileNotFoundError) as ex:
|
||||||
|
# log.error_or_exception("Error in rename file in path {}".format(ex))
|
||||||
|
# return _("Error in rename file in path: {}".format(str(ex)))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def delete_book_gdrive(book, book_format):
|
def delete_book_gdrive(book, book_format):
|
||||||
error = None
|
error = None
|
||||||
if book_format:
|
if book_format:
|
||||||
@@ -827,7 +844,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 = cw_advocate.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
|
img = 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")
|
||||||
@@ -891,7 +908,7 @@ def save_cover(img, book_path):
|
|||||||
else:
|
else:
|
||||||
imgc = Image(blob=io.BytesIO(img.content))
|
imgc = Image(blob=io.BytesIO(img.content))
|
||||||
imgc.format = 'jpeg'
|
imgc.format = 'jpeg'
|
||||||
imgc.transform_colorspace("srgb")
|
imgc.transform_colorspace("rgb")
|
||||||
img = imgc
|
img = imgc
|
||||||
except (BlobError, MissingDelegateError):
|
except (BlobError, MissingDelegateError):
|
||||||
log.error("Invalid cover file content")
|
log.error("Invalid cover file content")
|
||||||
@@ -956,23 +973,11 @@ def do_download_file(book, book_format, client, data, headers):
|
|||||||
else:
|
else:
|
||||||
download_name = book_name
|
download_name = book_name
|
||||||
|
|
||||||
# Clean up staged copies in /tmp/calibre_web after the response is sent
|
|
||||||
# (kepubify / calibre-export branches) so the temp dir does not grow unbounded.
|
|
||||||
if filename == get_temp_dir():
|
|
||||||
_tmp_path = os.path.join(filename, download_name + "." + book_format)
|
|
||||||
@after_this_request
|
|
||||||
def _cleanup_staged_download(resp):
|
|
||||||
try:
|
|
||||||
os.remove(_tmp_path)
|
|
||||||
except OSError as ex:
|
|
||||||
log.warning('Failed to remove staged download %s: %s', _tmp_path, ex)
|
|
||||||
return resp
|
|
||||||
response = make_response(send_from_directory(filename, download_name + "." + book_format))
|
response = make_response(send_from_directory(filename, download_name + "." + book_format))
|
||||||
# ToDo Check headers parameter
|
# ToDo Check headers parameter
|
||||||
for element in headers:
|
for element in headers:
|
||||||
response.headers[element[0]] = element[1]
|
response.headers[element[0]] = element[1]
|
||||||
log.info('Downloading file: \'%s\' by %s - %s', format(os.path.join(filename, book_name + "." + book_format)),
|
log.info('Downloading file: {}'.format(os.path.join(filename, book_name + "." + book_format)))
|
||||||
current_user.name, request.headers.get('X-Forwarded-For', request.remote_addr))
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -1102,17 +1107,11 @@ def get_download_link(book_id, book_format, client):
|
|||||||
file_name = book.title
|
file_name = book.title
|
||||||
if len(book.authors) > 0:
|
if len(book.authors) > 0:
|
||||||
file_name = file_name + ' - ' + book.authors[0].name
|
file_name = file_name + ' - ' + book.authors[0].name
|
||||||
original_name = file_name
|
file_name = get_valid_filename(file_name, replace_whitespace=False)
|
||||||
file_name = get_valid_filename(file_name, replace_whitespace=False, force_unidecode=True)
|
|
||||||
if client == "kindle":
|
|
||||||
quoted_file_name = file_name
|
|
||||||
else:
|
|
||||||
native_name = get_valid_filename(original_name, replace_whitespace=False, force_unidecode=False)
|
|
||||||
quoted_file_name = quote(native_name)
|
|
||||||
headers = Headers()
|
headers = Headers()
|
||||||
headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream")
|
headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream")
|
||||||
headers["Content-Disposition"] = ('attachment; filename="{}.{}"; filename*=UTF-8\'\'{}.{}').format(
|
headers["Content-Disposition"] = "attachment; filename=%s.%s; filename*=UTF-8''%s.%s" % (
|
||||||
file_name, book_format, quoted_file_name, book_format)
|
quote(file_name), book_format, quote(file_name), book_format)
|
||||||
return do_download_file(book, book_format, client, data1, headers)
|
return do_download_file(book, book_format, client, data1, headers)
|
||||||
else:
|
else:
|
||||||
log.error("Book id {} not found for downloading".format(book_id))
|
log.error("Book id {} not found for downloading".format(book_id))
|
||||||
|
|||||||
+14
-6
@@ -25,7 +25,20 @@ log = logger.create()
|
|||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from iso639 import languages
|
||||||
|
# iso_version = importlib.metadata.version("iso639")
|
||||||
|
get = languages.get
|
||||||
|
try:
|
||||||
|
if sys.version_info >= (3, 12):
|
||||||
|
import pkg_resources
|
||||||
|
except ImportError:
|
||||||
|
print("Python 3.12 isn't compatible with iso-639. Please install pycountry.")
|
||||||
|
except ImportError as ex:
|
||||||
from pycountry import languages as pyc_languages
|
from pycountry import languages as pyc_languages
|
||||||
|
#try:
|
||||||
|
# iso_version = importlib.metadata.version("pycountry") + ' (PyCountry)'
|
||||||
|
#except (ImportError, Exception):
|
||||||
|
# iso_version = "?" + ' (PyCountry)'
|
||||||
|
|
||||||
def _copy_fields(l):
|
def _copy_fields(l):
|
||||||
l.part1 = getattr(l, 'alpha_2', None)
|
l.part1 = getattr(l, 'alpha_2', None)
|
||||||
@@ -39,11 +52,6 @@ try:
|
|||||||
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):
|
||||||
@@ -101,6 +109,6 @@ def get_lang3(lang):
|
|||||||
ret_value = lang
|
ret_value = lang
|
||||||
else:
|
else:
|
||||||
ret_value = ""
|
ret_value = ""
|
||||||
except (KeyError, AttributeError):
|
except KeyError:
|
||||||
ret_value = lang
|
ret_value = lang
|
||||||
return ret_value
|
return ret_value
|
||||||
|
|||||||
@@ -12,384 +12,6 @@
|
|||||||
# map iso639 language codes to language names, translated
|
# map iso639 language codes to language names, translated
|
||||||
|
|
||||||
LANGUAGE_NAMES = {
|
LANGUAGE_NAMES = {
|
||||||
"ar": {
|
|
||||||
"abk": "Abkhazian",
|
|
||||||
"ace": "Achinese",
|
|
||||||
"ach": "Acoli",
|
|
||||||
"ada": "Adangme",
|
|
||||||
"ady": "Adyghe",
|
|
||||||
"aar": "Afar",
|
|
||||||
"afh": "Afrihili",
|
|
||||||
"afr": "الأفريكانس",
|
|
||||||
"ain": "Ainu (Japan)",
|
|
||||||
"aka": "Akan",
|
|
||||||
"akk": "Akkadian",
|
|
||||||
"sqi": "Albanian",
|
|
||||||
"ale": "Aleut",
|
|
||||||
"amh": "Amharic",
|
|
||||||
"anp": "Angika",
|
|
||||||
"ara": "العربية",
|
|
||||||
"arg": "Aragonese",
|
|
||||||
"arp": "Arapaho",
|
|
||||||
"arw": "Arawak",
|
|
||||||
"hye": "الأرمينية",
|
|
||||||
"asm": "Assamese",
|
|
||||||
"ast": "Asturian",
|
|
||||||
"ava": "Avaric",
|
|
||||||
"ave": "Avestan",
|
|
||||||
"awa": "Awadhi",
|
|
||||||
"aym": "Aymara",
|
|
||||||
"aze": "الأذربيجانية",
|
|
||||||
"ban": "البالية",
|
|
||||||
"bal": "Baluchi",
|
|
||||||
"bam": "Bambara",
|
|
||||||
"bas": "Basa (Cameroon)",
|
|
||||||
"bak": "Bashkir",
|
|
||||||
"eus": "الباسك",
|
|
||||||
"bej": "Beja",
|
|
||||||
"bel": "بلاروسي",
|
|
||||||
"bem": "Bemba (Zambia)",
|
|
||||||
"ben": "البنغالية",
|
|
||||||
"bit": "Berinomo",
|
|
||||||
"bho": "Bhojpuri",
|
|
||||||
"bik": "Bikol",
|
|
||||||
"byn": "Bilin",
|
|
||||||
"bin": "Bini",
|
|
||||||
"bis": "Bislama",
|
|
||||||
"zbl": "Blissymbols",
|
|
||||||
"bos": "Bosnian",
|
|
||||||
"bra": "Braj",
|
|
||||||
"bre": "البريتون",
|
|
||||||
"bug": "البجينيز",
|
|
||||||
"bul": "البلغارية",
|
|
||||||
"bua": "Buriat",
|
|
||||||
"mya": "البورمية",
|
|
||||||
"cad": "Caddo",
|
|
||||||
"cat": "الكاتالانية",
|
|
||||||
"ceb": "Cebuano",
|
|
||||||
"chg": "Chagatai",
|
|
||||||
"cha": "Chamorro",
|
|
||||||
"che": "Chechen",
|
|
||||||
"chr": "الشيروكى",
|
|
||||||
"chy": "Cheyenne",
|
|
||||||
"chb": "Chibcha",
|
|
||||||
"zho": "الصينية",
|
|
||||||
"chn": "Chinook jargon",
|
|
||||||
"chp": "Chipewyan",
|
|
||||||
"cho": "Choctaw",
|
|
||||||
"cht": "Cholón",
|
|
||||||
"chk": "Chuukese",
|
|
||||||
"chv": "Chuvash",
|
|
||||||
"cop": "القبطية",
|
|
||||||
"cor": "Cornish",
|
|
||||||
"cos": "Corsican",
|
|
||||||
"cre": "Cree",
|
|
||||||
"mus": "Creek",
|
|
||||||
"hrv": "الكرواتية",
|
|
||||||
"ces": "التشيكية",
|
|
||||||
"dak": "Dakota",
|
|
||||||
"dan": "الدنماركية",
|
|
||||||
"dar": "Dargwa",
|
|
||||||
"del": "Delaware",
|
|
||||||
"div": "Dhivehi",
|
|
||||||
"din": "Dinka",
|
|
||||||
"doi": "Dogri (macrolanguage)",
|
|
||||||
"dgr": "Dogrib",
|
|
||||||
"dua": "Duala",
|
|
||||||
"nld": "الهولندية",
|
|
||||||
"dse": "Dutch Sign Language",
|
|
||||||
"dyu": "Dyula",
|
|
||||||
"dzo": "الزونخاية",
|
|
||||||
"efi": "Efik",
|
|
||||||
"egy": "Egyptian (Ancient)",
|
|
||||||
"eka": "Ekajuk",
|
|
||||||
"elx": "Elamite",
|
|
||||||
"eng": "الإنجليزية",
|
|
||||||
"enu": "Enu",
|
|
||||||
"myv": "Erzya",
|
|
||||||
"epo": "الإسبرانتو",
|
|
||||||
"est": "الإستونية",
|
|
||||||
"ewe": "Ewe",
|
|
||||||
"ewo": "Ewondo",
|
|
||||||
"fan": "Fang (Equatorial Guinea)",
|
|
||||||
"fat": "Fanti",
|
|
||||||
"fao": "Faroese",
|
|
||||||
"fij": "Fijian",
|
|
||||||
"fil": "Filipino",
|
|
||||||
"fin": "الفنلندية",
|
|
||||||
"fon": "Fon",
|
|
||||||
"fra": "الفرنسية",
|
|
||||||
"fur": "Friulian",
|
|
||||||
"ful": "Fulah",
|
|
||||||
"gaa": "Ga",
|
|
||||||
"glg": "Galician",
|
|
||||||
"lug": "Ganda",
|
|
||||||
"gay": "Gayo",
|
|
||||||
"gba": "Gbaya (Central African Republic)",
|
|
||||||
"hmj": "Ge",
|
|
||||||
"gez": "Geez",
|
|
||||||
"kat": "Georgian",
|
|
||||||
"deu": "الألمانية",
|
|
||||||
"gil": "Gilbertese",
|
|
||||||
"gon": "Gondi",
|
|
||||||
"gor": "Gorontalo",
|
|
||||||
"got": "القوطية",
|
|
||||||
"grb": "Grebo",
|
|
||||||
"grn": "Guarani",
|
|
||||||
"guj": "الغوجاراتية",
|
|
||||||
"gwi": "Gwichʼin",
|
|
||||||
"hai": "Haida",
|
|
||||||
"hau": "Hausa",
|
|
||||||
"haw": "Hawaiian",
|
|
||||||
"heb": "العبرية",
|
|
||||||
"her": "Herero",
|
|
||||||
"hil": "Hiligaynon",
|
|
||||||
"hin": "الهندية",
|
|
||||||
"hmo": "Hiri Motu",
|
|
||||||
"hit": "Hittite",
|
|
||||||
"hmn": "Hmong",
|
|
||||||
"hun": "الهنغارية (المجرية)",
|
|
||||||
"hup": "Hupa",
|
|
||||||
"iba": "Iban",
|
|
||||||
"isl": "الآيسلندية",
|
|
||||||
"ido": "Ido",
|
|
||||||
"ibo": "Igbo",
|
|
||||||
"ilo": "Iloko",
|
|
||||||
"ind": "الإندونيسية",
|
|
||||||
"inh": "Ingush",
|
|
||||||
"ina": "Interlingua (International Auxiliary Language Association)",
|
|
||||||
"ile": "Interlingue",
|
|
||||||
"iku": "Inuktitut",
|
|
||||||
"ipk": "Inupiaq",
|
|
||||||
"gle": "الأيرلندية",
|
|
||||||
"ita": "الإيطالية",
|
|
||||||
"jpn": "اليابانية",
|
|
||||||
"jav": "الجاوية",
|
|
||||||
"jrb": "Judeo-Arabic",
|
|
||||||
"jpr": "Judeo-Persian",
|
|
||||||
"kbd": "Kabardian",
|
|
||||||
"kab": "Kabyle",
|
|
||||||
"kac": "Kachin",
|
|
||||||
"kal": "Kalaallisut",
|
|
||||||
"xal": "Kalmyk",
|
|
||||||
"kam": "Kamba (Kenya)",
|
|
||||||
"kan": "الكانادا",
|
|
||||||
"kau": "Kanuri",
|
|
||||||
"kaa": "Kara-Kalpak",
|
|
||||||
"krc": "Karachay-Balkar",
|
|
||||||
"krl": "Karelian",
|
|
||||||
"kas": "Kashmiri",
|
|
||||||
"csb": "Kashubian",
|
|
||||||
"kaw": "Kawi",
|
|
||||||
"kaz": "Kazakh",
|
|
||||||
"kha": "Khasi",
|
|
||||||
"kho": "Khotanese",
|
|
||||||
"kik": "Kikuyu",
|
|
||||||
"kmb": "Kimbundu",
|
|
||||||
"kin": "Kinyarwanda",
|
|
||||||
"kir": "Kirghiz",
|
|
||||||
"tlh": "Klingon",
|
|
||||||
"kom": "Komi",
|
|
||||||
"kon": "Kongo",
|
|
||||||
"kok": "Konkani (macrolanguage)",
|
|
||||||
"kor": "الكورية",
|
|
||||||
"kos": "Kosraean",
|
|
||||||
"kpe": "Kpelle",
|
|
||||||
"kua": "Kuanyama",
|
|
||||||
"kum": "Kumyk",
|
|
||||||
"kur": "الكردية",
|
|
||||||
"kru": "Kurukh",
|
|
||||||
"kut": "Kutenai",
|
|
||||||
"lad": "Ladino",
|
|
||||||
"lah": "Lahnda",
|
|
||||||
"lam": "Lamba",
|
|
||||||
"lao": "اللاو",
|
|
||||||
"lat": "Latin",
|
|
||||||
"lav": "اللاتفية",
|
|
||||||
"lez": "Lezghian",
|
|
||||||
"lim": "Limburgan",
|
|
||||||
"lin": "Lingala",
|
|
||||||
"lit": "الليتوانية",
|
|
||||||
"jbo": "Lojban",
|
|
||||||
"loz": "Lozi",
|
|
||||||
"lub": "Luba-Katanga",
|
|
||||||
"lua": "Luba-Lulua",
|
|
||||||
"lui": "Luiseno",
|
|
||||||
"smj": "Lule Sami",
|
|
||||||
"lun": "Lunda",
|
|
||||||
"luo": "Luo (Kenya and Tanzania)",
|
|
||||||
"lus": "Lushai",
|
|
||||||
"ltz": "Luxembourgish",
|
|
||||||
"mkd": "المقدونية",
|
|
||||||
"mad": "Madurese",
|
|
||||||
"mag": "Magahi",
|
|
||||||
"mai": "Maithili",
|
|
||||||
"mak": "Makasar",
|
|
||||||
"mlg": "Malagasy",
|
|
||||||
"msa": "Malay (macrolanguage)",
|
|
||||||
"mal": "الماليالام",
|
|
||||||
"mlt": "المالطية",
|
|
||||||
"mnc": "Manchu",
|
|
||||||
"mdr": "Mandar",
|
|
||||||
"man": "Mandingo",
|
|
||||||
"mni": "Manipuri",
|
|
||||||
"glv": "Manx",
|
|
||||||
"mri": "الموري",
|
|
||||||
"arn": "Mapudungun",
|
|
||||||
"mar": "Marathi",
|
|
||||||
"chm": "Mari (Russia)",
|
|
||||||
"mah": "Marshallese",
|
|
||||||
"mwr": "Marwari",
|
|
||||||
"mas": "Masai",
|
|
||||||
"men": "Mende (Sierra Leone)",
|
|
||||||
"mic": "Mi'kmaq",
|
|
||||||
"min": "Minangkabau",
|
|
||||||
"mwl": "Mirandese",
|
|
||||||
"moh": "Mohawk",
|
|
||||||
"mdf": "Moksha",
|
|
||||||
"lol": "Mongo",
|
|
||||||
"mon": "المغولية",
|
|
||||||
"mos": "Mossi",
|
|
||||||
"mul": "Multiple languages",
|
|
||||||
"nqo": "N'Ko",
|
|
||||||
"nau": "Nauru",
|
|
||||||
"nav": "Navajo",
|
|
||||||
"ndo": "Ndonga",
|
|
||||||
"nap": "Neapolitan",
|
|
||||||
"nia": "Nias",
|
|
||||||
"niu": "Niuean",
|
|
||||||
"zxx": "No linguistic content",
|
|
||||||
"nog": "Nogai",
|
|
||||||
"nor": "النرويجية",
|
|
||||||
"nob": "Norwegian Bokmål",
|
|
||||||
"nno": "Norwegian Nynorsk",
|
|
||||||
"nym": "Nyamwezi",
|
|
||||||
"nya": "Nyanja",
|
|
||||||
"nyn": "Nyankole",
|
|
||||||
"nyo": "Nyoro",
|
|
||||||
"nzi": "Nzima",
|
|
||||||
"oci": "Occitan (post 1500)",
|
|
||||||
"oji": "Ojibwa",
|
|
||||||
"orm": "Oromo",
|
|
||||||
"osa": "Osage",
|
|
||||||
"oss": "Ossetian",
|
|
||||||
"pal": "Pahlavi",
|
|
||||||
"pau": "Palauan",
|
|
||||||
"pli": "Pali",
|
|
||||||
"pam": "Pampanga",
|
|
||||||
"pag": "Pangasinan",
|
|
||||||
"pan": "Panjabi",
|
|
||||||
"pap": "Papiamento",
|
|
||||||
"fas": "الفارسي",
|
|
||||||
"phn": "الفينيقية",
|
|
||||||
"pon": "Pohnpeian",
|
|
||||||
"pol": "البولندية",
|
|
||||||
"por": "البرتغالية",
|
|
||||||
"pus": "Pashto",
|
|
||||||
"que": "Quechua",
|
|
||||||
"raj": "Rajasthani",
|
|
||||||
"rap": "Rapanui",
|
|
||||||
"ron": "الرومانية",
|
|
||||||
"roh": "Romansh",
|
|
||||||
"rom": "Romany",
|
|
||||||
"run": "Rundi",
|
|
||||||
"rus": "الروسية",
|
|
||||||
"smo": "Samoan",
|
|
||||||
"sad": "Sandawe",
|
|
||||||
"sag": "Sango",
|
|
||||||
"san": "Sanskrit",
|
|
||||||
"sat": "Santali",
|
|
||||||
"srd": "Sardinian",
|
|
||||||
"sas": "Sasak",
|
|
||||||
"sco": "Scots",
|
|
||||||
"sel": "Selkup",
|
|
||||||
"srp": "الصربية",
|
|
||||||
"srr": "Serer",
|
|
||||||
"shn": "Shan",
|
|
||||||
"sna": "Shona",
|
|
||||||
"scn": "Sicilian",
|
|
||||||
"sid": "Sidamo",
|
|
||||||
"bla": "Siksika",
|
|
||||||
"snd": "Sindhi",
|
|
||||||
"sin": "السينهالا",
|
|
||||||
"den": "Slave (Athapascan)",
|
|
||||||
"slk": "السلوفاكية",
|
|
||||||
"slv": "السلوفينية",
|
|
||||||
"sog": "Sogdian",
|
|
||||||
"som": "Somali",
|
|
||||||
"snk": "Soninke",
|
|
||||||
"spa": "الأسبانية",
|
|
||||||
"srn": "Sranan Tongo",
|
|
||||||
"suk": "Sukuma",
|
|
||||||
"sux": "Sumerian",
|
|
||||||
"sun": "السوندية",
|
|
||||||
"sus": "Susu",
|
|
||||||
"swa": "Swahili (macrolanguage)",
|
|
||||||
"ssw": "Swati",
|
|
||||||
"swe": "السويدية",
|
|
||||||
"syr": "السريانية",
|
|
||||||
"tgl": "التاغالوغية",
|
|
||||||
"tah": "Tahitian",
|
|
||||||
"tgk": "Tajik",
|
|
||||||
"tmh": "Tamashek",
|
|
||||||
"tam": "التاميلية",
|
|
||||||
"tat": "Tatar",
|
|
||||||
"tel": "التيلوغو",
|
|
||||||
"ter": "Tereno",
|
|
||||||
"tet": "Tetum",
|
|
||||||
"tha": "التايلاندية",
|
|
||||||
"bod": "التبتية",
|
|
||||||
"tig": "Tigre",
|
|
||||||
"tir": "Tigrinya",
|
|
||||||
"tem": "Timne",
|
|
||||||
"tiv": "Tiv",
|
|
||||||
"tli": "Tlingit",
|
|
||||||
"tpi": "Tok Pisin",
|
|
||||||
"tkl": "Tokelau",
|
|
||||||
"tog": "Tonga (Nyasa)",
|
|
||||||
"ton": "Tonga (Tonga Islands)",
|
|
||||||
"tsi": "Tsimshian",
|
|
||||||
"tso": "Tsonga",
|
|
||||||
"tsn": "Tswana",
|
|
||||||
"tum": "Tumbuka",
|
|
||||||
"tur": "التركية",
|
|
||||||
"tuk": "Turkmen",
|
|
||||||
"tvl": "Tuvalu",
|
|
||||||
"tyv": "Tuvinian",
|
|
||||||
"twi": "Twi",
|
|
||||||
"udm": "Udmurt",
|
|
||||||
"uga": "الأجاريتيكية",
|
|
||||||
"uig": "الاغورية",
|
|
||||||
"ukr": "Ukrainian",
|
|
||||||
"umb": "Umbundu",
|
|
||||||
"mis": "Uncoded languages",
|
|
||||||
"und": "Undetermined",
|
|
||||||
"urd": "الاردية",
|
|
||||||
"uzb": "Uzbek",
|
|
||||||
"vai": "الفاي",
|
|
||||||
"ven": "Venda",
|
|
||||||
"vie": "الفييتنامية",
|
|
||||||
"vol": "Volapük",
|
|
||||||
"vot": "Votic",
|
|
||||||
"wln": "الوالون",
|
|
||||||
"war": "Waray (Philippines)",
|
|
||||||
"was": "Washo",
|
|
||||||
"cym": "الويلزية",
|
|
||||||
"wal": "Wolaytta",
|
|
||||||
"wol": "Wolof",
|
|
||||||
"xho": "الهاوسا",
|
|
||||||
"sah": "Yakut",
|
|
||||||
"yao": "Yao",
|
|
||||||
"yap": "Yapese",
|
|
||||||
"yid": "Yiddish",
|
|
||||||
"yor": "Yoruba",
|
|
||||||
"zap": "Zapotec",
|
|
||||||
"zza": "Zaza",
|
|
||||||
"zen": "Zenaga",
|
|
||||||
"zha": "Zhuang",
|
|
||||||
"zul": "الزولو",
|
|
||||||
"zun": "Zuni"
|
|
||||||
},
|
|
||||||
"cs": {
|
"cs": {
|
||||||
"aar": "afarština",
|
"aar": "afarština",
|
||||||
"abk": "abchazajština",
|
"abk": "abchazajština",
|
||||||
@@ -8516,384 +8138,6 @@ 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",
|
||||||
|
|||||||
+14
-9
@@ -30,7 +30,6 @@ from uuid import uuid4
|
|||||||
from flask import Blueprint, request, url_for, g
|
from flask import Blueprint, request, url_for, g
|
||||||
from flask_babel import format_date
|
from flask_babel import format_date
|
||||||
from .cw_login import current_user
|
from .cw_login import current_user
|
||||||
from .clean_html import clean_string as html_clean_string
|
|
||||||
|
|
||||||
from . import constants, logger
|
from . import constants, logger
|
||||||
|
|
||||||
@@ -114,14 +113,26 @@ 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):
|
||||||
if not value or (isinstance(value, str) and not value.is_numeric()):
|
value = 0 if not value else value
|
||||||
return value
|
|
||||||
formated_value = ('{0:.' + str(decimals) + 'f}').format(value)
|
formated_value = ('{0:.' + str(decimals) + 'f}').format(value)
|
||||||
if formated_value.endswith('.' + "0" * decimals):
|
if formated_value.endswith('.' + "0" * decimals):
|
||||||
formated_value = formated_value.rstrip('0').rstrip('.')
|
formated_value = formated_value.rstrip('0').rstrip('.')
|
||||||
return formated_value
|
return formated_value
|
||||||
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('formatseriesindex')
|
||||||
|
def formatseriesindex_filter(series_index):
|
||||||
|
if series_index:
|
||||||
|
try:
|
||||||
|
if int(series_index) - series_index == 0:
|
||||||
|
return int(series_index)
|
||||||
|
else:
|
||||||
|
return series_index
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return series_index
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@jinjia.app_template_filter('escapedlink')
|
@jinjia.app_template_filter('escapedlink')
|
||||||
def escapedlink_filter(url, text):
|
def escapedlink_filter(url, text):
|
||||||
return "<a href='{}'>{}</a>".format(url, escape(text))
|
return "<a href='{}'>{}</a>".format(url, escape(text))
|
||||||
@@ -182,9 +193,3 @@ def contains_music(book_formats):
|
|||||||
if format.format.lower() in g.constants.EXTENSIONS_AUDIO:
|
if format.format.lower() in g.constants.EXTENSIONS_AUDIO:
|
||||||
result = True
|
result = True
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@jinjia.app_template_filter('clean_string')
|
|
||||||
def clean_string(unsafe_text):
|
|
||||||
return html_clean_string(unsafe_text)
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+84
-157
@@ -25,7 +25,6 @@ import zipfile
|
|||||||
from time import gmtime, strftime
|
from time import gmtime, strftime
|
||||||
import json
|
import json
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
import requests
|
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -42,11 +41,13 @@ 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_
|
||||||
from sqlalchemy.exc import StatementError
|
from sqlalchemy.exc import StatementError
|
||||||
|
from sqlalchemy.sql import select
|
||||||
|
import requests
|
||||||
|
|
||||||
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
|
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
|
||||||
from . import isoLanguages, limiter
|
from . import isoLanguages
|
||||||
from .epub import get_epub_layout
|
from .epub import get_epub_layout
|
||||||
from .constants import COVER_THUMBNAIL_SMALL, COVER_THUMBNAIL_MEDIUM, COVER_THUMBNAIL_LARGE, BASE_DIR
|
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
|
||||||
@@ -105,29 +106,24 @@ def make_request_to_kobo_store(sync_token=None):
|
|||||||
return store_response
|
return store_response
|
||||||
|
|
||||||
|
|
||||||
def redirect_or_proxy_request(auth=False):
|
def redirect_or_proxy_request():
|
||||||
if config.config_kobo_proxy:
|
if config.config_kobo_proxy:
|
||||||
try:
|
if request.method == "GET":
|
||||||
if request.method == "GET":
|
return redirect(get_store_url_for_current_request(), 307)
|
||||||
alfa = redirect(get_store_url_for_current_request(), 307)
|
else:
|
||||||
return alfa
|
# The Kobo device turns other request types into GET requests on redirects,
|
||||||
else:
|
# so we instead proxy to the Kobo store ourselves.
|
||||||
# The Kobo device turns other request types into GET requests on redirects,
|
store_response = make_request_to_kobo_store()
|
||||||
# so we instead proxy to the Kobo store ourselves.
|
|
||||||
store_response = make_request_to_kobo_store()
|
|
||||||
|
|
||||||
response_headers = store_response.headers
|
response_headers = store_response.headers
|
||||||
for header_key in CONNECTION_SPECIFIC_HEADERS:
|
for header_key in CONNECTION_SPECIFIC_HEADERS:
|
||||||
response_headers.pop(header_key, default=None)
|
response_headers.pop(header_key, default=None)
|
||||||
|
|
||||||
return make_response(
|
return make_response(
|
||||||
store_response.content, store_response.status_code, response_headers.items()
|
store_response.content, store_response.status_code, response_headers.items()
|
||||||
)
|
)
|
||||||
except Exception as e:
|
else:
|
||||||
log.error("Failed to receive or parse response from Kobo's endpoint: {}".format(e))
|
return make_response(jsonify({}))
|
||||||
if auth:
|
|
||||||
return make_calibre_web_auth_response()
|
|
||||||
return make_response(jsonify({}))
|
|
||||||
|
|
||||||
|
|
||||||
def convert_to_kobo_timestamp_string(timestamp):
|
def convert_to_kobo_timestamp_string(timestamp):
|
||||||
@@ -140,6 +136,7 @@ def convert_to_kobo_timestamp_string(timestamp):
|
|||||||
|
|
||||||
@kobo.route("/v1/library/sync")
|
@kobo.route("/v1/library/sync")
|
||||||
@requires_kobo_auth
|
@requires_kobo_auth
|
||||||
|
# @download_required
|
||||||
def HandleSyncRequest():
|
def HandleSyncRequest():
|
||||||
if not current_user.role_download():
|
if not current_user.role_download():
|
||||||
log.info("Users need download permissions for syncing library to Kobo reader")
|
log.info("Users need download permissions for syncing library to Kobo reader")
|
||||||
@@ -326,7 +323,7 @@ def generate_sync_response(sync_token, sync_results, set_cont=False):
|
|||||||
sync_token.to_headers(extra_headers)
|
sync_token.to_headers(extra_headers)
|
||||||
|
|
||||||
# log.debug("Kobo Sync Content: {}".format(sync_results))
|
# log.debug("Kobo Sync Content: {}".format(sync_results))
|
||||||
# jsonify decodes the Unicode string different to what kobo expects
|
# jsonify decodes the unicode string different to what kobo expects
|
||||||
response = make_response(json.dumps(sync_results), extra_headers)
|
response = make_response(json.dumps(sync_results), extra_headers)
|
||||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
return response
|
return response
|
||||||
@@ -367,7 +364,7 @@ def get_download_url_for_book(book_id, book_format):
|
|||||||
)
|
)
|
||||||
return url_for(
|
return url_for(
|
||||||
"kobo.download_book",
|
"kobo.download_book",
|
||||||
auth_token=get_auth_token(),
|
auth_token=kobo_auth.get_auth_token(),
|
||||||
book_id=book_id,
|
book_id=book_id,
|
||||||
book_format=book_format.lower(),
|
book_format=book_format.lower(),
|
||||||
_external=True,
|
_external=True,
|
||||||
@@ -426,7 +423,7 @@ def get_series(book):
|
|||||||
|
|
||||||
|
|
||||||
def get_seriesindex(book):
|
def get_seriesindex(book):
|
||||||
return book.series_index if isinstance(book.series_index, float) else 1
|
return book.series_index or 1
|
||||||
|
|
||||||
|
|
||||||
def get_language(book):
|
def get_language(book):
|
||||||
@@ -489,18 +486,15 @@ def get_metadata(book):
|
|||||||
|
|
||||||
if get_series(book):
|
if get_series(book):
|
||||||
name = get_series(book)
|
name = get_series(book)
|
||||||
try:
|
metadata["Series"] = {
|
||||||
metadata["Series"] = {
|
"Name": get_series(book),
|
||||||
"Name": get_series(book),
|
"Number": get_seriesindex(book), # ToDo Check int() ?
|
||||||
"Number": get_seriesindex(book), # ToDo Check int() ?
|
"NumberFloat": float(get_seriesindex(book)),
|
||||||
"NumberFloat": float(get_seriesindex(book)),
|
# Get a deterministic id based on the series name.
|
||||||
# Get a deterministic id based on the series name.
|
"Id": str(uuid.uuid3(uuid.NAMESPACE_DNS, name)),
|
||||||
"Id": str(uuid.uuid3(uuid.NAMESPACE_DNS, name)),
|
}
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
return metadata
|
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@@ -731,7 +725,7 @@ def sync_shelves(sync_token, sync_results, only_kobo_shelves=False):
|
|||||||
ub.session_commit()
|
ub.session_commit()
|
||||||
|
|
||||||
|
|
||||||
# Creates a Kobo "Tag" object from an ub.Shelf object
|
# Creates a Kobo "Tag" object from a ub.Shelf object
|
||||||
def create_kobo_tag(shelf):
|
def create_kobo_tag(shelf):
|
||||||
tag = {
|
tag = {
|
||||||
"Created": convert_to_kobo_timestamp_string(shelf.created),
|
"Created": convert_to_kobo_timestamp_string(shelf.created),
|
||||||
@@ -811,8 +805,6 @@ def HandleStateRequest(book_uuid):
|
|||||||
|
|
||||||
ub.session.merge(kobo_reading_state)
|
ub.session.merge(kobo_reading_state)
|
||||||
ub.session_commit()
|
ub.session_commit()
|
||||||
update_results_response["LastModified"] = convert_to_kobo_timestamp_string(kobo_reading_state.last_modified)
|
|
||||||
update_results_response["PriorityTimestamp"] = convert_to_kobo_timestamp_string(kobo_reading_state.priority_timestamp)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"RequestResult": "Success",
|
"RequestResult": "Success",
|
||||||
"UpdateResults": [update_results_response],
|
"UpdateResults": [update_results_response],
|
||||||
@@ -889,21 +881,14 @@ def get_statistics_response(statistics):
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def _clean_progress(value):
|
|
||||||
"""Return progress as int if it's a whole number, preserving Kobo device expectations."""
|
|
||||||
if value is not None and value == int(value):
|
|
||||||
return int(value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_bookmark_response(current_bookmark):
|
def get_current_bookmark_response(current_bookmark):
|
||||||
resp = {
|
resp = {
|
||||||
"LastModified": convert_to_kobo_timestamp_string(current_bookmark.last_modified),
|
"LastModified": convert_to_kobo_timestamp_string(current_bookmark.last_modified),
|
||||||
}
|
}
|
||||||
if current_bookmark.progress_percent is not None:
|
if current_bookmark.progress_percent:
|
||||||
resp["ProgressPercent"] = _clean_progress(current_bookmark.progress_percent)
|
resp["ProgressPercent"] = current_bookmark.progress_percent
|
||||||
if current_bookmark.content_source_progress_percent is not None:
|
if current_bookmark.content_source_progress_percent:
|
||||||
resp["ContentSourceProgressPercent"] = _clean_progress(current_bookmark.content_source_progress_percent)
|
resp["ContentSourceProgressPercent"] = current_bookmark.content_source_progress_percent
|
||||||
if current_bookmark.location_value:
|
if current_bookmark.location_value:
|
||||||
resp["Location"] = {
|
resp["Location"] = {
|
||||||
"Value": current_bookmark.location_value,
|
"Value": current_bookmark.location_value,
|
||||||
@@ -968,10 +953,8 @@ def HandleBookDeletionRequest(book_uuid):
|
|||||||
|
|
||||||
# TODO: Implement the following routes
|
# TODO: Implement the following routes
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET", "POST"])
|
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET"])
|
||||||
@kobo.route("/v1/library/<dummy>/preview", methods=["POST"])
|
|
||||||
def HandleUnimplementedRequest(dummy=None):
|
def HandleUnimplementedRequest(dummy=None):
|
||||||
[limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits]
|
|
||||||
log.debug("Unimplemented Library Request received: %s (request is forwarded to kobo if configured)",
|
log.debug("Unimplemented Library Request received: %s (request is forwarded to kobo if configured)",
|
||||||
request.base_url)
|
request.base_url)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
@@ -986,9 +969,6 @@ def HandleUnimplementedRequest(dummy=None):
|
|||||||
@kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"])
|
@kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/assets", methods=["GET"])
|
@kobo.route("/v1/assets", methods=["GET"])
|
||||||
def HandleUserRequest(dummy=None):
|
def HandleUserRequest(dummy=None):
|
||||||
[limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits]
|
|
||||||
log.error("Key: {}".format(limiter.current_limit.key))
|
|
||||||
log.error("Remaining: {}".format(limiter.current_limit.remaining))
|
|
||||||
log.debug("Unimplemented User Request received: %s (request is forwarded to kobo if configured)", request.base_url)
|
log.debug("Unimplemented User Request received: %s (request is forwarded to kobo if configured)", request.base_url)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
|
|
||||||
@@ -996,7 +976,6 @@ def HandleUserRequest(dummy=None):
|
|||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@kobo.route("/v1/user/loyalty/benefits", methods=["GET"])
|
@kobo.route("/v1/user/loyalty/benefits", methods=["GET"])
|
||||||
def handle_benefits():
|
def handle_benefits():
|
||||||
[limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits]
|
|
||||||
if config.config_kobo_proxy:
|
if config.config_kobo_proxy:
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
else:
|
else:
|
||||||
@@ -1006,7 +985,6 @@ def handle_benefits():
|
|||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@kobo.route("/v1/analytics/gettests", methods=["GET", "POST"])
|
@kobo.route("/v1/analytics/gettests", methods=["GET", "POST"])
|
||||||
def handle_getests():
|
def handle_getests():
|
||||||
[limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits]
|
|
||||||
if config.config_kobo_proxy:
|
if config.config_kobo_proxy:
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
else:
|
else:
|
||||||
@@ -1028,12 +1006,9 @@ def handle_getests():
|
|||||||
@kobo.route("/v1/products/dailydeal", methods=["GET", "POST"])
|
@kobo.route("/v1/products/dailydeal", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/products/deals", methods=["GET", "POST"])
|
@kobo.route("/v1/products/deals", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/products", methods=["GET", "POST"])
|
@kobo.route("/v1/products", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/products/<path:dummy>", methods=["GET", "POST"])
|
|
||||||
@kobo.route("/v1/products/<path:dummy>/", methods=["GET", "POST"])
|
|
||||||
@kobo.route("/v1/affiliate", methods=["GET", "POST"])
|
@kobo.route("/v1/affiliate", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/deals", methods=["GET", "POST"])
|
@kobo.route("/v1/deals", methods=["GET", "POST"])
|
||||||
def HandleProductsRequest(dummy=None):
|
def HandleProductsRequest(dummy=None):
|
||||||
[limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits]
|
|
||||||
log.debug("Unimplemented Products Request received: %s (request is forwarded to kobo if configured)",
|
log.debug("Unimplemented Products Request received: %s (request is forwarded to kobo if configured)",
|
||||||
request.base_url)
|
request.base_url)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
@@ -1059,16 +1034,13 @@ def make_calibre_web_auth_response():
|
|||||||
|
|
||||||
|
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@kobo.route("/v1/auth/refresh", methods=["POST"])
|
|
||||||
@kobo.route("/v1/auth/device", methods=["POST"])
|
@kobo.route("/v1/auth/device", methods=["POST"])
|
||||||
@requires_kobo_auth
|
@requires_kobo_auth
|
||||||
def HandleAuthRequest():
|
def HandleAuthRequest():
|
||||||
log.error(limiter.current_limit)
|
|
||||||
log.error(limiter.current_limit)
|
|
||||||
log.debug('Kobo Auth request')
|
log.debug('Kobo Auth request')
|
||||||
if config.config_kobo_proxy:
|
if config.config_kobo_proxy:
|
||||||
try:
|
try:
|
||||||
return redirect_or_proxy_request(auth=True)
|
return redirect_or_proxy_request()
|
||||||
except Exception:
|
except Exception:
|
||||||
log.error("Failed to receive or parse response from Kobo's auth endpoint. Falling back to un-proxied mode.")
|
log.error("Failed to receive or parse response from Kobo's auth endpoint. Falling back to un-proxied mode.")
|
||||||
return make_calibre_web_auth_response()
|
return make_calibre_web_auth_response()
|
||||||
@@ -1106,7 +1078,7 @@ def HandleInitRequest():
|
|||||||
kobo_resources["image_host"] = calibre_web_url
|
kobo_resources["image_host"] = calibre_web_url
|
||||||
kobo_resources["image_url_quality_template"] = unquote(calibre_web_url +
|
kobo_resources["image_url_quality_template"] = unquote(calibre_web_url +
|
||||||
url_for("kobo.HandleCoverImageRequest",
|
url_for("kobo.HandleCoverImageRequest",
|
||||||
auth_token=get_auth_token(),
|
auth_token=kobo_auth.get_auth_token(),
|
||||||
book_uuid="{ImageId}",
|
book_uuid="{ImageId}",
|
||||||
width="{width}",
|
width="{width}",
|
||||||
height="{height}",
|
height="{height}",
|
||||||
@@ -1114,17 +1086,15 @@ def HandleInitRequest():
|
|||||||
isGreyscale='isGreyscale'))
|
isGreyscale='isGreyscale'))
|
||||||
kobo_resources["image_url_template"] = unquote(calibre_web_url +
|
kobo_resources["image_url_template"] = unquote(calibre_web_url +
|
||||||
url_for("kobo.HandleCoverImageRequest",
|
url_for("kobo.HandleCoverImageRequest",
|
||||||
auth_token=get_auth_token(),
|
auth_token=kobo_auth.get_auth_token(),
|
||||||
book_uuid="{ImageId}",
|
book_uuid="{ImageId}",
|
||||||
width="{width}",
|
width="{width}",
|
||||||
height="{height}",
|
height="{height}",
|
||||||
isGreyscale='false'))
|
isGreyscale='false'))
|
||||||
kobo_resources["library_sync"] = calibre_web_url + url_for("kobo.HandleSyncRequest",
|
|
||||||
auth_token=kobo_auth.get_auth_token())
|
|
||||||
else:
|
else:
|
||||||
kobo_resources["image_host"] = url_for("web.index", _external=True).strip("/")
|
kobo_resources["image_host"] = url_for("web.index", _external=True).strip("/")
|
||||||
kobo_resources["image_url_quality_template"] = unquote(url_for("kobo.HandleCoverImageRequest",
|
kobo_resources["image_url_quality_template"] = unquote(url_for("kobo.HandleCoverImageRequest",
|
||||||
auth_token=get_auth_token(),
|
auth_token=kobo_auth.get_auth_token(),
|
||||||
book_uuid="{ImageId}",
|
book_uuid="{ImageId}",
|
||||||
width="{width}",
|
width="{width}",
|
||||||
height="{height}",
|
height="{height}",
|
||||||
@@ -1132,15 +1102,12 @@ def HandleInitRequest():
|
|||||||
isGreyscale='isGreyscale',
|
isGreyscale='isGreyscale',
|
||||||
_external=True))
|
_external=True))
|
||||||
kobo_resources["image_url_template"] = unquote(url_for("kobo.HandleCoverImageRequest",
|
kobo_resources["image_url_template"] = unquote(url_for("kobo.HandleCoverImageRequest",
|
||||||
auth_token=get_auth_token(),
|
auth_token=kobo_auth.get_auth_token(),
|
||||||
book_uuid="{ImageId}",
|
book_uuid="{ImageId}",
|
||||||
width="{width}",
|
width="{width}",
|
||||||
height="{height}",
|
height="{height}",
|
||||||
isGreyscale='false',
|
isGreyscale='false',
|
||||||
_external=True))
|
_external=True))
|
||||||
kobo_resources["library_sync"] = url_for("kobo.HandleSyncRequest",
|
|
||||||
auth_token=kobo_auth.get_auth_token(),
|
|
||||||
_external=True)
|
|
||||||
|
|
||||||
response = make_response(jsonify({"Resources": kobo_resources}))
|
response = make_response(jsonify({"Resources": kobo_resources}))
|
||||||
response.headers["x-kobo-apitoken"] = "e30="
|
response.headers["x-kobo-apitoken"] = "e30="
|
||||||
@@ -1156,46 +1123,26 @@ def download_book(book_id, book_format):
|
|||||||
|
|
||||||
|
|
||||||
def NATIVE_KOBO_RESOURCES():
|
def NATIVE_KOBO_RESOURCES():
|
||||||
kobo_file = os.path.join(BASE_DIR, "kobo_resources.txt")
|
|
||||||
try:
|
|
||||||
if os.path.isfile(kobo_file):
|
|
||||||
with open(kobo_file, "r") as f:
|
|
||||||
lines = f.read()
|
|
||||||
return json.loads(lines)
|
|
||||||
except Exception as e:
|
|
||||||
log.error(e)
|
|
||||||
return {
|
return {
|
||||||
"account_page": "https://www.kobo.com/account/settings",
|
"account_page": "https://secure.kobobooks.com/profile",
|
||||||
"account_page_rakuten": "https://my.rakuten.co.jp/",
|
"account_page_rakuten": "https://my.rakuten.co.jp/",
|
||||||
"add_device": "https://storeapi.kobo.com/v1/user/add-device",
|
|
||||||
"add_entitlement": "https://storeapi.kobo.com/v1/library/{RevisionIds}",
|
"add_entitlement": "https://storeapi.kobo.com/v1/library/{RevisionIds}",
|
||||||
"affiliaterequest": "https://storeapi.kobo.com/v1/affiliate",
|
"affiliaterequest": "https://storeapi.kobo.com/v1/affiliate",
|
||||||
"assets": "https://storeapi.kobo.com/v1/assets",
|
|
||||||
"audiobook": "https://storeapi.kobo.com/v1/products/audiobooks/{ProductId}",
|
|
||||||
"audiobook_detail_page": "https://www.kobo.com/{region}/{language}/audiobook/{slug}",
|
|
||||||
"audiobook_landing_page": "https://www.kobo.com/{region}/{language}/audiobooks",
|
|
||||||
"audiobook_preview": "https://storeapi.kobo.com/v1/products/audiobooks/{Id}/preview",
|
|
||||||
"audiobook_purchase_withcredit": "https://storeapi.kobo.com/v1/store/audiobook/{Id}",
|
|
||||||
"audiobook_subscription_orange_deal_inclusion_url": "https://authorize.kobo.com/inclusion",
|
"audiobook_subscription_orange_deal_inclusion_url": "https://authorize.kobo.com/inclusion",
|
||||||
"authorproduct_recommendations": "https://storeapi.kobo.com/v1/products/books/authors/recommendations",
|
"authorproduct_recommendations": "https://storeapi.kobo.com/v1/products/books/authors/recommendations",
|
||||||
"autocomplete": "https://storeapi.kobo.com/v1/products/autocomplete",
|
"autocomplete": "https://storeapi.kobo.com/v1/products/autocomplete",
|
||||||
"blackstone_header": {
|
"blackstone_header": {"key": "x-amz-request-payer", "value": "requester"},
|
||||||
"key": "x-amz-request-payer",
|
|
||||||
"value": "requester"
|
|
||||||
},
|
|
||||||
"book": "https://storeapi.kobo.com/v1/products/books/{ProductId}",
|
"book": "https://storeapi.kobo.com/v1/products/books/{ProductId}",
|
||||||
"book_detail_page": "https://www.kobo.com/{region}/{language}/ebook/{slug}",
|
"book_detail_page": "https://store.kobobooks.com/{culture}/ebook/{slug}",
|
||||||
"book_detail_page_rakuten": "http://books.rakuten.co.jp/rk/{crossrevisionid}",
|
"book_detail_page_rakuten": "https://books.rakuten.co.jp/rk/{crossrevisionid}",
|
||||||
"book_landing_page": "https://www.kobo.com/ebooks",
|
"book_landing_page": "https://store.kobobooks.com/ebooks",
|
||||||
"book_subscription": "https://storeapi.kobo.com/v1/products/books/subscriptions",
|
"book_subscription": "https://storeapi.kobo.com/v1/products/books/subscriptions",
|
||||||
"browse_history": "https://storeapi.kobo.com/v1/user/browsehistory",
|
|
||||||
"categories": "https://storeapi.kobo.com/v1/categories",
|
"categories": "https://storeapi.kobo.com/v1/categories",
|
||||||
"categories_page": "https://www.kobo.com/ebooks/categories",
|
"categories_page": "https://store.kobobooks.com/ebooks/categories",
|
||||||
"category": "https://storeapi.kobo.com/v1/categories/{CategoryId}",
|
"category": "https://storeapi.kobo.com/v1/categories/{CategoryId}",
|
||||||
"category_featured_lists": "https://storeapi.kobo.com/v1/categories/{CategoryId}/featured",
|
"category_featured_lists": "https://storeapi.kobo.com/v1/categories/{CategoryId}/featured",
|
||||||
"category_products": "https://storeapi.kobo.com/v1/categories/{CategoryId}/products",
|
"category_products": "https://storeapi.kobo.com/v1/categories/{CategoryId}/products",
|
||||||
"checkout_borrowed_book": "https://storeapi.kobo.com/v1/library/borrow",
|
"checkout_borrowed_book": "https://storeapi.kobo.com/v1/library/borrow",
|
||||||
"client_authd_referral": "https://authorize.kobo.com/api/AuthenticatedReferral/client/v1/getLink",
|
|
||||||
"configuration_data": "https://storeapi.kobo.com/v1/configuration",
|
"configuration_data": "https://storeapi.kobo.com/v1/configuration",
|
||||||
"content_access_book": "https://storeapi.kobo.com/v1/products/books/{ProductId}/access",
|
"content_access_book": "https://storeapi.kobo.com/v1/products/books/{ProductId}/access",
|
||||||
"customer_care_live_chat": "https://v2.zopim.com/widget/livechat.html?key=Y6gwUmnu4OATxN3Tli4Av9bYN319BTdO",
|
"customer_care_live_chat": "https://v2.zopim.com/widget/livechat.html?key=Y6gwUmnu4OATxN3Tli4Av9bYN319BTdO",
|
||||||
@@ -1206,112 +1153,92 @@ def NATIVE_KOBO_RESOURCES():
|
|||||||
"delete_tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/items/delete",
|
"delete_tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/items/delete",
|
||||||
"device_auth": "https://storeapi.kobo.com/v1/auth/device",
|
"device_auth": "https://storeapi.kobo.com/v1/auth/device",
|
||||||
"device_refresh": "https://storeapi.kobo.com/v1/auth/refresh",
|
"device_refresh": "https://storeapi.kobo.com/v1/auth/refresh",
|
||||||
"dictionary_host": "https://ereaderfiles.kobo.com",
|
"dictionary_host": "https://kbdownload1-a.akamaihd.net",
|
||||||
"discovery_host": "https://discovery.kobobooks.com",
|
"discovery_host": "https://discovery.kobobooks.com",
|
||||||
"ereaderdevices": "https://storeapi.kobo.com/v2/products/EReaderDeviceFeeds",
|
|
||||||
"eula_page": "https://www.kobo.com/termsofuse?style=onestore",
|
"eula_page": "https://www.kobo.com/termsofuse?style=onestore",
|
||||||
"exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange",
|
"exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange",
|
||||||
"external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}",
|
"external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}",
|
||||||
"facebook_sso_page": "https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://kobo.com/",
|
"facebook_sso_page":
|
||||||
|
"https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://store.kobobooks.com/",
|
||||||
"featured_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}",
|
"featured_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}",
|
||||||
"featured_lists": "https://storeapi.kobo.com/v1/products/featured",
|
"featured_lists": "https://storeapi.kobo.com/v1/products/featured",
|
||||||
"free_books_page": {
|
"free_books_page": {
|
||||||
"EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks",
|
"EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks",
|
||||||
"FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits",
|
"FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits",
|
||||||
"IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti",
|
"IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti",
|
||||||
"NL": "https://www.kobo.com/{region}/{language}/List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg",
|
"NL": "https://www.kobo.com/{region}/{language}/"
|
||||||
"PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis"
|
"List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg",
|
||||||
|
"PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis",
|
||||||
},
|
},
|
||||||
"fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback",
|
"fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback",
|
||||||
"funnel_metrics": "https://storeapi.kobo.com/v1/funnelmetrics",
|
|
||||||
"get_download_keys": "https://storeapi.kobo.com/v1/library/downloadkeys",
|
|
||||||
"get_download_link": "https://storeapi.kobo.com/v1/library/downloadlink",
|
|
||||||
"get_tests_request": "https://storeapi.kobo.com/v1/analytics/gettests",
|
"get_tests_request": "https://storeapi.kobo.com/v1/analytics/gettests",
|
||||||
"giftcard_epd_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem-ereader",
|
"giftcard_epd_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem-ereader",
|
||||||
"giftcard_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem",
|
"giftcard_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem",
|
||||||
"gpb_flow_enabled": "False",
|
"help_page": "https://www.kobo.com/help",
|
||||||
"help_page": "http://www.kobo.com/help",
|
"kobo_audiobooks_enabled": "False",
|
||||||
"image_host": "//cdn.kobo.com/book-images/",
|
|
||||||
"image_url_quality_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg",
|
|
||||||
"image_url_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg",
|
|
||||||
"instapaper_enabled": "True",
|
|
||||||
"instapaper_env_url": "https://www.instapaper.com/api/kobo",
|
|
||||||
"instapaper_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkinstapaper",
|
|
||||||
"kobo_audiobooks_credit_redemption": "False",
|
|
||||||
"kobo_audiobooks_enabled": "True",
|
|
||||||
"kobo_audiobooks_orange_deal_enabled": "False",
|
"kobo_audiobooks_orange_deal_enabled": "False",
|
||||||
"kobo_audiobooks_subscriptions_enabled": "False",
|
"kobo_audiobooks_subscriptions_enabled": "False",
|
||||||
"kobo_display_price": "True",
|
"kobo_nativeborrow_enabled": "True",
|
||||||
"kobo_dropbox_link_account_enabled": "False",
|
|
||||||
"kobo_google_tax": "False",
|
|
||||||
"kobo_googledrive_link_account_enabled": "False",
|
|
||||||
"kobo_nativeborrow_enabled": "False",
|
|
||||||
"kobo_onedrive_link_account_enabled": "False",
|
|
||||||
"kobo_onestorelibrary_enabled": "False",
|
"kobo_onestorelibrary_enabled": "False",
|
||||||
"kobo_privacyCentre_url": "https://www.kobo.com/privacy",
|
|
||||||
"kobo_redeem_enabled": "True",
|
"kobo_redeem_enabled": "True",
|
||||||
"kobo_shelfie_enabled": "False",
|
"kobo_shelfie_enabled": "False",
|
||||||
"kobo_subscriptions_enabled": "True",
|
"kobo_subscriptions_enabled": "False",
|
||||||
"kobo_superpoints_enabled": "True",
|
"kobo_superpoints_enabled": "False",
|
||||||
"kobo_wishlist_enabled": "True",
|
"kobo_wishlist_enabled": "True",
|
||||||
"library_book": "https://storeapi.kobo.com/v1/user/library/books/{LibraryItemId}",
|
"library_book": "https://storeapi.kobo.com/v1/user/library/books/{LibraryItemId}",
|
||||||
"library_items": "https://storeapi.kobo.com/v1/user/library",
|
"library_items": "https://storeapi.kobo.com/v1/user/library",
|
||||||
"library_metadata": "https://storeapi.kobo.com/v1/library/{Ids}/metadata",
|
"library_metadata": "https://storeapi.kobo.com/v1/library/{Ids}/metadata",
|
||||||
"library_prices": "https://storeapi.kobo.com/v1/user/library/previews/prices",
|
"library_prices": "https://storeapi.kobo.com/v1/user/library/previews/prices",
|
||||||
"library_search": "https://storeapi.kobo.com/v1/library/search",
|
"library_stack": "https://storeapi.kobo.com/v1/user/library/stacks/{LibraryItemId}",
|
||||||
"library_sync": "https://storeapi.kobo.com/v1/library/sync",
|
"library_sync": "https://storeapi.kobo.com/v1/library/sync",
|
||||||
"love_dashboard_page": "https://www.kobo.com/{region}/{language}/kobosuperpoints",
|
"love_dashboard_page": "https://store.kobobooks.com/{culture}/kobosuperpoints",
|
||||||
"love_points_redemption_page": "https://www.kobo.com/{region}/{language}/KoboSuperPointsRedemption?productId={ProductId}",
|
"love_points_redemption_page":
|
||||||
"magazine_landing_page": "https://www.kobo.com/emagazines",
|
"https://store.kobobooks.com/{culture}/KoboSuperPointsRedemption?productId={ProductId}",
|
||||||
"more_sign_in_options": "https://authorize.kobo.com/signin?returnUrl=http://kobo.com/#allProviders",
|
"magazine_landing_page": "https://store.kobobooks.com/emagazines",
|
||||||
"notebooks": "https://storeapi.kobo.com/api/internal/notebooks",
|
|
||||||
"notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration",
|
"notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration",
|
||||||
"oauth_host": "https://oauth.kobo.com",
|
"oauth_host": "https://oauth.kobo.com",
|
||||||
"password_retrieval_page": "https://www.kobo.com/passwordretrieval.html",
|
"overdrive_account": "https://auth.overdrive.com/account",
|
||||||
"personalizedrecommendations": "https://storeapi.kobo.com/v2/users/personalizedrecommendations",
|
"overdrive_library": "https://{libraryKey}.auth.overdrive.com/library",
|
||||||
"pocket_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkpocket",
|
"overdrive_library_finder_host": "https://libraryfinder.api.overdrive.com",
|
||||||
|
"overdrive_thunder_host": "https://thunder.api.overdrive.com",
|
||||||
|
"password_retrieval_page": "https://www.kobobooks.com/passwordretrieval.html",
|
||||||
"post_analytics_event": "https://storeapi.kobo.com/v1/analytics/event",
|
"post_analytics_event": "https://storeapi.kobo.com/v1/analytics/event",
|
||||||
"ppx_purchasing_url": "https://purchasing.kobo.com",
|
|
||||||
"privacy_page": "https://www.kobo.com/privacypolicy?style=onestore",
|
"privacy_page": "https://www.kobo.com/privacypolicy?style=onestore",
|
||||||
"product_nextread": "https://storeapi.kobo.com/v1/products/{ProductIds}/nextread",
|
"product_nextread": "https://storeapi.kobo.com/v1/products/{ProductIds}/nextread",
|
||||||
"product_prices": "https://storeapi.kobo.com/v1/products/{ProductIds}/prices",
|
"product_prices": "https://storeapi.kobo.com/v1/products/{ProductIds}/prices",
|
||||||
"product_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations",
|
"product_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations",
|
||||||
"product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews",
|
"product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews",
|
||||||
"products": "https://storeapi.kobo.com/v1/products",
|
"products": "https://storeapi.kobo.com/v1/products",
|
||||||
"productsv2": "https://storeapi.kobo.com/v2/products",
|
"provider_external_sign_in_page":
|
||||||
"provider_external_sign_in_page": "https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://kobo.com/",
|
"https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://store.kobobooks.com/",
|
||||||
|
"purchase_buy": "https://www.kobo.com/checkout/createpurchase/",
|
||||||
|
"purchase_buy_templated": "https://www.kobo.com/{culture}/checkout/createpurchase/{ProductId}",
|
||||||
"quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout",
|
"quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout",
|
||||||
"quickbuy_create": "https://storeapi.kobo.com/v1/store/quickbuy/purchase",
|
"quickbuy_create": "https://storeapi.kobo.com/v1/store/quickbuy/purchase",
|
||||||
"rakuten_token_exchange": "https://storeapi.kobo.com/v1/auth/rakuten_token_exchange",
|
|
||||||
"rating": "https://storeapi.kobo.com/v1/products/{ProductId}/rating/{Rating}",
|
"rating": "https://storeapi.kobo.com/v1/products/{ProductId}/rating/{Rating}",
|
||||||
"reading_services_host": "https://readingservices.kobo.com",
|
|
||||||
"reading_state": "https://storeapi.kobo.com/v1/library/{Ids}/state",
|
"reading_state": "https://storeapi.kobo.com/v1/library/{Ids}/state",
|
||||||
"redeem_interstitial_page": "https://www.kobo.com",
|
"redeem_interstitial_page": "https://store.kobobooks.com",
|
||||||
"registration_page": "https://authorize.kobo.com/signup?returnUrl=http://kobo.com/",
|
"registration_page": "https://authorize.kobo.com/signup?returnUrl=http://store.kobobooks.com/",
|
||||||
"related_items": "https://storeapi.kobo.com/v1/products/{Id}/related",
|
"related_items": "https://storeapi.kobo.com/v1/products/{Id}/related",
|
||||||
"remaining_book_series": "https://storeapi.kobo.com/v1/products/books/series/{SeriesId}",
|
"remaining_book_series": "https://storeapi.kobo.com/v1/products/books/series/{SeriesId}",
|
||||||
"rename_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}",
|
"rename_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}",
|
||||||
"review": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}",
|
"review": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}",
|
||||||
"review_sentiment": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}/sentiment/{Sentiment}",
|
"review_sentiment": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}/sentiment/{Sentiment}",
|
||||||
"shelfie_recommendations": "https://storeapi.kobo.com/v1/user/recommendations/shelfie",
|
"shelfie_recommendations": "https://storeapi.kobo.com/v1/user/recommendations/shelfie",
|
||||||
"sign_in_page": "https://authorize.kobo.com/signin?returnUrl=http://kobo.com/",
|
"sign_in_page": "https://authorize.kobo.com/signin?returnUrl=http://store.kobobooks.com/",
|
||||||
"social_authorization_host": "https://social.kobobooks.com:8443",
|
"social_authorization_host": "https://social.kobobooks.com:8443",
|
||||||
"social_host": "https://social.kobobooks.com",
|
"social_host": "https://social.kobobooks.com",
|
||||||
|
"stacks_host_productId": "https://store.kobobooks.com/collections/byproductid/",
|
||||||
"store_home": "www.kobo.com/{region}/{language}",
|
"store_home": "www.kobo.com/{region}/{language}",
|
||||||
"store_host": "www.kobo.com",
|
"store_host": "store.kobobooks.com",
|
||||||
"store_newreleases": "https://www.kobo.com/{region}/{language}/List/new-releases/961XUjtsU0qxkFItWOutGA",
|
"store_newreleases": "https://store.kobobooks.com/{culture}/List/new-releases/961XUjtsU0qxkFItWOutGA",
|
||||||
"store_search": "https://www.kobo.com/{region}/{language}/Search?Query={query}",
|
"store_search": "https://store.kobobooks.com/{culture}/Search?Query={query}",
|
||||||
"store_top50": "https://www.kobo.com/{region}/{language}/ebooks/Top",
|
"store_top50": "https://store.kobobooks.com/{culture}/ebooks/Top",
|
||||||
"subs_landing_page": "https://www.kobo.com/{region}/{language}/plus",
|
|
||||||
"subs_management_page": "https://www.kobo.com/{region}/{language}/account/subscriptions",
|
|
||||||
"subs_plans_page": "https://www.kobo.com/{region}/{language}/plus/plans",
|
|
||||||
"subs_purchase_buy_templated": "https://www.kobo.com/{region}/{language}/Checkoutoption/{ProductId}/{TierId}",
|
|
||||||
"tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/Items",
|
"tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/Items",
|
||||||
"tags": "https://storeapi.kobo.com/v1/library/tags",
|
"tags": "https://storeapi.kobo.com/v1/library/tags",
|
||||||
"taste_profile": "https://storeapi.kobo.com/v1/products/tasteprofile",
|
"taste_profile": "https://storeapi.kobo.com/v1/products/tasteprofile",
|
||||||
"terms_of_sale_page": "https://authorize.kobo.com/{region}/{language}/terms/termsofsale",
|
|
||||||
"update_accessibility_to_preview": "https://storeapi.kobo.com/v1/library/{EntitlementIds}/preview",
|
"update_accessibility_to_preview": "https://storeapi.kobo.com/v1/library/{EntitlementIds}/preview",
|
||||||
"use_one_store": "True",
|
"use_one_store": "False",
|
||||||
"user_loyalty_benefits": "https://storeapi.kobo.com/v1/user/loyalty/benefits",
|
"user_loyalty_benefits": "https://storeapi.kobo.com/v1/user/loyalty/benefits",
|
||||||
"user_platform": "https://storeapi.kobo.com/v1/user/platform",
|
"user_platform": "https://storeapi.kobo.com/v1/user/platform",
|
||||||
"user_profile": "https://storeapi.kobo.com/v1/user/profile",
|
"user_profile": "https://storeapi.kobo.com/v1/user/profile",
|
||||||
@@ -1319,6 +1246,6 @@ def NATIVE_KOBO_RESOURCES():
|
|||||||
"user_recommendations": "https://storeapi.kobo.com/v1/user/recommendations",
|
"user_recommendations": "https://storeapi.kobo.com/v1/user/recommendations",
|
||||||
"user_reviews": "https://storeapi.kobo.com/v1/user/reviews",
|
"user_reviews": "https://storeapi.kobo.com/v1/user/reviews",
|
||||||
"user_wishlist": "https://storeapi.kobo.com/v1/user/wishlist",
|
"user_wishlist": "https://storeapi.kobo.com/v1/user/wishlist",
|
||||||
"userguide_host": "https://ereaderfiles.kobo.com",
|
"userguide_host": "https://kbdownload1-a.akamaihd.net",
|
||||||
"wishlist_page": "https://www.kobo.com/{region}/{language}/account/wishlist"
|
"wishlist_page": "https://store.kobobooks.com/{region}/{language}/account/wishlist",
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-7
@@ -22,7 +22,7 @@
|
|||||||
This module also includes research notes into the auth protocol used by Kobo devices.
|
This module also includes research notes into the auth protocol used by Kobo devices.
|
||||||
|
|
||||||
Log-in:
|
Log-in:
|
||||||
When first booting a Kobo device the user must log in to a Kobo (or affiliate) account.
|
When first booting a Kobo device the user must sign into a Kobo (or affiliate) account.
|
||||||
Upon successful sign-in, the user is redirected to
|
Upon successful sign-in, the user is redirected to
|
||||||
https://auth.kobobooks.com/CrossDomainSignIn?id=<some id>
|
https://auth.kobobooks.com/CrossDomainSignIn?id=<some id>
|
||||||
which serves the following response:
|
which serves the following response:
|
||||||
@@ -41,7 +41,7 @@ issue for a few years now https://www.mobileread.com/forums/showpost.php?p=34768
|
|||||||
will still grant access given the userkey.)
|
will still grant access given the userkey.)
|
||||||
|
|
||||||
Official Kobo Store Api authorization:
|
Official Kobo Store Api authorization:
|
||||||
* For most of the endpoints we care about (sync, metadata, tags, etc.), the userKey is
|
* For most of the endpoints we care about (sync, metadata, tags, etc), the userKey is
|
||||||
passed in the x-kobo-userkey header, and is sufficient to authorize the API call.
|
passed in the x-kobo-userkey header, and is sufficient to authorize the API call.
|
||||||
* Some endpoints (e.g: AnnotationService) instead make use of Bearer tokens pass through
|
* Some endpoints (e.g: AnnotationService) instead make use of Bearer tokens pass through
|
||||||
an authorization header. To get a BearerToken, the device makes a POST request to the
|
an authorization header. To get a BearerToken, the device makes a POST request to the
|
||||||
@@ -67,6 +67,7 @@ from functools import wraps
|
|||||||
from flask import g, Blueprint, abort, request
|
from flask import g, Blueprint, abort, request
|
||||||
from .cw_login import login_user, current_user
|
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 . 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
|
||||||
@@ -81,8 +82,6 @@ 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>")
|
||||||
@user_login_required
|
@user_login_required
|
||||||
def generate_auth_token(user_id):
|
def generate_auth_token(user_id):
|
||||||
if current_user.id != user_id and not current_user.role_admin():
|
|
||||||
abort(403)
|
|
||||||
warning = False
|
warning = False
|
||||||
host_list = request.host.rsplit(':')
|
host_list = request.host.rsplit(':')
|
||||||
if len(host_list) == 1:
|
if len(host_list) == 1:
|
||||||
@@ -125,8 +124,6 @@ def generate_auth_token(user_id):
|
|||||||
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
|
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
|
||||||
@user_login_required
|
@user_login_required
|
||||||
def delete_auth_token(user_id):
|
def delete_auth_token(user_id):
|
||||||
if current_user.id != user_id and not current_user.role_admin():
|
|
||||||
abort(403)
|
|
||||||
# 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)\
|
||||||
.filter(ub.RemoteAuthToken.token_type==1).delete()
|
.filter(ub.RemoteAuthToken.token_type==1).delete()
|
||||||
@@ -157,6 +154,13 @@ def requires_kobo_auth(f):
|
|||||||
def inner(*args, **kwargs):
|
def inner(*args, **kwargs):
|
||||||
auth_token = get_auth_token()
|
auth_token = get_auth_token()
|
||||||
if auth_token is not None:
|
if auth_token is not None:
|
||||||
|
try:
|
||||||
|
limiter.check()
|
||||||
|
except RateLimitExceeded:
|
||||||
|
return abort(429)
|
||||||
|
except (ConnectionError, Exception) as e:
|
||||||
|
log.error("Connection error to limiter backend: %s", e)
|
||||||
|
return abort(429)
|
||||||
user = (
|
user = (
|
||||||
ub.session.query(ub.User)
|
ub.session.query(ub.User)
|
||||||
.join(ub.RemoteAuthToken)
|
.join(ub.RemoteAuthToken)
|
||||||
@@ -165,7 +169,7 @@ def requires_kobo_auth(f):
|
|||||||
)
|
)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
login_user(user)
|
login_user(user)
|
||||||
[limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits]
|
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
log.debug("Received Kobo request without a recognizable auth token.")
|
log.debug("Received Kobo request without a recognizable auth token.")
|
||||||
return abort(401)
|
return abort(401)
|
||||||
|
|||||||
@@ -51,15 +51,13 @@ def remove_synced_book(book_id, all=False, session=None):
|
|||||||
ub.session_commit(_session=session)
|
ub.session_commit(_session=session)
|
||||||
|
|
||||||
|
|
||||||
# If state == none, it will toggle the archive state of the passed book_id.
|
|
||||||
# state = true archives it, state = false unarchives it
|
|
||||||
def change_archived_books(book_id, state=None, message=None):
|
def change_archived_books(book_id, state=None, message=None):
|
||||||
archived_book = ub.session.query(ub.ArchivedBook).filter(and_(ub.ArchivedBook.user_id == int(current_user.id),
|
archived_book = ub.session.query(ub.ArchivedBook).filter(and_(ub.ArchivedBook.user_id == int(current_user.id),
|
||||||
ub.ArchivedBook.book_id == book_id)).first()
|
ub.ArchivedBook.book_id == book_id)).first()
|
||||||
if not archived_book: # and (state == True or state == None):
|
if not archived_book:
|
||||||
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 != None else not archived_book.is_archived
|
archived_book.is_archived = state if state else not archived_book.is_archived
|
||||||
archived_book.last_modified = datetime.now(timezone.utc) # 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)
|
||||||
|
|||||||
+11
-5
@@ -29,7 +29,7 @@ from .constants import CONFIG_DIR as _CONFIG_DIR
|
|||||||
ACCESS_FORMATTER_GEVENT = Formatter("%(message)s")
|
ACCESS_FORMATTER_GEVENT = Formatter("%(message)s")
|
||||||
ACCESS_FORMATTER_TORNADO = Formatter("[%(asctime)s] %(message)s")
|
ACCESS_FORMATTER_TORNADO = Formatter("[%(asctime)s] %(message)s")
|
||||||
|
|
||||||
FORMATTER = Formatter("[%(asctime)s] %(levelname)5s {%(filename)s:%(lineno)d} %(message)s")
|
FORMATTER = Formatter("[%(asctime)s] %(levelname)5s {%(name)s:%(lineno)d} %(message)s")
|
||||||
DEFAULT_LOG_LEVEL = logging.INFO
|
DEFAULT_LOG_LEVEL = logging.INFO
|
||||||
DEFAULT_LOG_FILE = os.path.join(_CONFIG_DIR, "calibre-web.log")
|
DEFAULT_LOG_FILE = os.path.join(_CONFIG_DIR, "calibre-web.log")
|
||||||
DEFAULT_ACCESS_LOG = os.path.join(_CONFIG_DIR, "access.log")
|
DEFAULT_ACCESS_LOG = os.path.join(_CONFIG_DIR, "access.log")
|
||||||
@@ -42,12 +42,18 @@ logging.addLevelName(logging.CRITICAL, "CRIT")
|
|||||||
|
|
||||||
class _Logger(logging.Logger):
|
class _Logger(logging.Logger):
|
||||||
|
|
||||||
def error_or_exception(self, message, stacklevel=1, *args, **kwargs):
|
def error_or_exception(self, message, stacklevel=2, *args, **kwargs):
|
||||||
is_debug = self.getEffectiveLevel() <= logging.DEBUG
|
is_debug = self.getEffectiveLevel() <= logging.DEBUG
|
||||||
if not is_debug:
|
if sys.version_info > (3, 7):
|
||||||
self.exception(message, stacklevel=stacklevel, *args, **kwargs)
|
if is_debug:
|
||||||
|
self.exception(message, stacklevel=stacklevel, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
self.error(message, stacklevel=stacklevel, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
self.error(message, stacklevel=stacklevel, *args, **kwargs)
|
if is_debug:
|
||||||
|
self.exception(message, stack_info=True, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
self.error(message, *args, **kwargs)
|
||||||
|
|
||||||
def debug_no_auth(self, message, *args, **kwargs):
|
def debug_no_auth(self, message, *args, **kwargs):
|
||||||
message = message.strip("\r\n")
|
message = message.strip("\r\n")
|
||||||
|
|||||||
+3
-3
@@ -24,7 +24,7 @@ from flask import request
|
|||||||
|
|
||||||
|
|
||||||
def request_username():
|
def request_username():
|
||||||
return request.authorization.username if request.authorization else ""
|
return request.authorization.username
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -66,8 +66,8 @@ def main():
|
|||||||
app.register_blueprint(tasks)
|
app.register_blueprint(tasks)
|
||||||
app.register_blueprint(web)
|
app.register_blueprint(web)
|
||||||
app.register_blueprint(basic)
|
app.register_blueprint(basic)
|
||||||
limiter.limit("3/minute", key_func=request_username)(opds)
|
|
||||||
app.register_blueprint(opds)
|
app.register_blueprint(opds)
|
||||||
|
limiter.limit("3/minute", key_func=request_username)(opds)
|
||||||
app.register_blueprint(jinjia)
|
app.register_blueprint(jinjia)
|
||||||
app.register_blueprint(about)
|
app.register_blueprint(about)
|
||||||
app.register_blueprint(shelf)
|
app.register_blueprint(shelf)
|
||||||
@@ -77,9 +77,9 @@ def main():
|
|||||||
app.register_blueprint(gdrive)
|
app.register_blueprint(gdrive)
|
||||||
app.register_blueprint(editbook)
|
app.register_blueprint(editbook)
|
||||||
if kobo_available:
|
if kobo_available:
|
||||||
limiter.limit("3/minute", key_func=get_remote_address)(kobo)
|
|
||||||
app.register_blueprint(kobo)
|
app.register_blueprint(kobo)
|
||||||
app.register_blueprint(kobo_auth)
|
app.register_blueprint(kobo_auth)
|
||||||
|
limiter.limit("3/minute", key_func=get_remote_address)(kobo)
|
||||||
if oauth_available:
|
if oauth_available:
|
||||||
app.register_blueprint(oauth)
|
app.register_blueprint(oauth)
|
||||||
success = web_server.start()
|
success = web_server.start()
|
||||||
|
|||||||
@@ -38,16 +38,14 @@ 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 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0',
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36',
|
||||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
|
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
|
||||||
'Sec-Fetch-Site': 'same-origin',
|
'sec-gpc': '1',
|
||||||
'Sec-Fetch-Mode': 'navigate',
|
'sec-fetch-site': 'none',
|
||||||
'Sec-Fetch-User': '?1',
|
'sec-fetch-mode': 'navigate',
|
||||||
'Sec-Fetch-Dest': 'document',
|
'sec-fetch-user': '?1',
|
||||||
'Upgrade-Insecure-Requests': '1',
|
'sec-fetch-dest': 'document',
|
||||||
'Alt-Used' : 'www.amazon.com',
|
'accept-encoding': 'gzip, deflate, br',
|
||||||
'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
|
||||||
@@ -64,7 +62,7 @@ class Amazon(Metadata):
|
|||||||
log.warning(ex)
|
log.warning(ex)
|
||||||
return []
|
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-ppd_csm_instrumentation_wrapper"})
|
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"})
|
||||||
if soup2 is None:
|
if soup2 is None:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
@@ -108,7 +106,7 @@ 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"})["src"]
|
match.cover = soup2.find("img", attrs={"class": "a-dynamic-image frontImage"})["src"]
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
match.cover = ""
|
match.cover = ""
|
||||||
return match, index
|
return match, index
|
||||||
@@ -134,7 +132,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[:3])}
|
fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:5])}
|
||||||
val = list(map(lambda x : x.result(), concurrent.futures.as_completed(fut)))
|
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
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class ComicVine(Metadata):
|
|||||||
result.raise_for_status()
|
result.raise_for_status()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(e)
|
log.warning(e)
|
||||||
return []
|
return None
|
||||||
for result in result.json()["results"]:
|
for result in result.json()["results"]:
|
||||||
match = self._parse_search_result(
|
match = self._parse_search_result(
|
||||||
result=result, generic_cover=generic_cover, locale=locale
|
result=result, generic_cover=generic_cover, locale=locale
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class Douban(Metadata):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(e)
|
log.warning(e)
|
||||||
return []
|
return None
|
||||||
|
|
||||||
match = MetaRecord(
|
match = MetaRecord(
|
||||||
id=id,
|
id=id,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from cps import logger, config
|
from cps import logger
|
||||||
from cps.isoLanguages import get_lang3, get_language_name
|
from cps.isoLanguages import get_lang3, get_language_name
|
||||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||||
|
|
||||||
@@ -31,14 +31,13 @@ log = logger.create()
|
|||||||
|
|
||||||
|
|
||||||
class Google(Metadata):
|
class Google(Metadata):
|
||||||
__name__ = "Google Books"
|
__name__ = "Google"
|
||||||
__id__ = "google"
|
__id__ = "google"
|
||||||
DESCRIPTION = "Google Books"
|
DESCRIPTION = "Google Books"
|
||||||
META_URL = "https://books.google.com/"
|
META_URL = "https://books.google.com/"
|
||||||
BOOK_URL = "https://books.google.com/books?id="
|
BOOK_URL = "https://books.google.com/books?id="
|
||||||
SEARCH_URL = "https://www.googleapis.com/books/v1/volumes?q="
|
SEARCH_URL = "https://www.googleapis.com/books/v1/volumes?q="
|
||||||
ISBN_TYPE = "ISBN_13"
|
ISBN_TYPE = "ISBN_13"
|
||||||
API_KEY = "&key=" + config.config_googlebooks_api_key
|
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||||
@@ -51,7 +50,7 @@ class Google(Metadata):
|
|||||||
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||||
query = "+".join(tokens)
|
query = "+".join(tokens)
|
||||||
try:
|
try:
|
||||||
results = requests.get(Google.SEARCH_URL + query + Google.API_KEY)
|
results = requests.get(Google.SEARCH_URL + query)
|
||||||
results.raise_for_status()
|
results.raise_for_status()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(e)
|
log.warning(e)
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ class OAuthBackend(SQLAlchemyBackend):
|
|||||||
Stores and retrieves OAuth tokens using a relational database through
|
Stores and retrieves OAuth tokens using a relational database through
|
||||||
the `SQLAlchemy`_ ORM.
|
the `SQLAlchemy`_ ORM.
|
||||||
|
|
||||||
_SQLAlchemy: https://www.sqlalchemy.org/
|
.. _SQLAlchemy: https://www.sqlalchemy.org/
|
||||||
"""
|
"""
|
||||||
def __init__(self, model, session, provider_id,
|
def __init__(self, model, session, provider_id,
|
||||||
user=None, user_id=None, user_required=None, anon_user=None,
|
user=None, user_id=None, user_required=None, anon_user=None,
|
||||||
|
|||||||
@@ -134,14 +134,6 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider
|
|||||||
oauth_entry = query.first()
|
oauth_entry = query.first()
|
||||||
# already bind with user, just login
|
# already bind with user, just login
|
||||||
if oauth_entry.user:
|
if oauth_entry.user:
|
||||||
# If a user is already logged in and it's a different account, reject the link
|
|
||||||
# to prevent account takeover via shared OAuth identities
|
|
||||||
if current_user and current_user.is_authenticated and oauth_entry.user_id != current_user.id:
|
|
||||||
flash(_("This %(oauth)s account is already linked to a different user",
|
|
||||||
oauth=provider_name), category="error")
|
|
||||||
log.warning("User %s tried to link OAuth account already bound to user %s",
|
|
||||||
current_user.id, oauth_entry.user_id)
|
|
||||||
return redirect(url_for('web.profile'))
|
|
||||||
login_user(oauth_entry.user)
|
login_user(oauth_entry.user)
|
||||||
log.debug("You are now logged in as: '%s'", oauth_entry.user.name)
|
log.debug("You are now logged in as: '%s'", oauth_entry.user.name)
|
||||||
flash(_("Success! You are now logged in as: %(nickname)s", nickname=oauth_entry.user.name),
|
flash(_("Success! You are now logged in as: %(nickname)s", nickname=oauth_entry.user.name),
|
||||||
|
|||||||
+31
-49
@@ -21,9 +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 datetime
|
import datetime
|
||||||
|
import json
|
||||||
from urllib.parse import unquote_plus
|
from urllib.parse import unquote_plus
|
||||||
|
|
||||||
from flask import Blueprint, request, render_template, make_response, abort, g, jsonify
|
from flask import Blueprint, request, render_template, make_response, abort, Response, g
|
||||||
from flask_babel import get_locale
|
from flask_babel import get_locale
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
@@ -87,8 +88,8 @@ def feed_letter_books(book_id):
|
|||||||
letter,
|
letter,
|
||||||
[db.Books.sort],
|
[db.Books.sort],
|
||||||
True, config.config_read_column)
|
True, config.config_read_column)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/new")
|
@opds.route("/opds/new")
|
||||||
@@ -100,8 +101,7 @@ def feed_new():
|
|||||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books, True, [db.Books.timestamp.desc()],
|
db.Books, True, [db.Books.timestamp.desc()],
|
||||||
True, config.config_read_column)
|
True, config.config_read_column)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/discover")
|
@opds.route("/opds/discover")
|
||||||
@@ -112,8 +112,7 @@ def feed_discover():
|
|||||||
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||||
entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page)
|
entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page)
|
||||||
pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page))
|
pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page))
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/rated")
|
@opds.route("/opds/rated")
|
||||||
@@ -126,8 +125,7 @@ def feed_best_rated():
|
|||||||
db.Books, db.Books.ratings.any(db.Ratings.rating > 9),
|
db.Books, db.Books.ratings.any(db.Ratings.rating > 9),
|
||||||
[db.Books.timestamp.desc()],
|
[db.Books.timestamp.desc()],
|
||||||
True, config.config_read_column)
|
True, config.config_read_column)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/hot")
|
@opds.route("/opds/hot")
|
||||||
@@ -151,8 +149,7 @@ def feed_hot():
|
|||||||
num_books = entries.__len__()
|
num_books = entries.__len__()
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1),
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1),
|
||||||
config.config_books_per_page, num_books)
|
config.config_books_per_page, num_books)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/author")
|
@opds.route("/opds/author")
|
||||||
@@ -177,8 +174,7 @@ def feed_letter_author(book_id):
|
|||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
entries.count())
|
entries.count())
|
||||||
entries = entries.limit(config.config_books_per_page).offset(off).all()
|
entries = entries.limit(config.config_books_per_page).offset(off).all()
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination)
|
||||||
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/author/<int:book_id>")
|
@opds.route("/opds/author/<int:book_id>")
|
||||||
@@ -201,8 +197,7 @@ def feed_publisherindex():
|
|||||||
.limit(config.config_books_per_page).offset(off)
|
.limit(config.config_books_per_page).offset(off)
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
len(calibre_db.session.query(db.Publishers).all()))
|
len(calibre_db.session.query(db.Publishers).all()))
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_publisher', pagination=pagination)
|
||||||
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_publisher', pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/publisher/<int:book_id>")
|
@opds.route("/opds/publisher/<int:book_id>")
|
||||||
@@ -235,8 +230,7 @@ def feed_letter_category(book_id):
|
|||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
entries.count())
|
entries.count())
|
||||||
entries = entries.offset(off).limit(config.config_books_per_page).all()
|
entries = entries.offset(off).limit(config.config_books_per_page).all()
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination)
|
||||||
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/category/<int:book_id>")
|
@opds.route("/opds/category/<int:book_id>")
|
||||||
@@ -269,8 +263,7 @@ def feed_letter_series(book_id):
|
|||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
entries.count())
|
entries.count())
|
||||||
entries = entries.offset(off).limit(config.config_books_per_page).all()
|
entries = entries.offset(off).limit(config.config_books_per_page).all()
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination)
|
||||||
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/series/<int:book_id>")
|
@opds.route("/opds/series/<int:book_id>")
|
||||||
@@ -282,8 +275,7 @@ def feed_series(book_id):
|
|||||||
db.Books.series.any(db.Series.id == book_id),
|
db.Books.series.any(db.Series.id == book_id),
|
||||||
[db.Books.series_index],
|
[db.Books.series_index],
|
||||||
True, config.config_read_column)
|
True, config.config_read_column)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/ratings")
|
@opds.route("/opds/ratings")
|
||||||
@@ -305,8 +297,7 @@ def feed_ratingindex():
|
|||||||
element = list()
|
element = list()
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
element.append(FeedObject(entry[0].id, _("{} Stars").format(entry.name)))
|
element.append(FeedObject(entry[0].id, _("{} Stars").format(entry.name)))
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination)
|
||||||
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/ratings/<book_id>")
|
@opds.route("/opds/ratings/<book_id>")
|
||||||
@@ -330,8 +321,7 @@ def feed_formatindex():
|
|||||||
element = list()
|
element = list()
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
element.append(FeedObject(entry.format, entry.format))
|
element.append(FeedObject(entry.format, entry.format))
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_format', pagination=pagination)
|
||||||
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_format', pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/formats/<book_id>")
|
@opds.route("/opds/formats/<book_id>")
|
||||||
@@ -343,8 +333,7 @@ def feed_format(book_id):
|
|||||||
db.Books.data.any(db.Data.format == book_id.upper()),
|
db.Books.data.any(db.Data.format == book_id.upper()),
|
||||||
[db.Books.timestamp.desc()],
|
[db.Books.timestamp.desc()],
|
||||||
True, config.config_read_column)
|
True, config.config_read_column)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/language")
|
@opds.route("/opds/language")
|
||||||
@@ -362,8 +351,7 @@ def feed_languagesindex():
|
|||||||
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))
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', listelements=languages, folder='opds.feed_languages', pagination=pagination)
|
||||||
return render_xml_template('feed.xml', listelements=languages, folder='opds.feed_languages', pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/language/<int:book_id>")
|
@opds.route("/opds/language/<int:book_id>")
|
||||||
@@ -375,8 +363,7 @@ def feed_languages(book_id):
|
|||||||
db.Books.languages.any(db.Languages.id == book_id),
|
db.Books.languages.any(db.Languages.id == book_id),
|
||||||
[db.Books.timestamp.desc()],
|
[db.Books.timestamp.desc()],
|
||||||
True, config.config_read_column)
|
True, config.config_read_column)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/shelfindex")
|
@opds.route("/opds/shelfindex")
|
||||||
@@ -390,8 +377,7 @@ def feed_shelfindex():
|
|||||||
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)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', listelements=shelf, folder='opds.feed_shelf', pagination=pagination)
|
||||||
return render_xml_template('feed.xml', listelements=shelf, folder='opds.feed_shelf', pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/shelf/<int:book_id>")
|
@opds.route("/opds/shelf/<int:book_id>")
|
||||||
@@ -431,16 +417,18 @@ def feed_shelf(book_id):
|
|||||||
except (OperationalError, InvalidRequestError) as e:
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||||
return render_xml_template('feed.xml', entries=result, pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@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 auth.current_user().role_download():
|
if not auth.current_user().role_download():
|
||||||
return abort(401)
|
return abort(403)
|
||||||
client = "kobo" if "Kobo" in request.headers.get('User-Agent') else ""
|
if "Kobo" in request.headers.get('User-Agent'):
|
||||||
|
client = "kobo"
|
||||||
|
else:
|
||||||
|
client = ""
|
||||||
return get_download_link(book_id, book_format.lower(), client)
|
return get_download_link(book_id, book_format.lower(), client)
|
||||||
|
|
||||||
|
|
||||||
@@ -466,7 +454,7 @@ def get_database_stats():
|
|||||||
stat['authors'] = calibre_db.session.query(db.Authors).count()
|
stat['authors'] = calibre_db.session.query(db.Authors).count()
|
||||||
stat['categories'] = calibre_db.session.query(db.Tags).count()
|
stat['categories'] = calibre_db.session.query(db.Tags).count()
|
||||||
stat['series'] = calibre_db.session.query(db.Series).count()
|
stat['series'] = calibre_db.session.query(db.Series).count()
|
||||||
return make_response(jsonify(stat))
|
return Response(json.dumps(stat), mimetype="application/json")
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/thumb_240_240/<book_id>")
|
@opds.route("/opds/thumb_240_240/<book_id>")
|
||||||
@@ -485,8 +473,7 @@ def feed_read_books():
|
|||||||
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)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||||
return render_xml_template('feed.xml', entries=result, pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/unreadbooks")
|
@opds.route("/opds/unreadbooks")
|
||||||
@@ -496,8 +483,7 @@ def feed_unread_books():
|
|||||||
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)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||||
return render_xml_template('feed.xml', entries=result, pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
class FeedObject:
|
class FeedObject:
|
||||||
@@ -519,8 +505,7 @@ def feed_search(term):
|
|||||||
entries, __, ___ = calibre_db.get_search_results(term, config=config)
|
entries, __, ___ = calibre_db.get_search_results(term, config=config)
|
||||||
entries_count = len(entries) if len(entries) > 0 else 1
|
entries_count = len(entries) if len(entries) > 0 else 1
|
||||||
pagination = Pagination(1, entries_count, entries_count)
|
pagination = Pagination(1, entries_count, entries_count)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination)
|
||||||
return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination, cc=cc)
|
|
||||||
else:
|
else:
|
||||||
return render_xml_template('feed.xml', searchterm="")
|
return render_xml_template('feed.xml', searchterm="")
|
||||||
|
|
||||||
@@ -542,8 +527,7 @@ def render_xml_dataset(data_table, book_id):
|
|||||||
getattr(db.Books, data_table.__tablename__).any(data_table.id == book_id),
|
getattr(db.Books, data_table.__tablename__).any(data_table.id == book_id),
|
||||||
[db.Books.timestamp.desc()],
|
[db.Books.timestamp.desc()],
|
||||||
True, config.config_read_column)
|
True, config.config_read_column)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc)
|
|
||||||
|
|
||||||
|
|
||||||
def render_element_index(database_column, linked_table, folder):
|
def render_element_index(database_column, linked_table, folder):
|
||||||
@@ -564,9 +548,7 @@ def render_element_index(database_column, linked_table, folder):
|
|||||||
elements.append({'id': entry.id, 'name': entry.id})
|
elements.append({'id': entry.id, 'name': entry.id})
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
len(entries) + 1)
|
len(entries) + 1)
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
|
||||||
return render_xml_template('feed.xml',
|
return render_xml_template('feed.xml',
|
||||||
letterelements=elements,
|
letterelements=elements,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
pagination=pagination,
|
pagination=pagination)
|
||||||
cc=cc)
|
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
from urllib.parse import urlparse, urljoin
|
from urllib.parse import urlparse, urljoin
|
||||||
|
|
||||||
from flask import request, url_for, current_app
|
from flask import request, url_for, redirect, current_app
|
||||||
|
|
||||||
|
|
||||||
def is_safe_url(target):
|
def is_safe_url(target):
|
||||||
|
|||||||
+11
-11
@@ -47,7 +47,7 @@ def get_sidebar_config(kwargs=None):
|
|||||||
if current_user.role_admin():
|
if current_user.role_admin():
|
||||||
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list',
|
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list',
|
||||||
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not current_user.is_anonymous),
|
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not current_user.is_anonymous),
|
||||||
"page": "download", "show_text": _('Show Downloaded Books'), "no_param":True,
|
"page": "download", "show_text": _('Show Downloaded Books'),
|
||||||
"config_show": content})
|
"config_show": content})
|
||||||
else:
|
else:
|
||||||
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list',
|
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list',
|
||||||
@@ -69,37 +69,37 @@ def get_sidebar_config(kwargs=None):
|
|||||||
"visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover",
|
"visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover",
|
||||||
"show_text": _('Show Random Books'), "config_show": True})
|
"show_text": _('Show Random Books'), "config_show": True})
|
||||||
sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat",
|
sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat",
|
||||||
"visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category", "no_param":True,
|
"visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category",
|
||||||
"show_text": _('Show Category Section'), "config_show": True})
|
"show_text": _('Show Category Section'), "config_show": True})
|
||||||
sidebar.append({"glyph": "glyphicon-bookmark", "text": _('Series'), "link": 'web.series_list', "id": "serie",
|
sidebar.append({"glyph": "glyphicon-bookmark", "text": _('Series'), "link": 'web.series_list', "id": "serie",
|
||||||
"visibility": constants.SIDEBAR_SERIES, 'public': True, "page": "series", "no_param":True,
|
"visibility": constants.SIDEBAR_SERIES, 'public': True, "page": "series",
|
||||||
"show_text": _('Show Series Section'), "config_show": True})
|
"show_text": _('Show Series Section'), "config_show": True})
|
||||||
sidebar.append({"glyph": "glyphicon-user", "text": _('Authors'), "link": 'web.author_list', "id": "author",
|
sidebar.append({"glyph": "glyphicon-user", "text": _('Authors'), "link": 'web.author_list', "id": "author",
|
||||||
"visibility": constants.SIDEBAR_AUTHOR, 'public': True, "page": "author", "no_param":True,
|
"visibility": constants.SIDEBAR_AUTHOR, 'public': True, "page": "author",
|
||||||
"show_text": _('Show Author Section'), "config_show": True})
|
"show_text": _('Show Author Section'), "config_show": True})
|
||||||
sidebar.append(
|
sidebar.append(
|
||||||
{"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "publisher",
|
{"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "publisher",
|
||||||
"visibility": constants.SIDEBAR_PUBLISHER, 'public': True, "page": "publisher", "no_param":True,
|
"visibility": constants.SIDEBAR_PUBLISHER, 'public': True, "page": "publisher",
|
||||||
"show_text": _('Show Publisher Section'), "config_show":True})
|
"show_text": _('Show Publisher Section'), "config_show":True})
|
||||||
sidebar.append({"glyph": "glyphicon-flag", "text": _('Languages'), "link": 'web.language_overview', "id": "lang",
|
sidebar.append({"glyph": "glyphicon-flag", "text": _('Languages'), "link": 'web.language_overview', "id": "lang",
|
||||||
"visibility": constants.SIDEBAR_LANGUAGE, 'public': (current_user.filter_language() == 'all'),
|
"visibility": constants.SIDEBAR_LANGUAGE, 'public': (current_user.filter_language() == 'all'),
|
||||||
"page": "language", "no_param":True,
|
"page": "language",
|
||||||
"show_text": _('Show Language Section'), "config_show": True})
|
"show_text": _('Show Language Section'), "config_show": True})
|
||||||
sidebar.append({"glyph": "glyphicon-star-empty", "text": _('Ratings'), "link": 'web.ratings_list', "id": "rate",
|
sidebar.append({"glyph": "glyphicon-star-empty", "text": _('Ratings'), "link": 'web.ratings_list', "id": "rate",
|
||||||
"visibility": constants.SIDEBAR_RATING, 'public': True, "no_param":True,
|
"visibility": constants.SIDEBAR_RATING, 'public': True,
|
||||||
"page": "rating", "show_text": _('Show Ratings Section'), "config_show": True})
|
"page": "rating", "show_text": _('Show Ratings Section'), "config_show": True})
|
||||||
sidebar.append({"glyph": "glyphicon-file", "text": _('File formats'), "link": 'web.formats_list', "id": "format",
|
sidebar.append({"glyph": "glyphicon-file", "text": _('File formats'), "link": 'web.formats_list', "id": "format",
|
||||||
"visibility": constants.SIDEBAR_FORMAT, 'public': True, "no_param":True,
|
"visibility": constants.SIDEBAR_FORMAT, 'public': True,
|
||||||
"page": "format", "show_text": _('Show File Formats Section'), "config_show": True})
|
"page": "format", "show_text": _('Show File Formats Section'), "config_show": True})
|
||||||
sidebar.append(
|
sidebar.append(
|
||||||
{"glyph": "glyphicon-folder-open", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived",
|
{"glyph": "glyphicon-trash", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived",
|
||||||
"visibility": constants.SIDEBAR_ARCHIVED, 'public': (not current_user.is_anonymous), "page": "archived",
|
"visibility": constants.SIDEBAR_ARCHIVED, 'public': (not current_user.is_anonymous), "page": "archived",
|
||||||
"show_text": _('Show Archived Books'), "config_show": content})
|
"show_text": _('Show Archived Books'), "config_show": content})
|
||||||
if not simple:
|
if not simple:
|
||||||
sidebar.append(
|
sidebar.append(
|
||||||
{"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list",
|
{"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list",
|
||||||
"visibility": constants.SIDEBAR_LIST, 'public': (not current_user.is_anonymous),
|
"visibility": constants.SIDEBAR_LIST, 'public': (not current_user.is_anonymous), "page": "list",
|
||||||
"show_text": _('Show Books List'), "config_show": content, "no_param":True})
|
"show_text": _('Show Books List'), "config_show": content})
|
||||||
g.shelves_access = ub.session.query(ub.Shelf).filter(
|
g.shelves_access = 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 == current_user.id)).order_by(ub.Shelf.name).all()
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -41,9 +41,9 @@ class ReverseProxied(object):
|
|||||||
"""Wrap the application in this middleware and configure the
|
"""Wrap the application in this middleware and configure the
|
||||||
front-end server to add these headers, to let you quietly bind
|
front-end server to add these headers, to let you quietly bind
|
||||||
this to a URL other than / and to an HTTP scheme that is
|
this to a URL other than / and to an HTTP scheme that is
|
||||||
different from what is used locally.
|
different than what is used locally.
|
||||||
|
|
||||||
Code courtesy of: https://flask.pocoo.org/snippets/35/
|
Code courtesy of: http://flask.pocoo.org/snippets/35/
|
||||||
|
|
||||||
In nginx:
|
In nginx:
|
||||||
location /myprefix {
|
location /myprefix {
|
||||||
@@ -61,13 +61,11 @@ class ReverseProxied(object):
|
|||||||
|
|
||||||
def __call__(self, environ, start_response):
|
def __call__(self, environ, start_response):
|
||||||
self.proxied = False
|
self.proxied = False
|
||||||
self.script_name = "/"
|
|
||||||
script_name = environ.get('HTTP_X_SCRIPT_NAME', '')
|
script_name = environ.get('HTTP_X_SCRIPT_NAME', '')
|
||||||
if script_name:
|
if script_name:
|
||||||
self.proxied = True
|
self.proxied = True
|
||||||
environ['SCRIPT_NAME'] = script_name
|
environ['SCRIPT_NAME'] = script_name
|
||||||
path_info = environ.get('PATH_INFO', '')
|
path_info = environ.get('PATH_INFO', '')
|
||||||
self.script_name = script_name
|
|
||||||
if path_info and path_info.startswith(script_name):
|
if path_info and path_info.startswith(script_name):
|
||||||
environ['PATH_INFO'] = path_info[len(script_name):]
|
environ['PATH_INFO'] = path_info[len(script_name):]
|
||||||
|
|
||||||
|
|||||||
+6
-20
@@ -177,24 +177,11 @@ def adv_search_serie(q, include_series_inputs, exclude_series_inputs):
|
|||||||
q = q.filter(not_(db.Books.series.any(db.Series.id == serie)))
|
q = q.filter(not_(db.Books.series.any(db.Series.id == serie)))
|
||||||
return q
|
return q
|
||||||
|
|
||||||
|
|
||||||
def adv_search_shelf(q, include_shelf_inputs, exclude_shelf_inputs):
|
def adv_search_shelf(q, include_shelf_inputs, exclude_shelf_inputs):
|
||||||
if len(include_shelf_inputs):
|
q = q.outerjoin(ub.BookShelf, db.Books.id == ub.BookShelf.book_id)\
|
||||||
in_shelf = (
|
.filter(or_(ub.BookShelf.shelf == None, ub.BookShelf.shelf.notin_(exclude_shelf_inputs)))
|
||||||
calibre_db.session.query(ub.BookShelf.id)
|
if len(include_shelf_inputs) > 0:
|
||||||
.filter(ub.BookShelf.book_id == db.Books.id)
|
q = q.filter(ub.BookShelf.shelf.in_(include_shelf_inputs))
|
||||||
.filter(ub.BookShelf.shelf.in_(include_shelf_inputs))
|
|
||||||
.exists()
|
|
||||||
)
|
|
||||||
q = q.filter(in_shelf)
|
|
||||||
if len(exclude_shelf_inputs):
|
|
||||||
ex_shelf = (
|
|
||||||
calibre_db.session.query(ub.BookShelf.id)
|
|
||||||
.filter(ub.BookShelf.book_id == db.Books.id)
|
|
||||||
.filter(ub.BookShelf.shelf.in_(exclude_shelf_inputs))
|
|
||||||
.exists()
|
|
||||||
)
|
|
||||||
q = q.filter(~ex_shelf)
|
|
||||||
return q
|
return q
|
||||||
|
|
||||||
def extend_search_term(searchterm,
|
def extend_search_term(searchterm,
|
||||||
@@ -258,6 +245,7 @@ def render_adv_search_results(term, offset=None, order=None, limit=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.create_functions()
|
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)\
|
||||||
@@ -277,7 +265,7 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
|||||||
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("comments")
|
description = term.get("comment")
|
||||||
read_status = term.get("read_status")
|
read_status = term.get("read_status")
|
||||||
if author_name:
|
if author_name:
|
||||||
author_name = strip_whitespaces(author_name).lower().replace(',', '|')
|
author_name = strip_whitespaces(author_name).lower().replace(',', '|')
|
||||||
@@ -353,12 +341,10 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
|||||||
q = adv_search_ratings(q, rating_high, rating_low)
|
q = adv_search_ratings(q, rating_high, rating_low)
|
||||||
|
|
||||||
if description:
|
if description:
|
||||||
pass
|
|
||||||
q = q.filter(db.Books.comments.any(func.lower(db.Comments.text).ilike("%" + description + "%")))
|
q = q.filter(db.Books.comments.any(func.lower(db.Comments.text).ilike("%" + description + "%")))
|
||||||
|
|
||||||
# search custom columns
|
# search custom columns
|
||||||
try:
|
try:
|
||||||
pass
|
|
||||||
q = adv_search_custom_columns(cc, term, q)
|
q = adv_search_custom_columns(cc, term, q)
|
||||||
except AttributeError as ex:
|
except AttributeError as ex:
|
||||||
log.debug_or_exception(ex)
|
log.debug_or_exception(ex)
|
||||||
|
|||||||
+10
-6
@@ -23,7 +23,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from flask import Blueprint, request, url_for, make_response, jsonify
|
from flask import Blueprint, Response, request, url_for
|
||||||
from .cw_login import current_user
|
from .cw_login import current_user
|
||||||
from flask_babel import get_locale
|
from flask_babel import get_locale
|
||||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
@@ -33,6 +33,7 @@ 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
|
from .usermanagement import user_login_required
|
||||||
|
|
||||||
|
# current_milli_time = lambda: int(round(time() * 1000))
|
||||||
|
|
||||||
meta = Blueprint("metadata", __name__)
|
meta = Blueprint("metadata", __name__)
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ def metadata_provider():
|
|||||||
provider.append(
|
provider.append(
|
||||||
{"name": c.__name__, "active": ac, "initial": ac, "id": c.__id__}
|
{"name": c.__name__, "active": ac, "initial": ac, "id": c.__id__}
|
||||||
)
|
)
|
||||||
return make_response(jsonify(provider))
|
return Response(json.dumps(provider), mimetype="application/json")
|
||||||
|
|
||||||
|
|
||||||
@meta.route("/metadata/provider", methods=["POST"])
|
@meta.route("/metadata/provider", methods=["POST"])
|
||||||
@@ -114,7 +115,9 @@ def metadata_change_active_provider(prov_name):
|
|||||||
provider = next((c for c in cl if c.__id__ == prov_name), None)
|
provider = next((c for c in cl if c.__id__ == prov_name), None)
|
||||||
if provider is not None:
|
if provider is not None:
|
||||||
data = provider.search(new_state.get("query", ""))
|
data = provider.search(new_state.get("query", ""))
|
||||||
return make_response(jsonify([asdict(x) for x in data]))
|
return Response(
|
||||||
|
json.dumps([asdict(x) for x in data]), mimetype="application/json"
|
||||||
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -127,7 +130,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")
|
||||||
# ret = cl[0].search(query, static_cover, locale)
|
# start = current_milli_time()
|
||||||
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
|
||||||
@@ -135,5 +138,6 @@ def metadata_search():
|
|||||||
if active.get(c.__id__, True)
|
if active.get(c.__id__, True)
|
||||||
}
|
}
|
||||||
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() or []) if x])
|
data.extend([asdict(x) for x in future.result() if x])
|
||||||
return make_response(jsonify(data))
|
# log.info({'Time elapsed {}'.format(current_milli_time()-start)})
|
||||||
|
return Response(json.dumps(data), mimetype="application/json")
|
||||||
|
|||||||
+1
-15
@@ -41,7 +41,7 @@ except ImportError:
|
|||||||
VERSION = 'Tornado ' + _version
|
VERSION = 'Tornado ' + _version
|
||||||
_GEVENT = False
|
_GEVENT = False
|
||||||
|
|
||||||
from . import logger, constants
|
from . import logger
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -216,13 +216,6 @@ class WebServer(object):
|
|||||||
try:
|
try:
|
||||||
sock, output = self._make_gevent_listener()
|
sock, output = self._make_gevent_listener()
|
||||||
log.info('Starting Gevent server on %s', output)
|
log.info('Starting Gevent server on %s', output)
|
||||||
try:
|
|
||||||
# Also print to stdout so interactive terminals show a clear success message
|
|
||||||
if constants.APP_MODE not in ['development', 'test']:
|
|
||||||
print(f"Calibre-Web: server started on {output}")
|
|
||||||
except Exception:
|
|
||||||
print(f"Calibre-Web: error {output}")
|
|
||||||
pass
|
|
||||||
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, handler_class=MyWSGIHandler,
|
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, handler_class=MyWSGIHandler,
|
||||||
error_log=log,
|
error_log=log,
|
||||||
spawn=Pool(), **ssl_args)
|
spawn=Pool(), **ssl_args)
|
||||||
@@ -273,13 +266,6 @@ class WebServer(object):
|
|||||||
output = _readable_listen_address(self.listen_address, self.listen_port)
|
output = _readable_listen_address(self.listen_address, self.listen_port)
|
||||||
http_server.listen(self.listen_port, self.listen_address)
|
http_server.listen(self.listen_port, self.listen_address)
|
||||||
log.info('Starting Tornado server on %s', output)
|
log.info('Starting Tornado server on %s', output)
|
||||||
# Also print to stdout so interactive terminals show a clear success message
|
|
||||||
try:
|
|
||||||
if constants.APP_MODE not in ['development', 'test']:
|
|
||||||
print(f"Calibre-Web: server started on {output}")
|
|
||||||
except Exception:
|
|
||||||
print(f"Calibre-Web: error {output}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.wsgiserver = IOLoop.current()
|
self.wsgiserver = IOLoop.current()
|
||||||
self.wsgiserver.start()
|
self.wsgiserver.start()
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ def send_messsage(token, msg):
|
|||||||
if creds and creds.expired and creds.refresh_token:
|
if creds and creds.expired and creds.refresh_token:
|
||||||
creds.refresh(Request())
|
creds.refresh(Request())
|
||||||
service = build('gmail', 'v1', credentials=creds)
|
service = build('gmail', 'v1', credentials=creds)
|
||||||
message_as_bytes = msg.as_bytes() # the message should be converted from string to bytes.
|
message_as_bytes = msg.as_bytes() # the message should converted from string to bytes.
|
||||||
message_as_base64 = base64.urlsafe_b64encode(message_as_bytes) # encode in base64 (printable letters coding)
|
message_as_base64 = base64.urlsafe_b64encode(message_as_bytes) # encode in base64 (printable letters coding)
|
||||||
raw = message_as_base64.decode() # convert to something JSON serializable
|
raw = message_as_base64.decode() # convert to something JSON serializable
|
||||||
body = {'raw': raw}
|
body = {'raw': raw}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import requests
|
|||||||
|
|
||||||
from goodreads.client import GoodreadsClient
|
from goodreads.client import GoodreadsClient
|
||||||
from goodreads.request import GoodreadsRequest
|
from goodreads.request import GoodreadsRequest
|
||||||
from lxml import etree
|
import xmltodict
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import Levenshtein
|
import Levenshtein
|
||||||
@@ -33,39 +33,6 @@ from .. import logger
|
|||||||
from ..clean_html import clean_string
|
from ..clean_html import clean_string
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def etree_to_dict(t):
|
|
||||||
"""
|
|
||||||
Convert lxml ElementTree to a nested dict (similar to xmltodict).
|
|
||||||
"""
|
|
||||||
d = {t.tag: {} if t.attrib else None}
|
|
||||||
children = list(t)
|
|
||||||
|
|
||||||
if children:
|
|
||||||
dd = {}
|
|
||||||
for dc in map(etree_to_dict, children):
|
|
||||||
for k, v in dc.items():
|
|
||||||
if k in dd:
|
|
||||||
if not isinstance(dd[k], list):
|
|
||||||
dd[k] = [dd[k]]
|
|
||||||
dd[k].append(v)
|
|
||||||
else:
|
|
||||||
dd[k] = v
|
|
||||||
d = {t.tag: dd}
|
|
||||||
|
|
||||||
if t.attrib:
|
|
||||||
d[t.tag].update(('@' + k, v) for k, v in t.attrib.items())
|
|
||||||
|
|
||||||
text = (t.text or '').strip()
|
|
||||||
if text:
|
|
||||||
if children or t.attrib:
|
|
||||||
d[t.tag]['#text'] = text
|
|
||||||
else:
|
|
||||||
d[t.tag] = text
|
|
||||||
|
|
||||||
return d
|
|
||||||
|
|
||||||
class my_GoodreadsClient(GoodreadsClient):
|
class my_GoodreadsClient(GoodreadsClient):
|
||||||
|
|
||||||
def request(self, *args, **kwargs):
|
def request(self, *args, **kwargs):
|
||||||
@@ -92,9 +59,7 @@ class my_GoodreadsRequest(GoodreadsRequest):
|
|||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise GoodreadsRequestException(resp.reason, self.path)
|
raise GoodreadsRequestException(resp.reason, self.path)
|
||||||
if self.req_format == 'xml':
|
if self.req_format == 'xml':
|
||||||
root = etree.fromstring(resp.content, parser=etree.XMLParser(resolve_entities=False, no_network=True))
|
data_dict = xmltodict.parse(resp.content)
|
||||||
data_dict = etree_to_dict(root)
|
|
||||||
|
|
||||||
return data_dict['GoodreadsResponse']
|
return data_dict['GoodreadsResponse']
|
||||||
else:
|
else:
|
||||||
raise Exception("Invalid format")
|
raise Exception("Invalid format")
|
||||||
@@ -152,7 +117,7 @@ def get_author_info(author_name):
|
|||||||
|
|
||||||
|
|
||||||
def get_other_books(author_info, library_books=None):
|
def get_other_books(author_info, library_books=None):
|
||||||
# Get all identifiers (ISBN, Goodreads, etc.) and filter author's books by that list so we show fewer duplicates
|
# Get all identifiers (ISBN, Goodreads, etc) and filter author's books by that list so we show fewer duplicates
|
||||||
# Note: Not all images will be shown, even though they're available on Goodreads.com.
|
# Note: Not all images will be shown, even though they're available on Goodreads.com.
|
||||||
# See https://www.goodreads.com/topic/show/18213769-goodreads-book-images
|
# See https://www.goodreads.com/topic/show/18213769-goodreads-book-images
|
||||||
|
|
||||||
|
|||||||
@@ -31,16 +31,6 @@ except ImportError:
|
|||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
def _escape_ldap_filter(s):
|
|
||||||
"""Escape special characters for safe use in LDAP filter strings (RFC 4515)."""
|
|
||||||
s = s.replace('\\', '\\5c')
|
|
||||||
s = s.replace('*', '\\2a')
|
|
||||||
s = s.replace('(', '\\28')
|
|
||||||
s = s.replace(')', '\\29')
|
|
||||||
s = s.replace('\x00', '\\00')
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
class LDAPLogger(object):
|
class LDAPLogger(object):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -158,11 +148,9 @@ def bind_user(username, password):
|
|||||||
|
|
||||||
:returns: True if login succeeded, False if login failed, None if server unavailable.
|
:returns: True if login succeeded, False if login failed, None if server unavailable.
|
||||||
'''
|
'''
|
||||||
# Escape LDAP special characters to prevent LDAP injection in search filters
|
|
||||||
safe_username = _escape_ldap_filter(username)
|
|
||||||
try:
|
try:
|
||||||
if _ldap.get_object_details(safe_username):
|
if _ldap.get_object_details(username):
|
||||||
result = _ldap.bind_user(safe_username, password)
|
result = _ldap.bind_user(username, password)
|
||||||
log.debug("LDAP login '%s': %r", username, result)
|
log.debug("LDAP login '%s': %r", username, result)
|
||||||
return result is not None, None
|
return result is not None, None
|
||||||
return None, None # User not found
|
return None, None # User not found
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class CalibreTask:
|
|||||||
self.run(*args)
|
self.run(*args)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
self._handleError(str(ex))
|
self._handleError(str(ex))
|
||||||
log.exception(ex)
|
log.error_or_exception(ex)
|
||||||
|
|
||||||
self.end_time = datetime.now()
|
self.end_time = datetime.now()
|
||||||
|
|
||||||
|
|||||||
+3
-50
@@ -102,52 +102,6 @@ def add_to_shelf(shelf_id, book_id):
|
|||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/massremove/<int:shelf_id>", methods=["POST"])
|
|
||||||
@user_login_required
|
|
||||||
def search_from_shelf(shelf_id):
|
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
|
||||||
if shelf is None:
|
|
||||||
log.error("Invalid shelf specified: {}".format(shelf_id))
|
|
||||||
flash(_("Invalid shelf specified"), category="error")
|
|
||||||
return redirect(url_for('web.index'))
|
|
||||||
|
|
||||||
if not check_shelf_edit_permissions(shelf):
|
|
||||||
log.warning("You are not allowed to remove a book from the shelf".format(shelf.name))
|
|
||||||
flash(_("You are not allowed to remove a book from the shelf"), category="error")
|
|
||||||
return redirect(url_for('web.index'))
|
|
||||||
|
|
||||||
if current_user.id in ub.searched_ids and ub.searched_ids[current_user.id]:
|
|
||||||
books_from_shelf = list()
|
|
||||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all()
|
|
||||||
if books_in_shelf:
|
|
||||||
book_ids = [book_id.book_id for book_id in books_in_shelf]
|
|
||||||
for searchid in ub.searched_ids[current_user.id]:
|
|
||||||
if searchid in book_ids:
|
|
||||||
books_from_shelf.append(searchid)
|
|
||||||
else:
|
|
||||||
log.error("No Books are part of {}".format(shelf.name))
|
|
||||||
flash(_("No Books are part of the shelf: %(name)s", name=shelf.name), category="error")
|
|
||||||
return redirect(url_for('web.index'))
|
|
||||||
|
|
||||||
# maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()[0] or 0
|
|
||||||
|
|
||||||
for book in books_from_shelf:
|
|
||||||
ub.session.delete(ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).filter(
|
|
||||||
ub.BookShelf.book_id == book).first())
|
|
||||||
shelf.last_modified = datetime.now(timezone.utc)
|
|
||||||
try:
|
|
||||||
ub.session.commit()
|
|
||||||
flash(_("Books have been removed from shelf: %(sname)s", sname=shelf.name), category="success")
|
|
||||||
except (OperationalError, InvalidRequestError) as e:
|
|
||||||
ub.session.rollback()
|
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
|
||||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
|
||||||
else:
|
|
||||||
log.error("Could not remove books from shelf: {}".format(shelf.name))
|
|
||||||
flash(_("Could not remove books from shelf: %(sname)s", sname=shelf.name), category="error")
|
|
||||||
return redirect(url_for('web.index'))
|
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
|
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
|
||||||
@user_login_required
|
@user_login_required
|
||||||
def search_to_shelf(shelf_id):
|
def search_to_shelf(shelf_id):
|
||||||
@@ -166,7 +120,9 @@ def search_to_shelf(shelf_id):
|
|||||||
books_for_shelf = list()
|
books_for_shelf = list()
|
||||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all()
|
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all()
|
||||||
if books_in_shelf:
|
if books_in_shelf:
|
||||||
book_ids = [book_id.book_id for book_id in books_in_shelf]
|
book_ids = list()
|
||||||
|
for book_id in books_in_shelf:
|
||||||
|
book_ids.append(book_id.book_id)
|
||||||
for searchid in ub.searched_ids[current_user.id]:
|
for searchid in ub.searched_ids[current_user.id]:
|
||||||
if searchid not in book_ids:
|
if searchid not in book_ids:
|
||||||
books_for_shelf.append(searchid)
|
books_for_shelf.append(searchid)
|
||||||
@@ -308,9 +264,6 @@ 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):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if not check_shelf_edit_permissions(shelf):
|
|
||||||
flash(_("Sorry you are not allowed to edit this shelf"), category="error")
|
|
||||||
return redirect(url_for('web.index'))
|
|
||||||
to_save = request.form.to_dict()
|
to_save = request.form.to_dict()
|
||||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by(
|
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by(
|
||||||
ub.BookShelf.order.asc()).all()
|
ub.BookShelf.order.asc()).all()
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
height: 75px;
|
|
||||||
padding: 5px 20px;
|
|
||||||
width: 100%;
|
|
||||||
display: table;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-bottom: 1px solid black;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > * {
|
|
||||||
display: inline-block;
|
|
||||||
display: table-cell;
|
|
||||||
vertical-align: middle;
|
|
||||||
float: none;
|
|
||||||
text-align: center;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > *:first-child {
|
|
||||||
text-align: left;
|
|
||||||
width: 1%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme{
|
|
||||||
text-align: center;
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
nav > *:last-child {
|
|
||||||
text-align: right;
|
|
||||||
width: 1%;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > a {
|
|
||||||
color: black;
|
|
||||||
margin: 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
margin: auto auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
form > input {
|
|
||||||
width: 18ch;
|
|
||||||
padding-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
form > * {
|
|
||||||
height: 50px;
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 0;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
form > span {
|
|
||||||
margin-left: -5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
padding: 0 10px;
|
|
||||||
margin: 0;
|
|
||||||
width: 160px;
|
|
||||||
height: 100%;
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
padding: 5px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 150px;
|
|
||||||
height: 250px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.listing {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
margin-right: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
padding: 10px 0;
|
|
||||||
height: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination > div {
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination > div:last-child {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
+53
-99
@@ -2018,7 +2018,7 @@ body > div.container-fluid > div > div.col-sm-10 > div.pagination > .page-previo
|
|||||||
display: none
|
display: none
|
||||||
}
|
}
|
||||||
|
|
||||||
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container1 > div.col-xs-12, body.downloadlist > div.container-fluid > div > div.col-sm-10 > div.container1 > div.col-xs-12, body.catlist > div.container-fluid > div > div.col-sm-10 > div.container1 > div.col-xs-12, body.langlist > div.container-fluid > div > div.col-sm-10 > div.container1 > div.col-sm-6, body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container1 > div.col-sm-6 {
|
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container > div.col-xs-12, body.catlist > div.container-fluid > div > div.col-sm-10 > div.container > div.col-xs-12, body.langlist > div.container-fluid > div > div.col-sm-10 > div.container > div.col-sm-6, body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container > div.col-sm-6 {
|
||||||
width: 100%
|
width: 100%
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2042,7 +2042,7 @@ body.serieslist > div.container-fluid > div > div.col-sm-10:before {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.container1 {
|
body > div.container-fluid > div > div.col-sm-10 > div.container {
|
||||||
width: calc(80% - 30px);
|
width: calc(80% - 30px);
|
||||||
margin: 120px 0 30px 20%;
|
margin: 120px 0 30px 20%;
|
||||||
border-top: 2px solid rgba(0, 0, 0, .3);
|
border-top: 2px solid rgba(0, 0, 0, .3);
|
||||||
@@ -2051,19 +2051,19 @@ body > div.container-fluid > div > div.col-sm-10 > div.container1 {
|
|||||||
max-width: calc(80% - 30px)
|
max-width: calc(80% - 30px)
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.container1 > div {
|
body > div.container-fluid > div > div.col-sm-10 > div.container > div {
|
||||||
padding: 0
|
padding: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row {
|
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row {
|
||||||
padding: 4px 0
|
padding: 4px 0
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row:nth-of-type(even) {
|
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row:nth-of-type(even) {
|
||||||
background: hsla(0, 0%, 100%, .02)
|
background: hsla(0, 0%, 100%, .02)
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row > .col-xs-6 {
|
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row > .col-xs-6 {
|
||||||
width: calc(91.666% - 30px);
|
width: calc(91.666% - 30px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-o-text-overflow: ellipsis;
|
-o-text-overflow: ellipsis;
|
||||||
@@ -2072,7 +2072,7 @@ body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row >
|
|||||||
padding-left: 60px
|
padding-left: 60px
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row > .col-xs-6 > a {
|
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row > .col-xs-6 > a {
|
||||||
-o-text-overflow: ellipsis;
|
-o-text-overflow: ellipsis;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -2082,7 +2082,7 @@ body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row >
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1 {
|
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container {
|
||||||
width: calc(80% - 30px);
|
width: calc(80% - 30px);
|
||||||
margin: 120px 0 30px 20%;
|
margin: 120px 0 30px 20%;
|
||||||
border-top: 2px solid rgba(0, 0, 0, .3);
|
border-top: 2px solid rgba(0, 0, 0, .3);
|
||||||
@@ -2091,19 +2091,19 @@ body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1
|
|||||||
max-width: calc(80% - 30px)
|
max-width: calc(80% - 30px)
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1 > div {
|
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container > div {
|
||||||
padding: 0
|
padding: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1 > div > .row {
|
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container > div > .row {
|
||||||
padding: 4px 0
|
padding: 4px 0
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1 > div > .row:nth-of-type(even) {
|
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container > div > .row:nth-of-type(even) {
|
||||||
background: hsla(0, 0%, 100%, .02)
|
background: hsla(0, 0%, 100%, .02)
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1 > div > .row > .col-xs-6 {
|
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container > div > .row > .col-xs-6 {
|
||||||
width: calc(91.666% - 30px);
|
width: calc(91.666% - 30px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-o-text-overflow: ellipsis;
|
-o-text-overflow: ellipsis;
|
||||||
@@ -2112,7 +2112,7 @@ body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1
|
|||||||
padding-left: 60px
|
padding-left: 60px
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container1 > div > .row > .col-xs-6 > a {
|
body > div.container-fluid > div > div.col-sm-10 > div.discover > div.container > div > .row > .col-xs-6 > a {
|
||||||
-o-text-overflow: ellipsis;
|
-o-text-overflow: ellipsis;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -2146,6 +2146,7 @@ body.catlist > div.container-fluid > div.row-fluid > div.col-sm-10:before {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
body.authorlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.langlist > div.container-fluid > div > div.col-sm-10:before {
|
body.authorlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.langlist > div.container-fluid > div > div.col-sm-10:before {
|
||||||
font-family: 'Glyphicons Halflings', serif;
|
font-family: 'Glyphicons Halflings', serif;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -2171,12 +2172,10 @@ body.authorlist > div.container-fluid > div.row-fluid > div.col-sm-10:before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.langlist > div.container-fluid > div > div.col-sm-10:before {
|
body.langlist > div.container-fluid > div > div.col-sm-10:before {
|
||||||
content: "\e034";
|
content: "\e034"
|
||||||
top: 240px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container:before, body.catlist > div.container-fluid > div > div.col-sm-10 > div.container:before, body.langlist > div.container-fluid > div > div.col-sm-10 > div.container:before, body.me > div.container-fluid > div > div.col-sm-10 > div.discover:before, body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.downloadlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.catlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.langlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.me > div.container-fluid > div > div.col-sm-10 > div.discover:before, body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
|
||||||
top: 60px;
|
top: 60px;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
color: #eee;
|
color: #eee;
|
||||||
@@ -2187,104 +2186,63 @@ body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container1:bef
|
|||||||
font-weight: 400
|
font-weight: 400
|
||||||
}
|
}
|
||||||
|
|
||||||
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
content: "Authors";
|
content: "Authors";
|
||||||
top: 115px;
|
top: 115px;
|
||||||
display: table;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 992px) {
|
@media screen and (max-width: 992px) {
|
||||||
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
top: 60px;
|
top: 60px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 992px) {
|
body.catlist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
body.downloadlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
|
||||||
top: 60px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body.downloadlist > div.container-fluid > div.row-fluid > div.col-sm-10:before {
|
|
||||||
font-family: Glyphicons Halflings, serif;
|
|
||||||
content: "\E026";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1;
|
|
||||||
font-size: 6vw;
|
|
||||||
position: fixed;
|
|
||||||
left: 240px;
|
|
||||||
top: 240px;
|
|
||||||
width: calc(20% - 55px);
|
|
||||||
text-align: center
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@media screen and (max-width: 992px) {
|
|
||||||
body.downloadlist > div.container-fluid > div.row-fluid > div.col-sm-10:before {
|
|
||||||
top: 180px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body.downloadlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
|
||||||
content: "Downloads";
|
|
||||||
top: 115px;
|
|
||||||
display: table;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
body.catlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
|
||||||
content: "Categories";
|
content: "Categories";
|
||||||
top: 115px;
|
top: 115px;
|
||||||
display: table;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 992px) {
|
@media screen and (max-width: 992px) {
|
||||||
body.catlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.catlist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
top: 60px;
|
top: 60px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
content: "Series";
|
content: "Series";
|
||||||
top: 115px;
|
top: 115px;
|
||||||
display: table;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 992px) {
|
@media screen and (max-width: 992px) {
|
||||||
body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
top: 60px;
|
top: 60px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
content: "File formats";
|
content: "File formats";
|
||||||
top: 115px;
|
top: 115px;
|
||||||
display: table;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 992px) {
|
@media screen and (max-width: 992px) {
|
||||||
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
top: 60px;
|
top: 60px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
content: "File formats";
|
content: "File formats";
|
||||||
top: 115px;
|
top: 115px;
|
||||||
display: table;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 992px) {
|
@media screen and (max-width: 992px) {
|
||||||
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
top: 60px;
|
top: 60px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.langlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.langlist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
content: "Languages";
|
content: "Languages"
|
||||||
top: 115px;
|
|
||||||
display: table;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body.advsearch > div.container-fluid > div > div.col-sm-10 > div.col-md-10.col-lg-6 {
|
body.advsearch > div.container-fluid > div > div.col-sm-10 > div.col-md-10.col-lg-6 {
|
||||||
@@ -3310,10 +3268,6 @@ 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;
|
||||||
@@ -4379,7 +4333,6 @@ 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 {
|
||||||
@@ -6463,7 +6416,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
|||||||
display: none !important
|
display: none !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.shelforder > .container-fluid > .row-fluid > .col-sm-10 > div.col-sm-6.col-lg-6.col-xs-6 > h2:before, body.advsearch > div.container-fluid > div > div.col-sm-10:before, body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.catlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.langlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.downloadlist > div.container-fluid > div > div.col-sm-10 > div.container1:before, body.login .navbar > .container-fluid > .navbar-header > button.navbar-toggle, body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
.shelforder > .container-fluid > .row-fluid > .col-sm-10 > div.col-sm-6.col-lg-6.col-xs-6 > h2:before, body.advsearch > div.container-fluid > div > div.col-sm-10:before, body.authorlist > div.container-fluid > div > div.col-sm-10 > div.container:before, body.catlist > div.container-fluid > div > div.col-sm-10 > div.container:before, body.langlist > div.container-fluid > div > div.col-sm-10 > div.container:before, body.login .navbar > .container-fluid > .navbar-header > button.navbar-toggle, body.serieslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
display: none
|
display: none
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6569,7 +6522,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
|||||||
background-size: 30px
|
background-size: 30px
|
||||||
}
|
}
|
||||||
|
|
||||||
body.advanced_search > div.container-fluid > div > div.col-sm-10 > div.col-sm-8:before, body.author:not(.authorlist) .undefined-img:before, body.authorlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.catlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.downloadlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.langlist > div.container-fluid > div > div.col-sm-10:before, body.ratingslist > div.container-fluid > div > div.col-sm-10:before, body.serieslist > div.container-fluid > div > div.col-sm-10:before, body.shelforder > div.container-fluid > div.row-fluid > div.col-sm-10:before {
|
body.advanced_search > div.container-fluid > div > div.col-sm-10 > div.col-sm-8:before, body.author:not(.authorlist) .undefined-img:before, body.authorlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.catlist > div.container-fluid > div.row-fluid > div.col-sm-10:before, body.langlist > div.container-fluid > div > div.col-sm-10:before, body.ratingslist > div.container-fluid > div > div.col-sm-10:before, body.serieslist > div.container-fluid > div > div.col-sm-10:before, body.shelforder > div.container-fluid > div.row-fluid > div.col-sm-10:before {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 62px;
|
top: 62px;
|
||||||
right: 23px;
|
right: 23px;
|
||||||
@@ -6589,7 +6542,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
|||||||
border-top: 1px solid hsla(0, 0%, 78%, .15)
|
border-top: 1px solid hsla(0, 0%, 78%, .15)
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.container1 {
|
body > div.container-fluid > div > div.col-sm-10 > div.container {
|
||||||
left: 0;
|
left: 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
@@ -6716,7 +6669,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
|||||||
margin-bottom: 5px
|
margin-bottom: 5px
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row {
|
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row {
|
||||||
padding: 0
|
padding: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7195,11 +7148,12 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > div.col-sm-3, body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > div.col-sm-3 {
|
body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > div.col-sm-3, body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > div.col-sm-3 {
|
||||||
position: relative;
|
max-width: 130px;
|
||||||
max-width: unset;
|
width: 130px;
|
||||||
width: 100%;
|
height: 180px;
|
||||||
height: unset;
|
margin: 0;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
|
position: absolute
|
||||||
}
|
}
|
||||||
|
|
||||||
body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9, body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9 {
|
body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9, body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9 {
|
||||||
@@ -7208,6 +7162,10 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
|||||||
width: 100%
|
width: 100%
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9 > .form-group:nth-child(1), body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9 > .form-group:nth-child(2), body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9 > .form-group:nth-child(1), body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > form > div.col-sm-9 > .form-group:nth-child(2) {
|
||||||
|
padding-left: 120px
|
||||||
|
}
|
||||||
|
|
||||||
#deleteButton, body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > div.col-sm-3 > div.text-center > #delete, body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > div.col-sm-3 > div.text-center > #delete {
|
#deleteButton, body.editbook > div.container-fluid > div.row-fluid > div.col-sm-10 > div.col-sm-3 > div.text-center > #delete, body.upload > div.container-fluid > div.row-fluid > div.col-sm-10 > div.col-sm-3 > div.text-center > #delete {
|
||||||
top: 48px;
|
top: 48px;
|
||||||
height: 42px
|
height: 42px
|
||||||
@@ -7483,11 +7441,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
|||||||
text-decoration: none
|
text-decoration: none
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row:nth-of-type(even) {
|
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row:nth-of-type(even) {
|
||||||
background: 0 0
|
background: 0 0
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div > div.col-sm-10 > div.container1 > div > .row {
|
body > div.container-fluid > div > div.col-sm-10 > div.container > div > .row {
|
||||||
border-bottom: 1px solid hsla(0, 0%, 78%, .15)
|
border-bottom: 1px solid hsla(0, 0%, 78%, .15)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7642,7 +7600,7 @@ body.formatslist > div.container-fluid > div > div.col-sm-10:before {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
content: "Publishers";
|
content: "Publishers";
|
||||||
top: 115px;
|
top: 115px;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@@ -7650,18 +7608,17 @@ body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container1:
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding-left: 15px;
|
padding-left: 15px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: table;
|
|
||||||
font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif;
|
font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||||
font-weight: 400
|
font-weight: 400
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 992px) {
|
@media screen and (max-width: 992px) {
|
||||||
body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
top: 60px;
|
top: 60px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
content: "Ratings";
|
content: "Ratings";
|
||||||
top: 115px;
|
top: 115px;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@@ -7669,24 +7626,23 @@ body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container1:be
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding-left: 15px;
|
padding-left: 15px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: table;
|
|
||||||
font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif;
|
font-family: Open Sans Bold, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||||
font-weight: 400
|
font-weight: 400
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 992px) {
|
@media screen and (max-width: 992px) {
|
||||||
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
top: 60px;
|
top: 60px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.ratingslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
content: "File formats";
|
content: "File formats";
|
||||||
top: 115px;
|
top: 115px;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@@ -7699,18 +7655,18 @@ body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container1:be
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 992px) {
|
@media screen and (max-width: 992px) {
|
||||||
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
top: 60px;
|
top: 60px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.formatslist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container1 > div.col-sm-6 {
|
body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container > div.col-sm-6 {
|
||||||
width: 100%
|
width: 100%
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7749,7 +7705,7 @@ body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container1
|
|||||||
color: hsla(0, 0%, 100%, .7)
|
color: hsla(0, 0%, 100%, .7)
|
||||||
}
|
}
|
||||||
|
|
||||||
body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container1:before {
|
body.publisherlist > div.container-fluid > div > div.col-sm-10 > div.container:before {
|
||||||
display: none !important
|
display: none !important
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7995,5 +7951,3 @@ div.comments[data-readmore] {
|
|||||||
transition: height 300ms;
|
transition: height 300ms;
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu > .offscreen
|
|
||||||
|
|||||||
@@ -26,14 +26,3 @@ body.serieslist.grid-view div.container-fluid > div > div.col-sm-10::before {
|
|||||||
input.datepicker {color: transparent}
|
input.datepicker {color: transparent}
|
||||||
input.datepicker:focus {color: transparent}
|
input.datepicker:focus {color: transparent}
|
||||||
input.datepicker:focus + input {color: #555}
|
input.datepicker:focus + input {color: #555}
|
||||||
|
|
||||||
.col-sm-3.col-lg-2.col-xs-6.book.session {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 767px) {
|
|
||||||
.row-fluid > .col-sm-2 {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,12 +13,7 @@
|
|||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amberTheme {
|
|
||||||
background: #e8bf5e;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blackTheme {
|
.blackTheme {
|
||||||
background: #000;
|
background: #000;
|
||||||
color: #fff;
|
color: #fff
|
||||||
}
|
}
|
||||||
Vendored
+1
@@ -157,6 +157,7 @@ fieldset[disabled] .twitter-typeahead .tt-input {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #cccccc;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
|
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
|
||||||
|
|||||||
+84
-169
@@ -1,10 +1,11 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: "fontello";
|
font-family: 'fontello';
|
||||||
src: url("fonts/fontello.eot?60518104");
|
src: url('fonts/fontello.eot?60518104');
|
||||||
src: url("fonts/fontello.eot?60518104#iefix") format("embedded-opentype"),
|
src:
|
||||||
url("fonts/fontello.woff?60518104") format("woff"),
|
url('fonts/fontello.eot?60518104#iefix') format('embedded-opentype'),
|
||||||
url("fonts/fontello.ttf?60518104") format("truetype"),
|
url('fonts/fontello.woff?60518104') format('woff'),
|
||||||
url("fonts/fontello.svg?60518104#fontello") format("svg");
|
url('fonts/fontello.ttf?60518104') format('truetype'),
|
||||||
|
url('fonts/fontello.svg?60518104#fontello') format('svg');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
@@ -15,7 +16,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.myselect {
|
.myselect {
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#main {
|
#main {
|
||||||
@@ -47,7 +48,7 @@ body {
|
|||||||
-webkit-transition: opacity 0.5s;
|
-webkit-transition: opacity 0.5s;
|
||||||
-moz-transition: opacity 0.5s;
|
-moz-transition: opacity 0.5s;
|
||||||
transition: opacity 0.5s;
|
transition: opacity 0.5s;
|
||||||
z-index: 100;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
#titlebar:hover {
|
#titlebar:hover {
|
||||||
@@ -76,6 +77,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#panels a {
|
#panels a {
|
||||||
|
visibility: hidden;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -92,18 +94,14 @@ body {
|
|||||||
box-shadow: inset 0 0 6px rgba(155, 155, 155, 0.8);
|
box-shadow: inset 0 0 6px rgba(155, 155, 155, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
#book-title {
|
#book-title { font-weight: 600; }
|
||||||
font-weight: 600;
|
#title-seperator { display: none; }
|
||||||
}
|
|
||||||
#title-seperator {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#viewer {
|
#viewer {
|
||||||
width: 80%;
|
width: 80%;
|
||||||
height: 80%;
|
height: 80%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
z-index: 0;
|
z-index: 2;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -139,7 +137,6 @@ body {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
padding-top: 160px;
|
padding-top: 160px;
|
||||||
padding-bottom: 160px;
|
padding-bottom: 160px;
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrow:hover {
|
.arrow:hover {
|
||||||
@@ -204,16 +201,10 @@ body {
|
|||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#title-controls {
|
#title-controls { float: right; }
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
#panels a::before {
|
#panels a::before { visibility: visible; }
|
||||||
visibility: visible;
|
#panels a:hover { color: #aaa; }
|
||||||
}
|
|
||||||
#panels a:hover {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list_item.currentChapter > a,
|
.list_item.currentChapter > a,
|
||||||
.list_item a:hover {
|
.list_item a:hover {
|
||||||
@@ -247,12 +238,8 @@ body {
|
|||||||
margin-top: -1px;
|
margin-top: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input::-webkit-input-placeholder {
|
input::-webkit-input-placeholder { color: #454545; }
|
||||||
color: #454545;
|
input:-moz-placeholder { color: #454545; }
|
||||||
}
|
|
||||||
input:-moz-placeholder {
|
|
||||||
color: #454545;
|
|
||||||
}
|
|
||||||
|
|
||||||
#divider {
|
#divider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -392,14 +379,14 @@ input:-moz-placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toc_toggle::before {
|
.toc_toggle::before {
|
||||||
content: "▸";
|
content: '▸';
|
||||||
color: #fff;
|
color: #fff;
|
||||||
margin-right: -4px;
|
margin-right: -4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.currentChapter > .toc_toggle::before,
|
.currentChapter > .toc_toggle::before,
|
||||||
.openChapter > .toc_toggle::before {
|
.openChapter > .toc_toggle::before {
|
||||||
content: "▾";
|
content: '▾';
|
||||||
}
|
}
|
||||||
|
|
||||||
.view {
|
.view {
|
||||||
@@ -513,36 +500,24 @@ input:-moz-placeholder {
|
|||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#settingsPanel .xsmall {
|
#settingsPanel .xsmall { font-size: x-small; }
|
||||||
font-size: x-small;
|
#settingsPanel .small { font-size: small; }
|
||||||
}
|
#settingsPanel .medium { font-size: medium; }
|
||||||
#settingsPanel .small {
|
#settingsPanel .large { font-size: large; }
|
||||||
font-size: small;
|
#settingsPanel .xlarge { font-size: x-large; }
|
||||||
}
|
|
||||||
#settingsPanel .medium {
|
|
||||||
font-size: medium;
|
|
||||||
}
|
|
||||||
#settingsPanel .large {
|
|
||||||
font-size: large;
|
|
||||||
}
|
|
||||||
#settingsPanel .xlarge {
|
|
||||||
font-size: x-large;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight {
|
.highlight { background-color: yellow; }
|
||||||
background-color: yellow;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
width: 630px;
|
||||||
width: 97vw;
|
height: auto;
|
||||||
height: 90dvh;
|
|
||||||
max-width: 630px;
|
|
||||||
z-index: 2000;
|
z-index: 2000;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
|
margin-left: -320px;
|
||||||
|
margin-top: -160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -565,7 +540,7 @@ input:-moz-placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.md-show ~ .overlay {
|
.md-show ~ .overlay {
|
||||||
/* opacity: 1; */
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +555,7 @@ input:-moz-placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.md-content > div {
|
.md-content > div {
|
||||||
padding: 10px 20px;
|
padding: 15px 40px 30px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -607,7 +582,6 @@ input:-moz-placeholder {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content .themes button.lightTheme,
|
|
||||||
.md-content .themes button.whiteTheme {
|
.md-content .themes button.whiteTheme {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
color: black;
|
color: black;
|
||||||
@@ -618,11 +592,6 @@ input:-moz-placeholder {
|
|||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content .themes button.amberTheme {
|
|
||||||
background-color: #e8bf5e;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-content .themes button.blackTheme {
|
.md-content .themes button.blackTheme {
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -670,47 +639,31 @@ input:-moz-placeholder {
|
|||||||
|
|
||||||
@media only screen and (max-width: 900px) {
|
@media only screen and (max-width: 900px) {
|
||||||
#viewer {
|
#viewer {
|
||||||
width: 100%;
|
width: 60%;
|
||||||
height: calc(100dvh - 99px);
|
margin-left: 20%;
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#viewer iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#prev {
|
#prev {
|
||||||
left: 20px;
|
left: 20px;
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#next {
|
#next {
|
||||||
right: 20px;
|
right: 20px;
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 550px) {
|
@media only screen and (max-width: 550px) {
|
||||||
#viewer {
|
#viewer {
|
||||||
width: 100%;
|
width: 80%;
|
||||||
height: calc(100dvh - 99px);
|
margin-left: 10%;
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#viewer iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#prev {
|
#prev {
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#next {
|
#next {
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrow {
|
.arrow {
|
||||||
@@ -760,7 +713,11 @@ input:-moz-placeholder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: landscape) /* and (-webkit-min-device-pixel-ratio: 2) */ {
|
@media only screen
|
||||||
|
and (min-device-width: 768px)
|
||||||
|
and (max-device-width: 1024px)
|
||||||
|
and (orientation: landscape)
|
||||||
|
/* and (-webkit-min-device-pixel-ratio: 2)*/ {
|
||||||
#viewer {
|
#viewer {
|
||||||
width: 80%;
|
width: 80%;
|
||||||
margin-left: 10%;
|
margin-left: 10%;
|
||||||
@@ -783,52 +740,47 @@ input:-moz-placeholder {
|
|||||||
/* For iPhone 6\6s portrait layouts only */
|
/* For iPhone 6\6s portrait layouts only */
|
||||||
@media only screen and (min-device-width: 375px) and (max-device-width: 667px) and (orientation: portrait) {
|
@media only screen and (min-device-width: 375px) and (max-device-width: 667px) and (orientation: portrait) {
|
||||||
#viewer {
|
#viewer {
|
||||||
width: 100%;
|
width: 300px;
|
||||||
height: calc(100dvh - 99px);
|
height: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#viewer iframe {
|
#viewer iframe {
|
||||||
width: 100%;
|
width: 300px;
|
||||||
height: 100%;
|
height: 480px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For iPhone 6\6s landscape layouts only */
|
/* For iPhone 6\6s landscape layouts only */
|
||||||
@media only screen and (min-device-width: 375px) and (max-device-width: 667px) and (orientation: landscape) {
|
@media only screen and (min-device-width: 375px) and (max-device-width: 667px) and (orientation: landscape) {
|
||||||
#viewer {
|
#viewer {
|
||||||
width: 100%;
|
width: 450px;
|
||||||
height: calc(100dvh - 99px);
|
height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#viewer iframe {
|
#viewer iframe {
|
||||||
width: 100%;
|
width: 450px;
|
||||||
height: 100%;
|
height: 300px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For iPhone portrait layouts only */
|
/* For iPhone portrait layouts only */
|
||||||
@media only screen and (max-device-width: 374px) and (orientation: portrait) {
|
@media only screen and (max-device-width: 374px) and (orientation: portrait) {
|
||||||
#viewer {
|
#viewer {
|
||||||
width: 100%;
|
width: 256px;
|
||||||
height: calc(100dvh - 99px);
|
height: 432px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#viewer iframe {
|
#viewer iframe {
|
||||||
width: 100%;
|
width: 256px;
|
||||||
height: 100%;
|
height: 432px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For iPhone landscape layouts only */
|
/* For iPhone landscape layouts only */
|
||||||
@media only screen and (max-device-width: 374px) and (orientation: landscape) {
|
@media only screen and (max-device-width: 374px) and (orientation: landscape) {
|
||||||
#viewer {
|
|
||||||
width: 100%;
|
|
||||||
height: calc(100dvh - 99px);
|
|
||||||
}
|
|
||||||
|
|
||||||
#viewer iframe {
|
#viewer iframe {
|
||||||
width: 100%;
|
width: 256px;
|
||||||
height: 100%;
|
height: 124px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,69 +804,32 @@ input:-moz-placeholder {
|
|||||||
font-size: 112%;
|
font-size: 112%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-search::before {
|
.icon-search::before { content: '\e807'; } /* '' */
|
||||||
content: "\e807";
|
.icon-resize-full-1::before { content: '\e804'; } /* '' */
|
||||||
} /* '' */
|
.icon-cancel-circled2::before { content: '\e80f'; } /* '' */
|
||||||
.icon-resize-full-1::before {
|
.icon-link::before { content: '\e80d'; } /* '' */
|
||||||
content: "\e804";
|
.icon-bookmark::before { content: '\e805'; } /* '' */
|
||||||
} /* '' */
|
.icon-bookmark-empty::before { content: '\e806'; } /* '' */
|
||||||
.icon-cancel-circled2::before {
|
.icon-download-cloud::before { content: '\e811'; } /* '' */
|
||||||
content: "\e80f";
|
.icon-edit::before { content: '\e814'; } /* '' */
|
||||||
} /* '' */
|
.icon-menu::before { content: '\e802'; } /* '' */
|
||||||
.icon-link::before {
|
.icon-cog::before { content: '\e813'; } /* '' */
|
||||||
content: "\e80d";
|
.icon-resize-full::before { content: '\e812'; } /* '' */
|
||||||
} /* '' */
|
.icon-cancel-circled::before { content: '\e80e'; } /* '' */
|
||||||
.icon-bookmark::before {
|
.icon-up-dir::before { content: '\e80c'; } /* '' */
|
||||||
content: "\e805";
|
.icon-right-dir::before { content: '\e80b'; } /* '' */
|
||||||
} /* '' */
|
.icon-angle-right::before { content: '\e809'; } /* '' */
|
||||||
.icon-bookmark-empty::before {
|
.icon-angle-down::before { content: '\e80a'; } /* '' */
|
||||||
content: "\e806";
|
.icon-right::before { content: '\e815'; } /* '' */
|
||||||
} /* '' */
|
.icon-list-1::before { content: '\e803'; } /* '' */
|
||||||
.icon-download-cloud::before {
|
.icon-list-numbered::before { content: '\e801'; } /* '' */
|
||||||
content: "\e811";
|
.icon-columns::before { content: '\e810'; } /* '' */
|
||||||
} /* '' */
|
.icon-list::before { content: '\e800'; } /* '' */
|
||||||
.icon-edit::before {
|
.icon-resize-small::before { content: '\e808'; } /* '' */
|
||||||
content: "\e814";
|
|
||||||
} /* '' */
|
#progress{
|
||||||
.icon-menu::before {
|
right: 4rem;
|
||||||
content: "\e802";
|
bottom: 4rem;
|
||||||
} /* '' */
|
width: fit-content;
|
||||||
.icon-cog::before {
|
position: absolute;
|
||||||
content: "\e813";
|
}
|
||||||
} /* '' */
|
|
||||||
.icon-resize-full::before {
|
|
||||||
content: "\e812";
|
|
||||||
} /* '' */
|
|
||||||
.icon-cancel-circled::before {
|
|
||||||
content: "\e80e";
|
|
||||||
} /* '' */
|
|
||||||
.icon-up-dir::before {
|
|
||||||
content: "\e80c";
|
|
||||||
} /* '' */
|
|
||||||
.icon-right-dir::before {
|
|
||||||
content: "\e80b";
|
|
||||||
} /* '' */
|
|
||||||
.icon-angle-right::before {
|
|
||||||
content: "\e809";
|
|
||||||
} /* '' */
|
|
||||||
.icon-angle-down::before {
|
|
||||||
content: "\e80a";
|
|
||||||
} /* '' */
|
|
||||||
.icon-right::before {
|
|
||||||
content: "\e815";
|
|
||||||
} /* '' */
|
|
||||||
.icon-list-1::before {
|
|
||||||
content: "\e803";
|
|
||||||
} /* '' */
|
|
||||||
.icon-list-numbered::before {
|
|
||||||
content: "\e801";
|
|
||||||
} /* '' */
|
|
||||||
.icon-columns::before {
|
|
||||||
content: "\e810";
|
|
||||||
} /* '' */
|
|
||||||
.icon-list::before {
|
|
||||||
content: "\e800";
|
|
||||||
} /* '' */
|
|
||||||
.icon-resize-small::before {
|
|
||||||
content: "\e808";
|
|
||||||
} /* '' */
|
|
||||||
|
|||||||
+34
-68
@@ -1,81 +1,47 @@
|
|||||||
.fontSizeWrapper {
|
.fontSizeWrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.slider {
|
.slider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(0, -50%);
|
transform: translate(0,-50%);
|
||||||
width: 90%;
|
width: 90%;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-shadow: 0px 15px 40px #7e6d5766;
|
box-shadow: 0px 15px 40px #7E6D5766;
|
||||||
}
|
}
|
||||||
.slider label {
|
.slider label {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-family: Open Sans;
|
font-family: Open Sans;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.slider input[type="range"] {
|
.slider input[type="range"] {
|
||||||
width: 80%;
|
width: 80%;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
background: black;
|
background: black;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
font-size: 14px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 400;
|
||||||
font-family: Open Sans;
|
font-family: Open Sans;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item ~ button {
|
.item~button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border: none;
|
border: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-top: 5%;
|
margin-top: 5%;
|
||||||
margin-right: 1%;
|
margin-right: 1%;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
|
||||||
|
|
||||||
#main,
|
|
||||||
#viewer,
|
|
||||||
#sidebar,
|
|
||||||
#tocView {
|
|
||||||
touch-action: pan-y;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
.read-footer #pages-count {
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
left: 12px;
|
|
||||||
bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#back-button {
|
|
||||||
width: 50px !important;
|
|
||||||
right: 50px;
|
|
||||||
// position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
#progress {
|
|
||||||
right: 4rem;
|
|
||||||
bottom: 2px;
|
|
||||||
width: fit-content;
|
|
||||||
position: absolute;
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
#add-to-shelves.dropdown-menu, #remove-from-shelves.dropdown-menu {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip.bottom .tooltip-inner {
|
.tooltip.bottom .tooltip-inner {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -433,9 +429,6 @@ div.log {
|
|||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-sm{
|
|
||||||
font-size: 70%;
|
|
||||||
}
|
|
||||||
#detailcover { cursor:zoom-in; }
|
#detailcover { cursor:zoom-in; }
|
||||||
#detailcover:-webkit-full-screen { cursor:zoom-out; border: 0; }
|
#detailcover:-webkit-full-screen { cursor:zoom-out; border: 0; }
|
||||||
#detailcover:-moz-full-screen { cursor:zoom-out; border: 0; }
|
#detailcover:-moz-full-screen { cursor:zoom-out; border: 0; }
|
||||||
@@ -445,43 +438,3 @@ div.log {
|
|||||||
.error-list {
|
.error-list {
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body > div.container-fluid > div.row-fluid > div.col-sm-10 {
|
|
||||||
max-height: calc(100vh - 111px);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
body > div.container-fluid > div.row-fluid > div.col-sm-2 {
|
|
||||||
max-height: calc(100vh - 111px);
|
|
||||||
overflow-y: auto;
|
|
||||||
/* hide scrollbar in IE, Edge */
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
|
|
||||||
/* hide scrollbar in Firefox */
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* hide scrollbar in WebKit browsers (Chrome, Safari, Opera) */
|
|
||||||
body > div.container-fluid > div.row-fluid > div.col-sm-2::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollable-col {
|
|
||||||
/* keep vertical scrolling */
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
/* hide scrollbar in IE, Edge */
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
|
|
||||||
/* hide scrollbar in Firefox */
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* hide scrollbar in WebKit browsers (Chrome, Safari, Opera) */
|
|
||||||
.scrollable-col::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
body > div.container-fluid {
|
|
||||||
padding-right: 0px;
|
|
||||||
}
|
|
||||||
|
|||||||
Regular → Executable
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
/* global Bloodhound, language, Modernizr, tinymce, getPath */
|
/* global Bloodhound, language, Modernizr, tinymce, getPath */
|
||||||
|
|
||||||
if ($("#comments").length && typeof tinymce !== "undefined") {
|
if ($("#comments").length) {
|
||||||
tinymce.init({
|
tinymce.init({
|
||||||
selector: "#comments",
|
selector: "#comments",
|
||||||
plugins: 'code',
|
plugins: 'code',
|
||||||
|
|||||||
@@ -103,11 +103,11 @@ $(function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$("#meta-info").html("<p class=\"text-danger\">" + msg.no_result + "</p>" + $("#meta-info")[0].innerHTML)
|
$("#meta-info").html("<p class=\"text-danger\">" + msg.no_result + "!</p>" + $("#meta-info")[0].innerHTML)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: function error() {
|
error: function error() {
|
||||||
$("#meta-info").html("<p class=\"text-danger\">" + msg.search_error + "</p>" + $("#meta-info")[0].innerHTML);
|
$("#meta-info").html("<p class=\"text-danger\">" + msg.search_error + "!</p>" + $("#meta-info")[0].innerHTML);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -1 +0,0 @@
|
|||||||
!function(a){a.fn.datepicker.dates.ar={days:["الأحد","الاثنين","الثلاثاء","الأربعاء","الخميس","الجمعة","السبت","الأحد"],daysShort:["أحد","اثنين","ثلاثاء","أربعاء","خميس","جمعة","سبت","أحد"],daysMin:["ح","ن","ث","ع","خ","ج","س","ح"],months:["يناير","فبراير","مارس","أبريل","مايو","يونيو","يوليو","أغسطس","سبتمبر","أكتوبر","نوفمبر","ديسمبر"],monthsShort:["يناير","فبراير","مارس","أبريل","مايو","يونيو","يوليو","أغسطس","سبتمبر","أكتوبر","نوفمبر","ديسمبر"],today:"هذا اليوم",rtl:!0}}(jQuery);
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
!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);
|
|
||||||
+1
-1
@@ -16667,7 +16667,7 @@ const PDFWorkerUtil = {
|
|||||||
{
|
{
|
||||||
if (isNodeJS) {
|
if (isNodeJS) {
|
||||||
PDFWorkerUtil.isWorkerDisabled = true;
|
PDFWorkerUtil.isWorkerDisabled = true;
|
||||||
GlobalWorkerOptions.workerSrc ||= "./pdf.worker.js";
|
GlobalWorkerOptions.workerSrc ||= "./pdf.worker.mjs";
|
||||||
}
|
}
|
||||||
PDFWorkerUtil.isSameOrigin = function (baseUrl, otherUrl) {
|
PDFWorkerUtil.isSameOrigin = function (baseUrl, otherUrl) {
|
||||||
let base;
|
let base;
|
||||||
-2
File diff suppressed because one or more lines are too long
-3
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -183,7 +183,7 @@ try {
|
|||||||
];
|
];
|
||||||
$.each(sequences, function(ignore, sequence) {
|
$.each(sequences, function(ignore, sequence) {
|
||||||
for (j = 0; j < word.length - 2; j += 1) {
|
for (j = 0; j < word.length - 2; j += 1) {
|
||||||
// iterate the word through a sliding window of size 3:
|
// iterate the word trough a sliding window of size 3:
|
||||||
if (
|
if (
|
||||||
sequence.indexOf(
|
sequence.indexOf(
|
||||||
word.toLowerCase().substring(j, j + 3)
|
word.toLowerCase().substring(j, j + 3)
|
||||||
|
|||||||
@@ -828,7 +828,7 @@ const defaultOptions = {
|
|||||||
kind: OptionKind.WORKER
|
kind: OptionKind.WORKER
|
||||||
},
|
},
|
||||||
workerSrc: {
|
workerSrc: {
|
||||||
value: "../build/pdf.worker.js",
|
value: "../build/pdf.worker.mjs",
|
||||||
kind: OptionKind.WORKER
|
kind: OptionKind.WORKER
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
+24
-26
@@ -49,7 +49,7 @@ function elementSorter(a, b) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic control/related handler to show/hide fields based on a 'checkbox' value
|
// Generic control/related handler to show/hide fields based on a checkbox' value
|
||||||
// e.g.
|
// e.g.
|
||||||
// <input type="checkbox" data-control="stuff-to-show">
|
// <input type="checkbox" data-control="stuff-to-show">
|
||||||
// <div data-related="stuff-to-show">...</div>
|
// <div data-related="stuff-to-show">...</div>
|
||||||
@@ -63,7 +63,7 @@ $(document).on("change", "input[type=\"checkbox\"][data-control]", function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generic control/related handler to show/hide fields based on a 'select' value
|
// Generic control/related handler to show/hide fields based on a select' value
|
||||||
$(document).on("change", "select[data-control]", function() {
|
$(document).on("change", "select[data-control]", function() {
|
||||||
var $this = $(this);
|
var $this = $(this);
|
||||||
var name = $this.data("control");
|
var name = $this.data("control");
|
||||||
@@ -79,7 +79,7 @@ $(document).on("change", "select[data-control]", function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generic control/related handler to show/hide fields based on a 'select' value
|
// Generic control/related handler to show/hide fields based on a select' value
|
||||||
// this one is made to show all values if select value is not 0
|
// this one is made to show all values if select value is not 0
|
||||||
$(document).on("change", "select[data-controlall]", function() {
|
$(document).on("change", "select[data-controlall]", function() {
|
||||||
var $this = $(this);
|
var $this = $(this);
|
||||||
@@ -229,12 +229,10 @@ $("#delete_confirm").click(function(event) {
|
|||||||
postButton(event, getPath() + "/delete/" + deleteId + "/" + bookFormat);
|
postButton(event, getPath() + "/delete/" + deleteId + "/" + bookFormat);
|
||||||
} else {
|
} else {
|
||||||
if (ajaxResponse) {
|
if (ajaxResponse) {
|
||||||
|
path = getPath() + "/ajax/delete/" + deleteId;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: getPath() + "/ajax/deletebook",
|
method:"post",
|
||||||
method: "post",
|
url: path,
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
data: JSON.stringify({"bookid": [deleteId]}),
|
|
||||||
timeout: 900,
|
timeout: 900,
|
||||||
success:function(data) {
|
success:function(data) {
|
||||||
data.forEach(function(item) {
|
data.forEach(function(item) {
|
||||||
@@ -410,17 +408,17 @@ $(function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// fix for infinite scroll on CaliBlur Theme (#981)
|
// fix for infinite scroll on CaliBlur Theme (#981)
|
||||||
|
if ($("body").hasClass("blur")) {
|
||||||
$(".col-sm-10").bind("scroll", function () {
|
$(".col-sm-10").bind("scroll", function () {
|
||||||
if (
|
if (
|
||||||
$(this).scrollTop() + $(this).innerHeight() >=
|
$(this).scrollTop() + $(this).innerHeight() >=
|
||||||
$(this)[0].scrollHeight
|
$(this)[0].scrollHeight
|
||||||
) {
|
) {
|
||||||
$loadMore.infiniteScroll("loadNextPage");
|
$loadMore.infiniteScroll("loadNextPage");
|
||||||
window.history.replaceState({}, null, $loadMore.infiniteScroll("getAbsolutePath"));
|
window.history.replaceState({}, null, $loadMore.infiniteScroll("getAbsolutePath"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#restart").click(function() {
|
$("#restart").click(function() {
|
||||||
@@ -728,20 +726,20 @@ $(function() {
|
|||||||
url: getPath() + "/ajax/simulatedbchange",
|
url: getPath() + "/ajax/simulatedbchange",
|
||||||
data: {config_calibre_dir: $("#config_calibre_dir").val(), csrf_token: $("input[name='csrf_token']").val()},
|
data: {config_calibre_dir: $("#config_calibre_dir").val(), csrf_token: $("input[name='csrf_token']").val()},
|
||||||
success: function success(data) {
|
success: function success(data) {
|
||||||
if ( !data.valid ) {
|
if ( data.change ) {
|
||||||
$("#InvalidDialog").modal('show');
|
if ( data.valid ) {
|
||||||
}
|
|
||||||
else{
|
|
||||||
if ( data.change ) {
|
|
||||||
confirmDialog(
|
confirmDialog(
|
||||||
"db_submit",
|
"db_submit",
|
||||||
"GeneralChangeModal",
|
"GeneralChangeModal",
|
||||||
0,
|
0,
|
||||||
changeDbSettings
|
changeDbSettings
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
|
$("#InvalidDialog").modal('show');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
changeDbSettings();
|
changeDbSettings();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
});
|
||||||
|
})
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
var reader;
|
var reader;
|
||||||
|
|
||||||
(function () {
|
(function() {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
EPUBJS.filePath = calibre.filePath;
|
EPUBJS.filePath = calibre.filePath;
|
||||||
@@ -10,7 +10,7 @@ var reader;
|
|||||||
|
|
||||||
reader = ePubReader(calibre.bookUrl, {
|
reader = ePubReader(calibre.bookUrl, {
|
||||||
restore: true,
|
restore: true,
|
||||||
bookmarks: calibre.bookmark ? [calibre.bookmark] : [],
|
bookmarks: calibre.bookmark ? [calibre.bookmark] : []
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.keys(themes).forEach(function (theme) {
|
Object.keys(themes).forEach(function (theme) {
|
||||||
@@ -52,95 +52,6 @@ var reader;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update progress percentage
|
|
||||||
let progressDiv = document.getElementById("progress");
|
|
||||||
// Pages counter (virtual pages via EPUB locations)
|
|
||||||
let pagesDiv = document.getElementById("pages-count");
|
|
||||||
// Honor saved visibility preference for pages counter
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var pref = localStorage.getItem("calibre.reader.showPages");
|
|
||||||
var show = pref === null ? true : pref === "true";
|
|
||||||
if (pagesDiv)
|
|
||||||
pagesDiv.style.visibility = show ? "visible" : "hidden";
|
|
||||||
} catch (e) {}
|
|
||||||
})();
|
|
||||||
|
|
||||||
reader.book.ready.then(() => {
|
|
||||||
let locations_key = reader.book.key() + "-locations";
|
|
||||||
// Key to persist last-read position for this book in localStorage
|
|
||||||
let position_key = "calibre.reader.position." + reader.book.key();
|
|
||||||
let stored_locations = localStorage.getItem(locations_key);
|
|
||||||
let make_locations, save_locations;
|
|
||||||
if (stored_locations) {
|
|
||||||
make_locations = Promise.resolve(
|
|
||||||
reader.book.locations.load(stored_locations)
|
|
||||||
);
|
|
||||||
// No-op because locations are already saved
|
|
||||||
save_locations = () => {};
|
|
||||||
} else {
|
|
||||||
make_locations = reader.book.locations.generate();
|
|
||||||
save_locations = () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
locations_key,
|
|
||||||
reader.book.locations.save()
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
make_locations
|
|
||||||
.then(() => {
|
|
||||||
// Try to restore last position (CFI) from localStorage if present
|
|
||||||
try {
|
|
||||||
var _savedPos = localStorage.getItem(position_key);
|
|
||||||
if (_savedPos) {
|
|
||||||
try {
|
|
||||||
var _posObj = JSON.parse(_savedPos);
|
|
||||||
if (_posObj && _posObj.cfi) {
|
|
||||||
// Display the saved CFI location
|
|
||||||
try {
|
|
||||||
reader.rendition.display(_posObj.cfi);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
reader.rendition.on("relocated", (location) => {
|
|
||||||
let percentage = Math.round(location.end.percentage * 100);
|
|
||||||
progressDiv.textContent = percentage + "%";
|
|
||||||
|
|
||||||
// Pages based on generated EPUB locations (CFI positions)
|
|
||||||
const cfi = location.start.cfi;
|
|
||||||
const current =
|
|
||||||
reader.book.locations.locationFromCfi(cfi) || 0; // 1-based index typically
|
|
||||||
const total = reader.book.locations.length() || 0;
|
|
||||||
|
|
||||||
if (total > 0) {
|
|
||||||
pagesDiv.textContent = current + "/" + total;
|
|
||||||
pagesDiv.style.visibility = "visible";
|
|
||||||
} else {
|
|
||||||
pagesDiv.textContent = "";
|
|
||||||
pagesDiv.style.visibility = "hidden";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist last position (CFI + percentage) to localStorage so reader can restore on next open
|
|
||||||
try {
|
|
||||||
var posObj = {
|
|
||||||
cfi: location.start.cfi,
|
|
||||||
percentage: location.start.percentage,
|
|
||||||
};
|
|
||||||
localStorage.setItem(
|
|
||||||
position_key,
|
|
||||||
JSON.stringify(posObj)
|
|
||||||
);
|
|
||||||
} catch (e) {}
|
|
||||||
});
|
|
||||||
reader.rendition.reportLocation();
|
|
||||||
progressDiv.style.visibility = "visible";
|
|
||||||
})
|
|
||||||
.then(save_locations);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} action - Add or remove bookmark
|
* @param {string} action - Add or remove bookmark
|
||||||
* @param {string|int} location - Location or zero
|
* @param {string|int} location - Location or zero
|
||||||
@@ -148,15 +59,11 @@ var reader;
|
|||||||
function updateBookmark(action, location) {
|
function updateBookmark(action, location) {
|
||||||
// Remove other bookmarks (there can only be one)
|
// Remove other bookmarks (there can only be one)
|
||||||
if (action === "add") {
|
if (action === "add") {
|
||||||
this.settings.bookmarks
|
this.settings.bookmarks.filter(function (bookmark) {
|
||||||
.filter(function (bookmark) {
|
return bookmark && bookmark !== location;
|
||||||
return bookmark && bookmark !== location;
|
}).map(function (bookmark) {
|
||||||
})
|
this.removeBookmark(bookmark);
|
||||||
.map(
|
}.bind(this));
|
||||||
function (bookmark) {
|
|
||||||
this.removeBookmark(bookmark);
|
|
||||||
}.bind(this)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var csrftoken = $("input[name='csrf_token']").val();
|
var csrftoken = $("input[name='csrf_token']").val();
|
||||||
@@ -165,7 +72,7 @@ var reader;
|
|||||||
$.ajax(calibre.bookmarkUrl, {
|
$.ajax(calibre.bookmarkUrl, {
|
||||||
method: "post",
|
method: "post",
|
||||||
data: { bookmark: location || "" },
|
data: { bookmark: location || "" },
|
||||||
headers: { "X-CSRFToken": csrftoken },
|
headers: { "X-CSRFToken": csrftoken }
|
||||||
}).fail(function (xhr, status, error) {
|
}).fail(function (xhr, status, error) {
|
||||||
alert(error);
|
alert(error);
|
||||||
});
|
});
|
||||||
@@ -174,17 +81,4 @@ var reader;
|
|||||||
// Default settings load
|
// Default settings load
|
||||||
const theme = localStorage.getItem("calibre.reader.theme") ?? "lightTheme";
|
const theme = localStorage.getItem("calibre.reader.theme") ?? "lightTheme";
|
||||||
selectTheme(theme);
|
selectTheme(theme);
|
||||||
|
|
||||||
// Restore saved font and font size after reader is ready
|
|
||||||
reader.book.ready.then(() => {
|
|
||||||
const savedFontSize = localStorage.getItem("calibre.reader.fontSize");
|
|
||||||
if (savedFontSize) {
|
|
||||||
reader.rendition.themes.fontSize(`${savedFontSize}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedFont = localStorage.getItem("calibre.reader.font");
|
|
||||||
if (savedFont && window.selectFont) {
|
|
||||||
window.selectFont(savedFont);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
+41
-455
@@ -81,78 +81,20 @@ $(function() {
|
|||||||
$("#merge_books").addClass("disabled");
|
$("#merge_books").addClass("disabled");
|
||||||
$("#merge_books").attr("aria-disabled", true);
|
$("#merge_books").attr("aria-disabled", true);
|
||||||
}
|
}
|
||||||
if (selections.length >= 1) {
|
|
||||||
$("#delete_selected_books").removeClass("disabled");
|
|
||||||
$("#delete_selected_books").attr("aria-disabled", false);
|
|
||||||
|
|
||||||
$("#archive_selected_books").removeClass("disabled");
|
|
||||||
$("#archive_selected_books").attr("aria-disabled", false);
|
|
||||||
|
|
||||||
$("#unarchive_selected_books").removeClass("disabled");
|
|
||||||
$("#unarchive_selected_books").attr("aria-disabled", false);
|
|
||||||
|
|
||||||
$("#read_selected_books").removeClass("disabled");
|
|
||||||
$("#read_selected_books").attr("aria-disabled", false);
|
|
||||||
|
|
||||||
$("#unread_selected_books").removeClass("disabled");
|
|
||||||
$("#unread_selected_books").attr("aria-disabled", false);
|
|
||||||
|
|
||||||
$("#edit_selected_books").removeClass("disabled");
|
|
||||||
$("#edit_selected_books").attr("aria-disabled", false);
|
|
||||||
} else {
|
|
||||||
$("#delete_selected_books").addClass("disabled");
|
|
||||||
$("#delete_selected_books").attr("aria-disabled", true);
|
|
||||||
|
|
||||||
$("#archive_selected_books").addClass("disabled");
|
|
||||||
$("#archive_selected_books").attr("aria-disabled", true);
|
|
||||||
|
|
||||||
$("#unarchive_selected_books").addClass("disabled");
|
|
||||||
$("#unarchive_selected_books").attr("aria-disabled", true);
|
|
||||||
|
|
||||||
$("#read_selected_books").addClass("disabled");
|
|
||||||
$("#read_selected_books").attr("aria-disabled", true);
|
|
||||||
|
|
||||||
$("#unread_selected_books").addClass("disabled");
|
|
||||||
$("#unread_selected_books").attr("aria-disabled", true);
|
|
||||||
|
|
||||||
$("#edit_selected_books").addClass("disabled");
|
|
||||||
$("#edit_selected_books").attr("aria-disabled", true);
|
|
||||||
}
|
|
||||||
if (selections.length < 1) {
|
if (selections.length < 1) {
|
||||||
// $("#book_delete_selection").addClass("disabled");
|
$("#delete_selection").addClass("disabled");
|
||||||
// $("#book_delete_selection").attr("aria-disabled", true);
|
$("#delete_selection").attr("aria-disabled", true);
|
||||||
$("#table_xchange").addClass("disabled");
|
$("#table_xchange").addClass("disabled");
|
||||||
$("#table_xchange").attr("aria-disabled", true);
|
$("#table_xchange").attr("aria-disabled", true);
|
||||||
} else {
|
} else {
|
||||||
// $("#book_delete_selection").removeClass("disabled");
|
$("#delete_selection").removeClass("disabled");
|
||||||
// $("#book_delete_selection").attr("aria-disabled", false);
|
$("#delete_selection").attr("aria-disabled", false);
|
||||||
$("#table_xchange").removeClass("disabled");
|
$("#table_xchange").removeClass("disabled");
|
||||||
$("#table_xchange").attr("aria-disabled", false);
|
$("#table_xchange").attr("aria-disabled", false);
|
||||||
|
|
||||||
}
|
}
|
||||||
handle_header_buttons();
|
|
||||||
});
|
});
|
||||||
|
$("#delete_selection").click(function() {
|
||||||
// Small block to initialize the state of the author/title sort inputs in metadata form
|
|
||||||
{
|
|
||||||
let checkA = $('#autoupdate_authorsort').prop('checked');
|
|
||||||
$('#author_sort_input').prop('disabled', checkA);
|
|
||||||
let checkT = $('#autoupdate_titlesort').prop('checked');
|
|
||||||
$('#title_sort_input').prop('disabled', checkT);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable/enable author and title sort input in respect to auto-update title/author sort being checked on or not
|
|
||||||
$("#autoupdate_authorsort").on('change', function(event) {
|
|
||||||
let checkA = $('#autoupdate_authorsort').prop('checked');
|
|
||||||
$('#author_sort_input').prop('disabled', checkA);
|
|
||||||
})
|
|
||||||
|
|
||||||
$("#autoupdate_titlesort").on('change', function(event) {
|
|
||||||
let checkT = $('#autoupdate_titlesort').prop('checked');
|
|
||||||
$('#title_sort_input').prop('disabled', checkT);
|
|
||||||
})
|
|
||||||
/////
|
|
||||||
|
|
||||||
$("#book_delete_selection").click(function () {
|
|
||||||
$("#books-table").bootstrapTable("uncheckAll");
|
$("#books-table").bootstrapTable("uncheckAll");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -161,7 +103,7 @@ $(function() {
|
|||||||
method:"post",
|
method:"post",
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
url: getPath() + "/ajax/mergebooks",
|
url: window.location.pathname + "/../ajax/mergebooks",
|
||||||
data: JSON.stringify({"Merge_books":selections}),
|
data: JSON.stringify({"Merge_books":selections}),
|
||||||
success: function success() {
|
success: function success() {
|
||||||
$("#books-table").bootstrapTable("refresh");
|
$("#books-table").bootstrapTable("refresh");
|
||||||
@@ -180,7 +122,7 @@ $(function() {
|
|||||||
method:"post",
|
method:"post",
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
url: getPath() + "/ajax/simulatemerge",
|
url: window.location.pathname + "/../ajax/simulatemerge",
|
||||||
data: JSON.stringify({"Merge_books":selections}),
|
data: JSON.stringify({"Merge_books":selections}),
|
||||||
success: function success(booTitles) {
|
success: function success(booTitles) {
|
||||||
$('#merge_from').empty();
|
$('#merge_from').empty();
|
||||||
@@ -193,256 +135,12 @@ $(function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#edit_selected_books").click(function(event) {
|
|
||||||
if ($(this).hasClass("disabled")) {
|
|
||||||
event.stopPropagation()
|
|
||||||
} else {
|
|
||||||
$('#edit_selected_modal').modal("show");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#edit_selected_confirm").click(function(event) {
|
|
||||||
$.ajax({
|
|
||||||
method:"post",
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
url: getPath() + "/ajax/editselectedbooks",
|
|
||||||
data: JSON.stringify({
|
|
||||||
"selections": selections,
|
|
||||||
"title": $("#title_input").val(),
|
|
||||||
"title_sort": $("#title_sort_input").val(),
|
|
||||||
"author_sort": $("#author_sort_input").val(),
|
|
||||||
"authors": $("#authors_input").val(),
|
|
||||||
"categories": $("#categories_input").val(),
|
|
||||||
"series": $("#series_input").val(),
|
|
||||||
"languages": $("#languages_input").val(),
|
|
||||||
"publishers": $("#publishers_input").val(),
|
|
||||||
"comments": $("#comments_input").val().toString(),
|
|
||||||
"checkA": $('#autoupdate_authorsort').prop('checked'),
|
|
||||||
"checkT": $('#autoupdate_titlesort').prop('checked')
|
|
||||||
}),
|
|
||||||
success: function success(data) {
|
|
||||||
let result = "";
|
|
||||||
$("#books-table").bootstrapTable("refresh");
|
|
||||||
$("#books-table").bootstrapTable("uncheckAll");
|
|
||||||
|
|
||||||
$("#title_input").val("");
|
|
||||||
$("#title_sort_input").val("");
|
|
||||||
$("#author_sort_input").val("");
|
|
||||||
$("#authors_input").val("");
|
|
||||||
$("#categories_input").val("");
|
|
||||||
$("#series_input").val("");
|
|
||||||
$("#languages_input").val("");
|
|
||||||
$("#publishers_input").val("");
|
|
||||||
$("#comments_input").val("");
|
|
||||||
|
|
||||||
$("#flash_success").remove();
|
|
||||||
$("#flash_danger").remove();
|
|
||||||
|
|
||||||
if (!jQuery.isEmptyObject(data)) {
|
|
||||||
data.forEach(function(item) {
|
|
||||||
if (item.success === true) {
|
|
||||||
result = "success";
|
|
||||||
} else {
|
|
||||||
result = "danger";
|
|
||||||
}
|
|
||||||
$(".navbar").after('<div class="row-fluid text-center">' +
|
|
||||||
'<div id="flash_' + result + '" class="alert alert-' + result + '">' + item.msg + '</div>' +
|
|
||||||
'</div>');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$(".table.table-striped").bootstrapTable("refresh");
|
|
||||||
// handleListServerResponse(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on('click', '#archive_selected_books', function(event) {
|
|
||||||
if ($(this).hasClass("disabled")) {
|
|
||||||
event.stopPropagation()
|
|
||||||
} else {
|
|
||||||
$('#archive_selected_modal').modal("show");
|
|
||||||
}
|
|
||||||
$.ajax({
|
|
||||||
method:"post",
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
url: getPath() + "/ajax/displayselectedbooks",
|
|
||||||
data: JSON.stringify({"selections":selections}),
|
|
||||||
success: function success(booTitles) {
|
|
||||||
$('#display-archive-selected-books').empty();
|
|
||||||
$.each(booTitles.books, function(i, item) {
|
|
||||||
$("<span>- " + item + "</span><p></p>").appendTo("#display-archive-selected-books");
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/*$(document).on('click', '#archive_selected_confirm', function(event) {
|
|
||||||
$.ajax({
|
|
||||||
method:"post",
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
url: getPath() + "/ajax/archiveselectedbooks",
|
|
||||||
data: JSON.stringify({"selections":selections, "archive": true}),
|
|
||||||
success: function success(booTitles) {
|
|
||||||
$("#books-table").bootstrapTable("refresh");
|
|
||||||
$("#books-table").bootstrapTable("uncheckAll");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on('click', '#unarchive_selected_books', function(event) {
|
|
||||||
if ($(this).hasClass("disabled")) {
|
|
||||||
event.stopPropagation()
|
|
||||||
} else {
|
|
||||||
$('#unarchive_selected_modal').modal("show");
|
|
||||||
}
|
|
||||||
$.ajax({
|
|
||||||
method:"post",
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
url: getPath() + "/ajax/displayselectedbooks",
|
|
||||||
data: JSON.stringify({"selections":selections}),
|
|
||||||
success: function success(booTitles) {
|
|
||||||
$('#display-unarchive-selected-books').empty();
|
|
||||||
$.each(booTitles.books, function(i, item) {
|
|
||||||
$("<span>- " + item + "</span><p></p>").appendTo("#display-unarchive-selected-books");
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on('click', '#unarchive_selected_confirm', function(event) {
|
|
||||||
$.ajax({
|
|
||||||
method:"post",
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
url: getPath() + "/ajax/archiveselectedbooks",
|
|
||||||
data: JSON.stringify({"selections":selections, "archive": false}),
|
|
||||||
success: function success(booTitles) {
|
|
||||||
$("#books-table").bootstrapTable("refresh");
|
|
||||||
$("#books-table").bootstrapTable("uncheckAll");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on('click', '#delete_selected_books', function(event) {
|
|
||||||
if ($(this).hasClass("disabled")) {
|
|
||||||
event.stopPropagation()
|
|
||||||
} else {
|
|
||||||
$('#delete_selected_modal').modal("show");
|
|
||||||
}
|
|
||||||
$.ajax({
|
|
||||||
method:"post",
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
url: getPath() + "/ajax/displayselectedbooks",
|
|
||||||
data: JSON.stringify({"selections":selections}),
|
|
||||||
success: function success(booTitles) {
|
|
||||||
$('#display-delete-selected-books').empty();
|
|
||||||
$.each(booTitles.books, function(i, item) {
|
|
||||||
$("<span>- " + item + "</span><p></p>").appendTo("#display-delete-selected-books");
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on('click', '#delete_selected_confirm', function(event) {
|
|
||||||
$.ajax({
|
|
||||||
method:"post",
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
url: getPath() + "/ajax/deleteselectedbooks",
|
|
||||||
data: JSON.stringify({"selections":selections}),
|
|
||||||
success: function success(booTitles) {
|
|
||||||
$("#books-table").bootstrapTable("refresh");
|
|
||||||
$("#books-table").bootstrapTable("uncheckAll");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on('click', '#read_selected_books', function(event) {
|
|
||||||
if ($(this).hasClass("disabled")) {
|
|
||||||
event.stopPropagation()
|
|
||||||
} else {
|
|
||||||
$('#read_selected_modal').modal("show");
|
|
||||||
}
|
|
||||||
$.ajax({
|
|
||||||
method:"post",
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
url: getPath() + "/ajax/displayselectedbooks",
|
|
||||||
data: JSON.stringify({"selections":selections}),
|
|
||||||
success: function success(booTitles) {
|
|
||||||
$('#display-read-selected-books').empty();
|
|
||||||
$.each(booTitles.books, function(i, item) {
|
|
||||||
$("<span>- " + item + "</span><p></p>").appendTo("#display-read-selected-books");
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on('click', '#read_selected_confirm', function(event) {
|
|
||||||
$.ajax({
|
|
||||||
method:"post",
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
url: getPath() + "/ajax/readselectedbooks",
|
|
||||||
data: JSON.stringify({"selections":selections, "markAsRead": true}),
|
|
||||||
success: function success(booTitles) {
|
|
||||||
$("#books-table").bootstrapTable("refresh");
|
|
||||||
$("#books-table").bootstrapTable("uncheckAll");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on('click', '#unread_selected_books', function(event) {
|
|
||||||
if ($(this).hasClass("disabled")) {
|
|
||||||
event.stopPropagation()
|
|
||||||
} else {
|
|
||||||
$('#unread_selected_modal').modal("show");
|
|
||||||
}
|
|
||||||
$.ajax({
|
|
||||||
method:"post",
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
url: getPath() + "/ajax/displayselectedbooks",
|
|
||||||
data: JSON.stringify({"selections":selections}),
|
|
||||||
success: function success(booTitles) {
|
|
||||||
$('#display-unread-selected-books').empty();
|
|
||||||
$.each(booTitles.books, function(i, item) {
|
|
||||||
$("<span>- " + item + "</span><p></p>").appendTo("#display-unread-selected-books");
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on('click', '#unread_selected_confirm', function(event) {
|
|
||||||
$.ajax({
|
|
||||||
method:"post",
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
url: getPath() + "/ajax/readselectedbooks",
|
|
||||||
data: JSON.stringify({"selections":selections, "markAsRead": false}),
|
|
||||||
success: function success(booTitles) {
|
|
||||||
$("#books-table").bootstrapTable("refresh");
|
|
||||||
$("#books-table").bootstrapTable("uncheckAll");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});*/
|
|
||||||
|
|
||||||
$("#table_xchange").click(function() {
|
$("#table_xchange").click(function() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method:"post",
|
method:"post",
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
url: getPath() + "/ajax/xchange",
|
url: window.location.pathname + "/../ajax/xchange",
|
||||||
data: JSON.stringify({"xchange":selections}),
|
data: JSON.stringify({"xchange":selections}),
|
||||||
success: function success() {
|
success: function success() {
|
||||||
$("#books-table").bootstrapTable("refresh");
|
$("#books-table").bootstrapTable("refresh");
|
||||||
@@ -459,10 +157,6 @@ $(function() {
|
|||||||
editable: {
|
editable: {
|
||||||
mode: "inline",
|
mode: "inline",
|
||||||
emptytext: "<span class='glyphicon glyphicon-plus'></span>",
|
emptytext: "<span class='glyphicon glyphicon-plus'></span>",
|
||||||
ajaxOptions: {
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
},
|
|
||||||
success: function (response, __) {
|
success: function (response, __) {
|
||||||
if (!response.success) return response.msg;
|
if (!response.success) return response.msg;
|
||||||
return {newValue: response.newValue};
|
return {newValue: response.newValue};
|
||||||
@@ -470,8 +164,7 @@ $(function() {
|
|||||||
params: function (params) {
|
params: function (params) {
|
||||||
params.checkA = $('#autoupdate_authorsort').prop('checked');
|
params.checkA = $('#autoupdate_authorsort').prop('checked');
|
||||||
params.checkT = $('#autoupdate_titlesort').prop('checked');
|
params.checkT = $('#autoupdate_titlesort').prop('checked');
|
||||||
params.pk = [params.pk];
|
return params
|
||||||
return JSON.stringify(params);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -505,7 +198,7 @@ $(function() {
|
|||||||
searchAlign: "left",
|
searchAlign: "left",
|
||||||
showSearchButton : true,
|
showSearchButton : true,
|
||||||
searchOnEnterKey: true,
|
searchOnEnterKey: true,
|
||||||
checkboxHeader: true,
|
checkboxHeader: false,
|
||||||
maintainMetaData: true,
|
maintainMetaData: true,
|
||||||
responseHandler: responseHandler,
|
responseHandler: responseHandler,
|
||||||
columns: column,
|
columns: column,
|
||||||
@@ -519,7 +212,7 @@ $(function() {
|
|||||||
$.ajax({
|
$.ajax({
|
||||||
method:"get",
|
method:"get",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
url: getPath() + "/ajax/sort_value/" + field + "/" + row.id,
|
url: window.location.pathname + "/../ajax/sort_value/" + field + "/" + row.id,
|
||||||
success: function success(data) {
|
success: function success(data) {
|
||||||
var key = Object.keys(data)[0];
|
var key = Object.keys(data)[0];
|
||||||
$("#books-table").bootstrapTable("updateCellByUniqueId", {
|
$("#books-table").bootstrapTable("updateCellByUniqueId", {
|
||||||
@@ -531,66 +224,6 @@ $(function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPostBody () {
|
|
||||||
// Remove all checkboxes from Headers for showing the texts in the column selector
|
|
||||||
$('.columns [data-field]').each(function(){
|
|
||||||
var elText = $(this).next().text();
|
|
||||||
$(this).next().empty();
|
|
||||||
var index = elText.lastIndexOf('\n', elText.length - 2);
|
|
||||||
if ( index > -1) {
|
|
||||||
elText = elText.substr(index);
|
|
||||||
}
|
|
||||||
$(this).next().text(elText);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onPostHeader() {
|
|
||||||
$(".form-check").each(function () {
|
|
||||||
var item = $(this).parent();
|
|
||||||
var parent = item.parent().parent();
|
|
||||||
if (parent.prop('nodeName') === "TH") {
|
|
||||||
item.prependTo(parent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($(".button_head").length) {
|
|
||||||
if (!$._data($(".button_head").get(0), "events")) {
|
|
||||||
$(".button_head").on("click", function () {
|
|
||||||
var result = $('#books-table').bootstrapTable('getSelections').map(a => a.id);
|
|
||||||
confirmDialog(
|
|
||||||
"btndelbook",
|
|
||||||
"GeneralDeleteModal",
|
|
||||||
0,
|
|
||||||
function () {
|
|
||||||
$.ajax({
|
|
||||||
method: "post",
|
|
||||||
url: getPath() + "/ajax/deletebook",
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
data: JSON.stringify({"bookid": result}),
|
|
||||||
success: function (data) {
|
|
||||||
selections = selections.filter((el) => !result.includes(el));
|
|
||||||
handleListServerResponse(data);
|
|
||||||
},
|
|
||||||
error: function (data) {
|
|
||||||
handleListServerResponse([{type: "danger", message: data.responseText}])
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($(".check_head").length) {
|
|
||||||
if (!$._data($(".check_head").get(0), "events")) {
|
|
||||||
$(".check_head").on("change", function () {
|
|
||||||
var val = $(this).data("set");
|
|
||||||
var name = $(this).data("name");
|
|
||||||
var data = $(this).data("val");
|
|
||||||
bookCheckboxHeader(val, name, data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
onColumnSwitch: function (field, checked) {
|
onColumnSwitch: function (field, checked) {
|
||||||
var visible = $("#books-table").bootstrapTable("getVisibleColumns");
|
var visible = $("#books-table").bootstrapTable("getVisibleColumns");
|
||||||
@@ -607,16 +240,10 @@ $(function() {
|
|||||||
method:"post",
|
method:"post",
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
url: getPath() + "/ajax/table_settings",
|
url: window.location.pathname + "/../ajax/table_settings",
|
||||||
data: "{" + st + "}",
|
data: "{" + st + "}",
|
||||||
});
|
});
|
||||||
handle_header_buttons();
|
|
||||||
},
|
},
|
||||||
onLoadSuccess: function() {
|
|
||||||
$("input:radio.check_head:checked").each(function () {
|
|
||||||
$(this).prop('checked', false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#domain_allow_submit").click(function(event) {
|
$("#domain_allow_submit").click(function(event) {
|
||||||
@@ -625,7 +252,7 @@ $(function() {
|
|||||||
$(this).closest("form").submit();
|
$(this).closest("form").submit();
|
||||||
$.ajax ({
|
$.ajax ({
|
||||||
method:"get",
|
method:"get",
|
||||||
url: getPath() + "/ajax/domainlist/1",
|
url: window.location.pathname + "/../../ajax/domainlist/1",
|
||||||
async: true,
|
async: true,
|
||||||
timeout: 900,
|
timeout: 900,
|
||||||
success:function(data) {
|
success:function(data) {
|
||||||
@@ -646,7 +273,7 @@ $(function() {
|
|||||||
$(this).closest("form").submit();
|
$(this).closest("form").submit();
|
||||||
$.ajax ({
|
$.ajax ({
|
||||||
method:"get",
|
method:"get",
|
||||||
url: getPath() + "/ajax/domainlist/0",
|
url: window.location.pathname + "/../../ajax/domainlist/0",
|
||||||
async: true,
|
async: true,
|
||||||
timeout: 900,
|
timeout: 900,
|
||||||
success:function(data) {
|
success:function(data) {
|
||||||
@@ -664,12 +291,12 @@ $(function() {
|
|||||||
function domainHandle(domainId) {
|
function domainHandle(domainId) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method:"post",
|
method:"post",
|
||||||
url: getPath() + "/ajax/deletedomain",
|
url: window.location.pathname + "/../../ajax/deletedomain",
|
||||||
data: {"domainid":domainId}
|
data: {"domainid":domainId}
|
||||||
});
|
});
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method:"get",
|
method:"get",
|
||||||
url: getPath() + "/ajax/domainlist/1",
|
url: window.location.pathname + "/../../ajax/domainlist/1",
|
||||||
async: true,
|
async: true,
|
||||||
timeout: 900,
|
timeout: 900,
|
||||||
success:function(data) {
|
success:function(data) {
|
||||||
@@ -678,7 +305,7 @@ $(function() {
|
|||||||
});
|
});
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method:"get",
|
method:"get",
|
||||||
url: getPath() + "/ajax/domainlist/0",
|
url: window.location.pathname + "/../../ajax/domainlist/0",
|
||||||
async: true,
|
async: true,
|
||||||
timeout: 900,
|
timeout: 900,
|
||||||
success:function(data) {
|
success:function(data) {
|
||||||
@@ -914,7 +541,7 @@ $(function() {
|
|||||||
method:"post",
|
method:"post",
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
url: getPath() + "/ajax/user_table_settings",
|
url: window.location.pathname + "/../../ajax/user_table_settings",
|
||||||
data: "{" + st + "}",
|
data: "{" + st + "}",
|
||||||
});
|
});
|
||||||
handle_header_buttons();
|
handle_header_buttons();
|
||||||
@@ -940,8 +567,8 @@ $(function() {
|
|||||||
|
|
||||||
function handle_header_buttons () {
|
function handle_header_buttons () {
|
||||||
if (selections.length < 1) {
|
if (selections.length < 1) {
|
||||||
$(".mass_selection").addClass("disabled");
|
$("#user_delete_selection").addClass("disabled");
|
||||||
$(".mass_selection").attr("aria-disabled", true);
|
$("#user_delete_selection").attr("aria-disabled", true);
|
||||||
$(".check_head").attr("aria-disabled", true);
|
$(".check_head").attr("aria-disabled", true);
|
||||||
$(".check_head").attr("disabled", true);
|
$(".check_head").attr("disabled", true);
|
||||||
$(".check_head").prop('checked', false);
|
$(".check_head").prop('checked', false);
|
||||||
@@ -953,8 +580,8 @@ function handle_header_buttons () {
|
|||||||
$(".multi_selector").attr("disabled", true);
|
$(".multi_selector").attr("disabled", true);
|
||||||
$(".header_select").attr("disabled", true);
|
$(".header_select").attr("disabled", true);
|
||||||
} else {
|
} else {
|
||||||
$(".mass_selection").removeClass("disabled");
|
$("#user_delete_selection").removeClass("disabled");
|
||||||
$(".mass_selection").attr("aria-disabled", false);
|
$("#user_delete_selection").attr("aria-disabled", false);
|
||||||
$(".check_head").attr("aria-disabled", false);
|
$(".check_head").attr("aria-disabled", false);
|
||||||
$(".check_head").removeAttr("disabled");
|
$(".check_head").removeAttr("disabled");
|
||||||
$(".button_head").attr("aria-disabled", false);
|
$(".button_head").attr("aria-disabled", false);
|
||||||
@@ -963,10 +590,8 @@ function handle_header_buttons () {
|
|||||||
$(".multi_head").removeClass("hidden");
|
$(".multi_head").removeClass("hidden");
|
||||||
$(".multi_selector").attr("aria-disabled", false);
|
$(".multi_selector").attr("aria-disabled", false);
|
||||||
$(".multi_selector").removeAttr("disabled");
|
$(".multi_selector").removeAttr("disabled");
|
||||||
|
$('.multi_selector').selectpicker('refresh');
|
||||||
$(".header_select").removeAttr("disabled");
|
$(".header_select").removeAttr("disabled");
|
||||||
if (typeof $.fn.selectpicker === "function") {
|
|
||||||
$('.multi_selector').selectpicker('refresh');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,17 +685,6 @@ 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() {
|
||||||
@@ -1093,7 +707,7 @@ function loadSuccess() {
|
|||||||
$("input[data-name='passwd_role'][data-pk='"+guest.data("pk")+"']").prop("disabled", true);
|
$("input[data-name='passwd_role'][data-pk='"+guest.data("pk")+"']").prop("disabled", true);
|
||||||
$("input[data-name='edit_shelf_role'][data-pk='"+guest.data("pk")+"']").prop("disabled", true);
|
$("input[data-name='edit_shelf_role'][data-pk='"+guest.data("pk")+"']").prop("disabled", true);
|
||||||
$("input[data-name='sidebar_read_and_unread'][data-pk='"+guest.data("pk")+"']").prop("disabled", true);
|
$("input[data-name='sidebar_read_and_unread'][data-pk='"+guest.data("pk")+"']").prop("disabled", true);
|
||||||
$(".user-remove[data-pk='" + guest.data("pk") + "']").hide();
|
$(".user-remove[data-pk='"+guest.data("pk")+"']").hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
function move_header_elements() {
|
function move_header_elements() {
|
||||||
@@ -1135,7 +749,7 @@ function move_header_elements() {
|
|||||||
function () {
|
function () {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method: "post",
|
method: "post",
|
||||||
url: getPath() + "/ajax/editlistusers/" + field,
|
url: window.location.pathname + "/../../ajax/editlistusers/" + field,
|
||||||
data: {"pk": result, "value": values, "action": val},
|
data: {"pk": result, "value": values, "action": val},
|
||||||
success: function (data) {
|
success: function (data) {
|
||||||
handleListServerResponse(data);
|
handleListServerResponse(data);
|
||||||
@@ -1149,6 +763,7 @@ function move_header_elements() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#user_delete_selection").click(function () {
|
$("#user_delete_selection").click(function () {
|
||||||
$("#user-table").bootstrapTable("uncheckAll");
|
$("#user-table").bootstrapTable("uncheckAll");
|
||||||
});
|
});
|
||||||
@@ -1179,10 +794,8 @@ function move_header_elements() {
|
|||||||
function () {
|
function () {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method: "post",
|
method: "post",
|
||||||
url: getPath() + "/ajax/deleteuser",
|
url: window.location.pathname + "/../../ajax/deleteuser",
|
||||||
contentType: "application/json; charset=utf-8",
|
data: {"userid": result},
|
||||||
dataType: "json",
|
|
||||||
data: JSON.stringify({"userid": result}),
|
|
||||||
success: function (data) {
|
success: function (data) {
|
||||||
selections = selections.filter((el) => !result.includes(el));
|
selections = selections.filter((el) => !result.includes(el));
|
||||||
handleListServerResponse(data);
|
handleListServerResponse(data);
|
||||||
@@ -1208,7 +821,7 @@ function handleListServerResponse (data) {
|
|||||||
'</div>');
|
'</div>');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
$(".table.table-striped").bootstrapTable("refresh");
|
$("#user-table").bootstrapTable("refresh");
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkboxChange(checkbox, userId, field, field_index) {
|
function checkboxChange(checkbox, userId, field, field_index) {
|
||||||
@@ -1223,15 +836,13 @@ function checkboxChange(checkbox, userId, field, field_index) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookCheckboxChange(checkbox, bookId, field) {
|
function BookCheckboxChange(checkbox, userId, field) {
|
||||||
var value = checkbox.checked ? "True" : "False";
|
var value = checkbox.checked ? "True" : "False";
|
||||||
var element = checkbox;
|
var element = checkbox;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method: "post",
|
method: "post",
|
||||||
url: getPath() + "/ajax/editbooks/" + field,
|
url: getPath() + "/ajax/editbooks/" + field,
|
||||||
data: JSON.stringify({"pk": [bookId], "value": value}),
|
data: {"pk": userId, "value": value},
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
error: function(data) {
|
error: function(data) {
|
||||||
element.checked = !element.checked;
|
element.checked = !element.checked;
|
||||||
handleListServerResponse([{type:"danger", message:data.responseText}])
|
handleListServerResponse([{type:"danger", message:data.responseText}])
|
||||||
@@ -1240,13 +851,14 @@ function BookCheckboxChange(checkbox, bookId, field) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function selectHeader(element, field) {
|
function selectHeader(element, field) {
|
||||||
if (element.value !== "None") {
|
if (element.value !== "None") {
|
||||||
confirmDialog(element.id, "GeneralChangeModal", 0, function () {
|
confirmDialog(element.id, "GeneralChangeModal", 0, function () {
|
||||||
var result = $('#user-table').bootstrapTable('getSelections').map(a => a.id);
|
var result = $('#user-table').bootstrapTable('getSelections').map(a => a.id);
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method: "post",
|
method: "post",
|
||||||
url: getPath() + "/ajax/editlistusers/" + field,
|
url: window.location.pathname + "/../../ajax/editlistusers/" + field,
|
||||||
data: {"pk": result, "value": element.value},
|
data: {"pk": result, "value": element.value},
|
||||||
error: function (data) {
|
error: function (data) {
|
||||||
handleListServerResponse([{type:"danger", message:data.responseText}])
|
handleListServerResponse([{type:"danger", message:data.responseText}])
|
||||||
@@ -1259,35 +871,12 @@ function selectHeader(element, field) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bookCheckboxHeader(CheckboxState, text, field_index) {
|
|
||||||
confirmDialog(text, "GeneralChangeModal", 0, function() {
|
|
||||||
var result = $('#books-table').bootstrapTable('getSelections').map(a => a.id);
|
|
||||||
$.ajax({
|
|
||||||
method: "post",
|
|
||||||
url: getPath() + "/ajax/editbooks/" + field_index,
|
|
||||||
data: JSON.stringify({"pk": result, "field_index": field_index, "value": CheckboxState, multi: "True"}),
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
error: function (data) {
|
|
||||||
handleListServerResponse([{type:"danger", message:data.responseText}])
|
|
||||||
},
|
|
||||||
success: function (data) {
|
|
||||||
handleListServerResponse (data, true)
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},function() {
|
|
||||||
$("input:radio.check_head:checked").each(function() {
|
|
||||||
$(this).prop('checked', false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkboxHeader(CheckboxState, field, field_index) {
|
function checkboxHeader(CheckboxState, field, field_index) {
|
||||||
confirmDialog(field, "GeneralChangeModal", 0, function() {
|
confirmDialog(field, "GeneralChangeModal", 0, function() {
|
||||||
var result = $('#user-table').bootstrapTable('getSelections').map(a => a.id);
|
var result = $('#user-table').bootstrapTable('getSelections').map(a => a.id);
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method: "post",
|
method: "post",
|
||||||
url: getPath() + "/ajax/editlistusers/" + field,
|
url: window.location.pathname + "/../../ajax/editlistusers/" + field,
|
||||||
data: {"pk": result, "field_index": field_index, "value": CheckboxState},
|
data: {"pk": result, "field_index": field_index, "value": CheckboxState},
|
||||||
error: function (data) {
|
error: function (data) {
|
||||||
handleListServerResponse([{type:"danger", message:data.responseText}])
|
handleListServerResponse([{type:"danger", message:data.responseText}])
|
||||||
@@ -1303,7 +892,7 @@ function checkboxHeader(CheckboxState, field, field_index) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteUser(a, id){
|
function deleteUser(a,id){
|
||||||
confirmDialog(
|
confirmDialog(
|
||||||
"btndeluser",
|
"btndeluser",
|
||||||
"GeneralDeleteModal",
|
"GeneralDeleteModal",
|
||||||
@@ -1311,10 +900,8 @@ function deleteUser(a, id){
|
|||||||
function() {
|
function() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method:"post",
|
method:"post",
|
||||||
url: getPath() + "/ajax/deleteuser",
|
url: window.location.pathname + "/../../ajax/deleteuser",
|
||||||
contentType: "application/json; charset=utf-8",
|
data: {"userid":id},
|
||||||
dataType: "json",
|
|
||||||
data: JSON.stringify({"userid": [id]}),
|
|
||||||
success: function (data) {
|
success: function (data) {
|
||||||
userId = parseInt(id, 10);
|
userId = parseInt(id, 10);
|
||||||
selections = selections.filter(item => item !== userId);
|
selections = selections.filter(item => item !== userId);
|
||||||
@@ -1341,10 +928,8 @@ function storeLocation() {
|
|||||||
function user_handle (userId) {
|
function user_handle (userId) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method:"post",
|
method:"post",
|
||||||
contentType: "application/json; charset=utf-8",
|
url: window.location.pathname + "/../../ajax/deleteuser",
|
||||||
dataType: "json",
|
data: {"userid":userId}
|
||||||
data: JSON.stringify({"userid": [userId]}),
|
|
||||||
url: getPath() + "/ajax/deleteuser",
|
|
||||||
});
|
});
|
||||||
$("#user-table").bootstrapTable("refresh");
|
$("#user-table").bootstrapTable("refresh");
|
||||||
}
|
}
|
||||||
@@ -1352,5 +937,6 @@ function user_handle (userId) {
|
|||||||
function shorten_html(value, response) {
|
function shorten_html(value, response) {
|
||||||
if(value) {
|
if(value) {
|
||||||
$(this).html("[...]");
|
$(this).html("[...]");
|
||||||
|
// value.split('\n').slice(0, 2).join("") +
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,5 @@ import re
|
|||||||
|
|
||||||
|
|
||||||
def strip_whitespaces(text):
|
def strip_whitespaces(text):
|
||||||
return re.sub(r"(^[\s\u200B-\u200D\ufeff]+)|([\s\u200B-\u200D\ufeff]+$)","", text)
|
return re.sub("(^[\s\u200B-\u200D\ufeff]+)|([\s\u200B-\u200D\ufeff]+$)","", text)
|
||||||
|
|
||||||
|
|||||||
+112
-129
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import glob
|
from 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
|
||||||
@@ -28,7 +28,7 @@ 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, app
|
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 _
|
||||||
@@ -60,39 +60,36 @@ class TaskConvert(CalibreTask):
|
|||||||
self.results = dict()
|
self.results = dict()
|
||||||
|
|
||||||
def run(self, worker_thread):
|
def run(self, worker_thread):
|
||||||
df_cover = None
|
|
||||||
cur_book = None
|
|
||||||
self.worker_thread = worker_thread
|
self.worker_thread = worker_thread
|
||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
with app.app_context():
|
worker_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
worker_db = db.CalibreDB(app)
|
cur_book = worker_db.get_book(self.book_id)
|
||||||
cur_book = worker_db.get_book(self.book_id)
|
self.title = cur_book.title
|
||||||
self.title = cur_book.title
|
data = worker_db.get_book_format(self.book_id, self.settings['old_book_format'])
|
||||||
data = worker_db.get_book_format(self.book_id, self.settings['old_book_format'])
|
df = gdriveutils.getFileFromEbooksFolder(cur_book.path,
|
||||||
df = gdriveutils.getFileFromEbooksFolder(cur_book.path,
|
data.name + "." + self.settings['old_book_format'].lower())
|
||||||
data.name + "." + self.settings['old_book_format'].lower())
|
df_cover = gdriveutils.getFileFromEbooksFolder(cur_book.path, "cover.jpg")
|
||||||
df_cover = gdriveutils.getFileFromEbooksFolder(cur_book.path, "cover.jpg")
|
if df:
|
||||||
if df:
|
datafile_cover = None
|
||||||
datafile_cover = None
|
datafile = os.path.join(config.get_book_path(),
|
||||||
datafile = os.path.join(config.get_book_path(),
|
cur_book.path,
|
||||||
cur_book.path,
|
data.name + "." + self.settings['old_book_format'].lower())
|
||||||
data.name + "." + self.settings['old_book_format'].lower())
|
if df_cover:
|
||||||
if df_cover:
|
datafile_cover = os.path.join(config.get_book_path(),
|
||||||
datafile_cover = os.path.join(config.get_book_path(),
|
cur_book.path, "cover.jpg")
|
||||||
cur_book.path, "cover.jpg")
|
if not os.path.exists(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)):
|
os.makedirs(os.path.join(config.get_book_path(), cur_book.path))
|
||||||
os.makedirs(os.path.join(config.get_book_path(), cur_book.path))
|
df.GetContentFile(datafile)
|
||||||
df.GetContentFile(datafile)
|
if df_cover:
|
||||||
if df_cover:
|
df_cover.GetContentFile(datafile_cover)
|
||||||
df_cover.GetContentFile(datafile_cover)
|
worker_db.session.close()
|
||||||
# worker_db.session.close()
|
else:
|
||||||
else:
|
# ToDo Include cover in error handling
|
||||||
# ToDo Include cover in error handling
|
error_message = _("%(format)s not found on Google Drive: %(fn)s",
|
||||||
error_message = _("%(format)s not found on Google Drive: %(fn)s",
|
format=self.settings['old_book_format'],
|
||||||
format=self.settings['old_book_format'],
|
fn=data.name + "." + self.settings['old_book_format'].lower())
|
||||||
fn=data.name + "." + self.settings['old_book_format'].lower())
|
worker_db.session.close()
|
||||||
# worker_db.session.close()
|
return self._handleError(error_message)
|
||||||
return self._handleError(error_message)
|
|
||||||
|
|
||||||
filename = self._convert_ebook_format()
|
filename = self._convert_ebook_format()
|
||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
@@ -127,89 +124,88 @@ class TaskConvert(CalibreTask):
|
|||||||
|
|
||||||
def _convert_ebook_format(self):
|
def _convert_ebook_format(self):
|
||||||
error_message = None
|
error_message = None
|
||||||
with app.app_context():
|
local_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
local_db = db.CalibreDB(app)
|
file_path = self.file_path
|
||||||
file_path = self.file_path
|
book_id = self.book_id
|
||||||
book_id = self.book_id
|
format_old_ext = '.' + self.settings['old_book_format'].lower()
|
||||||
format_old_ext = '.' + self.settings['old_book_format'].lower()
|
format_new_ext = '.' + self.settings['new_book_format'].lower()
|
||||||
format_new_ext = '.' + self.settings['new_book_format'].lower()
|
|
||||||
|
|
||||||
# check to see if destination format already exists - or if book is in database
|
# check to see if destination format already exists - or if book is in database
|
||||||
# if it does - mark the conversion task as complete and return a success
|
# if it does - mark the conversion task as complete and return a success
|
||||||
# this will allow to send to E-Reader workflow to continue to work
|
# this will allow to send to E-Reader workflow to continue to work
|
||||||
if os.path.isfile(file_path + format_new_ext) or\
|
if os.path.isfile(file_path + format_new_ext) or\
|
||||||
local_db.get_book_format(self.book_id, self.settings['new_book_format']):
|
local_db.get_book_format(self.book_id, self.settings['new_book_format']):
|
||||||
log.info("Book id %d already converted to %s", book_id, format_new_ext)
|
log.info("Book id %d already converted to %s", book_id, format_new_ext)
|
||||||
cur_book = local_db.get_book(book_id)
|
cur_book = local_db.get_book(book_id)
|
||||||
self.title = cur_book.title
|
self.title = cur_book.title
|
||||||
self.results['path'] = cur_book.path
|
self.results['path'] = cur_book.path
|
||||||
self.results['title'] = self.title
|
self.results['title'] = self.title
|
||||||
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id)\
|
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id)\
|
||||||
|
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
|
||||||
|
if not new_format:
|
||||||
|
new_format = db.Data(name=os.path.basename(file_path),
|
||||||
|
book_format=self.settings['new_book_format'].upper(),
|
||||||
|
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
|
||||||
|
try:
|
||||||
|
local_db.session.merge(new_format)
|
||||||
|
local_db.session.commit()
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
local_db.session.rollback()
|
||||||
|
log.error("Database error: %s", e)
|
||||||
|
local_db.session.close()
|
||||||
|
self._handleError(N_("Oops! Database Error: %(error)s.", error=e))
|
||||||
|
return
|
||||||
|
self._handleSuccess()
|
||||||
|
local_db.session.close()
|
||||||
|
return os.path.basename(file_path + format_new_ext)
|
||||||
|
else:
|
||||||
|
log.info("Book id %d - target format of %s does not exist. Moving forward with convert.",
|
||||||
|
book_id,
|
||||||
|
format_new_ext)
|
||||||
|
|
||||||
|
if config.config_kepubifypath and format_old_ext == '.epub' and format_new_ext == '.kepub':
|
||||||
|
check, error_message = self._convert_kepubify(file_path,
|
||||||
|
format_old_ext,
|
||||||
|
format_new_ext)
|
||||||
|
else:
|
||||||
|
# check if calibre converter-executable is existing
|
||||||
|
if not os.path.exists(config.config_converterpath):
|
||||||
|
self._handleError(N_("Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath))
|
||||||
|
return
|
||||||
|
has_cover = local_db.get_book(book_id).has_cover
|
||||||
|
check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext, has_cover)
|
||||||
|
|
||||||
|
if check == 0:
|
||||||
|
cur_book = local_db.get_book(book_id)
|
||||||
|
if os.path.isfile(file_path + format_new_ext):
|
||||||
|
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id) \
|
||||||
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
|
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
|
||||||
if not new_format:
|
if not new_format:
|
||||||
new_format = db.Data(name=os.path.basename(file_path),
|
new_format = db.Data(name=cur_book.data[0].name,
|
||||||
book_format=self.settings['new_book_format'].upper(),
|
book_format=self.settings['new_book_format'].upper(),
|
||||||
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
|
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
|
||||||
try:
|
try:
|
||||||
local_db.session.merge(new_format)
|
local_db.session.merge(new_format)
|
||||||
local_db.session.commit()
|
local_db.session.commit()
|
||||||
|
if self.settings['new_book_format'].upper() in ['KEPUB', 'EPUB', 'EPUB3']:
|
||||||
|
ub_session = init_db_thread()
|
||||||
|
remove_synced_book(book_id, True, ub_session)
|
||||||
|
ub_session.close()
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
local_db.session.rollback()
|
local_db.session.rollback()
|
||||||
log.error("Database error: %s", e)
|
log.error("Database error: %s", e)
|
||||||
local_db.session.close()
|
local_db.session.close()
|
||||||
self._handleError(N_("Oops! Database Error: %(error)s.", error=e))
|
self._handleError(error_message)
|
||||||
return
|
return
|
||||||
|
self.results['path'] = cur_book.path
|
||||||
|
self.title = cur_book.title
|
||||||
|
self.results['title'] = self.title
|
||||||
|
if not config.config_use_google_drive:
|
||||||
self._handleSuccess()
|
self._handleSuccess()
|
||||||
local_db.session.close()
|
return os.path.basename(file_path + format_new_ext)
|
||||||
return os.path.basename(file_path + format_new_ext)
|
|
||||||
else:
|
else:
|
||||||
log.info("Book id %d - target format of %s does not exist. Moving forward with convert.",
|
error_message = N_('%(format)s format not found on disk', format=format_new_ext.upper())
|
||||||
book_id,
|
local_db.session.close()
|
||||||
format_new_ext)
|
|
||||||
|
|
||||||
if config.config_kepubifypath and format_old_ext == '.epub' and format_new_ext == '.kepub':
|
|
||||||
check, error_message = self._convert_kepubify(file_path,
|
|
||||||
format_old_ext,
|
|
||||||
format_new_ext)
|
|
||||||
else:
|
|
||||||
# check if calibre converter-executable is existing
|
|
||||||
if not os.path.exists(config.config_converterpath):
|
|
||||||
self._handleError(N_("Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath))
|
|
||||||
return
|
|
||||||
has_cover = local_db.get_book(book_id).has_cover
|
|
||||||
check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext, has_cover)
|
|
||||||
|
|
||||||
if check == 0:
|
|
||||||
cur_book = local_db.get_book(book_id)
|
|
||||||
if os.path.isfile(file_path + format_new_ext):
|
|
||||||
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id) \
|
|
||||||
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
|
|
||||||
if not new_format:
|
|
||||||
new_format = db.Data(name=cur_book.data[0].name,
|
|
||||||
book_format=self.settings['new_book_format'].upper(),
|
|
||||||
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
|
|
||||||
try:
|
|
||||||
local_db.session.merge(new_format)
|
|
||||||
local_db.session.commit()
|
|
||||||
if self.settings['new_book_format'].upper() in ['KEPUB', 'EPUB', 'EPUB3']:
|
|
||||||
ub_session = init_db_thread()
|
|
||||||
remove_synced_book(book_id, True, ub_session)
|
|
||||||
ub_session.close()
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
local_db.session.rollback()
|
|
||||||
log.error("Database error: %s", e)
|
|
||||||
local_db.session.close()
|
|
||||||
self._handleError(error_message)
|
|
||||||
return
|
|
||||||
self.results['path'] = cur_book.path
|
|
||||||
self.title = cur_book.title
|
|
||||||
self.results['title'] = self.title
|
|
||||||
if not config.config_use_google_drive:
|
|
||||||
self._handleSuccess()
|
|
||||||
return os.path.basename(file_path + format_new_ext)
|
|
||||||
else:
|
|
||||||
error_message = N_('%(format)s format not found on disk', format=format_new_ext.upper())
|
|
||||||
local_db.session.close()
|
|
||||||
log.info("ebook converter failed with error while converting book")
|
log.info("ebook converter failed with error while converting book")
|
||||||
if not error_message:
|
if not error_message:
|
||||||
error_message = N_('Ebook converter failed with unknown error')
|
error_message = N_('Ebook converter failed with unknown error')
|
||||||
@@ -246,7 +242,7 @@ class TaskConvert(CalibreTask):
|
|||||||
|
|
||||||
# move file
|
# move file
|
||||||
if check == 0:
|
if check == 0:
|
||||||
converted_file = glob.glob(glob.escape(os.path.splitext(filename)[0]) + "*.kepub.epub")
|
converted_file = glob(os.path.splitext(filename)[0] + "*.kepub.epub")
|
||||||
if len(converted_file) == 1:
|
if len(converted_file) == 1:
|
||||||
copyfile(converted_file[0], (file_path + format_new_ext))
|
copyfile(converted_file[0], (file_path + format_new_ext))
|
||||||
os.unlink(converted_file[0])
|
os.unlink(converted_file[0])
|
||||||
@@ -272,48 +268,35 @@ class TaskConvert(CalibreTask):
|
|||||||
|
|
||||||
opf_command = [calibredb_binarypath, 'show_metadata', '--as-opf', str(self.book_id),
|
opf_command = [calibredb_binarypath, 'show_metadata', '--as-opf', str(self.book_id),
|
||||||
'--with-library', library_path]
|
'--with-library', library_path]
|
||||||
p = process_open(opf_command, quotes, my_env, newlines=False)
|
p = process_open(opf_command, quotes, my_env)
|
||||||
lines = list()
|
p.wait()
|
||||||
while p.poll() is None:
|
|
||||||
lines.append(p.stdout.readline())
|
|
||||||
check = p.returncode
|
check = p.returncode
|
||||||
calibre_traceback = p.stderr.readlines()
|
calibre_traceback = p.stderr.readlines()
|
||||||
if check == 0:
|
if check == 0:
|
||||||
path_tmp_opf = os.path.join(tmp_dir, "metadata_" + str(uuid4()) + ".opf")
|
path_tmp_opf = os.path.join(tmp_dir, "metadata_" + str(uuid4()) + ".opf")
|
||||||
with open(path_tmp_opf, 'wb') as fd:
|
with open(path_tmp_opf, 'w') as fd:
|
||||||
fd.write(b''.join(lines))
|
copyfileobj(p.stdout, fd)
|
||||||
else:
|
else:
|
||||||
error_message = ""
|
error_message = ""
|
||||||
for ele in calibre_traceback:
|
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'):
|
if not ele.startswith('Traceback') and not ele.startswith(' File'):
|
||||||
error_message = N_("Calibre failed with error: %(error)s", error=ele)
|
error_message = N_("Calibre failed with error: %(error)s", error=ele)
|
||||||
return check, error_message
|
return check, error_message
|
||||||
quotes = [1, 2]
|
quotes = [1, 2, 4, 6]
|
||||||
quotes_index = 3
|
|
||||||
command = [config.config_converterpath, (file_path + format_old_ext),
|
command = [config.config_converterpath, (file_path + format_old_ext),
|
||||||
(file_path + format_new_ext)]
|
(file_path + format_new_ext)]
|
||||||
if config.config_embed_metadata:
|
if config.config_embed_metadata:
|
||||||
quotes.append(4)
|
|
||||||
quotes_index = 5
|
|
||||||
command.extend(['--from-opf', path_tmp_opf])
|
command.extend(['--from-opf', path_tmp_opf])
|
||||||
if has_cover:
|
if has_cover:
|
||||||
quotes.append(6)
|
command.extend(['--cover', os.path.join(os.path.dirname(file_path), 'cover.jpg')])
|
||||||
command.extend(['--cover', os.path.join(os.path.dirname(file_path), 'cover.jpg')])
|
quotes_index = 3
|
||||||
quotes_index = 7
|
|
||||||
if config.config_calibre:
|
if config.config_calibre:
|
||||||
parameters = re.findall(r"(--[\w-]+)(?:(\s(?:(\".+\")|(?:.+?)))(?:\s|$))?",
|
parameters = config.config_calibre.split(" ")
|
||||||
config.config_calibre, re.IGNORECASE | re.UNICODE)
|
for param in parameters:
|
||||||
if parameters:
|
command.append(param)
|
||||||
for param in parameters:
|
quotes.append(quotes_index)
|
||||||
command.append(strip_whitespaces(param[0]))
|
quotes_index += 1
|
||||||
quotes_index += 1
|
|
||||||
if param[1] != "":
|
|
||||||
parsed = strip_whitespaces(param[1]).strip("\"")
|
|
||||||
command.append(parsed)
|
|
||||||
quotes.append(quotes_index)
|
|
||||||
quotes_index += 1
|
|
||||||
p = process_open(command, quotes, newlines=False)
|
p = process_open(command, quotes, newlines=False)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
return 1, N_("Ebook-converter failed: %(error)s", error=e)
|
return 1, N_("Ebook-converter failed: %(error)s", error=e)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
from flask_babel import lazy_gettext as N_
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
from cps import config, logger, db, ub, app
|
from cps import config, logger, db, ub
|
||||||
from cps.services.worker import CalibreTask
|
from cps.services.worker import CalibreTask
|
||||||
|
|
||||||
|
|
||||||
@@ -26,13 +26,11 @@ 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.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
|
|
||||||
def run(self, worker_thread):
|
def run(self, worker_thread):
|
||||||
with app.app_context():
|
self.calibre_db.reconnect_db(config, ub.app_DB_path)
|
||||||
calibre_db = db.CalibreDB(app)
|
self.calibre_db.session.close()
|
||||||
calibre_db.reconnect_db(config, ub.app_DB_path)
|
|
||||||
# self.calibre_db.session.close()
|
|
||||||
self._handleSuccess()
|
self._handleSuccess()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
+11
-15
@@ -25,7 +25,7 @@ import mimetypes
|
|||||||
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.utils import formatdate, parseaddr, make_msgid
|
from email.utils import formatdate, parseaddr
|
||||||
from email.generator import Generator
|
from email.generator import Generator
|
||||||
from flask_babel import lazy_gettext as N_
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ 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
|
from cps.string_helper import strip_whitespaces
|
||||||
|
import uuid
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
@@ -55,8 +55,8 @@ class EmailBase:
|
|||||||
return (code, resp)
|
return (code, resp)
|
||||||
|
|
||||||
def send(self, strg):
|
def send(self, strg):
|
||||||
"""Send 'strg' to the server."""
|
"""Send `strg' to the server."""
|
||||||
log.debug_no_auth('send: {}'.format(strg[:300]), stacklevel=2)
|
log.debug_no_auth('send: {}'.format(strg[:300]))
|
||||||
if hasattr(self, 'sock') and self.sock:
|
if hasattr(self, 'sock') and self.sock:
|
||||||
try:
|
try:
|
||||||
if self.transferSize:
|
if self.transferSize:
|
||||||
@@ -102,7 +102,7 @@ class Email(EmailBase, smtplib.SMTP):
|
|||||||
smtplib.SMTP.__init__(self, *args, **kwargs)
|
smtplib.SMTP.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
# Class for sending ssl encrypted email with ability to get current progress, derived from emailbase class
|
# Class for sending ssl encrypted email with ability to get current progress, , derived from emailbase class
|
||||||
class EmailSSL(EmailBase, smtplib.SMTP_SSL):
|
class EmailSSL(EmailBase, smtplib.SMTP_SSL):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -142,7 +142,7 @@ class TaskEmail(CalibreTask):
|
|||||||
message['To'] = self.recipient
|
message['To'] = self.recipient
|
||||||
message['Subject'] = self.subject
|
message['Subject'] = self.subject
|
||||||
message['Date'] = formatdate(localtime=True)
|
message['Date'] = formatdate(localtime=True)
|
||||||
message['Message-ID'] = make_msgid(domain=self.get_msgid_domain())
|
message['Message-Id'] = "{}@{}".format(uuid.uuid4(), self.get_msgid_domain())
|
||||||
message.set_content(self.text.encode('UTF-8'), "text", "plain")
|
message.set_content(self.text.encode('UTF-8'), "text", "plain")
|
||||||
if self.attachment:
|
if self.attachment:
|
||||||
data = self._get_attachment(self.filepath, self.attachment)
|
data = self._get_attachment(self.filepath, self.attachment)
|
||||||
@@ -169,14 +169,10 @@ class TaskEmail(CalibreTask):
|
|||||||
else:
|
else:
|
||||||
self.send_gmail_email(msg)
|
self.send_gmail_email(msg)
|
||||||
except MemoryError as e:
|
except MemoryError as e:
|
||||||
log.error_or_exception(e, stacklevel=2)
|
log.error_or_exception(e, stacklevel=3)
|
||||||
self._handleError('MemoryError sending e-mail: {}'.format(str(e)))
|
self._handleError('MemoryError sending e-mail: {}'.format(str(e)))
|
||||||
except (smtplib.SMTPRecipientsRefused) as e:
|
|
||||||
log.error_or_exception(e, stacklevel=2)
|
|
||||||
self._handleError('Smtplib Error sending e-mail: {}'.format(
|
|
||||||
(list(e.args[0].values())[0][1]).decode('utf-8)').replace("\n", '. ')))
|
|
||||||
except (smtplib.SMTPException, smtplib.SMTPAuthenticationError) as e:
|
except (smtplib.SMTPException, smtplib.SMTPAuthenticationError) as e:
|
||||||
log.error_or_exception(e, stacklevel=2)
|
log.error_or_exception(e, stacklevel=3)
|
||||||
if hasattr(e, "smtp_error"):
|
if hasattr(e, "smtp_error"):
|
||||||
text = e.smtp_error.decode('utf-8').replace("\n", '. ')
|
text = e.smtp_error.decode('utf-8').replace("\n", '. ')
|
||||||
elif hasattr(e, "message"):
|
elif hasattr(e, "message"):
|
||||||
@@ -187,10 +183,10 @@ class TaskEmail(CalibreTask):
|
|||||||
text = ''
|
text = ''
|
||||||
self._handleError('Smtplib Error sending e-mail: {}'.format(text))
|
self._handleError('Smtplib Error sending e-mail: {}'.format(text))
|
||||||
except (socket.error) as e:
|
except (socket.error) as e:
|
||||||
log.error_or_exception(e, stacklevel=2)
|
log.error_or_exception(e, stacklevel=3)
|
||||||
self._handleError('Socket Error sending e-mail: {}'.format(e.strerror))
|
self._handleError('Socket Error sending e-mail: {}'.format(e.strerror))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.error_or_exception(ex, stacklevel=2)
|
log.error_or_exception(ex, stacklevel=3)
|
||||||
self._handleError('Error sending e-mail: {}'.format(ex))
|
self._handleError('Error sending e-mail: {}'.format(ex))
|
||||||
|
|
||||||
def send_standard_email(self, msg):
|
def send_standard_email(self, msg):
|
||||||
@@ -273,7 +269,7 @@ class TaskEmail(CalibreTask):
|
|||||||
if config.config_binariesdir and config.config_embed_metadata:
|
if config.config_binariesdir and config.config_embed_metadata:
|
||||||
os.remove(datafile)
|
os.remove(datafile)
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
log.error_or_exception(e, stacklevel=2)
|
log.error_or_exception(e, stacklevel=3)
|
||||||
log.error('The requested file could not be read. Maybe wrong permissions?')
|
log.error('The requested file could not be read. Maybe wrong permissions?')
|
||||||
return None
|
return None
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
import os
|
import os
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from cps import config, db, gdriveutils, logger, app
|
from cps import config, db, gdriveutils, logger
|
||||||
from cps.services.worker import CalibreTask
|
from cps.services.worker import CalibreTask
|
||||||
from flask_babel import lazy_gettext as N_
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ class TaskBackupMetadata(CalibreTask):
|
|||||||
task_message=N_('Backing up Metadata')):
|
task_message=N_('Backing up Metadata')):
|
||||||
super(TaskBackupMetadata, self).__init__(task_message)
|
super(TaskBackupMetadata, self).__init__(task_message)
|
||||||
self.log = logger.create()
|
self.log = logger.create()
|
||||||
# self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
self.export_language = export_language
|
self.export_language = export_language
|
||||||
self.translated_title = translated_title
|
self.translated_title = translated_title
|
||||||
self.set_dirty = set_dirty
|
self.set_dirty = set_dirty
|
||||||
@@ -46,51 +46,47 @@ class TaskBackupMetadata(CalibreTask):
|
|||||||
self.backup_metadata()
|
self.backup_metadata()
|
||||||
|
|
||||||
def set_all_books_dirty(self):
|
def set_all_books_dirty(self):
|
||||||
with app.app_context():
|
try:
|
||||||
calibre_dbb = db.CalibreDB(app)
|
books = self.calibre_db.session.query(db.Books).all()
|
||||||
try:
|
for book in books:
|
||||||
books = calibre_dbb.session.query(db.Books).all()
|
self.calibre_db.set_metadata_dirty(book.id)
|
||||||
for book in books:
|
self.calibre_db.session.commit()
|
||||||
calibre_dbb.set_metadata_dirty(book.id)
|
self._handleSuccess()
|
||||||
calibre_dbb.session.commit()
|
except Exception as ex:
|
||||||
self._handleSuccess()
|
self.log.debug('Error adding book for backup: ' + str(ex))
|
||||||
except Exception as ex:
|
self._handleError('Error adding book for backup: ' + str(ex))
|
||||||
self.log.debug('Error adding book for backup: ' + str(ex))
|
self.calibre_db.session.rollback()
|
||||||
self._handleError('Error adding book for backup: ' + str(ex))
|
self.calibre_db.session.close()
|
||||||
calibre_dbb.session.rollback()
|
|
||||||
# self.calibre_db.session.close()
|
|
||||||
|
|
||||||
def backup_metadata(self):
|
def backup_metadata(self):
|
||||||
with app.app_context():
|
try:
|
||||||
try:
|
metadata_backup = self.calibre_db.session.query(db.Metadata_Dirtied).all()
|
||||||
calibre_dbb = db.CalibreDB(app)
|
custom_columns = (self.calibre_db.session.query(db.CustomColumns)
|
||||||
metadata_backup = calibre_dbb.session.query(db.Metadata_Dirtied).all()
|
.filter(db.CustomColumns.mark_for_delete == 0)
|
||||||
custom_columns = (calibre_dbb.session.query(db.CustomColumns)
|
.filter(db.CustomColumns.datatype.notin_(db.cc_exceptions))
|
||||||
.filter(db.CustomColumns.mark_for_delete == 0)
|
.order_by(db.CustomColumns.label).all())
|
||||||
.filter(db.CustomColumns.datatype.notin_(db.cc_exceptions))
|
count = len(metadata_backup)
|
||||||
.order_by(db.CustomColumns.label).all())
|
i = 0
|
||||||
count = len(metadata_backup)
|
for backup in metadata_backup:
|
||||||
i = 0
|
book = self.calibre_db.session.query(db.Books).filter(db.Books.id == backup.book).one_or_none()
|
||||||
for backup in metadata_backup:
|
self.calibre_db.session.query(db.Metadata_Dirtied).filter(
|
||||||
book = calibre_dbb.session.query(db.Books).filter(db.Books.id == backup.book).one_or_none()
|
db.Metadata_Dirtied.book == backup.book).delete()
|
||||||
calibre_dbb.session.query(db.Metadata_Dirtied).filter(
|
self.calibre_db.session.commit()
|
||||||
db.Metadata_Dirtied.book == backup.book).delete()
|
if book:
|
||||||
calibre_dbb.session.commit()
|
self.open_metadata(book, custom_columns)
|
||||||
if book:
|
else:
|
||||||
self.open_metadata(book, custom_columns)
|
self.log.error("Book {} not found in database".format(backup.book))
|
||||||
else:
|
i += 1
|
||||||
self.log.error("Book {} not found in database".format(backup.book))
|
self.progress = (1.0 / count) * i
|
||||||
i += 1
|
self._handleSuccess()
|
||||||
self.progress = (1.0 / count) * i
|
self.calibre_db.session.close()
|
||||||
self._handleSuccess()
|
|
||||||
# self.calibre_db.session.close()
|
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
b = "NaN" if not hasattr(book, 'id') else book.id
|
b = "NaN" if not hasattr(book, 'id') else book.id
|
||||||
self.log.debug('Error creating metadata backup for book {}: '.format(b) + str(ex))
|
self.log.debug('Error creating metadata backup for book {}: '.format(b) + str(ex))
|
||||||
self._handleError('Error creating metadata backup: ' + str(ex))
|
self._handleError('Error creating metadata backup: ' + str(ex))
|
||||||
calibre_dbb.session.rollback()
|
self.calibre_db.session.rollback()
|
||||||
# self.calibre_db.session.close()
|
self.calibre_db.session.close()
|
||||||
|
|
||||||
def open_metadata(self, book, custom_columns):
|
def open_metadata(self, book, custom_columns):
|
||||||
# package = self.create_new_metadata_backup(book, custom_columns)
|
# package = self.create_new_metadata_backup(book, custom_columns)
|
||||||
|
|||||||
+55
-61
@@ -23,11 +23,10 @@ from io import BytesIO
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from .. import constants
|
from .. import constants
|
||||||
from cps import config, db, fs, gdriveutils, logger, ub, app
|
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 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
|
||||||
@@ -114,10 +113,9 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_books_with_covers(book_id=-1):
|
def get_books_with_covers(book_id=-1):
|
||||||
filter_exp = (db.Books.id == book_id) if book_id != -1 else True
|
filter_exp = (db.Books.id == book_id) if book_id != -1 else True
|
||||||
with app.app_context():
|
calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
calibre_db = db.CalibreDB(app) #, expire_on_commit=False, init=True)
|
books_cover = calibre_db.session.query(db.Books).filter(db.Books.has_cover == 1).filter(filter_exp).all()
|
||||||
books_cover = calibre_db.session.query(db.Books).filter(db.Books.has_cover == 1).filter(filter_exp).all()
|
calibre_db.session.close()
|
||||||
# calibre_db.session.close()
|
|
||||||
return books_cover
|
return books_cover
|
||||||
|
|
||||||
def get_book_cover_thumbnails(self, book_id):
|
def get_book_cover_thumbnails(self, book_id):
|
||||||
@@ -248,7 +246,7 @@ class TaskGenerateSeriesThumbnails(CalibreTask):
|
|||||||
super(TaskGenerateSeriesThumbnails, self).__init__(task_message)
|
super(TaskGenerateSeriesThumbnails, self).__init__(task_message)
|
||||||
self.log = logger.create()
|
self.log = logger.create()
|
||||||
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.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,
|
||||||
@@ -256,60 +254,58 @@ class TaskGenerateSeriesThumbnails(CalibreTask):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def run(self, worker_thread):
|
def run(self, worker_thread):
|
||||||
with app.app_context():
|
if self.calibre_db.session and use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED:
|
||||||
calibre_db = db.CalibreDB(app)
|
self.message = 'Scanning Series'
|
||||||
if calibre_db.session and use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED:
|
all_series = self.get_series_with_four_plus_books()
|
||||||
self.message = 'Scanning Series'
|
count = len(all_series)
|
||||||
all_series = self.get_series_with_four_plus_books(calibre_db)
|
|
||||||
count = len(all_series)
|
|
||||||
|
|
||||||
total_generated = 0
|
total_generated = 0
|
||||||
for i, series in enumerate(all_series):
|
for i, series in enumerate(all_series):
|
||||||
generated = 0
|
generated = 0
|
||||||
series_thumbnails = self.get_series_thumbnails(series.id)
|
series_thumbnails = self.get_series_thumbnails(series.id)
|
||||||
series_books = self.get_series_books(series.id, calibre_db)
|
series_books = self.get_series_books(series.id)
|
||||||
|
|
||||||
# Generate new thumbnails for missing covers
|
# Generate new thumbnails for missing covers
|
||||||
resolutions = list(map(lambda t: t.resolution, series_thumbnails))
|
resolutions = list(map(lambda t: t.resolution, series_thumbnails))
|
||||||
missing_resolutions = list(set(self.resolutions).difference(resolutions))
|
missing_resolutions = list(set(self.resolutions).difference(resolutions))
|
||||||
for resolution in missing_resolutions:
|
for resolution in missing_resolutions:
|
||||||
|
generated += 1
|
||||||
|
self.create_series_thumbnail(series, series_books, resolution)
|
||||||
|
|
||||||
|
# Replace outdated or missing thumbnails
|
||||||
|
for thumbnail in series_thumbnails:
|
||||||
|
if any(book.last_modified > thumbnail.generated_at for book in series_books):
|
||||||
generated += 1
|
generated += 1
|
||||||
self.create_series_thumbnail(series, series_books, resolution)
|
self.update_series_thumbnail(series_books, thumbnail)
|
||||||
|
|
||||||
# Replace outdated or missing thumbnails
|
elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS):
|
||||||
for thumbnail in series_thumbnails:
|
generated += 1
|
||||||
if any(book.last_modified > thumbnail.generated_at for book in series_books):
|
self.update_series_thumbnail(series_books, thumbnail)
|
||||||
generated += 1
|
|
||||||
self.update_series_thumbnail(series_books, thumbnail)
|
|
||||||
|
|
||||||
elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS):
|
# Increment the progress
|
||||||
generated += 1
|
self.progress = (1.0 / count) * i
|
||||||
self.update_series_thumbnail(series_books, thumbnail)
|
|
||||||
|
|
||||||
# Increment the progress
|
if generated > 0:
|
||||||
self.progress = (1.0 / count) * i
|
total_generated += generated
|
||||||
|
self.message = N_('Generated {0} series thumbnails').format(total_generated)
|
||||||
|
|
||||||
if generated > 0:
|
# Check if job has been cancelled or ended
|
||||||
total_generated += generated
|
if self.stat == STAT_CANCELLED:
|
||||||
self.message = N_('Generated {0} series thumbnails').format(total_generated)
|
self.log.info(f'GenerateSeriesThumbnails task has been cancelled.')
|
||||||
|
return
|
||||||
|
|
||||||
# Check if job has been cancelled or ended
|
if self.stat == STAT_ENDED:
|
||||||
if self.stat == STAT_CANCELLED:
|
self.log.info(f'GenerateSeriesThumbnails task has been ended.')
|
||||||
self.log.info(f'GenerateSeriesThumbnails task has been cancelled.')
|
return
|
||||||
return
|
|
||||||
|
|
||||||
if self.stat == STAT_ENDED:
|
if total_generated == 0:
|
||||||
self.log.info(f'GenerateSeriesThumbnails task has been ended.')
|
self.self_cleanup = True
|
||||||
return
|
|
||||||
|
|
||||||
if total_generated == 0:
|
self._handleSuccess()
|
||||||
self.self_cleanup = True
|
self.app_db_session.remove()
|
||||||
|
|
||||||
self._handleSuccess()
|
def get_series_with_four_plus_books(self):
|
||||||
self.app_db_session.remove()
|
return self.calibre_db.session \
|
||||||
|
|
||||||
def get_series_with_four_plus_books(self, calibre_db):
|
|
||||||
return calibre_db.session \
|
|
||||||
.query(db.Series) \
|
.query(db.Series) \
|
||||||
.join(db.books_series_link) \
|
.join(db.books_series_link) \
|
||||||
.join(db.Books) \
|
.join(db.Books) \
|
||||||
@@ -318,8 +314,8 @@ class TaskGenerateSeriesThumbnails(CalibreTask):
|
|||||||
.having(func.count('book_series_link') > 3) \
|
.having(func.count('book_series_link') > 3) \
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
def get_series_books(self, series_id, calibre_db):
|
def get_series_books(self, series_id):
|
||||||
return calibre_db.session \
|
return self.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) \
|
||||||
@@ -465,15 +461,13 @@ class TaskClearCoverThumbnailCache(CalibreTask):
|
|||||||
|
|
||||||
def run(self, worker_thread):
|
def run(self, worker_thread):
|
||||||
if self.app_db_session:
|
if self.app_db_session:
|
||||||
# delete superfluous thumbnails
|
if self.book_id == 0: # delete superfluous thumbnails
|
||||||
if self.book_id == 0:
|
calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
with app.app_context():
|
thumbnails = (calibre_db.session.query(ub.Thumbnail)
|
||||||
calibre_db = db.CalibreDB(app)
|
.join(db.Books, ub.Thumbnail.entity_id == db.Books.id, isouter=True)
|
||||||
thumbnails = (calibre_db.session.query(ub.Thumbnail)
|
.filter(db.Books.id==None)
|
||||||
.join(db.Books, ub.Thumbnail.entity_id == db.Books.id, isouter=True)
|
.all())
|
||||||
.filter(db.Books.id==None)
|
calibre_db.session.close()
|
||||||
.all())
|
|
||||||
# calibre_db.session.close()
|
|
||||||
elif self.book_id > 0: # make sure single book is selected
|
elif self.book_id > 0: # make sure single book is selected
|
||||||
thumbnails = self.get_thumbnails_for_book(self.book_id)
|
thumbnails = self.get_thumbnails_for_book(self.book_id)
|
||||||
if self.book_id < 0:
|
if self.book_id < 0:
|
||||||
|
|||||||
@@ -71,7 +71,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|formatfloat(2)}})
|
({{entry.Books.series_index|formatseriesindex}})
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.Books.ratings.__len__() > 0 %}
|
{% if entry.Books.ratings.__len__() > 0 %}
|
||||||
@@ -122,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|formatfloat(2)}})
|
({{entry.series_index|formatseriesindex}})
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="rating">
|
<div class="rating">
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<h2>Details</h2>
|
<h2>Details</h2>
|
||||||
|
|
||||||
{% if entry.series|length > 0 %}
|
{% if entry.series|length > 0 %}
|
||||||
<p>{{ _("Book %(index)s of %(range)s", index=entry.series_index|formatfloat(2), range=entry.series[0].name|e) }}</p>
|
<p>{{ _("Book %(index)s of %(range)s", index=entry.series_index | formatfloat(2), range=(entry.series[0].name)|safe) }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if entry.languages|length > 0 %}
|
{% if entry.languages|length > 0 %}
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
{% if entry.comments|length > 0 and entry.comments[0].text|length > 0 %}
|
{% if entry.comments|length > 0 and entry.comments[0].text|length > 0 %}
|
||||||
<div>
|
<div>
|
||||||
<h2 id="decription">{{ _('Description:') }}</h2>
|
<h2 id="decription">{{ _('Description:') }}</h2>
|
||||||
{{ entry.comments[0].text|clean_string|safe }}
|
{{ entry.comments[0].text|safe }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user